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({ setRole(event.target.value as Exclude) @@ -173,9 +175,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 ( + + + + Add agent + + Pick a runtime, adjust the default name if needed, and describe what + this agent should do in the channel. + + + +
+
+
+
Agent
+ + + + + event.preventDefault()} + > + { + setSelectedProviderId(value); + setHasEditedName(false); + }} + value={selectedProvider?.id ?? ""} + > + {providers.map((provider) => ( + + {provider.label} + + ))} + + + +
+ +
+ + { + setHasEditedName(true); + setName(event.target.value); + }} + spellCheck={false} + value={name} + /> +
+
+ +

+ The name defaults to the runtime, but you can edit it before adding + the agent. +

+ +
+ +