diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index fb27c659d5e..61b76908e2b 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { useCreateChannelManagedAgentsMutation, usePersonasQuery, + useRelayAgentsQuery, useTeamsQuery, type CreateChannelManagedAgentResult, } from "@/features/agents/hooks"; @@ -12,8 +13,14 @@ import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRunti import { getUsableTeams } from "@/features/agents/lib/teamPersonas"; import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection"; import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection"; +import { AddChannelExistingAgentsSection } from "@/features/channels/ui/AddChannelExistingAgentsSection"; import { useInChannelPersonaIds } from "@/features/channels/ui/useInChannelPersonaIds"; +import { + useAddChannelMembersMutation, + useChannelMembersQuery, +} from "@/features/channels/hooks"; import type { AcpRuntime } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Dialog } from "@/shared/ui/dialog"; @@ -63,12 +70,18 @@ export function AddChannelBotDialog({ onOpenChange, }: AddChannelBotDialogProps) { const personasQuery = usePersonasQuery(); + const relayAgentsQuery = useRelayAgentsQuery({ enabled: open }); const teamsQuery = useTeamsQuery(); + const channelMembersQuery = useChannelMembersQuery( + channelId, + open && channelId !== null, + ); const inChannelPersonaIds = useInChannelPersonaIds( channelId, open && channelId !== null, ); const createBotsMutation = useCreateChannelManagedAgentsMutation(channelId); + const addMembersMutation = useAddChannelMembersMutation(channelId); const personas = React.useMemo( () => getActivePersonas(personasQuery.data ?? []), [personasQuery.data], @@ -80,6 +93,8 @@ export function AddChannelBotDialog({ const [selectedPersonaIds, setSelectedPersonaIds] = React.useState( [], ); + const [selectedExistingAgentPubkeys, setSelectedExistingAgentPubkeys] = + React.useState([]); const [submissionNotice, setSubmissionNotice] = React.useState( null, ); @@ -91,6 +106,22 @@ export function AddChannelBotDialog({ () => personas.filter((persona) => selectedPersonaIds.includes(persona.id)), [personas, selectedPersonaIds], ); + const relayAgents = React.useMemo( + () => + [...(relayAgentsQuery.data ?? [])].sort((left, right) => + left.name.localeCompare(right.name), + ), + [relayAgentsQuery.data], + ); + const inChannelPubkeys = React.useMemo( + () => + new Set( + (channelMembersQuery.data ?? []).map((member) => + normalizePubkey(member.pubkey), + ), + ), + [channelMembersQuery.data], + ); React.useEffect(() => { setSelectedPersonaIds((current) => @@ -102,11 +133,24 @@ export function AddChannelBotDialog({ ); }, [inChannelPersonaIds, personas]); + React.useEffect(() => { + const addablePubkeys = new Set( + relayAgents + .map((agent) => normalizePubkey(agent.pubkey)) + .filter((pubkey) => !inChannelPubkeys.has(pubkey)), + ); + setSelectedExistingAgentPubkeys((current) => + current.filter((pubkey) => addablePubkeys.has(normalizePubkey(pubkey))), + ); + }, [inChannelPubkeys, relayAgents]); + function reset() { setSelectedPersonaIds([]); + setSelectedExistingAgentPubkeys([]); setSubmissionNotice(null); setSubmissionError(null); createBotsMutation.reset(); + addMembersMutation.reset(); } function handleOpenChange(next: boolean) { @@ -135,7 +179,13 @@ export function AddChannelBotDialog({ } async function handleSubmit() { - if (providers.length === 0 || selectedPersonas.length === 0) return; + if ( + selectedExistingAgentPubkeys.length === 0 && + selectedPersonas.length === 0 + ) { + return; + } + if (selectedPersonas.length > 0 && providers.length === 0) return; const inputs = selectedPersonas.map((persona) => { const resolved = resolvePersonaRuntime( @@ -160,46 +210,94 @@ export function AddChannelBotDialog({ setSubmissionNotice(null); setSubmissionError(null); - try { - const result = await createBotsMutation.mutateAsync(inputs); - if (result.failures.length === 0) { - if (result.successes[0]) onAdded?.(result.successes[0]); - handleOpenChange(false); - return; + const failures: Array<{ name: string; error: string }> = []; + let addedCount = 0; + + if (selectedExistingAgentPubkeys.length > 0) { + try { + const result = await addMembersMutation.mutateAsync({ + pubkeys: selectedExistingAgentPubkeys, + role: "bot", + }); + addedCount += result.added.length; + const failedPubkeys = new Set( + result.errors.map((failure) => normalizePubkey(failure.pubkey)), + ); + setSelectedExistingAgentPubkeys((current) => + current.filter((pubkey) => + failedPubkeys.has(normalizePubkey(pubkey)), + ), + ); + for (const failure of result.errors) { + failures.push({ + name: + relayAgents.find( + (agent) => + normalizePubkey(agent.pubkey) === + normalizePubkey(failure.pubkey), + )?.name ?? "agent", + error: failure.error, + }); + } + } catch (error) { + failures.push({ + name: "existing agents", + error: + error instanceof Error ? error.message : "Could not add agents.", + }); } + } - const failedPersonaIds = new Set( - result.failures - .map((failure) => failure.personaId) - .filter((personaId): personaId is string => Boolean(personaId)), - ); - setSelectedPersonaIds((current) => - current.filter((personaId) => failedPersonaIds.has(personaId)), - ); - if (result.successes.length > 0) { - setSubmissionNotice( - `Added ${result.successes.length} ${formatAgentCountLabel( - result.successes.length, - )}.`, + if (inputs.length > 0) { + try { + const result = await createBotsMutation.mutateAsync(inputs); + addedCount += result.successes.length; + if (result.successes[0]) onAdded?.(result.successes[0]); + const failedPersonaIds = new Set( + result.failures + .map((failure) => failure.personaId) + .filter((personaId): personaId is string => Boolean(personaId)), + ); + setSelectedPersonaIds((current) => + current.filter((personaId) => failedPersonaIds.has(personaId)), ); + failures.push(...result.failures); + } catch (error) { + failures.push({ + name: "new agents", + error: + error instanceof Error ? error.message : "Could not create agents.", + }); } - setSubmissionError(formatBatchFailureSummary(result.failures)); - } catch { - // The mutation error is rendered inline. } + + if (failures.length === 0) { + handleOpenChange(false); + return; + } + if (addedCount > 0) { + setSubmissionNotice( + `Added ${addedCount} ${formatAgentCountLabel(addedCount)}.`, + ); + } + setSubmissionError(formatBatchFailureSummary(failures)); } + const totalSelected = + selectedExistingAgentPubkeys.length + selectedPersonas.length; + const isPending = + createBotsMutation.isPending || addMembersMutation.isPending; const canSubmit = - providers.length > 0 && - selectedPersonas.length > 0 && - !providersLoading && - !createBotsMutation.isPending; - const addButtonLabel = createBotsMutation.isPending - ? selectedPersonas.length > 1 - ? `Adding ${selectedPersonas.length}…` + totalSelected > 0 && + (selectedPersonas.length === 0 || + (providers.length > 0 && !providersLoading)) && + !isPending; + const addButtonLabel = isPending + ? totalSelected > 1 + ? `Adding ${totalSelected}…` : "Adding…" - : selectedPersonas.length > 1 - ? `Add ${selectedPersonas.length} agents` + : totalSelected > 1 + ? `Add ${totalSelected} agents` : "Add agent"; return ( @@ -235,23 +333,45 @@ export function AddChannelBotDialog({ scrollAreaTestId="add-channel-bot-dialog-scroll-area" title="Add agents" > - { - setSelectedPersonaIds((current) => toggleValue(current, personaId)); + { + setSelectedExistingAgentPubkeys((current) => + toggleValue(current, pubkey), + ); setSubmissionNotice(null); setSubmissionError(null); }} - personas={personas} - selectedPersonaIds={selectedPersonaIds} + selectedPubkeys={selectedExistingAgentPubkeys} /> - {teams.length > 0 ? ( + {providers.length > 0 || providersLoading ? ( + 0} + inChannelPersonaIds={inChannelPersonaIds} + isLoading={personasQuery.isLoading} + onCreateAgent={handleCreateAgent} + onTogglePersona={(personaId) => { + setSelectedPersonaIds((current) => + toggleValue(current, personaId), + ); + setSubmissionNotice(null); + setSubmissionError(null); + }} + personas={personas} + selectedPersonaIds={selectedPersonaIds} + /> + ) : null} + + {teams.length > 0 && providers.length > 0 ? (

- Install an agent runtime before adding an agent to this channel. + This computer cannot create a new agent until an agent runtime is + installed. Existing agents can still be added.

) : null} @@ -280,6 +401,11 @@ export function AddChannelBotDialog({ {personasQuery.error.message}

) : null} + {relayAgentsQuery.error instanceof Error ? ( +

+ {relayAgentsQuery.error.message} +

+ ) : null} {submissionNotice ? (

{submissionNotice} diff --git a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx index e27953d6460..93e96b12d01 100644 --- a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx @@ -88,6 +88,7 @@ function CreateAgentRow({ onCreateAgent }: { onCreateAgent: () => void }) { } type AddChannelBotPersonasSectionProps = { + availableLabel?: string; canToggleSelections: boolean; inChannelPersonaIds?: ReadonlySet; isLoading: boolean; @@ -103,6 +104,7 @@ type AddChannelBotPersonasSectionProps = { }; export function AddChannelBotPersonasSection({ + availableLabel = "Your agents", canToggleSelections, inChannelPersonaIds, isLoading, @@ -133,7 +135,7 @@ export function AddChannelBotPersonasSection({ {!isLoading && available.length > 0 ? (

- Your agents + {availableLabel}
{available.map((persona) => ( void; + selected: boolean; +}) { + return ( + + ); +} + +export function AddChannelExistingAgentsSection({ + agents, + canToggleSelections, + inChannelPubkeys, + isLoading, + onToggleAgent, + selectedPubkeys, +}: { + agents: RelayAgent[]; + canToggleSelections: boolean; + inChannelPubkeys: ReadonlySet; + isLoading: boolean; + onToggleAgent: (pubkey: string) => void; + selectedPubkeys: readonly string[]; +}) { + if (!isLoading && agents.length === 0) { + return null; + } + + const available = agents.filter( + (agent) => !inChannelPubkeys.has(normalizePubkey(agent.pubkey)), + ); + const inChannel = agents.filter((agent) => + inChannelPubkeys.has(normalizePubkey(agent.pubkey)), + ); + + return ( +
+
+

+ Existing agents +

+

+ Add an agent that is already running on another computer. No local + runtime is needed. +

+
+ + {isLoading ? ( +

+ Loading existing agents… +

+ ) : null} + + {available.length > 0 ? ( +
+ {available.map((agent) => ( + onToggleAgent(agent.pubkey)} + selected={selectedPubkeys.includes(agent.pubkey)} + /> + ))} +
+ ) : null} + + {inChannel.length > 0 ? ( +
+ {inChannel.map((agent) => ( + undefined} + selected={false} + /> + ))} +
+ ) : null} +
+ ); +} diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index d433829437d..61b12d46161 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1860,6 +1860,58 @@ test("empty channel shows intro actions", async ({ page }) => { ); }); +test("a client without runtimes can add an existing remote agent", async ({ + page, +}) => { + const remoteAgentPubkey = + "1234123412341234123412341234123412341234123412341234123412341234"; + await installMockBridge(page, { + acpRuntimesCatalog: [], + relayAgents: [ + { + pubkey: remoteAgentPubkey, + name: "PM Bot", + channels: ["general"], + channelIds: ["00000000-0000-4000-8000-000000000001"], + status: "online", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-random").click(); + await page.getByTestId("channel-intro-action-create-agent").click(); + + const existingAgent = page.getByTestId( + `add-existing-agent-${remoteAgentPubkey}`, + ); + await expect(existingAgent).toContainText("PM Bot"); + await expect(existingAgent).toContainText("Online"); + await existingAgent.click(); + + const submit = page + .getByTestId("add-channel-bot-dialog-footer") + .getByRole("button", { name: "Add agent" }); + await expect(submit).toBeEnabled(); + await submit.click(); + await expect(page.getByTestId("add-channel-bot-dialog")).toHaveCount(0); + + const commands = await readCommandPayloadLog(page); + expect( + commands.filter((entry) => entry.command === "create_managed_agent"), + ).toHaveLength(0); + expect(commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: "add_channel_members", + payload: expect.objectContaining({ + pubkeys: [remoteAgentPubkey], + role: "bot", + }), + }), + ]), + ); +}); + test("short channel with messages shows intro actions on open", async ({ page, }) => {