mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user