fix: UI fixes for system message avatars and agents page (#749)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-26 10:46:34 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6d50090c01
commit 8a468a9876
13 changed files with 118 additions and 368 deletions
+24 -9
View File
@@ -136,10 +136,11 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
.await
.unwrap_or_default();
let counts = count_members_by_channel(&members_events);
let membership = collect_members_by_channel(&members_events);
for channel in &mut channels {
if let Some(count) = counts.get(&channel.id) {
channel.member_count = *count;
if let Some(info) = membership.get(&channel.id) {
channel.member_count = info.count;
channel.member_pubkeys = info.pubkeys.clone();
}
}
}
@@ -147,12 +148,19 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
Ok(channels)
}
/// Build a `channel_id → unique-member-count` map from a batch of kind:39002
/// events. Events without a `d` tag are skipped; member dedupe is delegated to
struct ChannelMembership {
count: i64,
pubkeys: Vec<String>,
}
/// Build a `channel_id → membership` map from a batch of kind:39002 events.
/// Events without a `d` tag are skipped; member dedupe is delegated to
/// [`nostr_convert::channel_members_from_event`] so the parsing rules match the
/// per-channel `get_channel_members` path.
fn count_members_by_channel(events: &[nostr::Event]) -> std::collections::HashMap<String, i64> {
let mut counts: std::collections::HashMap<String, i64> =
fn collect_members_by_channel(
events: &[nostr::Event],
) -> std::collections::HashMap<String, ChannelMembership> {
let mut map: std::collections::HashMap<String, ChannelMembership> =
std::collections::HashMap::with_capacity(events.len());
for ev in events {
let Some(d) = ev.tags.iter().find_map(|t| {
@@ -164,9 +172,16 @@ fn count_members_by_channel(events: &[nostr::Event]) -> std::collections::HashMa
let Ok(resp) = nostr_convert::channel_members_from_event(ev) else {
continue;
};
counts.insert(d, resp.members.len() as i64);
let pubkeys: Vec<String> = resp.members.iter().map(|m| m.pubkey.clone()).collect();
map.insert(
d,
ChannelMembership {
count: pubkeys.len() as i64,
pubkeys,
},
);
}
counts
map
}
#[tauri::command]
@@ -39,10 +39,18 @@ fn counts_unique_p_tags_per_channel() {
vec![vec!["d", "chan-2"], vec!["p", PK_C, "", "member"]],
);
let counts = count_members_by_channel(&[e1, e2]);
assert_eq!(counts.get("chan-1"), Some(&2));
assert_eq!(counts.get("chan-2"), Some(&1));
assert_eq!(counts.len(), 2);
let membership = collect_members_by_channel(&[e1, e2]);
assert_eq!(membership.get("chan-1").map(|m| m.count), Some(2));
assert_eq!(membership.get("chan-2").map(|m| m.count), Some(1));
assert_eq!(membership.len(), 2);
let mut pks: Vec<&str> = membership["chan-1"]
.pubkeys
.iter()
.map(|s| s.as_str())
.collect();
pks.sort();
assert_eq!(pks, vec![PK_A, PK_B]);
}
#[test]
@@ -57,15 +65,15 @@ fn dedupes_repeated_pubkeys() {
vec!["p", PK_B, "", "member"],
],
);
let counts = count_members_by_channel(&[e]);
assert_eq!(counts.get("chan-1"), Some(&2));
let membership = collect_members_by_channel(&[e]);
assert_eq!(membership.get("chan-1").map(|m| m.count), Some(2));
}
#[test]
fn skips_event_without_d_tag() {
let e = ev(39002, "", vec![vec!["p", PK_A, "", "member"]]);
let counts = count_members_by_channel(&[e]);
assert!(counts.is_empty());
let membership = collect_members_by_channel(&[e]);
assert!(membership.is_empty());
}
#[test]
@@ -74,12 +82,13 @@ fn zero_member_channel_is_recorded() {
// not be absent from the map (the caller relies on `get` returning
// `Some(0)` to overwrite a default).
let e = ev(39002, "", vec![vec!["d", "chan-1"]]);
let counts = count_members_by_channel(&[e]);
assert_eq!(counts.get("chan-1"), Some(&0));
let membership = collect_members_by_channel(&[e]);
assert_eq!(membership.get("chan-1").map(|m| m.count), Some(0));
assert!(membership["chan-1"].pubkeys.is_empty());
}
#[test]
fn empty_input_yields_empty_map() {
let counts = count_members_by_channel(&[]);
assert!(counts.is_empty());
let membership = collect_members_by_channel(&[]);
assert!(membership.is_empty());
}
+2
View File
@@ -82,6 +82,8 @@ pub struct ChannelInfo {
pub topic: Option<String>,
pub purpose: Option<String>,
pub member_count: i64,
#[serde(default)]
pub member_pubkeys: Vec<String>,
pub last_message_at: Option<String>,
pub archived_at: Option<String>,
#[serde(default)]
+1
View File
@@ -130,6 +130,7 @@ pub fn channel_info_from_event(
topic,
purpose,
member_count,
member_pubkeys: Vec::new(),
last_message_at,
archived_at,
participants,
@@ -1,4 +1,5 @@
import type { ManagedAgent, PresenceLookup } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { ManagedAgentRow } from "./ManagedAgentRow";
export type AgentGroupRowsProps = {
@@ -39,11 +40,11 @@ export function AgentGroupRows({
onToggleStartOnAppLaunch,
}: AgentGroupRowsProps) {
return (
<div className="space-y-2 border-t border-border/50 px-3 py-2">
<div className="divide-y divide-border/50 border-t border-border/50">
{agents.map((agent) => (
<ManagedAgentRow
agent={agent}
channelNames={channelsByPubkey[agent.pubkey] ?? []}
channelNames={channelsByPubkey[normalizePubkey(agent.pubkey)] ?? []}
isActionPending={isActionPending}
isLogSelected={selectedLogAgentPubkey === agent.pubkey}
key={agent.pubkey}
@@ -28,10 +28,9 @@ export function ManagedAgentLogPanel({
return (
<section
className={cn(
"border border-border/70 shadow-xs",
isInline
? "rounded-2xl bg-background/80 p-4"
: "rounded-[28px] bg-card/90 p-5",
? ""
: "rounded-[28px] border border-border/70 bg-card/90 p-5 shadow-xs",
)}
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
@@ -90,10 +90,8 @@ export function ManagedAgentRow({
return (
<div
className={cn(
"overflow-hidden rounded-xl border bg-card/70 transition-colors",
isLogSelected
? "border-primary/40 bg-primary/5 shadow-xs"
: "border-border/70 hover:bg-muted/20",
"overflow-hidden transition-colors",
isLogSelected ? "bg-primary/5" : "hover:bg-muted/20",
)}
data-testid={`managed-agent-${agent.pubkey}`}
>
@@ -234,7 +232,11 @@ function AgentSummary({
{channelNames.length > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{channelNames.map((name) => (
<Badge className="normal-case" key={name} variant="secondary">
<Badge
className="normal-case tracking-normal"
key={name}
variant="outline"
>
# {name}
</Badge>
))}
@@ -1,326 +0,0 @@
import * as React from "react";
import {
ChevronDown,
ChevronRight,
Ellipsis,
OctagonX,
Trash2,
} from "lucide-react";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import type { ManagedAgent, PresenceLookup } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Skeleton } from "@/shared/ui/skeleton";
import { CreateNewButton } from "./CreateNewButton";
import { ManagedAgentRow } from "./ManagedAgentRow";
type PersonaGroup = {
key: string;
label: string;
agents: ManagedAgent[];
};
function groupAgentsByPersona(
agents: ManagedAgent[],
personaLabelsById: Record<string, string>,
): PersonaGroup[] {
const grouped = new Map<string, ManagedAgent[]>();
const ungrouped: ManagedAgent[] = [];
const unknownPersona: ManagedAgent[] = [];
for (const agent of agents) {
if (!agent.personaId) {
ungrouped.push(agent);
} else if (personaLabelsById[agent.personaId]) {
const existing = grouped.get(agent.personaId) ?? [];
existing.push(agent);
grouped.set(agent.personaId, existing);
} else {
unknownPersona.push(agent);
}
}
const groups: PersonaGroup[] = [];
for (const [personaId, groupAgents] of grouped) {
groups.push({
key: personaId,
label: personaLabelsById[personaId],
agents: groupAgents,
});
}
if (unknownPersona.length > 0) {
groups.push({
key: "__unknown__",
label: "Unknown Persona",
agents: unknownPersona,
});
}
if (ungrouped.length > 0) {
groups.push({
key: "__ungrouped__",
label: "Custom Agents",
agents: ungrouped,
});
}
return groups;
}
export function ManagedAgentsSection({
actionErrorMessage,
actionNoticeMessage,
agents,
channelsByPubkey,
error,
isActionPending,
isLoading,
logContent,
logError,
logLoading,
personaLabelsById,
presenceLoaded,
presenceLookup,
onAddToChannel,
onBulkRemoveStopped,
onBulkStopRunning,
onCreate,
onDelete,
onSelectLogAgent,
onStart,
onStop,
onToggleStartOnAppLaunch,
selectedLogAgentPubkey,
}: {
actionErrorMessage: string | null;
actionNoticeMessage: string | null;
agents: ManagedAgent[];
channelsByPubkey: Record<string, string[]>;
error: Error | null;
isActionPending: boolean;
isLoading: boolean;
logContent: string | null;
logError: Error | null;
logLoading: boolean;
personaLabelsById: Record<string, string>;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
onAddToChannel: (agent: ManagedAgent) => void;
onBulkRemoveStopped: () => void;
onBulkStopRunning: () => void;
onCreate: () => void;
onDelete: (pubkey: string) => void;
onSelectLogAgent: (pubkey: string | null) => void;
onStart: (pubkey: string) => void;
onStop: (pubkey: string) => void;
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
selectedLogAgentPubkey: string | null;
}) {
const runningCount = agents.filter((a) => isManagedAgentActive(a)).length;
const stoppedCount = agents.filter(
(a) => a.status === "stopped" || a.status === "not_deployed",
).length;
const groups = React.useMemo(
() => groupAgentsByPersona(agents, personaLabelsById),
[agents, personaLabelsById],
);
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(
new Set(),
);
function toggleGroup(key: string) {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}
useFeedbackToasts(actionNoticeMessage, actionErrorMessage);
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold tracking-tight">
Managed agents
</h3>
<p className="text-sm text-muted-foreground">
Agent profiles and process state local and remote.
</p>
</div>
<div className="flex items-center gap-2">
{agents.length > 0 ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Bulk actions"
className="h-7 w-7"
size="icon"
variant="ghost"
>
<Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
disabled={isActionPending || runningCount === 0}
onClick={onBulkStopRunning}
>
<OctagonX className="h-4 w-4" />
Stop all running ({runningCount})
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isActionPending || stoppedCount === 0}
onClick={onBulkRemoveStopped}
>
<Trash2 className="h-4 w-4" />
Remove all stopped ({stoppedCount})
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<CreateNewButton
ariaLabel="Create agent"
label="Agent"
onClick={onCreate}
/>
</div>
</div>
{isLoading ? (
<Card className="overflow-hidden">
{["first", "second"].map((key) => (
<div
className="flex items-center gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
key={key}
>
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
</div>
))}
</Card>
) : null}
{!isLoading && agents.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 local agents yet
</p>
<p className="mt-2 text-sm text-muted-foreground">
Create one to generate a keypair, mint a token, and launch the ACP
harness from the desktop app.
</p>
</div>
) : null}
{!isLoading && agents.length > 0 ? (
<div className="space-y-3" data-testid="managed-agents-table">
{groups.map((group) => {
const isCollapsed = collapsedGroups.has(group.key);
return (
<div key={group.key} className="space-y-2">
<button
className="flex w-full items-center gap-2 rounded-lg px-1 py-1 text-left transition-colors hover:bg-muted/40"
onClick={() => toggleGroup(group.key)}
type="button"
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="text-sm font-medium">{group.label}</span>
<span className="text-xs text-muted-foreground">
({group.agents.length})
</span>
</button>
{!isCollapsed ? (
<div className="space-y-2">
{group.agents.map((agent) => (
<ManagedAgentRow
agent={agent}
channelNames={channelsByPubkey[agent.pubkey] ?? []}
isActionPending={isActionPending}
isLogSelected={selectedLogAgentPubkey === agent.pubkey}
key={agent.pubkey}
logContent={
selectedLogAgentPubkey === agent.pubkey
? logContent
: null
}
logError={
selectedLogAgentPubkey === agent.pubkey
? logError
: null
}
logLoading={
selectedLogAgentPubkey === agent.pubkey && logLoading
}
personaLabelsById={personaLabelsById}
presenceLoaded={presenceLoaded}
presenceLookup={presenceLookup}
onAddToChannel={onAddToChannel}
onDelete={onDelete}
onSelectLogAgent={onSelectLogAgent}
onStart={onStart}
onStop={onStop}
onToggleStartOnAppLaunch={onToggleStartOnAppLaunch}
/>
))}
</div>
) : null}
</div>
);
})}
</div>
) : null}
{!isLoading && stoppedCount > 0 ? (
<div className="flex items-center justify-between rounded-xl border border-border/60 bg-muted/30 px-4 py-2.5">
<p className="text-sm text-muted-foreground">
{stoppedCount} stopped {stoppedCount === 1 ? "agent" : "agents"}
</p>
<Button
className="text-destructive"
disabled={isActionPending}
onClick={onBulkRemoveStopped}
size="sm"
variant="ghost"
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Remove stopped
</Button>
</div>
) : null}
{error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</p>
) : null}
</section>
);
}
@@ -246,11 +246,11 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
return (
<div
key={g.persona.id}
className={`rounded-xl border border-border/70 bg-card/40${isDeactivated ? " opacity-60" : ""}`}
className={`overflow-hidden rounded-xl border border-border/70 bg-card/40${isDeactivated ? " opacity-60" : ""}`}
>
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex items-center gap-2 px-3 py-2 transition-colors hover:bg-muted/40">
<button
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg py-1 text-left transition-colors hover:bg-muted/40"
className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left"
onClick={() => toggle(g.persona.id)}
type="button"
>
@@ -537,10 +537,10 @@ function CollapsibleAgentGroup({
}) {
const isCollapsed = collapsed.has(groupKey);
return (
<div className="rounded-xl border border-border/70 bg-card/40">
<div className="px-3 py-2">
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/40">
<div className="px-3 py-2 transition-colors hover:bg-muted/40">
<button
className="flex w-full items-center gap-2 rounded-lg py-1 text-left transition-colors hover:bg-muted/40"
className="flex w-full items-center gap-2 py-1 text-left"
onClick={() => onToggle(groupKey)}
type="button"
>
@@ -79,13 +79,29 @@ export function useManagedAgentActions() {
const channelsByPubkey = React.useMemo(() => {
const map: Record<string, string[]> = {};
// Seed from relay agent profiles (kind:10100 events).
for (const ra of relayAgentsQuery.data ?? []) {
if (ra.channels.length > 0) {
map[ra.pubkey] = ra.channels;
map[normalizePubkey(ra.pubkey)] = ra.channels;
}
}
// Fill in from channel member lists (kind:39002) for any managed agents
// not already covered by relay agent data.
const normalizedManaged = new Set(
managedAgents.map((a) => normalizePubkey(a.pubkey)),
);
for (const ch of channelsQuery.data ?? []) {
for (const pk of ch.memberPubkeys) {
const key = normalizePubkey(pk);
if (!normalizedManaged.has(key)) continue;
if (!map[key]) map[key] = [];
if (!map[key].includes(ch.name)) {
map[key].push(ch.name);
}
}
}
return map;
}, [relayAgentsQuery.data]);
}, [relayAgentsQuery.data, channelsQuery.data, managedAgents]);
// Clear log selection if the agent was removed
React.useEffect(() => {
@@ -121,15 +121,32 @@ function SystemMessageAvatar({
})
: "Someone";
const singlePubkey = actorPubkey ?? targetPubkey;
if (!hasActorAndTarget) {
return (
const avatar = (
<UserAvatar
avatarUrl={resolveAvatarUrl(actorPubkey ?? targetPubkey, profiles)}
avatarUrl={resolveAvatarUrl(singlePubkey, profiles)}
className="!h-9 !w-9 shrink-0 text-[10px]"
displayName={actorLabel}
testId="system-message-avatar"
/>
);
if (singlePubkey) {
return (
<UserProfilePopover pubkey={singlePubkey}>
<button
className="shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{avatar}
</button>
</UserProfilePopover>
);
}
return avatar;
}
const targetLabel = resolveUserLabel({
@@ -139,7 +156,7 @@ function SystemMessageAvatar({
preferResolvedSelfLabel: true,
});
return (
const dualAvatar = (
<div
className="relative h-9 w-9 shrink-0"
data-testid="system-message-avatar"
@@ -156,6 +173,17 @@ function SystemMessageAvatar({
/>
</div>
);
return (
<UserProfilePopover pubkey={actorPubkey}>
<button
className="shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{dualAvatar}
</button>
</UserProfilePopover>
);
}
function describeSystemEvent(
+2
View File
@@ -97,6 +97,7 @@ type RawChannel = {
topic: string | null;
purpose: string | null;
member_count: number;
member_pubkeys: string[];
last_message_at: string | null;
archived_at: string | null;
participants: string[];
@@ -355,6 +356,7 @@ function fromRawChannel(channel: RawChannel): Channel {
topic: channel.topic,
purpose: channel.purpose,
memberCount: channel.member_count,
memberPubkeys: channel.member_pubkeys ?? [],
lastMessageAt: channel.last_message_at,
archivedAt: channel.archived_at,
participants: channel.participants,
+1
View File
@@ -11,6 +11,7 @@ export type Channel = {
topic: string | null;
purpose: string | null;
memberCount: number;
memberPubkeys: string[];
lastMessageAt: string | null;
archivedAt: string | null;
participants: string[];