diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2e..27ffc0bfb8f 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -2,6 +2,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + getManagedAgentPrimaryActionLabel, + isManagedAgentLive, + resolveManagedAgentDisplayPresence, startManagedAgentWithRules, respawnManagedAgentWithRules, } from "./managedAgentControlActions.ts"; @@ -166,3 +169,85 @@ test("test_respawn_onStopped_fires_before_start_resolves", async () => { "onStopped must fire after stop resolves and before start is called", ); }); + +// --- presence-aware primary action / liveness --------------------------------- + +test("provider deployed + presence offline → Deploy, not live", () => { + const remote = agent({ + backend: { type: "provider", id: "blox", config: {} }, + backendAgentId: "remote-1", + status: "deployed", + }); + + assert.equal(isManagedAgentLive(remote, "offline"), false); + assert.equal(getManagedAgentPrimaryActionLabel(remote, "offline"), "Deploy"); +}); + +test("provider deployed + presence online/away → Shutdown, live", () => { + const remote = agent({ + backend: { type: "provider", id: "blox", config: {} }, + backendAgentId: "remote-1", + status: "deployed", + }); + + assert.equal(isManagedAgentLive(remote, "online"), true); + assert.equal(getManagedAgentPrimaryActionLabel(remote, "online"), "Shutdown"); + assert.equal(isManagedAgentLive(remote, "away"), true); + assert.equal(getManagedAgentPrimaryActionLabel(remote, "away"), "Shutdown"); +}); + +test("provider stopped + presence online → Shutdown (presence wins)", () => { + const remote = agent({ + backend: { type: "provider", id: "blox", config: {} }, + backendAgentId: "remote-1", + status: "stopped", + }); + + assert.equal(isManagedAgentLive(remote, "online"), true); + assert.equal(getManagedAgentPrimaryActionLabel(remote, "online"), "Shutdown"); +}); + +test("local running/stopped labels unchanged without presence", () => { + const running = agent({ status: "running" }); + const stopped = agent({ status: "stopped" }); + + assert.equal(isManagedAgentLive(running), true); + assert.equal(getManagedAgentPrimaryActionLabel(running), "Stop"); + assert.equal(isManagedAgentLive(stopped), false); + assert.equal(getManagedAgentPrimaryActionLabel(stopped), "Start agent"); +}); + +test("missing presence on provider → not live / Deploy", () => { + const remote = agent({ + backend: { type: "provider", id: "blox", config: {} }, + backendAgentId: "remote-1", + status: "deployed", + }); + + assert.equal(isManagedAgentLive(remote), false); + assert.equal(isManagedAgentLive(remote, null), false); + assert.equal(isManagedAgentLive(remote, undefined), false); + assert.equal(getManagedAgentPrimaryActionLabel(remote), "Deploy"); + assert.equal(getManagedAgentPrimaryActionLabel(remote, null), "Deploy"); + assert.equal( + resolveManagedAgentDisplayPresence(remote, undefined), + "offline", + ); + assert.equal(resolveManagedAgentDisplayPresence(remote, "online"), "online"); +}); + +test("local running without presence row stays online (infra, not relay)", () => { + const running = agent({ status: "running" }); + const stopped = agent({ status: "stopped" }); + + assert.equal(resolveManagedAgentDisplayPresence(running, undefined), "online"); + assert.equal(resolveManagedAgentDisplayPresence(running, null), "online"); + assert.equal(resolveManagedAgentDisplayPresence(running, "offline"), "online"); + assert.equal(resolveManagedAgentDisplayPresence(stopped, undefined), "offline"); + assert.equal(resolveManagedAgentDisplayPresence(stopped, "online"), "offline"); +}); + +test("undefined agent falls back to relay presence", () => { + assert.equal(resolveManagedAgentDisplayPresence(undefined, "away"), "away"); + assert.equal(resolveManagedAgentDisplayPresence(undefined, undefined), "offline"); +}); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 8a4a6898cce..9f822140329 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -3,6 +3,7 @@ import type { Channel, ManagedAgent, PresenceLookup, + PresenceStatus, RelayAgent, } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -31,13 +32,59 @@ export type ManagedAgentActionResult = { noticeMessage?: string; }; +/** Infrastructure axis — local process running or remote record still deployed. */ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } -export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { +/** Relay presence axis — agent appears live to peers. */ +export function isManagedAgentPresenceLive( + presence?: PresenceStatus | null, +): boolean { + return presence === "online" || presence === "away"; +} + +/** + * Liveness for primary Deploy/Shutdown (and Stop/Start) affordances. + * Provider-backed agents use relay presence; local agents use infrastructure status. + */ +export function isManagedAgentLive( + agent: Pick, + presence?: PresenceStatus | null, +): boolean { + if (agent.backend.type === "provider") { + return isManagedAgentPresenceLive(presence); + } + return isManagedAgentActive(agent); +} + +/** + * Avatar / presence-dot display for bots and managed agents. + * Provider-backed: relay presence only (never map infrastructure `deployed` → online). + * Local / non-provider: infrastructure status (running/deployed → online). + */ +export function resolveManagedAgentDisplayPresence( + agent: ManagedAgent | undefined, + presence?: PresenceStatus | null, +): PresenceStatus { + if (agent?.backend.type === "provider") { + return presence ?? "offline"; + } + if (agent && isManagedAgentActive(agent)) { + return "online"; + } + if (agent) { + return "offline"; + } + return presence ?? "offline"; +} + +export function getManagedAgentPrimaryActionLabel( + agent: ManagedAgent, + presence?: PresenceStatus | null, +) { if (agent.backend.type === "provider") { - return isManagedAgentActive(agent) ? "Shutdown" : "Deploy"; + return isManagedAgentLive(agent, presence) ? "Shutdown" : "Deploy"; } if (isManagedAgentActive(agent)) { diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 9ec3151bb09..ec6af722863 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -655,7 +655,11 @@ export function MembersSidebar({ }} onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined} onManagedAgentAction={(agent) => { - void handleAgentLifecycleAction(agent, managedAgentRuntime); + void handleAgentLifecycleAction( + agent, + managedAgentRuntime, + memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null, + ); }} onOpenProfile={handleOpenProfile} onRemoveMember={handleRemoveMember} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index b375649292d..ae1dd54c80e 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -17,6 +17,7 @@ import { import { getManagedAgentPrimaryActionLabel, isManagedAgentActive, + isManagedAgentLive, } from "@/features/agents/lib/managedAgentControlActions"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -279,6 +280,7 @@ export function MembersSidebarMemberCard({ onUntimeout={onUntimeout} onViewActivity={onViewActivity} pairAction={pairAction} + presenceStatus={presenceStatus} /> ) : null} @@ -307,6 +309,7 @@ function MemberActionsMenu({ onUntimeout, onViewActivity, pairAction, + presenceStatus, }: { canChangeRole: boolean; canModerateMember: boolean; @@ -327,6 +330,7 @@ function MemberActionsMenu({ onUntimeout: (member: ChannelMember) => void; onViewActivity?: (pubkey: string) => void; pairAction?: ManagedAgentPairAction; + presenceStatus?: PresenceStatus | null; }) { const showChangeRole = canChangeRole && !memberIsBot && member.role !== "owner"; @@ -367,10 +371,13 @@ function MemberActionsMenu({ > {pairAction ? getPairActionIcon(pairAction) - : getManagedAgentActionIcon(managedAgent)} + : getManagedAgentActionIcon(managedAgent, presenceStatus)} {pairAction ? MANAGED_AGENT_PAIR_ACTION_LABELS[pairAction] - : getManagedAgentPrimaryActionLabel(managedAgent)} + : getManagedAgentPrimaryActionLabel( + managedAgent, + presenceStatus, + )} {onEditRespondTo ? ( ; } -function getManagedAgentActionIcon(agent: ManagedAgent) { - if (isManagedAgentActive(agent)) { +function getManagedAgentActionIcon( + agent: ManagedAgent, + presence?: PresenceStatus | null, +) { + if (isManagedAgentLive(agent, presence)) { return ; } diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts index cc8f4062210..95d60fabf49 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -8,6 +8,7 @@ import { import { respawnManagedAgentWithRules, isManagedAgentActive, + isManagedAgentLive, startManagedAgentWithRules, stopManagedAgentWithRules, } from "@/features/agents/lib/managedAgentControlActions"; @@ -25,6 +26,7 @@ import type { ChannelMember, ManagedAgent, ManagedAgentRuntimeStatus, + PresenceStatus, } from "@/shared/api/types"; type UseMembersSidebarActionsOptions = { @@ -144,6 +146,7 @@ export function useMembersSidebarActions({ async function handleLifecycleAction( agent: ManagedAgent, runtime?: ManagedAgentRuntimeStatus, + presence?: PresenceStatus | null, ) { clearActionFeedback(); setActiveActionKey(`agent:${agent.pubkey}`); @@ -170,7 +173,7 @@ export function useMembersSidebarActions({ return; } - if (isManagedAgentActive(agent)) { + if (isManagedAgentLive(agent, presence)) { await stopManagedAgentWithRules({ agent, ...EMPTY_AGENT_CONTEXT, diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 998ea3232a8..947fa3d9805 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -452,7 +452,7 @@ export function UserProfilePanel({ const { handleAgentPrimaryAction, handleAgentRestart } = useAgentLifecycleActions({ channels: channelsQuery.data, - managedAgent, + managedAgent, presenceStatus, relayAgents: relayAgentsQuery.data, startManagedAgent: startAgentMutation.mutateAsync, stopManagedAgent: stopAgentMutation.mutateAsync, diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index dbe17473f65..54a4379cbac 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -4,7 +4,8 @@ import { ChevronDown, ChevronUp, Pencil } from "lucide-react"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { getManagedAgentPrimaryActionLabel, - isManagedAgentActive, + isManagedAgentLive, + resolveManagedAgentDisplayPresence, } from "@/features/agents/lib/managedAgentControlActions"; import { RestartDiffBadge } from "@/features/agents/ui/RestartDiffBadge"; import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel"; @@ -190,11 +191,7 @@ export function ProfileSummaryView({ }: ProfileSummaryViewProps) { const activeTurns = useAgentWorking(isBot ? pubkey : null).channels; const avatarStatus = isBot - ? managedAgent - ? isManagedAgentActive(managedAgent) - ? "online" - : "offline" - : (presenceStatus ?? "offline") + ? resolveManagedAgentDisplayPresence(managedAgent, presenceStatus) : presenceStatus; const stickyLayoutRef = React.useRef(null); const [primaryActionsConcealed, setPrimaryActionsConcealed] = @@ -428,12 +425,13 @@ export function ProfileSummaryView({ agentActionDisabled={isAgentActionPending} agentActionLabel={ isOwner === true && managedAgent - ? getManagedAgentPrimaryActionLabel(managedAgent) + ? getManagedAgentPrimaryActionLabel(managedAgent, presenceStatus) : undefined } agentActionLive={ - managedAgent?.status === "running" || - managedAgent?.status === "deployed" + managedAgent + ? isManagedAgentLive(managedAgent, presenceStatus) + : false } onAgentPrimaryAction={ isOwner === true && managedAgent diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts index 62d0d3c7ad6..6d8a0759c1d 100644 --- a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -2,23 +2,30 @@ import * as React from "react"; import { toast } from "sonner"; import { - isManagedAgentActive, + isManagedAgentLive, respawnManagedAgentWithRules, startManagedAgentWithRules, stopManagedAgentWithRules, } from "@/features/agents/lib/managedAgentControlActions"; import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks"; -import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; +import type { + Channel, + ManagedAgent, + PresenceStatus, + RelayAgent, +} from "@/shared/api/types"; export function useAgentLifecycleActions({ channels, managedAgent, + presenceStatus, relayAgents, startManagedAgent, stopManagedAgent, }: { channels: readonly Channel[] | undefined; managedAgent: ManagedAgent | undefined; + presenceStatus?: PresenceStatus | null; relayAgents: readonly RelayAgent[] | undefined; startManagedAgent: (pubkey: string) => Promise; stopManagedAgent: (pubkey: string) => Promise; @@ -27,7 +34,7 @@ export function useAgentLifecycleActions({ if (!managedAgent) return; try { - if (isManagedAgentActive(managedAgent)) { + if (isManagedAgentLive(managedAgent, presenceStatus)) { const result = await stopManagedAgentWithRules({ agent: managedAgent, channels: channels ?? [], @@ -58,6 +65,7 @@ export function useAgentLifecycleActions({ }, [ channels, managedAgent, + presenceStatus, relayAgents, startManagedAgent, stopManagedAgent,