From 8def39a436f3d4d60e67e85dd707dd8380778f6e Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 5 May 2026 12:18:14 -0400 Subject: [PATCH] Anchor agent activity to messages Co-authored-by: Cursor --- desktop/scripts/check-file-sizes.mjs | 3 +- .../features/channels/ui/BotActivityBar.tsx | 20 +++- .../src/features/channels/ui/ChannelPane.tsx | 102 ++++++++++++++++-- .../features/channels/ui/ChannelScreen.tsx | 37 ++++--- .../features/messages/ui/MessageReactions.tsx | 7 +- .../features/messages/ui/MessageTimeline.tsx | 35 +++--- .../messages/ui/TimelineMessageList.tsx | 66 +++++++----- 7 files changed, 196 insertions(+), 74 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index e7e272d0a7..b0f8e314e7 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -35,7 +35,8 @@ const overrides = new Map([ ["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], - ["src/features/channels/ui/ChannelScreen.tsx", 530], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + ["src/features/channels/ui/ChannelPane.tsx", 520], // composer/timeline/sidebar orchestration + anchored agent activity footers + ["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification ["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates ["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) ["src/features/settings/ui/SettingsView.tsx", 600], diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 070526a54f..6d49c0435e 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -1,5 +1,6 @@ -import { Bot, Loader2 } from "lucide-react"; +import { Loader2 } from "lucide-react"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { @@ -10,11 +11,13 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; type BotActivityBarProps = { agents: ManagedAgent[]; onOpenAgentSession: (pubkey: string) => void; openAgentSessionPubkey: string | null; + profiles?: UserProfileLookup; typingBotPubkeys: string[]; }; @@ -30,6 +33,7 @@ export function BotActivityBar({ agents, onOpenAgentSession, openAgentSessionPubkey, + profiles, typingBotPubkeys, }: BotActivityBarProps) { if (typingBotPubkeys.length === 0) { @@ -53,6 +57,8 @@ export function BotActivityBar({ openAgentSessionPubkey, ); const isCompact = typingAgents.length >= COMPACT_THRESHOLD; + const agentAvatarUrl = (agent: ManagedAgent) => + profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null; return (
onOpenAgentSession(agent.pubkey)} type="button" > - + {agent.name} @@ -112,7 +122,11 @@ export function BotActivityBar({ key={agent.pubkey} onClick={() => onOpenAgentSession(agent.pubkey)} > - + {agent.name} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index bacec7079e..dc3231ed28 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -5,6 +5,7 @@ import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; +import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; @@ -50,10 +51,49 @@ function getInitialThreadPanelWidth(): number { } } +function messageMentionsPubkey(message: TimelineMessage, pubkey: string) { + const normalizedPubkey = pubkey.toLowerCase(); + if (message.pubkey?.toLowerCase() === normalizedPubkey) { + return false; + } + + return ( + message.tags?.some( + (tag) => tag[0] === "p" && tag[1]?.toLowerCase() === normalizedPubkey, + ) ?? false + ); +} + +function findLatestMentionedMessageId( + messages: TimelineMessage[], + pubkey: string, +) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message && messageMentionsPubkey(message, pubkey)) { + return message.id; + } + } + + return null; +} + +function addPubkey( + map: Map, + messageId: string, + pubkey: string, +) { + const current = map.get(messageId) ?? []; + if (!current.some((value) => value.toLowerCase() === pubkey.toLowerCase())) { + current.push(pubkey); + } + map.set(messageId, current); +} + type ChannelPaneProps = { activeChannel: Channel | null; agentSessionAgents: ManagedAgent[]; - botTypingPubkeys: string[]; + botTypingEntries: TypingIndicatorEntry[]; channelFind: ReturnType; currentPubkey?: string; editTarget?: { @@ -118,7 +158,7 @@ type ChannelPaneProps = { export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentSessionAgents, - botTypingPubkeys, + botTypingEntries, channelFind, currentPubkey, editTarget = null, @@ -230,7 +270,53 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel.channelType === "forum" || isSending; const hasTypingActivity = typingPubkeys.length > 0; - const hasBotActivity = botTypingPubkeys.length > 0; + const { messageActivityFooters, unanchoredBotTypingPubkeys } = + React.useMemo(() => { + const botPubkeysByMessageId = new Map(); + const unanchoredPubkeys: string[] = []; + for (const entry of botTypingEntries) { + const messageId = + entry.threadHeadId ?? + findLatestMentionedMessageId(messages, entry.pubkey); + if (messageId) { + addPubkey(botPubkeysByMessageId, messageId, entry.pubkey); + } else if ( + !unanchoredPubkeys.some( + (pubkey) => pubkey.toLowerCase() === entry.pubkey.toLowerCase(), + ) + ) { + unanchoredPubkeys.push(entry.pubkey); + } + } + + const footers: Record = {}; + for (const [messageId, pubkeys] of botPubkeysByMessageId) { + footers[messageId] = ( +
+ +
+ ); + } + + return { + messageActivityFooters: footers, + unanchoredBotTypingPubkeys: unanchoredPubkeys, + }; + }, [ + agentSessionAgents, + botTypingEntries, + messages, + onOpenAgentSession, + openAgentSessionPubkey, + profiles, + ]); + const hasBotActivity = unanchoredBotTypingPubkeys.length > 0; const selectedAgent = React.useMemo( () => @@ -263,6 +349,7 @@ export const ChannelPane = React.memo(function ChannelPane({ fetchOlder={fetchOlder} hasOlderMessages={hasOlderMessages} isFetchingOlder={isFetchingOlder} + messageFooters={messageActivityFooters} personaLookup={personaLookup} profiles={profiles} emptyDescription={ @@ -306,7 +393,8 @@ export const ChannelPane = React.memo(function ChannelPane({ agents={agentSessionAgents} onOpenAgentSession={onOpenAgentSession} openAgentSessionPubkey={openAgentSessionPubkey} - typingBotPubkeys={botTypingPubkeys} + profiles={profiles} + typingBotPubkeys={unanchoredBotTypingPubkeys} />
@@ -401,9 +489,9 @@ export const ChannelPane = React.memo(function ChannelPane({ agent={selectedAgent} canResetWidth={canResetThreadPanelWidth} channel={activeChannel} - isWorking={botTypingPubkeys.some( - (pubkey) => - pubkey.toLowerCase() === selectedAgent.pubkey.toLowerCase(), + isWorking={botTypingEntries.some( + (entry) => + entry.pubkey.toLowerCase() === selectedAgent.pubkey.toLowerCase(), )} onClose={onCloseAgentSession} onResetWidth={handleThreadPanelWidthReset} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 4b0f3d6e04..b85a826ce4 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -34,7 +34,10 @@ import { import { buildThreadPanelData } from "@/features/messages/lib/threadPanel"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors"; -import { useChannelTyping } from "@/features/messages/useChannelTyping"; +import { + type TypingIndicatorEntry, + useChannelTyping, +} from "@/features/messages/useChannelTyping"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; import type { @@ -142,13 +145,6 @@ export function ChannelScreen({ currentPubkey, latestMessageEvent, ); - const mainTypingPubkeys = React.useMemo( - () => - typingEntries - .filter((entry) => entry.threadHeadId === null) - .map((entry) => entry.pubkey), - [typingEntries], - ); const threadTypingPubkeys = React.useMemo( () => typingEntries @@ -170,21 +166,28 @@ export function ChannelScreen({ }); const managedAgentsQuery = useManagedAgentsQuery(); useManagedAgentObserverBridge(managedAgentsQuery.data ?? []); - const { humanTypingPubkeys, botTypingPubkeys } = React.useMemo(() => { + const { botTypingEntries, humanTypingPubkeys } = React.useMemo<{ + botTypingEntries: TypingIndicatorEntry[]; + humanTypingPubkeys: string[]; + }>(() => { const localAgentSet = new Set( (managedAgentsQuery.data ?? []) .filter((agent) => agent.backend.type === "local") .map((agent) => agent.pubkey.toLowerCase()), ); + const channelTypingEntries = typingEntries.filter( + (entry) => entry.threadHeadId === null, + ); + const agentTypingEntries = typingEntries.filter((entry) => + localAgentSet.has(entry.pubkey.toLowerCase()), + ); return { - humanTypingPubkeys: mainTypingPubkeys.filter( - (pk) => !localAgentSet.has(pk.toLowerCase()), - ), - botTypingPubkeys: mainTypingPubkeys.filter((pk) => - localAgentSet.has(pk.toLowerCase()), - ), + botTypingEntries: agentTypingEntries, + humanTypingPubkeys: channelTypingEntries + .filter((entry) => !localAgentSet.has(entry.pubkey.toLowerCase())) + .map((entry) => entry.pubkey), }; - }, [mainTypingPubkeys, managedAgentsQuery.data]); + }, [managedAgentsQuery.data, typingEntries]); const messageProfiles = React.useMemo(() => { const base = mergeCurrentProfileIntoLookup( @@ -468,7 +471,7 @@ export function ChannelScreen({ +
{reactions.map((reaction) => ( Promise; hasOlderMessages?: boolean; isFetchingOlder?: boolean; + messageFooters?: Record; /** Map from lowercase pubkey → persona display name for bot members. */ personaLookup?: Map; profiles?: UserProfileLookup; @@ -55,6 +56,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ fetchOlder, hasOlderMessages = true, isFetchingOlder = false, + messageFooters, personaLookup, profiles, onDelete, @@ -172,23 +174,22 @@ export const MessageTimeline = React.memo(function MessageTimeline({ ) : null} {!isLoading && messages.length > 0 ? ( - <> - - + ) : null}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 17430c44ea..c322d3ae70 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -17,6 +17,7 @@ type TimelineMessageListProps = { activeReplyTargetId?: string | null; currentPubkey?: string; highlightedMessageId?: string | null; + messageFooters?: Record; messages: TimelineMessage[]; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; @@ -41,6 +42,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ activeReplyTargetId = null, currentPubkey, highlightedMessageId = null, + messageFooters, messages, onDelete, onEdit, @@ -77,17 +79,21 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ } if (message.kind === KIND_SYSTEM_MESSAGE) { + const footer = messageFooters?.[message.id] ?? null; currentDayGroup?.elements.push( - , +
+ + {footer} +
, ); } else if (summary && onReply) { + const footer = messageFooters?.[message.id] ?? null; currentDayGroup?.elements.push(
+ {footer}
, ); } else { const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; const isSearchActive = message.id === searchActiveMessageId; + const footer = messageFooters?.[message.id] ?? null; currentDayGroup?.elements.push( - , +
+ + {footer} +
, ); } }