From 21c41a770e459e619d833bbc3241683d1d63e533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor=20Balzer?= Date: Fri, 21 Aug 2026 18:48:44 -0300 Subject: [PATCH 1/3] fix(desktop): list accessible relay agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: João Vitor Balzer --- .../agents/lib/availableRelayAgents.test.mjs | 80 ++ .../agents/lib/availableRelayAgents.ts | 34 + desktop/src/features/agents/ui/AgentsView.tsx | 686 ++---------------- .../features/agents/ui/RelayAgentsSection.tsx | 96 +++ .../ui/relayAgentsDirectoryContract.test.mjs | 27 + .../huddle/components/AddAgentDialog.tsx | 155 ++-- 6 files changed, 345 insertions(+), 733 deletions(-) create mode 100644 desktop/src/features/agents/lib/availableRelayAgents.test.mjs create mode 100644 desktop/src/features/agents/lib/availableRelayAgents.ts create mode 100644 desktop/src/features/agents/ui/RelayAgentsSection.tsx create mode 100644 desktop/src/features/agents/ui/relayAgentsDirectoryContract.test.mjs diff --git a/desktop/src/features/agents/lib/availableRelayAgents.test.mjs b/desktop/src/features/agents/lib/availableRelayAgents.test.mjs new file mode 100644 index 00000000000..32ec3df463d --- /dev/null +++ b/desktop/src/features/agents/lib/availableRelayAgents.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { availableRelayAgents } from "./availableRelayAgents.ts"; + +const ME = "a".repeat(64); +const OTHER = "b".repeat(64); + +function agent(name, pubkey, overrides = {}) { + return { + pubkey, + ownerPubkey: null, + name, + agentType: "relay", + channels: [], + channelIds: [], + capabilities: [], + status: "offline", + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +test("lists only relay agents the current identity may instruct", () => { + const result = availableRelayAgents( + [ + agent("Pokebalzer", "1".repeat(64), { + respondTo: "allowlist", + respondToAllowlist: [ME], + status: "online", + }), + agent("Relay Master", "2".repeat(64), { ownerPubkey: ME }), + agent("Someone else's", "3".repeat(64), { ownerPubkey: OTHER }), + agent("Shared channel", "4".repeat(64), { + respondTo: "anyone", + channelIds: ["general"], + }), + agent("Unshared", "5".repeat(64), { + respondTo: "anyone", + channelIds: ["private-elsewhere"], + }), + ], + new Set(["general"]), + ME, + ); + + assert.deepEqual( + result.map(({ name }) => name), + ["Pokebalzer", "Relay Master", "Shared channel"], + ); +}); + +test("deduplicates pubkeys and keeps status-first ordering", () => { + const duplicate = "6".repeat(64); + const result = availableRelayAgents( + [ + agent("Offline", "7".repeat(64), { + respondTo: "allowlist", + respondToAllowlist: [ME], + }), + agent("Online", duplicate.toUpperCase(), { + respondTo: "allowlist", + respondToAllowlist: [ME], + status: "online", + }), + agent("Duplicate", duplicate, { + respondTo: "allowlist", + respondToAllowlist: [ME], + }), + ], + new Set(), + ME, + ); + + assert.deepEqual( + result.map(({ name }) => name), + ["Online", "Offline"], + ); +}); diff --git a/desktop/src/features/agents/lib/availableRelayAgents.ts b/desktop/src/features/agents/lib/availableRelayAgents.ts new file mode 100644 index 00000000000..aca5192c053 --- /dev/null +++ b/desktop/src/features/agents/lib/availableRelayAgents.ts @@ -0,0 +1,34 @@ +import { relayAgentIsSharedWithUser } from "./agentAutocompleteEligibility"; +import type { RelayAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +const STATUS_PRIORITY: Record = { + online: 0, + away: 1, + offline: 2, +}; + +/** Relay agents the current identity can actually instruct. */ +export function availableRelayAgents( + relayAgents: readonly RelayAgent[] | undefined, + sharedChannelIds: ReadonlySet, + currentPubkey?: string | null, +): RelayAgent[] { + const seen = new Set(); + + return (relayAgents ?? []) + .filter((agent) => { + const pubkey = normalizePubkey(agent.pubkey); + if (seen.has(pubkey)) return false; + if (!relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) { + return false; + } + seen.add(pubkey); + return true; + }) + .sort((left, right) => { + const status = + STATUS_PRIORITY[left.status] - STATUS_PRIORITY[right.status]; + return status || left.name.localeCompare(right.name); + }); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index e1e1f37f35f..9da4ac8bd6c 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,661 +1,61 @@ import * as React from "react"; -import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react"; -import { - consumePendingSnapshotImport, - subscribeSnapshotImport, -} from "@/features/agents/openSnapshotImportFromUrlEvent"; -import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; -import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; -import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; -import { AgentDialog } from "./AgentDialog"; -import { PersonaCatalogDialog } from "./PersonaCatalogDialog"; -import { PersonaDeleteDialog } from "./PersonaDeleteDialog"; -import { PersonaShareDialog } from "./PersonaShareDialog"; -import { AgentSnapshotExportDialog } from "./AgentSnapshotExportDialog"; -import { AgentSnapshotImportDialog } from "./AgentSnapshotImportDialog"; -import { TeamSnapshotExportDialog } from "./TeamSnapshotExportDialog"; -import { TeamSnapshotImportDialog } from "./TeamSnapshotImportDialog"; -import { TeamShareDialog } from "./TeamShareDialog"; -import { TeamDeleteDialog } from "./TeamDeleteDialog"; -import { TeamDialog } from "./TeamDialog"; -import { TeamsSection } from "./TeamsSection"; -import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; -import { useManagedAgentActions } from "./useManagedAgentActions"; -import { usePersonaActions } from "./usePersonaActions"; -import { useTeamActions } from "./useTeamActions"; + +import { useRelayAgentsQuery } from "@/features/agents/hooks"; +import { getSharedChannelIds } from "@/features/agents/lib/agentAutocompleteEligibility"; +import { availableRelayAgents } from "@/features/agents/lib/availableRelayAgents"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; -import { useBakedBuildEnvQuery } from "@/features/agents/hooks"; -import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; -import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; -import { Button } from "@/shared/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; import { PageHeader } from "@/shared/ui/PageHeader"; -import { getInheritedAgentDefaults } from "./bakedEnvHelpers"; +import { RelayAgentsSection } from "./RelayAgentsSection"; export function AgentsView() { - const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel(); - const { globalConfig } = useGlobalAgentConfig(); - const { data: bakedEnv } = useBakedBuildEnvQuery({ enabled: true }); - const inheritedDefaults = getInheritedAgentDefaults(globalConfig, bakedEnv); - const agents = useManagedAgentActions(); - const personas = usePersonaActions(); - const teamImportInputRef = React.useRef(null); - const aiDefaultsTriggerRef = React.useRef(null); - const fullAiDefaultsTriggerRef = React.useRef(null); - const compactActionsTriggerRef = React.useRef(null); - const [isAiDefaultsOpen, setIsAiDefaultsOpen] = React.useState(false); - - function openUnifiedCatalog() { - personas.prepareCreate(); - personas.openCatalog(); - } - - function openAiDefaults(trigger: HTMLButtonElement | null) { - aiDefaultsTriggerRef.current = trigger; - setIsAiDefaultsOpen(true); - } - - function setAiDefaultsDialogOpen(open: boolean) { - if (!open) { - aiDefaultsTriggerRef.current = - fullAiDefaultsTriggerRef.current?.offsetParent !== null - ? fullAiDefaultsTriggerRef.current - : compactActionsTriggerRef.current; - } - setIsAiDefaultsOpen(open); - } - - const teamActions = useTeamActions( - { - setActionNoticeMessage: agents.setActionNoticeMessage, - setActionErrorMessage: agents.setActionErrorMessage, - }, - { - refetchManagedAgents: agents.refetchManagedAgents, - refetchRelayAgents: agents.refetchRelayAgents, - }, + const { openProfilePanel } = useProfilePanel(); + const identityQuery = useIdentityQuery(); + const channelsQuery = useChannelsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); + + const sharedChannelIds = React.useMemo( + () => getSharedChannelIds(channelsQuery.data), + [channelsQuery.data], ); - - const isActionPending = - agents.isPending || - personas.isPending || - teamActions.createTeamMutation.isPending || - teamActions.updateTeamMutation.isPending || - teamActions.deleteTeamMutation.isPending; - const runningAgentCount = agents.managedAgents.filter((agent) => - isManagedAgentActive(agent), - ).length; - const hasSavedAgentDefaults = Boolean( - globalConfig.preferred_runtime?.trim() || - globalConfig.provider?.trim() || - globalConfig.model?.trim() || - Object.values(globalConfig.env_vars).some( - (value) => value.trim().length > 0, + const agents = React.useMemo( + () => + availableRelayAgents( + relayAgentsQuery.data, + sharedChannelIds, + identityQuery.data?.pubkey, ), + [identityQuery.data?.pubkey, relayAgentsQuery.data, sharedChannelIds], ); - // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable - React.useEffect(() => { - // Consume a snapshot import that was enqueued before navigation (e.g. from - // a timeline AgentSnapshotCard click that navigated here). - const pending = consumePendingSnapshotImport(); - if (pending) { - if (pending.snapshotKind === "team") { - void teamActions.handleImportTeamSnapshotFile( - pending.fileBytes, - pending.fileName, - ); - } else { - void personas.handleImportSnapshotFile( - pending.fileBytes, - pending.fileName, - ); - } - } - - return subscribeSnapshotImport(({ fileBytes, fileName, snapshotKind }) => { - if (snapshotKind === "team") { - void teamActions.handleImportTeamSnapshotFile(fileBytes, fileName); - } else { - void personas.handleImportSnapshotFile(fileBytes, fileName); - } - }); - }, []); return ( - <> -
-
- -
- - {runningAgentCount > 0 ? ( - - ) : null} -
- - - - - - - { - openAiDefaults(compactActionsTriggerRef.current); - }} - > - - {hasSavedAgentDefaults - ? "Agent defaults" - : "Set agent defaults"} - - {runningAgentCount > 0 ? ( - { - void agents.handleBulkStopRunning(); - }} - > - - Stop running agents - - ) : null} - - - - } - description="Set up and manage your agents." - title="Agents" - /> -
- { - openProfilePanel?.(pubkey, options); - }} - onOpenPersonaProfile={(persona) => { - openPersonaProfilePanel?.(persona); - }} - onStartAgent={(pubkey) => { - void agents.handleStart(pubkey); - }} - onRestartAgent={(pubkey) => { - void agents.handleRestart(pubkey); - }} - onStartPersona={(persona) => { - void agents.handleStartPersona(persona); - }} - // Persona props - personas={personas.libraryPersonas} - personasError={ - personas.personasQuery.error instanceof Error - ? personas.personasQuery.error - : null - } - personaFeedbackErrorMessage={ - personas.personaFeedbackSurface === "library" - ? personas.personaErrorMessage - : null - } - personaFeedbackNoticeMessage={ - personas.personaFeedbackSurface === "library" - ? personas.personaNoticeMessage - : null - } - isPersonasLoading={personas.personasQuery.isLoading} - isPersonasPending={personas.isPending} - onOpenCatalog={openUnifiedCatalog} - onDuplicatePersona={personas.openDuplicate} - onEditPersona={personas.openEdit} - onSharePersona={personas.openShare} - onDeactivatePersona={(persona) => { - void personas.handleSetActive(persona, false, "library"); - }} - onDeletePersona={personas.openDelete} - /> - - { - teamImportInputRef.current?.click(); - }} - personas={personas.libraryPersonas} - teams={teamActions.teams} - /> -
-
-
- - - - {agents.agentToAddToChannel ? ( - { - if (!open) { - agents.setAgentToAddToChannel(null); - } - }} - open={agents.agentToAddToChannel !== null} - /> - ) : null} - {personas.personaDialogState ? ( - { - if (!open) { - personas.setPersonaDialogState(null); - } - }} - onSubmit={(input, options) => - personas.handleSubmit( - input, - undefined, - undefined, - undefined, - options, - ) - } - open={personas.personaDialogState !== null} - publishCatalogUpdatesOnSave={ - "id" in personas.personaDialogState.initialValues && - personas.sharedCatalogPersonaIdSet.has( - personas.personaDialogState.initialValues.id, - ) - } - submitLabel={personas.personaDialogState.submitLabel} - title={personas.personaDialogState.title} - /> - ) : null} - {personas.personaToDelete ? ( - a.personaId === personas.personaToDelete?.id, - ).length - } - onConfirm={(persona) => { - void personas.handleDelete(persona); - }} - onOpenChange={(open) => { - if (!open) { - personas.setPersonaToDelete(null); - } - }} - open={personas.personaToDelete !== null} - persona={personas.personaToDelete} - /> - ) : null} - {personas.personaToShare ? ( - { - const shareTarget = personas.personaToShare; - if (!shareTarget) return; - void personas.setPersonaCatalogShareLevel( - shareTarget.persona, - shareLevel, - ); - }} - onExport={() => { - const shareTarget = personas.personaToShare; - if (!shareTarget) return; - personas.setPersonaToShare(null); - personas.setPersonaToExportSnapshot(shareTarget); - }} - onOpenChange={(open) => { - if (!open) { - personas.setPersonaToShare(null); - } - }} - open={personas.personaToShare !== null} - persona={personas.personaToShare.persona} - /> - ) : null} - {personas.personaToExportSnapshot ? ( - { - if (personas.personaToExportSnapshot) { - personas.handleExportSnapshot( - personas.personaToExportSnapshot.persona, - personas.personaToExportSnapshot.linkedAgentPubkey, - personas.personaToExportSnapshot.effectiveAvatarUrl, - memoryLevel, - format, - ); - } - }} - onOpenChange={(open) => { - if (!open) { - personas.setPersonaToExportSnapshot(null); - } - }} - /> - ) : null} - {personas.snapshotImportState ? ( - { - void personas.handleConfirmSnapshotImport(keepAllowlist); - }} - onOpenChange={(open) => { - if (!open) { - personas.closeSnapshotImportDialog(); - } - }} - /> - ) : null} - {personas.isCatalogDialogOpen ? ( - ( - { - if (!open) onRequestClose(); - }} - onSubmitDefinition={personas.handleSubmit} - runtimes={personas.acpRuntimesQuery.data ?? []} - runtimeCatalogStatus={ - personas.acpRuntimesQuery.isLoading - ? "loading" - : personas.acpRuntimesQuery.isError - ? "error" - : "ready" - } - submitLabel="Add agent" - /> - )} +
+
+ + { - personas.clearFeedback("catalog"); - }} - onImportFile={(fileBytes, fileName) => { - void personas.handleImportSnapshotFile(fileBytes, fileName); - }} - onOpenChange={personas.setIsCatalogDialogOpen} - onSelectPersona={async (persona, active) => { - const addedPersona = await personas.handleSetActive( - persona, - active, - "catalog", - ); - if (!active || !addedPersona) return; - - personas.setIsCatalogDialogOpen(false); - openPersonaProfilePanel?.(addedPersona); - }} - open={personas.isCatalogDialogOpen} - personas={personas.catalogPersonas} - /> - ) : null} - {teamActions.teamDialogState ? ( - { - if (!open) { - teamActions.setTeamDialogState(null); - } + onOpenAgentProfile={(pubkey, options) => { + openProfilePanel?.(pubkey, options); }} - onDeleteRemovedPersonas={teamActions.handleDeleteRemovedPersonas} - onSubmit={teamActions.handleTeamSubmit} - open={teamActions.teamDialogState !== null} - personas={personas.libraryPersonas} - submitLabel={teamActions.teamDialogState.submitLabel} - title={teamActions.teamDialogState.title} /> - ) : null} - {teamActions.teamToDelete ? ( - { - void teamActions.handleDeleteTeam(team); - }} - onOpenChange={(open) => { - if (!open) { - teamActions.setTeamToDelete(null); - } - }} - open={teamActions.teamToDelete !== null} - team={teamActions.teamToDelete} - /> - ) : null} - {teamActions.teamToAddToChannel ? ( - { - if (!open) { - teamActions.setTeamToAddToChannel(null); - } - }} - open={teamActions.teamToAddToChannel !== null} - personas={personas.libraryPersonas} - team={teamActions.teamToAddToChannel} - /> - ) : null} - {teamActions.teamToShare ? ( - { - if (teamActions.teamToShare) { - const team = teamActions.teamToShare; - teamActions.setTeamToShare(null); - teamActions.openExportSnapshot(team); - } - }} - onOpenChange={(open) => { - if (!open) { - teamActions.setTeamToShare(null); - } - }} - open={teamActions.teamToShare !== null} - team={teamActions.teamToShare} - /> - ) : null} - {teamActions.teamToExport ? ( - { - if (teamActions.teamToExport) { - teamActions.handleExportTeamSnapshot( - teamActions.teamToExport, - memoryLevel, - format, - ); - } - }} - onOpenChange={(open) => { - if (!open) { - teamActions.setTeamToExport(null); - } - }} - /> - ) : null} - {teamActions.teamSnapshotImportState ? ( - { - void teamActions.handleConfirmTeamSnapshotImport(keepAllowlist); - }} - onOpenChange={(open) => { - if (!open) { - teamActions.closeTeamSnapshotImportDialog(); - } - }} - /> - ) : null} - {/* Hidden file input for team snapshot import via file picker */} - { - const file = e.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - const buffer = reader.result as ArrayBuffer; - const fileBytes = Array.from(new Uint8Array(buffer)); - void teamActions.handleImportTeamSnapshotFile(fileBytes, file.name); - }; - reader.readAsArrayBuffer(file); - // Reset so the same file can be picked again. - e.target.value = ""; - }} - /> - +
+
); } diff --git a/desktop/src/features/agents/ui/RelayAgentsSection.tsx b/desktop/src/features/agents/ui/RelayAgentsSection.tsx new file mode 100644 index 00000000000..aa767c5d8c7 --- /dev/null +++ b/desktop/src/features/agents/ui/RelayAgentsSection.tsx @@ -0,0 +1,96 @@ +import { useUserProfileQuery } from "@/features/profile/hooks"; +import type { RelayAgent } from "@/shared/api/types"; +import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; +import { Badge } from "@/shared/ui/badge"; +import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; +import { AgentIdentityCard } from "./AgentIdentityCard"; +import { IDENTITY_CARD_GRID_CLASS } from "./UnifiedAgentsSection"; + +export function RelayAgentsSection({ + agents, + error, + isLoading, + onOpenAgentProfile, +}: { + agents: readonly RelayAgent[]; + error: Error | null; + isLoading: boolean; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; +}) { + return ( +
+ {isLoading ? ( +
+ + + +
+ ) : null} + + {!isLoading && agents.length > 0 ? ( +
+ {agents.map((agent) => ( + + ))} +
+ ) : null} + + {!isLoading && agents.length === 0 && !error ? ( +

+ No relay agents are available to your identity yet. +

+ ) : null} + + {error ? ( +

+ {error.message} +

+ ) : null} +
+ ); +} + +function RelayAgentCard({ + agent, + onOpenAgentProfile, +}: { + agent: RelayAgent; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; +}) { + const profileQuery = useUserProfileQuery(agent.pubkey); + const statusLabel = + agent.status === "online" + ? "Online" + : agent.status === "away" + ? "Away" + : "Offline"; + + return ( + onOpenAgentProfile(agent.pubkey)} + statusBadge={ + + {statusLabel} + + } + /> + ); +} diff --git a/desktop/src/features/agents/ui/relayAgentsDirectoryContract.test.mjs b/desktop/src/features/agents/ui/relayAgentsDirectoryContract.test.mjs new file mode 100644 index 00000000000..2cad1e43863 --- /dev/null +++ b/desktop/src/features/agents/ui/relayAgentsDirectoryContract.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +function read(relativePath) { + return readFileSync(new URL(relativePath, import.meta.url), "utf8"); +} + +test("agents page is backed by accessible relay agents, not local demos", () => { + const source = read("./AgentsView.tsx"); + + assert.match(source, /useRelayAgentsQuery\(\)/); + assert.match(source, /availableRelayAgents\(/); + assert.match(source, / { + const source = read("../../huddle/components/AddAgentDialog.tsx"); + + assert.match(source, /useRelayAgentsQuery\(\{ enabled: open \}\)/); + assert.match(source, /availableRelayAgents\(/); + assert.match(source, /Already in huddle/); + assert.match(source, /disabled=\{adding !== null \|\| isCurrent\}/); + assert.doesNotMatch(source, /list_managed_agents/); +}); diff --git a/desktop/src/features/huddle/components/AddAgentDialog.tsx b/desktop/src/features/huddle/components/AddAgentDialog.tsx index 2e403518da2..18015546416 100644 --- a/desktop/src/features/huddle/components/AddAgentDialog.tsx +++ b/desktop/src/features/huddle/components/AddAgentDialog.tsx @@ -1,19 +1,16 @@ -import { invoke } from "@tauri-apps/api/core"; import { LoaderCircle } from "lucide-react"; import * as React from "react"; +import { useRelayAgentsQuery } from "@/features/agents/hooks"; +import { getSharedChannelIds } from "@/features/agents/lib/agentAutocompleteEligibility"; +import { availableRelayAgents } from "@/features/agents/lib/availableRelayAgents"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import { Dialog } from "@/shared/ui/dialog"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; -import type { ManagedAgentBackend } from "@/shared/api/types"; - -type ManagedAgentSummary = { - pubkey: string; - name: string; - status: string; - avatar_url: string | null; - backend: ManagedAgentBackend; -}; +import { Dialog } from "@/shared/ui/dialog"; type AgentAddResult = { ephemeral_added: boolean; @@ -34,98 +31,65 @@ export function AddAgentDialog({ onAdd, currentAgentPubkeys, }: AddAgentDialogProps) { - const [agents, setAgents] = React.useState([]); - const [loading, setLoading] = React.useState(true); + const identityQuery = useIdentityQuery(); + const channelsQuery = useChannelsQuery({ enabled: open }); + const relayAgentsQuery = useRelayAgentsQuery({ enabled: open }); const [adding, setAdding] = React.useState(null); const [error, setError] = React.useState(null); const [warning, setWarning] = React.useState(null); React.useEffect(() => { if (!open) return; - let cancelled = false; - setAgents([]); - setLoading(true); setAdding(null); setError(null); setWarning(null); - - invoke("list_managed_agents") - .then((nextAgents) => { - if (!cancelled) setAgents(nextAgents); - }) - .catch((e: unknown) => { - if (cancelled) return; - console.error("Failed to load agents:", e); - setError("Could not load agents."); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - - return () => { - cancelled = true; - }; }, [open]); - const availableAgents = agents.filter( - (agent) => - !currentAgentPubkeys.some( - (pubkey) => pubkey.toLowerCase() === agent.pubkey.toLowerCase(), + const sharedChannelIds = React.useMemo( + () => getSharedChannelIds(channelsQuery.data), + [channelsQuery.data], + ); + const agents = React.useMemo( + () => + availableRelayAgents( + relayAgentsQuery.data, + sharedChannelIds, + identityQuery.data?.pubkey, ), + [identityQuery.data?.pubkey, relayAgentsQuery.data, sharedChannelIds], + ); + const profilesQuery = useUsersBatchQuery( + agents.map(({ pubkey }) => pubkey), + { enabled: open && agents.length > 0 }, + ); + const currentAgentSet = React.useMemo( + () => new Set(currentAgentPubkeys.map(normalizePubkey)), + [currentAgentPubkeys], ); + const loading = + identityQuery.isPending || + channelsQuery.isPending || + relayAgentsQuery.isPending || + (agents.length > 0 && profilesQuery.isPending); - async function handleAdd(agent: ManagedAgentSummary) { - if (adding) return; - setAdding(agent.pubkey); + async function handleAdd(pubkey: string) { + if (adding || currentAgentSet.has(normalizePubkey(pubkey))) return; + setAdding(pubkey); setError(null); setWarning(null); - let startedForAdd = false; try { - const isLocal = agent.backend.type === "local"; - const needsStart = isLocal - ? agent.status !== "running" - : agent.status !== "deployed"; - if (needsStart && isLocal) { - await invoke("start_managed_agent", { pubkey: agent.pubkey }); - startedForAdd = true; - } - const result = await onAdd(agent.pubkey); - if (needsStart && !isLocal) { - try { - await invoke("start_managed_agent", { pubkey: agent.pubkey }); - } catch (startError: unknown) { - const msg = - startError instanceof Error - ? startError.message - : String(startError); - setWarning(`Added to huddle, but could not start agent: ${msg}`); - console.error("Failed to start agent after huddle add:", startError); - return; - } - } + const result = await onAdd(pubkey); if (result.parent_error) { - // Agent was added to the ephemeral channel but parent channel add failed. - // Show as a warning — don't close the dialog so the user can see it. setWarning( `Added to huddle, but parent channel failed: ${result.parent_error}`, ); } else { onClose(); } - } catch (e: unknown) { - if (startedForAdd) { - try { - await invoke("stop_managed_agent", { pubkey: agent.pubkey }); - } catch (rollbackError: unknown) { - console.error( - "Failed to stop agent after huddle add failed:", - rollbackError, - ); - } - } - const msg = e instanceof Error ? e.message : String(e); - setError(`Failed to add agent: ${msg}`); - console.error("Failed to add agent to huddle:", e); + } catch (cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + setError(`Failed to add agent: ${message}`); + console.error("Failed to add relay agent to huddle:", cause); } finally { setAdding(null); } @@ -133,15 +97,15 @@ export function AddAgentDialog({ return ( { - if (!open) onClose(); + onOpenChange={(nextOpen) => { + if (!nextOpen) onClose(); }} open={open} > @@ -161,36 +125,47 @@ export function AddAgentDialog({

Loading agents…

- ) : availableAgents.length === 0 ? ( + ) : agents.length === 0 ? (

- All available agents are already in this huddle. + No relay agents are available to your identity.

) : (
    - {availableAgents.map((agent) => { + {agents.map((agent) => { + const normalizedPubkey = normalizePubkey(agent.pubkey); const isAdding = adding === agent.pubkey; + const isCurrent = currentAgentSet.has(normalizedPubkey); + const profile = profilesQuery.data?.profiles[normalizedPubkey]; return (
  • ); From 954e09b0e1eadd2d384a541482011ee691592b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor=20Balzer?= Date: Fri, 21 Aug 2026 19:00:26 -0300 Subject: [PATCH 2/3] fix(huddle): surface blocked agent voice input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: João Vitor Balzer --- .../features/huddle/components/HuddleBar.tsx | 30 +++++++++++ .../huddle/lib/agentVoiceReadiness.test.mjs | 53 +++++++++++++++++++ .../huddle/lib/agentVoiceReadiness.ts | 49 +++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 desktop/src/features/huddle/lib/agentVoiceReadiness.test.mjs create mode 100644 desktop/src/features/huddle/lib/agentVoiceReadiness.ts diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index 16641f4cc34..ffeeecf798c 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -21,6 +21,7 @@ import { signRelayEvent } from "@/shared/api/tauri"; import type { RelayEvent } from "@/shared/api/types"; import { KIND_HUDDLE_REACTION } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; +import { isMacPlatform } from "@/shared/lib/platform"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { useDocumentVisible } from "@/shared/lib/useDocumentVisible"; import { Button } from "@/shared/ui/button"; @@ -28,6 +29,7 @@ import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useHuddle, useHuddleLevels } from "../HuddleContext"; +import { getAgentVoiceReadiness } from "../lib/agentVoiceReadiness"; import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog"; import type { HuddleAgentVoiceSettings } from "./AgentVoiceMenu"; import { MicControls, SpeakerControls } from "./MicControls"; @@ -494,6 +496,15 @@ export function HuddleBar({ const hasAvailableMic = micConnected; const ttsEnabled = barState.tts_enabled; const transcriptionEnabled = barState.transcription_enabled; + const pushToTalkShortcut = isMacPlatform() ? "⌃Space" : "Ctrl+Space"; + const agentVoiceReadiness = getAgentVoiceReadiness({ + hasAgents: barState.agent_pubkeys.length > 0, + isMuted, + isPttMode, + micConnected: hasAvailableMic, + pushToTalkShortcut, + transcriptionEnabled, + }); // Self-removing detection: remote-peer audio plays through native rodio // today (outside the WebView render graph), so the browser's AEC has no // far-end reference. The AEC follow-up PR flips this constant in the @@ -627,6 +638,25 @@ export function HuddleBar({ )} + {agentVoiceReadiness && ( + + )} + setShowAddAgent(false)} diff --git a/desktop/src/features/huddle/lib/agentVoiceReadiness.test.mjs b/desktop/src/features/huddle/lib/agentVoiceReadiness.test.mjs new file mode 100644 index 00000000000..1c1f983d75e --- /dev/null +++ b/desktop/src/features/huddle/lib/agentVoiceReadiness.test.mjs @@ -0,0 +1,53 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; + +import { getAgentVoiceReadiness } from "./agentVoiceReadiness.ts"; + +const READY = { + hasAgents: true, + isMuted: false, + isPttMode: true, + micConnected: true, + pushToTalkShortcut: "Ctrl+Space", + transcriptionEnabled: true, +}; + +test("does not warn when no agent is enrolled", () => { + assert.equal( + getAgentVoiceReadiness({ + ...READY, + hasAgents: false, + isMuted: true, + }), + null, + ); +}); + +test("explains that agents need transcription", () => { + assert.deepEqual( + getAgentVoiceReadiness({ ...READY, transcriptionEnabled: false }), + { + action: "enable_transcription", + message: "Transcript is off — turn it on so agents can hear you.", + }, + ); +}); + +test("makes muted push-to-talk visible with the platform shortcut", () => { + assert.deepEqual(getAgentVoiceReadiness({ ...READY, isMuted: true }), { + action: "unmute", + message: + "Mic muted — click to unmute or hold Ctrl+Space to talk to agents.", + }); +}); + +test("reports an unavailable microphone before accepting speech", () => { + assert.deepEqual(getAgentVoiceReadiness({ ...READY, micConnected: false }), { + action: null, + message: "Microphone unavailable — agents cannot hear you.", + }); +}); + +test("does not warn after voice input is ready", () => { + assert.equal(getAgentVoiceReadiness(READY), null); +}); diff --git a/desktop/src/features/huddle/lib/agentVoiceReadiness.ts b/desktop/src/features/huddle/lib/agentVoiceReadiness.ts new file mode 100644 index 00000000000..8c68097423c --- /dev/null +++ b/desktop/src/features/huddle/lib/agentVoiceReadiness.ts @@ -0,0 +1,49 @@ +export type AgentVoiceReadiness = + | { action: "enable_transcription"; message: string } + | { action: "unmute"; message: string } + | { action: null; message: string } + | null; + +type AgentVoiceReadinessInput = { + hasAgents: boolean; + isMuted: boolean; + isPttMode: boolean; + micConnected: boolean; + pushToTalkShortcut: string; + transcriptionEnabled: boolean; +}; + +/** + * Returns the blocking reason an enrolled agent cannot receive spoken input. + * The UI keeps microphone activation explicit while making the failure state + * visible instead of silently producing an empty transcript. + */ +export function getAgentVoiceReadiness({ + hasAgents, + isMuted, + isPttMode, + micConnected, + pushToTalkShortcut, + transcriptionEnabled, +}: AgentVoiceReadinessInput): AgentVoiceReadiness { + if (!hasAgents) return null; + if (!micConnected) { + return { + action: null, + message: "Microphone unavailable — agents cannot hear you.", + }; + } + if (!transcriptionEnabled) { + return { + action: "enable_transcription", + message: "Transcript is off — turn it on so agents can hear you.", + }; + } + if (!isMuted) return null; + return { + action: "unmute", + message: isPttMode + ? `Mic muted — click to unmute or hold ${pushToTalkShortcut} to talk to agents.` + : "Mic muted — click to unmute and talk to agents.", + }; +} From 02e170e810a236cb6c9f93311e87105d19034110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor=20Balzer?= Date: Fri, 21 Aug 2026 19:14:34 -0300 Subject: [PATCH 3/3] feat(agents): highlight viewer public key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: João Vitor Balzer --- desktop/src/features/agents/ui/AgentsView.tsx | 37 +++++++++++++++++++ .../ui/relayAgentsDirectoryContract.test.mjs | 5 +++ 2 files changed, 42 insertions(+) diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 9da4ac8bd6c..22109006f64 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -6,7 +6,9 @@ import { availableRelayAgents } from "@/features/agents/lib/availableRelayAgents import { useChannelsQuery } from "@/features/channels/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; +import { safeNpub } from "@/shared/lib/nostrUtils"; import { PageHeader } from "@/shared/ui/PageHeader"; +import { CopyButton } from "./CopyButton"; import { RelayAgentsSection } from "./RelayAgentsSection"; export function AgentsView() { @@ -28,6 +30,8 @@ export function AgentsView() { ), [identityQuery.data?.pubkey, relayAgentsQuery.data, sharedChannelIds], ); + const userPubkey = identityQuery.data?.pubkey ?? null; + const userNpub = userPubkey ? safeNpub(userPubkey) : null; return (
    @@ -39,6 +43,39 @@ export function AgentsView() { description="Agents available to you on this relay." title="Your agents" /> + {userPubkey ? ( +
    +
    +
    +

    + Your Buzz public key +

    +

    + {userNpub ?? userPubkey} +

    +
    +
    + + {userNpub ? ( + + ) : null} +
    +
    +

    + This is your public identifier. Your private key is never shown. +

    +
    + ) : null} assert.match(source, /useRelayAgentsQuery\(\)/); assert.match(source, /availableRelayAgents\(/); assert.match(source, /