Add compact channel bot toolbar (#86)

This commit is contained in:
Wes
2026-03-17 10:03:04 -07:00
committed by GitHub
parent 661c004f5a
commit ee609c24a0
9 changed files with 927 additions and 58 deletions
+15 -25
View File
@@ -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<AppView, "settings">;
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<AppView>("home");
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
@@ -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() {
<ChatHeader
actions={
activeChannel ? (
<Button
aria-label="Manage channel"
data-testid="channel-management-trigger"
onClick={() => {
<ChannelMembersBar
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
onManageChannel={() => {
setIsChannelManagementOpen(true);
}}
size="icon"
type="button"
variant="outline"
>
<Settings2 className="h-4 w-4" />
</Button>
/>
) : null
}
channelType={activeChannel?.channelType}
@@ -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<ChannelRole, "owner">;
ensureRunning?: boolean;
};
export type AttachManagedAgentToChannelResult = {
agent: ManagedAgent;
membershipAdded: boolean;
restarted: boolean;
started: boolean;
};
export type EnsureChannelAgentPresetInput = {
provider: ChannelAgentProvider;
role?: Exclude<ChannelRole, "owner">;
ensureRunning?: boolean;
};
export type EnsureChannelAgentPresetResult =
AttachManagedAgentToChannelResult & {
created: boolean;
providerId: string;
};
export type CreateChannelManagedAgentInput = {
provider: ChannelAgentProvider;
name: string;
systemPrompt?: string;
role?: Exclude<ChannelRole, "owner">;
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<string>,
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<EnsureChannelAgentPresetResult> {
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<CreateChannelManagedAgentResult> {
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,
};
}
+135 -1
View File
@@ -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<typeof useQueryClient>,
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<EnsureChannelAgentPresetResult> => {
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<CreateChannelManagedAgentResult> => {
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<EnsureGooseInChannelResult> => {
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,
@@ -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<Exclude<ChannelRole, "owner">>("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({
<DialogTitle>Add agent to channel</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
@@ -110,7 +110,9 @@ export function AddAgentToChannelDialog({
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
disabled={channels.length === 0 || addMembersMutation.isPending}
disabled={
channels.length === 0 || attachAgentMutation.isPending
}
id="agent-channel-id"
onChange={(event) => setChannelId(event.target.value)}
value={channelId}
@@ -139,7 +141,7 @@ export function AddAgentToChannelDialog({
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
disabled={addMembersMutation.isPending}
disabled={attachAgentMutation.isPending}
id="agent-channel-role"
onChange={(event) =>
setRole(event.target.value as Exclude<ChannelRole, "owner">)
@@ -173,9 +175,9 @@ export function AddAgentToChannelDialog({
</p>
) : null}
{addMembersMutation.error instanceof Error ? (
{attachAgentMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{addMembersMutation.error.message}
{attachAgentMutation.error.message}
</p>
) : null}
</div>
@@ -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"}
</Button>
</div>
</div>
+20 -6
View File
@@ -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();
}
@@ -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 (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add agent</DialogTitle>
<DialogDescription>
Pick a runtime, adjust the default name if needed, and describe what
this agent should do in the channel.
</DialogDescription>
</DialogHeader>
<div className="space-y-3.5">
<div className="grid gap-3 sm:grid-cols-[max-content,minmax(0,1fr)] sm:items-end">
<div className="space-y-1.5">
<div className="text-sm font-medium">Agent</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-9 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-3 text-sm font-medium text-foreground shadow-none hover:bg-muted/70"
disabled={!canChooseProvider}
size="default"
type="button"
variant="ghost"
>
<span className="truncate">{providerTriggerLabel}</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-40"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuRadioGroup
onValueChange={(value) => {
setSelectedProviderId(value);
setHasEditedName(false);
}}
value={selectedProvider?.id ?? ""}
>
{providers.map((provider) => (
<DropdownMenuRadioItem
key={provider.id}
value={provider.id}
>
{provider.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="channel-bot-name">
Name
</label>
<Input
autoCapitalize="none"
autoCorrect="off"
disabled={createBotMutation.isPending}
id="channel-bot-name"
onChange={(event) => {
setHasEditedName(true);
setName(event.target.value);
}}
spellCheck={false}
value={name}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
The name defaults to the runtime, but you can edit it before adding
the agent.
</p>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="channel-bot-prompt">
Prompt
</label>
<Textarea
className="min-h-24"
disabled={createBotMutation.isPending}
id="channel-bot-prompt"
onChange={(event) => setPrompt(event.target.value)}
placeholder="What should this agent help with in the channel?"
value={prompt}
/>
<p className="text-xs text-muted-foreground">
Saved as the agent&apos;s system prompt override.
</p>
</div>
{providersErrorMessage ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{providersErrorMessage}
</p>
) : null}
{createBotMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{createBotMutation.error.message}
</p>
) : null}
</div>
<div className="flex justify-end gap-2">
<Button
onClick={() => handleOpenChange(false)}
size="sm"
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={!canSubmit}
onClick={() => void handleSubmit()}
size="sm"
type="button"
>
{createBotMutation.isPending ? "Adding..." : "Add agent"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -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 (
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded-full bg-muted/55 px-2.5 text-muted-foreground">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
<span className="relative top-px min-w-[1ch] text-[13px] font-medium leading-none text-muted-foreground/80 tabular-nums">
{loading ? "..." : count}
</span>
<span className="sr-only">{loading ? `Loading ${label}` : label}</span>
</div>
);
}
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 (
<React.Fragment>
<div className="flex items-center gap-2">
<CountStat
count={peopleCount}
icon={UserRound}
label="people"
loading={membersQuery.isLoading}
/>
<CountStat
count={botCount}
icon={Bot}
label="bots"
loading={membersQuery.isLoading}
/>
<Button
aria-label="Add agent"
className="h-9 w-9 rounded-full"
data-testid="channel-add-bot-trigger"
disabled={!canAddAgents}
onClick={() => {
setIsAddBotOpen(true);
}}
size="icon"
type="button"
variant="outline"
>
<Plus className="h-4 w-4" />
</Button>
<Button
aria-label="Manage channel"
className="h-9 w-9 rounded-full"
data-testid="channel-management-trigger"
onClick={onManageChannel}
size="icon"
type="button"
variant="outline"
>
<Settings2 className="h-4 w-4" />
</Button>
</div>
<AddChannelBotDialog
channelId={channel.id}
onOpenChange={setIsAddBotOpen}
open={isAddBotOpen}
providers={providers}
providersErrorMessage={dialogErrorMessage}
providersLoading={providersQuery.isLoading}
/>
</React.Fragment>
);
}
+7 -1
View File
@@ -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();
}
@@ -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();
}