mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): improve Agents page UX (#298)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -169,6 +169,15 @@ export function AgentsView() {
|
||||
[managedAgents],
|
||||
);
|
||||
const managedPresenceQuery = usePresenceQuery(managedPubkeyList);
|
||||
const channelsByPubkey = React.useMemo(() => {
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const ra of relayAgentsQuery.data ?? []) {
|
||||
if (ra.channels.length > 0) {
|
||||
map[ra.pubkey] = ra.channels;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [relayAgentsQuery.data]);
|
||||
|
||||
// Clear log selection if the agent was removed
|
||||
React.useEffect(() => {
|
||||
@@ -536,6 +545,7 @@ export function AgentsView() {
|
||||
actionErrorMessage={actionErrorMessage}
|
||||
actionNoticeMessage={actionNoticeMessage}
|
||||
agents={managedAgents}
|
||||
channelsByPubkey={channelsByPubkey}
|
||||
error={
|
||||
managedAgentsQuery.error instanceof Error
|
||||
? managedAgentsQuery.error
|
||||
|
||||
@@ -32,6 +32,7 @@ import { truncatePubkey } from "./agentUi";
|
||||
|
||||
export function ManagedAgentRow({
|
||||
agent,
|
||||
channelNames,
|
||||
isActionPending,
|
||||
isLogSelected,
|
||||
logContent,
|
||||
@@ -48,6 +49,7 @@ export function ManagedAgentRow({
|
||||
onToggleStartOnAppLaunch,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
channelNames: string[];
|
||||
isActionPending: boolean;
|
||||
isLogSelected: boolean;
|
||||
logContent: string | null;
|
||||
@@ -66,9 +68,7 @@ export function ManagedAgentRow({
|
||||
const isActive = agent.status === "running" || agent.status === "deployed";
|
||||
const isLocal = agent.backend.type === "local";
|
||||
const runtimeSource =
|
||||
agent.backend.type === "local"
|
||||
? `ACP ${agent.acpCommand}`
|
||||
: `Provider ${agent.backend.id}`;
|
||||
agent.backend.type === "provider" ? `Provider ${agent.backend.id}` : null;
|
||||
const personaLabel = agent.personaId
|
||||
? (personaLabelsById[agent.personaId] ?? null)
|
||||
: null;
|
||||
@@ -105,6 +105,7 @@ export function ManagedAgentRow({
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.8fr)_minmax(120px,0.8fr)_minmax(0,1.1fr)] lg:gap-4">
|
||||
<AgentSummary
|
||||
agent={agent}
|
||||
channelNames={channelNames}
|
||||
isExpandable
|
||||
isLogSelected={isLogSelected}
|
||||
personaLabel={personaLabel}
|
||||
@@ -123,6 +124,7 @@ export function ManagedAgentRow({
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.8fr)_minmax(120px,0.8fr)_minmax(0,1.1fr)] lg:gap-4">
|
||||
<AgentSummary
|
||||
agent={agent}
|
||||
channelNames={channelNames}
|
||||
isExpandable={false}
|
||||
isLogSelected={false}
|
||||
personaLabel={personaLabel}
|
||||
@@ -175,12 +177,14 @@ export function ManagedAgentRow({
|
||||
|
||||
function AgentSummary({
|
||||
agent,
|
||||
channelNames,
|
||||
isExpandable,
|
||||
isLogSelected,
|
||||
personaLabel,
|
||||
presenceStatus,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
channelNames: string[];
|
||||
isExpandable: boolean;
|
||||
isLogSelected: boolean;
|
||||
personaLabel: string | null;
|
||||
@@ -222,12 +226,19 @@ function AgentSummary({
|
||||
) : (
|
||||
<span>Remote deployment</span>
|
||||
)}
|
||||
{isExpandable ? (
|
||||
<span>
|
||||
{isLogSelected ? "Logs visible inline" : "Click to view logs"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{channelNames.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{channelNames.map((name) => (
|
||||
<span
|
||||
className="inline-flex rounded-md bg-muted/60 px-1.5 py-0.5 text-[11px] text-muted-foreground"
|
||||
key={name}
|
||||
>
|
||||
# {name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,7 +270,7 @@ function RuntimeBlock({
|
||||
runtimeSource,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
runtimeSource: string;
|
||||
runtimeSource: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1 lg:pt-0.5">
|
||||
@@ -269,10 +280,12 @@ function RuntimeBlock({
|
||||
<p className="truncate font-mono text-xs text-foreground">
|
||||
{agent.agentCommand}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{runtimeSource}</span>
|
||||
{agent.model ? <span>{agent.model}</span> : null}
|
||||
</div>
|
||||
{runtimeSource || agent.model ? (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{runtimeSource ? <span>{runtimeSource}</span> : null}
|
||||
{agent.model ? <span>{agent.model}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export function ManagedAgentsSection({
|
||||
actionErrorMessage,
|
||||
actionNoticeMessage,
|
||||
agents,
|
||||
channelsByPubkey,
|
||||
error,
|
||||
isActionPending,
|
||||
isLoading,
|
||||
@@ -28,6 +29,7 @@ export function ManagedAgentsSection({
|
||||
actionErrorMessage: string | null;
|
||||
actionNoticeMessage: string | null;
|
||||
agents: ManagedAgent[];
|
||||
channelsByPubkey: Record<string, string[]>;
|
||||
error: Error | null;
|
||||
isActionPending: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -97,6 +99,7 @@ export function ManagedAgentsSection({
|
||||
{agents.map((agent) => (
|
||||
<ManagedAgentRow
|
||||
agent={agent}
|
||||
channelNames={channelsByPubkey[agent.pubkey] ?? []}
|
||||
isActionPending={isActionPending}
|
||||
isLogSelected={selectedLogAgentPubkey === agent.pubkey}
|
||||
key={agent.pubkey}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { ChevronDown, ChevronRight, Search } from "lucide-react";
|
||||
|
||||
import type { RelayAgent } from "@/shared/api/types";
|
||||
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { truncatePubkey } from "./agentUi";
|
||||
|
||||
export function RelayDirectorySection({
|
||||
@@ -14,125 +17,131 @@ export function RelayDirectorySection({
|
||||
managedPubkeys: Set<string>;
|
||||
relayAgents: RelayAgent[];
|
||||
}) {
|
||||
const sortedAgents = [...relayAgents].sort((left, right) => {
|
||||
const leftManaged = managedPubkeys.has(left.pubkey);
|
||||
const rightManaged = managedPubkeys.has(right.pubkey);
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
|
||||
if (leftManaged !== rightManaged) {
|
||||
return leftManaged ? -1 : 1;
|
||||
}
|
||||
// Only show agents that are NOT managed locally — those are already in the
|
||||
// managed agents section above.
|
||||
const otherAgents = React.useMemo(
|
||||
() => relayAgents.filter((agent) => !managedPubkeys.has(agent.pubkey)),
|
||||
[relayAgents, managedPubkeys],
|
||||
);
|
||||
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
const filteredAgents = React.useMemo(() => {
|
||||
if (!searchQuery.trim()) return otherAgents;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return otherAgents.filter(
|
||||
(agent) =>
|
||||
agent.name.toLowerCase().includes(query) ||
|
||||
agent.agentType.toLowerCase().includes(query) ||
|
||||
agent.channels.some((ch) => ch.toLowerCase().includes(query)),
|
||||
);
|
||||
}, [otherAgents, searchQuery]);
|
||||
|
||||
const sortedAgents = React.useMemo(
|
||||
() =>
|
||||
[...filteredAgents].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
),
|
||||
[filteredAgents],
|
||||
);
|
||||
|
||||
if (isLoading || otherAgents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<section className="space-y-3">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
type="button"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<h3 className="text-sm font-semibold tracking-tight">
|
||||
Relay directory
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bot and agent identities visible to the current desktop user.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({otherAgents.length} other agent{otherAgents.length !== 1 ? "s" : ""}
|
||||
)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm">
|
||||
<div className="grid gap-0">
|
||||
{["directory-1", "directory-2", "directory-3"].map((key) => (
|
||||
<div
|
||||
className="grid grid-cols-[minmax(0,2fr)_auto_minmax(0,1fr)_minmax(0,1.6fr)_auto] items-center gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
|
||||
key={key}
|
||||
>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-5 w-12 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search by name, type, or channel..."
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && relayAgents.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
No relay-visible agents yet
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Start one of your local harnesses or join an existing bot to a
|
||||
channel and it will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && relayAgents.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="w-full border-collapse text-left text-sm"
|
||||
data-testid="relay-directory-table"
|
||||
>
|
||||
<thead className="bg-muted/35 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Agent</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Channels</th>
|
||||
<th className="px-4 py-3">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedAgents.map((agent) => {
|
||||
const isManagedLocally = managedPubkeys.has(agent.pubkey);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="border-b border-border/60 last:border-b-0"
|
||||
key={agent.pubkey}
|
||||
>
|
||||
<td className="min-w-[16rem] px-4 py-3 align-top">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{agent.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{truncatePubkey(agent.pubkey)}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<PresenceBadge
|
||||
className="px-2.5 py-0.5 text-[11px]"
|
||||
status={agent.status}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top text-muted-foreground">
|
||||
{agent.agentType || "Unknown"}
|
||||
</td>
|
||||
<td className="max-w-[20rem] px-4 py-3 align-top text-muted-foreground">
|
||||
<span className="block truncate">
|
||||
{agent.channels.length > 0
|
||||
? agent.channels.join(", ")
|
||||
: "No visible channel memberships"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<span className="inline-flex rounded-full border border-border/70 bg-background/70 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
{isManagedLocally ? "Local" : "Relay"}
|
||||
</span>
|
||||
</td>
|
||||
{sortedAgents.length === 0 ? (
|
||||
<p className="px-1 py-3 text-sm text-muted-foreground">
|
||||
{searchQuery.trim()
|
||||
? "No agents match your search."
|
||||
: "No other agents on this relay."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="w-full border-collapse text-left text-sm"
|
||||
data-testid="relay-directory-table"
|
||||
>
|
||||
<thead className="bg-muted/35 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Agent</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Channels</th>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedAgents.map((agent) => (
|
||||
<tr
|
||||
className="border-b border-border/60 last:border-b-0"
|
||||
key={agent.pubkey}
|
||||
>
|
||||
<td className="min-w-[16rem] px-4 py-3 align-top">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{agent.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{truncatePubkey(agent.pubkey)}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<PresenceBadge
|
||||
className="px-2.5 py-0.5 text-[11px]"
|
||||
status={agent.status}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top text-muted-foreground">
|
||||
{agent.agentType || "Unknown"}
|
||||
</td>
|
||||
<td className="max-w-[20rem] px-4 py-3 align-top text-muted-foreground">
|
||||
<span className="block truncate">
|
||||
{agent.channels.length > 0
|
||||
? agent.channels.join(", ")
|
||||
: "No visible channel memberships"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
|
||||
@@ -152,7 +152,7 @@ function MemberActionsMenu({
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-all group-hover:opacity-100 hover:bg-muted hover:text-foreground data-[state=open]:opacity-100"
|
||||
className="invisible flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground group-hover:visible hover:bg-muted hover:text-foreground data-[state=open]:visible"
|
||||
data-testid={`sidebar-member-menu-${member.pubkey}`}
|
||||
type="button"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user