diff --git a/desktop/src/features/agents/lib/connectedRelayAgents.test.mjs b/desktop/src/features/agents/lib/connectedRelayAgents.test.mjs new file mode 100644 index 0000000000..f794fa7952 --- /dev/null +++ b/desktop/src/features/agents/lib/connectedRelayAgents.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { connectedRelayAgents } from "./connectedRelayAgents.ts"; + +function relayAgent(name, pubkey) { + return { + pubkey, + name, + agentType: "external", + channels: [], + channelIds: [], + capabilities: [], + status: "online", + respondTo: null, + respondToAllowlist: [], + }; +} + +describe("connectedRelayAgents", () => { + it("excludes locally managed identities case-insensitively", () => { + const managedPubkey = "A".repeat(64); + const result = connectedRelayAgents( + [ + relayAgent("Starter", managedPubkey), + relayAgent("Alpha", "b".repeat(64)), + ], + new Set([managedPubkey.toLowerCase()]), + ); + + assert.deepEqual( + result.map((agent) => agent.name), + ["Alpha"], + ); + }); + + it("sorts connected agents by name without mutating the relay result", () => { + const relayAgents = [ + relayAgent("Delta", "c".repeat(64)), + relayAgent("Bravo", "d".repeat(64)), + ]; + + assert.deepEqual( + connectedRelayAgents(relayAgents, new Set()).map((agent) => agent.name), + ["Bravo", "Delta"], + ); + assert.deepEqual( + relayAgents.map((agent) => agent.name), + ["Delta", "Bravo"], + ); + }); +}); diff --git a/desktop/src/features/agents/lib/connectedRelayAgents.ts b/desktop/src/features/agents/lib/connectedRelayAgents.ts new file mode 100644 index 0000000000..bf381d51ea --- /dev/null +++ b/desktop/src/features/agents/lib/connectedRelayAgents.ts @@ -0,0 +1,11 @@ +import type { RelayAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function connectedRelayAgents( + relayAgents: readonly RelayAgent[], + managedPubkeys: ReadonlySet, +): RelayAgent[] { + return [...relayAgents] + .filter((agent) => !managedPubkeys.has(normalizePubkey(agent.pubkey))) + .sort((left, right) => left.name.localeCompare(right.name)); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 8e1c47c615..724e08b031 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -20,6 +20,7 @@ import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; +import { ConnectedRelayAgentsSection } from "./ConnectedRelayAgentsSection"; import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; @@ -32,6 +33,12 @@ import { Button } from "@/shared/ui/button"; import { PageHeader } from "@/shared/ui/PageHeader"; import { getInheritedAgentDefaults } from "./bakedEnvHelpers"; +const WELCOME_TEAM_PERSONA_IDS = new Set([ + "builtin:fizz", + "builtin:honey", + "builtin:bumble", +]); + export function AgentsView() { const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel(); const { globalConfig } = useGlobalAgentConfig(); @@ -74,6 +81,18 @@ export function AgentsView() { // most providers persist the model as a provider env var (e.g. DATABRICKS_MODEL) // or inherit a baked build default, leaving `globalConfig.model` null. const configuredGlobalModel = inheritedDefaults.model.value; + const hasConnectedRelayAgents = agents.connectedRelayAgents.length > 0; + const visibleManagedAgents = hasConnectedRelayAgents + ? agents.managedAgents.filter( + (agent) => + !agent.personaId || !WELCOME_TEAM_PERSONA_IDS.has(agent.personaId), + ) + : agents.managedAgents; + const visiblePersonas = hasConnectedRelayAgents + ? personas.libraryPersonas.filter( + (persona) => !WELCOME_TEAM_PERSONA_IDS.has(persona.id), + ) + : personas.libraryPersonas; // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable React.useEffect(() => { @@ -140,11 +159,23 @@ export function AgentsView() { title="Agents" />
+ { + openProfilePanel?.(pubkey); + }} + /> 0} - personas={personas.libraryPersonas} + personas={visiblePersonas} personasError={ personas.personasQuery.error instanceof Error ? personas.personasQuery.error diff --git a/desktop/src/features/agents/ui/ConnectedRelayAgentsSection.tsx b/desktop/src/features/agents/ui/ConnectedRelayAgentsSection.tsx new file mode 100644 index 0000000000..8c2151d816 --- /dev/null +++ b/desktop/src/features/agents/ui/ConnectedRelayAgentsSection.tsx @@ -0,0 +1,86 @@ +import { useUserProfileQuery } from "@/features/profile/hooks"; +import type { RelayAgent } from "@/shared/api/types"; +import { Badge } from "@/shared/ui/badge"; +import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; +import { AgentIdentityCard } from "./AgentIdentityCard"; + +type ConnectedRelayAgentsSectionProps = { + agents: RelayAgent[]; + error: Error | null; + isLoading: boolean; + onOpenAgentProfile: (pubkey: string) => void; +}; + +const CARD_GRID_CLASS = + "mx-auto grid w-full max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3"; + +export function ConnectedRelayAgentsSection({ + agents, + error, + isLoading, + onOpenAgentProfile, +}: ConnectedRelayAgentsSectionProps) { + if (!isLoading && agents.length === 0 && !error) return null; + + return ( +
+
+

Connected agents

+

+ Agents connected through this community relay. +

+
+ + {isLoading ? ( +
+ {["first", "second", "third", "fourth"].map((key) => ( + + ))} +
+ ) : ( +
+ {agents.map((agent) => ( + + ))} +
+ )} + + {error ? ( +

+ {error.message} +

+ ) : null} +
+ ); +} + +function ConnectedRelayAgentCard({ + agent, + onOpenAgentProfile, +}: { + agent: RelayAgent; + onOpenAgentProfile: (pubkey: string) => void; +}) { + const profileQuery = useUserProfileQuery(agent.pubkey); + const title = profileQuery.data?.displayName?.trim() || agent.name; + + return ( + onOpenAgentProfile(agent.pubkey)} + statusBadge={ + + {agent.status === "online" ? "Connected" : agent.status} + + } + /> + ); +} diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index e1c2e9c9fc..ac3a28dc89 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -13,6 +13,7 @@ import { useDeleteManagedAgentMutation, } from "@/features/agents/hooks"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; +import { connectedRelayAgents } from "@/features/agents/lib/connectedRelayAgents"; import { useChannelsQuery } from "@/features/channels/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; import type { @@ -91,10 +92,15 @@ export function useManagedAgentActions() { // AppShell); this hook only reads derived state. const managedPubkeys = React.useMemo( - () => new Set(managedAgents.map((agent) => agent.pubkey)), + () => new Set(managedAgents.map((agent) => normalizePubkey(agent.pubkey))), [managedAgents], ); + const connectedAgents = React.useMemo( + () => connectedRelayAgents(relayAgentsQuery.data ?? [], managedPubkeys), + [managedPubkeys, relayAgentsQuery.data], + ); + const managedPubkeyList = React.useMemo( () => managedAgents.map((agent) => agent.pubkey), [managedAgents], @@ -403,6 +409,7 @@ export function useManagedAgentActions() { managedAgentLogQuery, managedPresenceQuery, managedAgents, + connectedRelayAgents: connectedAgents, managedPubkeys, channelIdToName, channelsByPubkey,