diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 4cac69eada..a9acbe0b22 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -1,6 +1,5 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
-import { Settings2 } from "lucide-react";
import { ChannelPane } from "@/app/ChannelPane";
import { AgentsView } from "@/features/agents/ui/AgentsView";
@@ -12,6 +11,7 @@ import {
useSelectedChannel,
} from "@/features/channels/hooks";
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
+import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet";
import { useHomeFeedQuery } from "@/features/home/hooks";
import { HomeView } from "@/features/home/ui/HomeView";
@@ -48,7 +48,6 @@ import { relayClient } from "@/shared/api/relayClient";
import { getEventById, joinChannel } from "@/shared/api/tauri";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
-import { Button } from "@/shared/ui/button";
import {
SidebarInset,
SidebarProvider,
@@ -58,18 +57,6 @@ import {
type AppView = "home" | "channel" | "settings" | "agents";
type MainView = Exclude;
-function createSearchAnchorEvent(hit: SearchHit): RelayEvent {
- return {
- id: hit.eventId,
- pubkey: hit.pubkey,
- created_at: hit.createdAt,
- kind: hit.kind,
- tags: [["h", hit.channelId]],
- content: hit.content,
- sig: "",
- };
-}
-
export function AppShell() {
const [selectedView, setSelectedView] = React.useState("home");
const [settingsSection, setSettingsSection] = React.useState(
@@ -304,7 +291,15 @@ export function AppShell() {
(hit: SearchHit) => {
setSearchAnchor(hit);
setSearchAnchorChannelId(hit.channelId);
- setSearchAnchorEvent(createSearchAnchorEvent(hit));
+ setSearchAnchorEvent({
+ id: hit.eventId,
+ pubkey: hit.pubkey,
+ created_at: hit.createdAt,
+ kind: hit.kind,
+ tags: [["h", hit.channelId]],
+ content: hit.content,
+ sig: "",
+ });
void handleOpenChannel(hit.channelId);
void getEventById(hit.eventId)
@@ -549,18 +544,13 @@ export function AppShell() {
{
+ {
setIsChannelManagementOpen(true);
}}
- size="icon"
- type="button"
- variant="outline"
- >
-
-
+ />
) : null
}
channelType={activeChannel?.channelType}
diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts
new file mode 100644
index 0000000000..ac65011865
--- /dev/null
+++ b/desktop/src/features/agents/channelAgents.ts
@@ -0,0 +1,273 @@
+import { DEFAULT_MANAGED_AGENT_SCOPES } from "@/features/tokens/lib/scopeOptions";
+import {
+ addChannelMembers,
+ createManagedAgent,
+ getChannelMembers,
+ listManagedAgents,
+ startManagedAgent,
+ stopManagedAgent,
+} from "@/shared/api/tauri";
+import type {
+ AcpProvider,
+ ChannelRole,
+ ManagedAgent,
+} from "@/shared/api/types";
+
+type ChannelAgentProvider = Pick<
+ AcpProvider,
+ "id" | "label" | "command" | "defaultArgs"
+>;
+
+export type AttachManagedAgentToChannelInput = {
+ agent: ManagedAgent;
+ role?: Exclude;
+ ensureRunning?: boolean;
+};
+
+export type AttachManagedAgentToChannelResult = {
+ agent: ManagedAgent;
+ membershipAdded: boolean;
+ restarted: boolean;
+ started: boolean;
+};
+
+export type EnsureChannelAgentPresetInput = {
+ provider: ChannelAgentProvider;
+ role?: Exclude;
+ ensureRunning?: boolean;
+};
+
+export type EnsureChannelAgentPresetResult =
+ AttachManagedAgentToChannelResult & {
+ created: boolean;
+ providerId: string;
+ };
+
+export type CreateChannelManagedAgentInput = {
+ provider: ChannelAgentProvider;
+ name: string;
+ systemPrompt?: string;
+ role?: Exclude;
+ ensureRunning?: boolean;
+};
+
+export type CreateChannelManagedAgentResult =
+ AttachManagedAgentToChannelResult & {
+ created: true;
+ providerId: string;
+ };
+
+function normalizePubkey(pubkey: string) {
+ return pubkey.trim().toLowerCase();
+}
+
+function commandBasename(command: string) {
+ const normalized = command.trim().replace(/\\/g, "/");
+ const parts = normalized.split("/");
+ return parts[parts.length - 1] ?? normalized;
+}
+
+function commandsMatch(left: string, right: string) {
+ return (
+ commandBasename(left).toLowerCase() === commandBasename(right).toLowerCase()
+ );
+}
+
+function parseTimestamp(value: string | null | undefined) {
+ if (!value) {
+ return 0;
+ }
+
+ const timestamp = Date.parse(value);
+ return Number.isNaN(timestamp) ? 0 : timestamp;
+}
+
+export async function attachManagedAgentToChannel(
+ channelId: string,
+ input: AttachManagedAgentToChannelInput,
+) {
+ const role = input.role ?? "bot";
+ const ensureRunning = input.ensureRunning ?? true;
+ const members = await getChannelMembers(channelId);
+ const membershipAdded = !members.some(
+ (member) =>
+ normalizePubkey(member.pubkey) === normalizePubkey(input.agent.pubkey),
+ );
+
+ await addChannelMembers({
+ channelId,
+ pubkeys: [input.agent.pubkey],
+ role,
+ });
+
+ let agent = input.agent;
+ let started = false;
+ let restarted = false;
+
+ if (ensureRunning) {
+ if (membershipAdded && input.agent.status === "running") {
+ await stopManagedAgent(input.agent.pubkey);
+ agent = await startManagedAgent(input.agent.pubkey);
+ restarted = true;
+ } else if (input.agent.status !== "running") {
+ agent = await startManagedAgent(input.agent.pubkey);
+ started = true;
+ }
+ }
+
+ return {
+ agent,
+ membershipAdded,
+ restarted,
+ started,
+ } satisfies AttachManagedAgentToChannelResult;
+}
+
+function pickPreferredManagedAgent(agents: ManagedAgent[]) {
+ return [...agents].sort((left, right) => {
+ const leftRunningScore = left.status === "running" ? 1 : 0;
+ const rightRunningScore = right.status === "running" ? 1 : 0;
+ if (leftRunningScore !== rightRunningScore) {
+ return rightRunningScore - leftRunningScore;
+ }
+
+ const leftTokenScore = left.hasApiToken ? 1 : 0;
+ const rightTokenScore = right.hasApiToken ? 1 : 0;
+ if (leftTokenScore !== rightTokenScore) {
+ return rightTokenScore - leftTokenScore;
+ }
+
+ return parseTimestamp(right.updatedAt) - parseTimestamp(left.updatedAt);
+ })[0];
+}
+
+function buildChannelAgentName(providerId: string, providerLabel: string) {
+ const normalizedProviderId = providerId.trim().toLowerCase();
+ if (normalizedProviderId.length > 0) {
+ return normalizedProviderId;
+ }
+
+ return providerLabel.trim().toLowerCase() || "agent";
+}
+
+function pickPreferredChannelPresetAgent(
+ agents: ManagedAgent[],
+ memberPubkeys: ReadonlySet,
+ providerCommand: string,
+ expectedName: string,
+) {
+ const inChannelAgent = pickPreferredManagedAgent(
+ agents.filter(
+ (agent) =>
+ commandsMatch(agent.agentCommand, providerCommand) &&
+ memberPubkeys.has(normalizePubkey(agent.pubkey)),
+ ),
+ );
+ if (inChannelAgent) {
+ return inChannelAgent;
+ }
+
+ return pickPreferredManagedAgent(
+ agents.filter(
+ (agent) =>
+ commandsMatch(agent.agentCommand, providerCommand) &&
+ agent.name.trim().toLowerCase() === expectedName.trim().toLowerCase(),
+ ),
+ );
+}
+
+export async function ensureChannelAgentPresetInChannel(
+ channelId: string,
+ input: EnsureChannelAgentPresetInput,
+): Promise {
+ const role = input.role ?? "bot";
+ const ensureRunning = input.ensureRunning ?? true;
+ const members = await getChannelMembers(channelId);
+ const memberPubkeys = new Set(
+ members.map((member) => normalizePubkey(member.pubkey)),
+ );
+ const managedAgents = await listManagedAgents();
+ const expectedName = buildChannelAgentName(
+ input.provider.id,
+ input.provider.label,
+ );
+ const existingAgent = pickPreferredChannelPresetAgent(
+ managedAgents,
+ memberPubkeys,
+ input.provider.command,
+ expectedName,
+ );
+
+ if (existingAgent) {
+ const attached = await attachManagedAgentToChannel(channelId, {
+ agent: existingAgent,
+ role,
+ ensureRunning,
+ });
+ return {
+ ...attached,
+ created: false,
+ providerId: input.provider.id,
+ };
+ }
+
+ const created = await createManagedAgent({
+ name: expectedName,
+ acpCommand: "sprout-acp",
+ agentCommand: input.provider.command,
+ agentArgs: input.provider.defaultArgs,
+ mcpCommand: "sprout-mcp-server",
+ mintToken: true,
+ tokenName: `${expectedName} agent`,
+ tokenScopes: DEFAULT_MANAGED_AGENT_SCOPES,
+ spawnAfterCreate: false,
+ });
+ const attached = await attachManagedAgentToChannel(channelId, {
+ agent: created.agent,
+ role,
+ ensureRunning,
+ });
+
+ return {
+ ...attached,
+ created: true,
+ providerId: input.provider.id,
+ };
+}
+
+export async function createChannelManagedAgent(
+ channelId: string,
+ input: CreateChannelManagedAgentInput,
+): Promise {
+ const role = input.role ?? "bot";
+ const ensureRunning = input.ensureRunning ?? true;
+ const trimmedName = input.name.trim();
+
+ if (trimmedName.length === 0) {
+ throw new Error("Agent name is required.");
+ }
+
+ const created = await createManagedAgent({
+ name: trimmedName,
+ acpCommand: "sprout-acp",
+ agentCommand: input.provider.command,
+ agentArgs: input.provider.defaultArgs,
+ mcpCommand: "sprout-mcp-server",
+ mintToken: true,
+ tokenName: `${trimmedName} agent`,
+ tokenScopes: DEFAULT_MANAGED_AGENT_SCOPES,
+ systemPrompt: input.systemPrompt?.trim() || undefined,
+ spawnAfterCreate: false,
+ });
+ const attached = await attachManagedAgentToChannel(channelId, {
+ agent: created.agent,
+ role,
+ ensureRunning,
+ });
+
+ return {
+ ...attached,
+ created: true,
+ providerId: input.provider.id,
+ };
+}
diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts
index a0cfec35ca..62d2766761 100644
--- a/desktop/src/features/agents/hooks.ts
+++ b/desktop/src/features/agents/hooks.ts
@@ -1,8 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
- createManagedAgent,
+ attachManagedAgentToChannel,
+ createChannelManagedAgent,
+ ensureChannelAgentPresetInChannel,
+} from "@/features/agents/channelAgents";
+import { channelsQueryKey } from "@/features/channels/hooks";
+import {
deleteManagedAgent,
+ createManagedAgent,
discoverAcpProviders,
discoverManagedAgentPrereqs,
getManagedAgentLog,
@@ -17,12 +23,50 @@ import type {
ManagedAgent,
MintManagedAgentTokenInput,
} from "@/shared/api/types";
+import type {
+ AttachManagedAgentToChannelInput,
+ AttachManagedAgentToChannelResult,
+ CreateChannelManagedAgentInput,
+ CreateChannelManagedAgentResult,
+ EnsureChannelAgentPresetInput,
+ EnsureChannelAgentPresetResult,
+} from "@/features/agents/channelAgents";
+export type {
+ AttachManagedAgentToChannelInput,
+ AttachManagedAgentToChannelResult,
+ CreateChannelManagedAgentInput,
+ CreateChannelManagedAgentResult,
+ EnsureChannelAgentPresetInput,
+ EnsureChannelAgentPresetResult,
+} from "@/features/agents/channelAgents";
export const relayAgentsQueryKey = ["relay-agents"] as const;
export const managedAgentsQueryKey = ["managed-agents"] as const;
export const acpProvidersQueryKey = ["acp-providers"] as const;
export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const;
+export type EnsureGooseInChannelResult = AttachManagedAgentToChannelResult & {
+ created: boolean;
+};
+
+async function invalidateAgentQueries(
+ queryClient: ReturnType,
+ channelId: string | null,
+) {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
+ queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }),
+ queryClient.invalidateQueries({ queryKey: channelsQueryKey }),
+ ...(channelId
+ ? [
+ queryClient.invalidateQueries({
+ queryKey: ["channels", channelId, "members"],
+ }),
+ ]
+ : []),
+ ]);
+}
+
export function useAcpProvidersQuery() {
return useQuery({
queryKey: acpProvidersQueryKey,
@@ -136,6 +180,96 @@ export function useMintManagedAgentTokenMutation() {
});
}
+export function useAttachManagedAgentToChannelMutation(
+ channelId: string | null,
+) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (input: AttachManagedAgentToChannelInput) => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ return attachManagedAgentToChannel(channelId, input);
+ },
+ onSettled: async () => {
+ await invalidateAgentQueries(queryClient, channelId);
+ },
+ });
+}
+
+export function useEnsureChannelAgentPresetMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (
+ input: EnsureChannelAgentPresetInput,
+ ): Promise => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ return ensureChannelAgentPresetInChannel(channelId, input);
+ },
+ onSettled: async () => {
+ await invalidateAgentQueries(queryClient, channelId);
+ },
+ });
+}
+
+export function useCreateChannelManagedAgentMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (
+ input: CreateChannelManagedAgentInput,
+ ): Promise => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ return createChannelManagedAgent(channelId, input);
+ },
+ onSettled: async () => {
+ await invalidateAgentQueries(queryClient, channelId);
+ },
+ });
+}
+
+export function useEnsureGooseInChannelMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (): Promise => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ const attached = await ensureChannelAgentPresetInChannel(channelId, {
+ provider: {
+ id: "goose",
+ label: "Goose",
+ command: "goose",
+ defaultArgs: ["acp"],
+ },
+ role: "bot",
+ });
+
+ return {
+ agent: attached.agent,
+ membershipAdded: attached.membershipAdded,
+ restarted: attached.restarted,
+ started: attached.started,
+ created: attached.created,
+ };
+ },
+ onSettled: async () => {
+ await invalidateAgentQueries(queryClient, channelId);
+ },
+ });
+}
+
export function useManagedAgentLogQuery(
pubkey: string | null,
lineCount = 120,
diff --git a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx
index 4177f958ee..96dcd88f1d 100644
--- a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx
+++ b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx
@@ -1,9 +1,10 @@
import * as React from "react";
import {
- useAddChannelMembersMutation,
- useChannelsQuery,
-} from "@/features/channels/hooks";
+ type AttachManagedAgentToChannelResult,
+ useAttachManagedAgentToChannelMutation,
+} from "@/features/agents/hooks";
+import { useChannelsQuery } from "@/features/channels/hooks";
import type { Channel, ChannelRole, ManagedAgent } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
@@ -23,13 +24,18 @@ export function AddAgentToChannelDialog({
}: {
agent: ManagedAgent | null;
open: boolean;
- onAdded: (channel: Channel, agent: ManagedAgent) => void;
+ onAdded: (
+ channel: Channel,
+ result: AttachManagedAgentToChannelResult,
+ ) => void;
onOpenChange: (open: boolean) => void;
}) {
const channelsQuery = useChannelsQuery();
const [channelId, setChannelId] = React.useState("");
const [role, setRole] = React.useState>("bot");
- const addMembersMutation = useAddChannelMembersMutation(channelId || null);
+ const attachAgentMutation = useAttachManagedAgentToChannelMutation(
+ channelId || null,
+ );
const channels = React.useMemo(
() =>
(channelsQuery.data ?? []).filter(
@@ -41,7 +47,7 @@ export function AddAgentToChannelDialog({
function reset() {
setChannelId("");
setRole("bot");
- addMembersMutation.reset();
+ attachAgentMutation.reset();
}
function handleOpenChange(next: boolean) {
@@ -71,19 +77,12 @@ export function AddAgentToChannelDialog({
}
try {
- const result = await addMembersMutation.mutateAsync({
- pubkeys: [agent.pubkey],
+ const result = await attachAgentMutation.mutateAsync({
+ agent,
role,
});
- const membershipError = result.errors.find(
- (error) => error.pubkey === agent.pubkey,
- );
-
- if (membershipError) {
- throw new Error(membershipError.error);
- }
- onAdded(selectedChannel, agent);
+ onAdded(selectedChannel, result);
handleOpenChange(false);
} catch {
// React Query stores the error; keep the dialog open and render it inline.
@@ -98,8 +97,9 @@ export function AddAgentToChannelDialog({
Add agent to channel
Add {agent?.name ?? "this agent"} to a channel so desktop chat can
- `@mention` it. If the harness is already running, restart it after
- adding membership so it subscribes to the new channel.
+ `@mention` it. Running agents are restarted automatically when
+ they join a new channel so the harness picks up the new
+ subscription immediately.
@@ -110,7 +110,9 @@ export function AddAgentToChannelDialog({
) : null}
- {addMembersMutation.error instanceof Error ? (
+ {attachAgentMutation.error instanceof Error ? (
- {addMembersMutation.error.message}
+ {attachAgentMutation.error.message}
) : null}
@@ -194,13 +196,13 @@ export function AddAgentToChannelDialog({
!agent ||
!selectedChannel ||
channelsQuery.isLoading ||
- addMembersMutation.isPending
+ attachAgentMutation.isPending
}
onClick={() => void handleSubmit()}
size="sm"
type="button"
>
- {addMembersMutation.isPending ? "Adding..." : "Add to channel"}
+ {attachAgentMutation.isPending ? "Adding..." : "Add to channel"}
diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx
index e8f3833cd4..77eb89457f 100644
--- a/desktop/src/features/agents/ui/AgentsView.tsx
+++ b/desktop/src/features/agents/ui/AgentsView.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import {
+ type AttachManagedAgentToChannelResult,
useDeleteManagedAgentMutation,
useManagedAgentLogQuery,
useManagedAgentsQuery,
@@ -143,13 +144,26 @@ export function AgentsView() {
}
}
- function handleAddedToChannel(channel: Channel, agent: ManagedAgent) {
+ function handleAddedToChannel(
+ channel: Channel,
+ result: AttachManagedAgentToChannelResult,
+ ) {
setActionErrorMessage(null);
- setActionNoticeMessage(
- agent.status === "running"
- ? `Added ${agent.name} to ${channel.name}. Restart the agent to subscribe to the new channel.`
- : `Added ${agent.name} to ${channel.name}.`,
- );
+ setActionNoticeMessage(() => {
+ if (result.restarted) {
+ return `Added ${result.agent.name} to ${channel.name} and restarted it so the new channel subscription is live.`;
+ }
+
+ if (result.started) {
+ return `Added ${result.agent.name} to ${channel.name} and spawned it.`;
+ }
+
+ if (result.membershipAdded) {
+ return `Added ${result.agent.name} to ${channel.name}.`;
+ }
+
+ return `${result.agent.name} is already in ${channel.name}.`;
+ });
void managedAgentsQuery.refetch();
void relayAgentsQuery.refetch();
}
diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx
new file mode 100644
index 0000000000..f144115ba3
--- /dev/null
+++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx
@@ -0,0 +1,265 @@
+import { ChevronDown } from "lucide-react";
+import * as React from "react";
+
+import {
+ useCreateChannelManagedAgentMutation,
+ type CreateChannelManagedAgentResult,
+} from "@/features/agents/hooks";
+import type { AcpProvider } from "@/shared/api/types";
+import { Button } from "@/shared/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/shared/ui/dropdown-menu";
+import { Input } from "@/shared/ui/input";
+import { Textarea } from "@/shared/ui/textarea";
+
+type AddChannelBotDialogProps = {
+ channelId: string | null;
+ open: boolean;
+ providers: AcpProvider[];
+ providersErrorMessage?: string | null;
+ providersLoading?: boolean;
+ onAdded?: (result: CreateChannelManagedAgentResult) => void;
+ onOpenChange: (open: boolean) => void;
+};
+
+function defaultBotName(provider: AcpProvider | null) {
+ if (!provider) {
+ return "";
+ }
+
+ const normalizedId = provider.id.trim().toLowerCase();
+ if (normalizedId.length > 0) {
+ return normalizedId;
+ }
+
+ return provider.label.trim().toLowerCase() || "agent";
+}
+
+export function AddChannelBotDialog({
+ channelId,
+ open,
+ providers,
+ providersErrorMessage,
+ providersLoading = false,
+ onAdded,
+ onOpenChange,
+}: AddChannelBotDialogProps) {
+ const createBotMutation = useCreateChannelManagedAgentMutation(channelId);
+ const [selectedProviderId, setSelectedProviderId] = React.useState("");
+ const [name, setName] = React.useState("");
+ const [prompt, setPrompt] = React.useState("");
+ const [hasEditedName, setHasEditedName] = React.useState(false);
+
+ const selectedProvider = React.useMemo(
+ () =>
+ providers.find((provider) => provider.id === selectedProviderId) ??
+ providers[0] ??
+ null,
+ [providers, selectedProviderId],
+ );
+
+ React.useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ if (!selectedProviderId && providers[0]) {
+ setSelectedProviderId(providers[0].id);
+ }
+ }, [open, providers, selectedProviderId]);
+
+ React.useEffect(() => {
+ if (!selectedProvider || hasEditedName) {
+ return;
+ }
+
+ setName(defaultBotName(selectedProvider));
+ }, [hasEditedName, selectedProvider]);
+
+ function reset() {
+ setSelectedProviderId(providers[0]?.id ?? "");
+ setName(providers[0] ? defaultBotName(providers[0]) : "");
+ setPrompt("");
+ setHasEditedName(false);
+ createBotMutation.reset();
+ }
+
+ function handleOpenChange(next: boolean) {
+ if (!next) {
+ reset();
+ }
+
+ onOpenChange(next);
+ }
+
+ async function handleSubmit() {
+ if (!selectedProvider) {
+ return;
+ }
+
+ try {
+ const result = await createBotMutation.mutateAsync({
+ provider: selectedProvider,
+ name,
+ systemPrompt: prompt,
+ role: "bot",
+ });
+ onAdded?.(result);
+ handleOpenChange(false);
+ } catch {
+ // The mutation error is rendered inline.
+ }
+ }
+
+ const canSubmit =
+ selectedProvider !== null &&
+ name.trim().length > 0 &&
+ !providersLoading &&
+ !createBotMutation.isPending;
+ const canChooseProvider =
+ providers.length > 0 && !providersLoading && !createBotMutation.isPending;
+ const providerTriggerLabel = providersLoading
+ ? "Loading runtimes..."
+ : (selectedProvider?.label ?? "No runtimes found");
+
+ return (
+
+ );
+}
diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx
new file mode 100644
index 0000000000..53c72fe4de
--- /dev/null
+++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx
@@ -0,0 +1,179 @@
+import { Bot, Plus, Settings2, UserRound } from "lucide-react";
+import * as React from "react";
+
+import {
+ useAcpProvidersQuery,
+ useManagedAgentsQuery,
+ useRelayAgentsQuery,
+} from "@/features/agents/hooks";
+import { useChannelMembersQuery } from "@/features/channels/hooks";
+import type { Channel } from "@/shared/api/types";
+import { Button } from "@/shared/ui/button";
+import { AddChannelBotDialog } from "./AddChannelBotDialog";
+
+type ChannelMembersBarProps = {
+ channel: Channel;
+ currentPubkey?: string;
+ onManageChannel: () => void;
+};
+
+function normalizePubkey(pubkey: string) {
+ return pubkey.trim().toLowerCase();
+}
+
+function CountStat({
+ count,
+ icon: Icon,
+ label,
+ loading,
+}: {
+ count: number;
+ icon: React.ComponentType<{ className?: string }>;
+ label: string;
+ loading?: boolean;
+}) {
+ return (
+
+
+
+ {loading ? "..." : count}
+
+ {loading ? `Loading ${label}` : label}
+
+ );
+}
+
+export function ChannelMembersBar({
+ channel,
+ currentPubkey,
+ onManageChannel,
+}: ChannelMembersBarProps) {
+ const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
+ const membersQuery = useChannelMembersQuery(channel.id);
+ const providersQuery = useAcpProvidersQuery();
+ const managedAgentsQuery = useManagedAgentsQuery();
+ const relayAgentsQuery = useRelayAgentsQuery();
+ const members = membersQuery.data ?? [];
+ const providers = React.useMemo(
+ () =>
+ [...(providersQuery.data ?? [])].sort((left, right) => {
+ const leftPriority = left.id === "goose" ? 0 : 1;
+ const rightPriority = right.id === "goose" ? 0 : 1;
+ if (leftPriority !== rightPriority) {
+ return leftPriority - rightPriority;
+ }
+
+ return left.label.localeCompare(right.label);
+ }),
+ [providersQuery.data],
+ );
+ const managedAgents = managedAgentsQuery.data ?? [];
+ const relayAgents = relayAgentsQuery.data ?? [];
+ const normalizedCurrentPubkey = currentPubkey
+ ? normalizePubkey(currentPubkey)
+ : null;
+ const selfMember =
+ members.find(
+ (member) => normalizePubkey(member.pubkey) === normalizedCurrentPubkey,
+ ) ?? null;
+ const canAddAgents =
+ channel.channelType !== "dm" &&
+ channel.archivedAt === null &&
+ (selfMember?.role === "owner" || selfMember?.role === "admin");
+ const managedAgentPubkeys = React.useMemo(
+ () => new Set(managedAgents.map((agent) => normalizePubkey(agent.pubkey))),
+ [managedAgents],
+ );
+ const relayAgentPubkeys = React.useMemo(
+ () => new Set(relayAgents.map((agent) => normalizePubkey(agent.pubkey))),
+ [relayAgents],
+ );
+ const peopleCount = React.useMemo(
+ () =>
+ members.filter((member) => {
+ const normalizedPubkey = normalizePubkey(member.pubkey);
+ return (
+ member.role !== "bot" &&
+ !managedAgentPubkeys.has(normalizedPubkey) &&
+ !relayAgentPubkeys.has(normalizedPubkey)
+ );
+ }).length,
+ [managedAgentPubkeys, members, relayAgentPubkeys],
+ );
+ const botCount = members.length - peopleCount;
+ const previousChannelIdRef = React.useRef(channel.id);
+
+ React.useEffect(() => {
+ if (previousChannelIdRef.current === channel.id) {
+ return;
+ }
+
+ previousChannelIdRef.current = channel.id;
+ setIsAddBotOpen(false);
+ }, [channel.id]);
+
+ const dialogErrorMessage =
+ providersQuery.error instanceof Error
+ ? providersQuery.error.message
+ : managedAgentsQuery.error instanceof Error
+ ? managedAgentsQuery.error.message
+ : relayAgentsQuery.error instanceof Error
+ ? relayAgentsQuery.error.message
+ : null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx
index b3a5222afe..2dc65470a2 100644
--- a/desktop/src/features/chat/ui/ChatHeader.tsx
+++ b/desktop/src/features/chat/ui/ChatHeader.tsx
@@ -46,7 +46,13 @@ function ChannelIcon({
function handlePointerDown(e: React.PointerEvent) {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
- if (target.closest('button, a, input, [role="button"]')) return;
+ if (
+ target.closest(
+ 'button, a, input, textarea, select, [role="button"], [role="textbox"], [contenteditable="true"]',
+ )
+ ) {
+ return;
+ }
e.preventDefault();
getCurrentWindow().startDragging();
}
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 4a4b036058..ecb5c0ca73 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -400,7 +400,13 @@ export function AppSidebar({
function handleDragPointerDown(e: React.PointerEvent) {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
- if (target.closest('button, a, input, [role="button"]')) return;
+ if (
+ target.closest(
+ 'button, a, input, textarea, select, [role="button"], [role="textbox"], [contenteditable="true"]',
+ )
+ ) {
+ return;
+ }
e.preventDefault();
getCurrentWindow().startDragging();
}