feat(desktop): add pair-scoped agent runtime UI

Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
2026-07-19 10:49:19 -04:00
co-authored by Tyler Longwell
parent 8908bd6b71
commit 39383abdf5
12 changed files with 433 additions and 21 deletions
+2 -2
View File
@@ -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<HTMLElement>(null);
const location = useLocation();
const queryClient = useQueryClient();
useManagedAgentRuntimeReconciliation(communitiesHook.communities);
const {
goAgents,
goChannel,
@@ -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<typeof setTimeout> | 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]);
}
@@ -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<ManagedAgentRuntimeStatus[]>(
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,
);
},
);
},
});
}
@@ -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,
);
});
@@ -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),
);
}
@@ -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]);
}
@@ -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<HTMLInputElement>(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={
@@ -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}
</div>
)}
{managedAgent ? (
<span
className="sr-only"
{managedAgentRuntime || managedAgent ? (
<Badge
className="mt-1 normal-case tracking-normal"
data-testid={`sidebar-managed-agent-status-${member.pubkey}`}
variant={
managedAgentRuntime &&
agentCommunityAvailability(managedAgentRuntime) === "Here"
? "default"
: "secondary"
}
>
{formatManagedAgentStatus(managedAgent)}
</span>
{managedAgentRuntime
? agentCommunityAvailability(managedAgentRuntime)
: "Unavailable"}
</Badge>
) : null}
{managedAgent ? (
<span
@@ -0,0 +1,129 @@
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import {
useManagedAgentRuntimeAction,
useManagedAgentRuntimesQuery,
} from "@/features/agents/managedAgentRuntimeHooks";
import {
agentCommunityAvailability,
agentCommunityStatusDetail,
} from "@/features/agents/managedAgentRuntimeStatus";
import type { ManagedAgentRuntimeStatus } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Badge } from "@/shared/ui/badge";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
export function ActiveAgentCommunitiesSettingsCard() {
const agentsQuery = useManagedAgentsQuery();
const runtimesQuery = useManagedAgentRuntimesQuery();
const action = useManagedAgentRuntimeAction();
const [pendingRuntimeId, setPendingRuntimeId] = React.useState<string | null>(
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 (
<section className="min-w-0" data-testid="active-agent-communities">
<SettingsSectionHeader
title="Active in communities"
description="See and control each community where this device runs your agents."
/>
<div className="overflow-hidden rounded-xl border border-border/60">
{runtimesQuery.isPending ? (
<p className="px-4 py-3 text-sm text-muted-foreground">Loading…</p>
) : runtimes.length === 0 ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
No agent community runtimes found.
</p>
) : (
runtimes.map((runtime) => {
const status = agentCommunityAvailability(runtime);
const detail = agentCommunityStatusDetail(runtime);
const pending = pendingRuntimeId === runtime.runtimeId;
return (
<div
className="flex items-center gap-3 border-b border-border/60 px-4 py-3 last:border-b-0"
data-testid={`agent-community-runtime-${runtime.runtimeId}`}
key={runtime.runtimeId}
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium">
{agentNames.get(runtime.pubkey.toLowerCase()) ??
truncatePubkey(runtime.pubkey)}
</p>
<Badge
variant={status === "Here" ? "default" : "secondary"}
>
{status}
</Badge>
</div>
<p className="truncate text-xs text-muted-foreground">
{runtime.relayUrl}
</p>
{detail ? (
<p className="text-xs text-muted-foreground">{detail}</p>
) : null}
</div>
{runtime.localSetup ? (
<Button
disabled={pending}
onClick={() => void runAction(runtime)}
size="sm"
type="button"
variant="outline"
>
{pending
? "Working…"
: runtime.lifecycle === "stopped"
? "Start"
: runtime.lifecycle === "failed"
? "Restart"
: "Stop"}
</Button>
) : null}
</div>
);
})
)}
</div>
{action.error instanceof Error ? (
<p className="mt-2 text-sm text-destructive">{action.error.message}</p>
) : null}
</section>
);
}
@@ -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 (
<div className="space-y-12">
<PreventSleepSettingsCard />
<ActiveAgentCommunitiesSettingsCard />
<GlobalAgentConfigSettingsCard />
</div>
);
+39 -1
View File
@@ -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<ManagedAgent> {
const response = await invokeTauri<RawManagedAgent>("start_managed_agent", {
@@ -46,3 +49,38 @@ export async function setManagedAgentAutoRestart(
);
return fromRawManagedAgent(response);
}
export async function listManagedAgentRuntimes(): Promise<
ManagedAgentRuntimeStatus[]
> {
return invokeTauri<ManagedAgentRuntimeStatus[]>(
"list_managed_agent_runtimes",
);
}
export async function startManagedAgentRuntime(
pubkey: string,
relayUrl: string,
): Promise<ManagedAgentRuntimeStatus> {
return invokeTauri("start_managed_agent_runtime", { pubkey, relayUrl });
}
export async function stopManagedAgentRuntime(
pubkey: string,
relayUrl: string,
): Promise<ManagedAgentRuntimeStatus> {
return invokeTauri("stop_managed_agent_runtime", { pubkey, relayUrl });
}
export async function restartManagedAgentRuntime(
pubkey: string,
relayUrl: string,
): Promise<ManagedAgentRuntimeStatus> {
return invokeTauri("restart_managed_agent_runtime", { pubkey, relayUrl });
}
export async function reconcileManagedAgentRuntimes(
communities: readonly { relayUrl: string }[],
): Promise<ManagedAgentRuntimeStatus[]> {
return invokeTauri("reconcile_managed_agent_runtimes", { communities });
}
+23
View File
@@ -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<string, unknown> };