From 39383abdf592ec4f2199e4a87181fe557daea4db Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 19 Jul 2026 10:49:19 -0400 Subject: [PATCH] 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 };