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 (
+
+
+
+
+ {agent.name}
+
+
+ {inChannel ? "Already in this channel" : statusLabel(agent.status)}
+
+
+ {inChannel ? (
+
+
+ In channel
+
+ ) : (
+
+ {selected ? : null}
+
+ )}
+
+ );
+}
+
+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,
}) => {