mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add compact channel bot toolbar (#86)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user