From 39383abdf592ec4f2199e4a87181fe557daea4db Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 19 Jul 2026 10:49:19 -0400 Subject: [PATCH 1/5] feat(desktop): add pair-scoped agent runtime UI Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/src/app/AppShell.tsx | 4 +- .../agents/lib/useAgentsDataRefresh.ts | 8 ++ .../agents/managedAgentRuntimeHooks.ts | 54 ++++++++ .../agents/managedAgentRuntimeStatus.test.mjs | 70 ++++++++++ .../agents/managedAgentRuntimeStatus.ts | 49 +++++++ .../useManagedAgentRuntimeReconciliation.ts | 23 ++++ .../features/channels/ui/MembersSidebar.tsx | 16 +++ .../channels/ui/MembersSidebarMemberCard.tsx | 36 ++--- .../ui/ActiveAgentCommunitiesSettingsCard.tsx | 129 ++++++++++++++++++ .../features/settings/ui/SettingsPanels.tsx | 2 + desktop/src/shared/api/tauriManagedAgents.ts | 40 +++++- desktop/src/shared/api/types.ts | 23 ++++ 12 files changed, 433 insertions(+), 21 deletions(-) create mode 100644 desktop/src/features/agents/managedAgentRuntimeHooks.ts create mode 100644 desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs create mode 100644 desktop/src/features/agents/managedAgentRuntimeStatus.ts create mode 100644 desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts create mode 100644 desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 5b65584e8..5621a2f38 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; - import { deriveShellRoute } from "@/app/AppShell.helpers"; import { AppShellProvider } from "@/app/AppShellContext"; import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; @@ -37,6 +36,7 @@ import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { PreventSleepProvider } from "@/features/agents/usePreventSleep"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh"; +import { useManagedAgentRuntimeReconciliation } from "@/features/agents/useManagedAgentRuntimeReconciliation"; import { useAutoRestartPolicy } from "@/features/agents/lib/useAutoRestartPolicy"; import { usePersonaSync } from "@/features/agents/lib/usePersonaSync"; import { useAgentObserverIngestion } from "@/features/agents/useAgentObserverIngestion"; @@ -89,7 +89,6 @@ import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; - const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); return { default: module.SettingsScreen }; @@ -115,6 +114,7 @@ export function AppShell() { const mainInsetRef = React.useRef(null); const location = useLocation(); const queryClient = useQueryClient(); + useManagedAgentRuntimeReconciliation(communitiesHook.communities); const { goAgents, goChannel, diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index 83aa99547..9f655a77f 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -8,6 +8,7 @@ import { relayAgentsQueryKey, teamsQueryKey, } from "@/features/agents/hooks"; +import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; // Trailing-coalesce window: a backfill burst (up to 500 inbound events fed // one-by-one through reconcile) fires one `agents-data-changed` per event. @@ -27,6 +28,12 @@ export function useAgentsDataRefresh(): void { useEffect(() => { let timer: ReturnType | undefined; + const unlistenRuntime = listen("managed-agent-runtime-status", () => { + void queryClient.invalidateQueries({ + queryKey: managedAgentRuntimesQueryKey, + }); + }); + const unlisten = listen("agents-data-changed", () => { if (timer !== undefined) clearTimeout(timer); timer = setTimeout(() => { @@ -40,6 +47,7 @@ export function useAgentsDataRefresh(): void { return () => { if (timer !== undefined) clearTimeout(timer); void unlisten.then((fn) => fn()); + void unlistenRuntime.then((fn) => fn()); }; }, [queryClient]); } diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts new file mode 100644 index 000000000..2b0b78943 --- /dev/null +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -0,0 +1,54 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + listManagedAgentRuntimes, + restartManagedAgentRuntime, + startManagedAgentRuntime, + stopManagedAgentRuntime, +} from "@/shared/api/tauriManagedAgents"; +import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; + +export const managedAgentRuntimesQueryKey = ["managed-agent-runtimes"] as const; + +export function useManagedAgentRuntimesQuery(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + queryKey: managedAgentRuntimesQueryKey, + queryFn: listManagedAgentRuntimes, + }); +} + +export function useManagedAgentRuntimeAction() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + action, + pubkey, + relayUrl, + }: { + action: "start" | "stop" | "restart"; + pubkey: string; + relayUrl: string; + }) => { + if (action === "stop") return stopManagedAgentRuntime(pubkey, relayUrl); + if (action === "restart") { + return restartManagedAgentRuntime(pubkey, relayUrl); + } + return startManagedAgentRuntime(pubkey, relayUrl); + }, + onSuccess: (runtime) => { + queryClient.setQueryData( + managedAgentRuntimesQueryKey, + (current = []) => { + const index = current.findIndex( + (candidate) => candidate.runtimeId === runtime.runtimeId, + ); + if (index === -1) return [...current, runtime]; + return current.map((candidate, candidateIndex) => + candidateIndex === index ? runtime : candidate, + ); + }, + ); + }, + }); +} diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs new file mode 100644 index 000000000..4652e88ff --- /dev/null +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + agentCommunityAvailability, + agentCommunityStatusDetail, + findManagedAgentRuntime, +} from "./managedAgentRuntimeStatus.ts"; + +const runtime = (overrides = {}) => ({ + runtimeId: "runtime-a", + pubkey: "aa", + relayUrl: "wss://relay.example", + localSetup: true, + lifecycle: "ready", + pid: 1, + error: null, + logPath: null, + ...overrides, +}); + +test("projects every backend lifecycle to the four product labels", () => { + assert.equal(agentCommunityAvailability(runtime()), "Here"); + for (const lifecycle of ["starting", "listening", "waking"]) { + assert.equal(agentCommunityAvailability(runtime({ lifecycle })), "Waking"); + } + for (const lifecycle of ["failed", "stopped"]) { + assert.equal( + agentCommunityAvailability(runtime({ lifecycle })), + "Unavailable", + ); + } +}); + +test("backend-authoritative local setup takes precedence", () => { + assert.equal( + agentCommunityAvailability( + runtime({ localSetup: false, lifecycle: "ready" }), + ), + "Needs setup on this device", + ); +}); + +test("unavailable detail distinguishes stopped and failed", () => { + assert.equal( + agentCommunityStatusDetail(runtime({ lifecycle: "stopped" })), + "Stopped by you", + ); + assert.equal( + agentCommunityStatusDetail( + runtime({ lifecycle: "failed", error: "Relay timed out" }), + ), + "Relay timed out", + ); +}); + +test("selects one relay without collapsing same-pubkey pairs", () => { + const runtimes = [ + runtime({ relayUrl: "wss://a.example", lifecycle: "ready" }), + runtime({ relayUrl: "wss://b.example", lifecycle: "failed" }), + ]; + assert.equal( + findManagedAgentRuntime(runtimes, "AA", "wss://b.example")?.lifecycle, + "failed", + ); + assert.equal( + findManagedAgentRuntime(runtimes, "aa", "wss://c.example"), + undefined, + ); +}); diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts new file mode 100644 index 000000000..a428264c7 --- /dev/null +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -0,0 +1,49 @@ +import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; + +export type AgentCommunityAvailability = + | "Here" + | "Waking" + | "Needs setup on this device" + | "Unavailable"; + +export function agentCommunityAvailability( + runtime: ManagedAgentRuntimeStatus, +): AgentCommunityAvailability { + if (!runtime.localSetup) return "Needs setup on this device"; + + switch (runtime.lifecycle) { + case "starting": + case "listening": + case "waking": + return "Waking"; + case "ready": + return "Here"; + case "failed": + case "stopped": + return "Unavailable"; + } +} + +export function agentCommunityStatusDetail( + runtime: ManagedAgentRuntimeStatus, +): string | null { + if (!runtime.localSetup) + return "Set up this agent on this device to start it."; + if (runtime.lifecycle === "stopped") return "Stopped by you"; + if (runtime.lifecycle === "failed") + return runtime.error ?? "Could not connect"; + return null; +} + +export function findManagedAgentRuntime( + runtimes: readonly ManagedAgentRuntimeStatus[], + pubkey: string, + relayUrl: string, +): ManagedAgentRuntimeStatus | undefined { + const normalizedPubkey = pubkey.toLowerCase(); + return runtimes.find( + (runtime) => + runtime.pubkey.toLowerCase() === normalizedPubkey && + (runtime.relayUrl === relayUrl || runtime.requestedRelayUrl === relayUrl), + ); +} diff --git a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts new file mode 100644 index 000000000..c1abecbde --- /dev/null +++ b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts @@ -0,0 +1,23 @@ +import { useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; + +import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; +import { reconcileManagedAgentRuntimes } from "@/shared/api/tauriManagedAgents"; + +export function useManagedAgentRuntimeReconciliation( + communities: readonly { relayUrl: string }[], +): void { + const queryClient = useQueryClient(); + const reconciled = React.useRef(false); + + React.useEffect(() => { + if (reconciled.current) return; + reconciled.current = true; + + void reconcileManagedAgentRuntimes(communities) + .then((runtimes) => { + queryClient.setQueryData(managedAgentRuntimesQueryKey, runtimes); + }) + .catch(() => undefined); + }, [communities, queryClient]); +} diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 48a04b591..367f1ab3e 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -50,6 +50,8 @@ import { MODAL_SEARCH_SHELL_CLASS, } from "@/shared/ui/modalSearchStyles"; import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard"; +import { useManagedAgentRuntimesQuery } from "@/features/agents/managedAgentRuntimeHooks"; +import { findManagedAgentRuntime } from "@/features/agents/managedAgentRuntimeStatus"; import { EditRespondToDialog } from "./EditRespondToDialog"; import { useMembersSidebarActions } from "./useMembersSidebarActions"; import { useMembersSidebarModeration } from "./useMembersSidebarModeration"; @@ -124,6 +126,7 @@ type MembersSidebarProps = { open: boolean; onOpenChange: (open: boolean) => void; onViewActivity?: (pubkey: string) => void; + relayUrl?: string; }; export function MembersSidebar({ @@ -132,8 +135,12 @@ export function MembersSidebar({ open, onOpenChange, onViewActivity, + relayUrl, }: MembersSidebarProps) { const channelId = channel?.id ?? null; + const managedAgentRuntimesQuery = useManagedAgentRuntimesQuery({ + enabled: open, + }); const queryClient = useQueryClient(); const searchInputRef = React.useRef(null); const [searchQuery, setSearchQuery] = React.useState(""); @@ -587,6 +594,15 @@ export function MembersSidebar({ ? managedAgentByPubkey.get(normalizePubkey(member.pubkey)) : undefined } + managedAgentRuntime={ + memberIsBot && relayUrl + ? findManagedAgentRuntime( + managedAgentRuntimesQuery.data ?? [], + member.pubkey, + relayUrl, + ) + : undefined + } member={member} memberIsBot={memberIsBot} memberAvatarLabel={ diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 823451743..b594a4ee2 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -20,13 +20,16 @@ import { } from "@/features/agents/lib/managedAgentControlActions"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; +import { agentCommunityAvailability } from "@/features/agents/managedAgentRuntimeStatus"; import { truncatePubkey } from "@/shared/lib/pubkey"; import type { ChannelMember, ManagedAgent, + ManagedAgentRuntimeStatus, PresenceStatus, } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; import { DropdownMenu, DropdownMenuContent, @@ -45,6 +48,7 @@ type MembersSidebarMemberCardProps = { isActionPending: boolean; isArchived: boolean; managedAgent?: ManagedAgent; + managedAgentRuntime?: ManagedAgentRuntimeStatus; member: ChannelMember; memberAvatarLabel: string; memberIsBot: boolean; @@ -104,19 +108,6 @@ function formatRespondToLabel(agent: ManagedAgent) { } } -function formatManagedAgentStatus(agent: ManagedAgent) { - switch (agent.status) { - case "running": - return "Running"; - case "stopped": - return "Stopped"; - case "deployed": - return "Deployed"; - case "not_deployed": - return "Not deployed"; - } -} - export function MembersSidebarMemberCard({ canChangeRole, canModerate, @@ -124,6 +115,7 @@ export function MembersSidebarMemberCard({ isActionPending, isArchived, managedAgent, + managedAgentRuntime, member, memberAvatarLabel, memberIsBot, @@ -205,13 +197,21 @@ export function MembersSidebarMemberCard({ ) : null} )} - {managedAgent ? ( - - {formatManagedAgentStatus(managedAgent)} - + {managedAgentRuntime + ? agentCommunityAvailability(managedAgentRuntime) + : "Unavailable"} + ) : null} {managedAgent ? ( ( + null, + ); + + const agentNames = React.useMemo( + () => + new Map( + (agentsQuery.data ?? []).map((agent) => [ + agent.pubkey.toLowerCase(), + agent.name, + ]), + ), + [agentsQuery.data], + ); + const runtimes = runtimesQuery.data ?? []; + + async function runAction(runtime: ManagedAgentRuntimeStatus) { + setPendingRuntimeId(runtime.runtimeId); + try { + await action.mutateAsync({ + action: + runtime.lifecycle === "starting" || + runtime.lifecycle === "listening" || + runtime.lifecycle === "waking" || + runtime.lifecycle === "ready" + ? "stop" + : runtime.lifecycle === "stopped" + ? "start" + : "restart", + pubkey: runtime.pubkey, + relayUrl: runtime.relayUrl, + }); + } finally { + setPendingRuntimeId(null); + } + } + + return ( +
+ +
+ {runtimesQuery.isPending ? ( +

Loading…

+ ) : runtimes.length === 0 ? ( +

+ No agent community runtimes found. +

+ ) : ( + runtimes.map((runtime) => { + const status = agentCommunityAvailability(runtime); + const detail = agentCommunityStatusDetail(runtime); + const pending = pendingRuntimeId === runtime.runtimeId; + return ( +
+
+
+

+ {agentNames.get(runtime.pubkey.toLowerCase()) ?? + truncatePubkey(runtime.pubkey)} +

+ + {status} + +
+

+ {runtime.relayUrl} +

+ {detail ? ( +

{detail}

+ ) : null} +
+ {runtime.localSetup ? ( + + ) : null} +
+ ); + }) + )} +
+ {action.error instanceof Error ? ( +

{action.error.message}

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 7ea86d82e..42b30d71d 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -64,6 +64,7 @@ import { MobilePairingCard } from "./MobilePairingCard"; import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; +import { ActiveAgentCommunitiesSettingsCard } from "./ActiveAgentCommunitiesSettingsCard"; import { GlobalAgentConfigSettingsCard } from "./GlobalAgentConfigSettingsCard"; import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; @@ -725,6 +726,7 @@ export function renderSettingsSection( return (
+
); diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index f9e762578..55713c1ea 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -3,7 +3,10 @@ import { invokeTauri, type RawManagedAgent, } from "@/shared/api/tauri"; -import type { ManagedAgent } from "@/shared/api/types"; +import type { + ManagedAgent, + ManagedAgentRuntimeStatus, +} from "@/shared/api/types"; export async function startManagedAgent(pubkey: string): Promise { const response = await invokeTauri("start_managed_agent", { @@ -46,3 +49,38 @@ export async function setManagedAgentAutoRestart( ); return fromRawManagedAgent(response); } + +export async function listManagedAgentRuntimes(): Promise< + ManagedAgentRuntimeStatus[] +> { + return invokeTauri( + "list_managed_agent_runtimes", + ); +} + +export async function startManagedAgentRuntime( + pubkey: string, + relayUrl: string, +): Promise { + return invokeTauri("start_managed_agent_runtime", { pubkey, relayUrl }); +} + +export async function stopManagedAgentRuntime( + pubkey: string, + relayUrl: string, +): Promise { + return invokeTauri("stop_managed_agent_runtime", { pubkey, relayUrl }); +} + +export async function restartManagedAgentRuntime( + pubkey: string, + relayUrl: string, +): Promise { + return invokeTauri("restart_managed_agent_runtime", { pubkey, relayUrl }); +} + +export async function reconcileManagedAgentRuntimes( + communities: readonly { relayUrl: string }[], +): Promise { + return invokeTauri("reconcile_managed_agent_runtimes", { communities }); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 85ff218e8..0481e5e87 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -311,6 +311,29 @@ export type RelayAgent = { respondToAllowlist: string[]; }; +export type ManagedAgentRuntimeLifecycle = + | "starting" + | "listening" + | "waking" + | "ready" + | "failed" + | "stopped"; + +export type ManagedAgentRuntimeStatus = { + /** Opaque backend-owned identity for this canonical agent + relay pair. */ + runtimeId: string; + pubkey: string; + /** Original community descriptor, present on startup reconciliation results. */ + requestedRelayUrl?: string; + /** Canonical, backend-owned pair identity component. Do not normalize in TS. */ + relayUrl: string; + localSetup: boolean; + lifecycle: ManagedAgentRuntimeLifecycle; + pid: number | null; + error: string | null; + logPath: string | null; +}; + export type ManagedAgentBackend = | { type: "local" } | { type: "provider"; id: string; config: Record }; From 8134248e0007cfc9c6c0b3006201d6b6fabdb3ef Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 19 Jul 2026 10:53:35 -0400 Subject: [PATCH 2/5] fix(desktop): align managed runtime identity contract Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../agents/managedAgentRuntimeHooks.ts | 4 +++- .../agents/managedAgentRuntimeStatus.test.mjs | 9 ++++++++- .../agents/managedAgentRuntimeStatus.ts | 6 ++++++ .../useManagedAgentRuntimeReconciliation.ts | 7 ++++++- .../ui/ActiveAgentCommunitiesSettingsCard.tsx | 18 ++++++++++-------- desktop/src/shared/api/types.ts | 4 +--- 6 files changed, 34 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts index 2b0b78943..673247aef 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -41,7 +41,9 @@ export function useManagedAgentRuntimeAction() { managedAgentRuntimesQueryKey, (current = []) => { const index = current.findIndex( - (candidate) => candidate.runtimeId === runtime.runtimeId, + (candidate) => + candidate.pubkey === runtime.pubkey && + candidate.relayUrl === runtime.relayUrl, ); if (index === -1) return [...current, runtime]; return current.map((candidate, candidateIndex) => diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs index 4652e88ff..57c0863f4 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs @@ -5,10 +5,10 @@ import { agentCommunityAvailability, agentCommunityStatusDetail, findManagedAgentRuntime, + managedAgentRuntimeKey, } from "./managedAgentRuntimeStatus.ts"; const runtime = (overrides = {}) => ({ - runtimeId: "runtime-a", pubkey: "aa", relayUrl: "wss://relay.example", localSetup: true, @@ -54,6 +54,13 @@ test("unavailable detail distinguishes stopped and failed", () => { ); }); +test("pair key cannot collide at component boundaries", () => { + assert.notEqual( + managedAgentRuntimeKey(runtime({ pubkey: "ab", relayUrl: "c" })), + managedAgentRuntimeKey(runtime({ pubkey: "a", relayUrl: "bc" })), + ); +}); + test("selects one relay without collapsing same-pubkey pairs", () => { const runtimes = [ runtime({ relayUrl: "wss://a.example", lifecycle: "ready" }), diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts index a428264c7..ea5094c7e 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.ts +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -35,6 +35,12 @@ export function agentCommunityStatusDetail( return null; } +export function managedAgentRuntimeKey( + runtime: Pick, +): string { + return JSON.stringify([runtime.pubkey, runtime.relayUrl]); +} + export function findManagedAgentRuntime( runtimes: readonly ManagedAgentRuntimeStatus[], pubkey: string, diff --git a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts index c1abecbde..8456e881e 100644 --- a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts +++ b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts @@ -18,6 +18,11 @@ export function useManagedAgentRuntimeReconciliation( .then((runtimes) => { queryClient.setQueryData(managedAgentRuntimesQueryKey, runtimes); }) - .catch(() => undefined); + .catch((error) => { + console.warn( + "[managed-agent-runtimes] startup reconcile failed:", + error, + ); + }); }, [communities, queryClient]); } diff --git a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx b/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx index 81f608df2..0db798575 100644 --- a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx @@ -8,6 +8,7 @@ import { import { agentCommunityAvailability, agentCommunityStatusDetail, + managedAgentRuntimeKey, } from "@/features/agents/managedAgentRuntimeStatus"; import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; @@ -19,9 +20,9 @@ export function ActiveAgentCommunitiesSettingsCard() { const agentsQuery = useManagedAgentsQuery(); const runtimesQuery = useManagedAgentRuntimesQuery(); const action = useManagedAgentRuntimeAction(); - const [pendingRuntimeId, setPendingRuntimeId] = React.useState( - null, - ); + const [pendingRuntimeKey, setPendingRuntimeKey] = React.useState< + string | null + >(null); const agentNames = React.useMemo( () => @@ -36,7 +37,7 @@ export function ActiveAgentCommunitiesSettingsCard() { const runtimes = runtimesQuery.data ?? []; async function runAction(runtime: ManagedAgentRuntimeStatus) { - setPendingRuntimeId(runtime.runtimeId); + setPendingRuntimeKey(managedAgentRuntimeKey(runtime)); try { await action.mutateAsync({ action: @@ -52,7 +53,7 @@ export function ActiveAgentCommunitiesSettingsCard() { relayUrl: runtime.relayUrl, }); } finally { - setPendingRuntimeId(null); + setPendingRuntimeKey(null); } } @@ -73,12 +74,13 @@ export function ActiveAgentCommunitiesSettingsCard() { runtimes.map((runtime) => { const status = agentCommunityAvailability(runtime); const detail = agentCommunityStatusDetail(runtime); - const pending = pendingRuntimeId === runtime.runtimeId; + const runtimeKey = managedAgentRuntimeKey(runtime); + const pending = pendingRuntimeKey === runtimeKey; return (
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 0481e5e87..bdbb6b5a0 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -320,10 +320,8 @@ export type ManagedAgentRuntimeLifecycle = | "stopped"; export type ManagedAgentRuntimeStatus = { - /** Opaque backend-owned identity for this canonical agent + relay pair. */ - runtimeId: string; pubkey: string; - /** Original community descriptor, present on startup reconciliation results. */ + /** Exact submitted descriptor, present only on startup reconcile results. */ requestedRelayUrl?: string; /** Canonical, backend-owned pair identity component. Do not normalize in TS. */ relayUrl: string; From b890dcbea7b70bb62befff485f353bafd91d01ff Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 19 Jul 2026 10:57:01 -0400 Subject: [PATCH 3/5] test(desktop): expose managed runtime pair selectors Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/src/app/AppShell.tsx | 2 +- .../features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 5621a2f38..77b26dd63 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -114,7 +114,7 @@ export function AppShell() { const mainInsetRef = React.useRef(null); const location = useLocation(); const queryClient = useQueryClient(); - useManagedAgentRuntimeReconciliation(communitiesHook.communities); + useManagedAgentRuntimeReconciliation(communitiesHook.communities); // sync storage snapshot const { goAgents, goChannel, diff --git a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx b/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx index 0db798575..268bd863f 100644 --- a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx @@ -79,6 +79,8 @@ export function ActiveAgentCommunitiesSettingsCard() { return (
From 035f9c5d0f959f0d56f5470a27fd4a77030e7f19 Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 19 Jul 2026 11:05:34 -0400 Subject: [PATCH 4/5] feat(desktop): route managed runtime lifecycle frames Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/src/features/agents/observerRelayStore.ts | 3 +++ desktop/src/shared/api/tauriManagedAgents.ts | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 919cbbc20..035f19131 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -4,6 +4,7 @@ import { subscribeToAgentObserverFrames } from "@/shared/api/observerRelay"; import type { RelayEvent, ManagedAgent } from "@/shared/api/types"; import type { ControlResultFrame } from "@/shared/api/types"; import { putAgentSessionConfig } from "@/shared/api/tauri"; +import { putManagedAgentRuntimeLifecycle } from "@/shared/api/tauriManagedAgents"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { decryptObserverEvent } from "@/shared/api/tauriObserver"; import { @@ -402,6 +403,8 @@ async function handleRelayObserverEvent( onSessionConfigCaptured?.(agentPubkey); } else if (parsed.kind === "control_result") { dispatchControlResult(agentPubkey, parsed.payload); + } else if (parsed.kind === "managed_agent_runtime_lifecycle") { + void putManagedAgentRuntimeLifecycle(agentPubkey, parsed.payload); } } catch (error) { if (activeGeneration !== generation) { diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index 55713c1ea..c74b099f8 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -79,6 +79,16 @@ export async function restartManagedAgentRuntime( return invokeTauri("restart_managed_agent_runtime", { pubkey, relayUrl }); } +export async function putManagedAgentRuntimeLifecycle( + outerPubkey: string, + payload: unknown, +): Promise { + return invokeTauri("put_managed_agent_runtime_lifecycle", { + outerPubkey, + payload, + }); +} + export async function reconcileManagedAgentRuntimes( communities: readonly { relayUrl: string }[], ): Promise { From d3c8175206a0ba429c31338b06423e4ca5a8408b Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 19 Jul 2026 11:08:01 -0400 Subject: [PATCH 5/5] fix(desktop): handle dropped lifecycle frames Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/src/features/agents/observerRelayStore.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 035f19131..7c2f0d9ce 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -404,7 +404,11 @@ async function handleRelayObserverEvent( } else if (parsed.kind === "control_result") { dispatchControlResult(agentPubkey, parsed.payload); } else if (parsed.kind === "managed_agent_runtime_lifecycle") { - void putManagedAgentRuntimeLifecycle(agentPubkey, parsed.payload); + void putManagedAgentRuntimeLifecycle(agentPubkey, parsed.payload).catch( + (error) => { + console.debug("Late/untracked lifecycle frame dropped:", error); + }, + ); } } catch (error) { if (activeGeneration !== generation) {