From 7fa2a43d3b487cb9bdfebf9f949ee5e59de9f145 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 29 Apr 2026 20:58:26 -0400 Subject: [PATCH] Polish active agent activity UI Made-with: Cursor --- .../agents/ui/AgentSessionToolItem.tsx | 4 +- .../agents/ui/AgentSessionTranscriptList.tsx | 42 ++- .../features/channels/ui/BotActivityBar.tsx | 254 +++++++++--------- .../src/features/channels/ui/ChannelPane.tsx | 20 +- .../features/channels/ui/ChannelScreen.tsx | 18 +- .../src/features/messages/ui/MessageRow.tsx | 12 - .../features/messages/ui/MessageTimeline.tsx | 11 + .../src/features/messages/useChannelTyping.ts | 57 ++-- 8 files changed, 223 insertions(+), 195 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 6d4205ea1..7372eb972 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -42,13 +42,13 @@ export function ToolItem({ ); return ( -
+
- + {ToolIcon ? ( {items.map((item) => ( -
+
))} @@ -71,32 +71,26 @@ function MessageItem({ }) { const isAssistant = item.role === "assistant"; const text = item.text.trim(); + const label = isAssistant ? agentName : item.title; return (
-
- {isAssistant ? ( -
+
+
+ {isAssistant ? ( - {agentName} -
- ) : null} + ) : null} + {label} +
{isAssistant ? ( @@ -116,8 +110,8 @@ function ThoughtItem({ item: Extract; }) { return ( -
- +
+ {item.title} @@ -135,8 +129,8 @@ function MetadataItem({ item: Extract; }) { return ( -
- +
+ {item.title} @@ -173,7 +167,7 @@ function LifecycleItem({ return (
diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index c0179d0c7..f04cac66c 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -1,157 +1,171 @@ +import * as React from "react"; + import { Bot, 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 { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const AGENT_LIST_HEIGHT_ESTIMATE_PX = 220; type BotActivityBarProps = { agents: ManagedAgent[]; onOpenAgentSession: (pubkey: string) => void; openAgentSessionPubkey: string | null; + profiles?: UserProfileLookup; typingBotPubkeys: string[]; }; -const COMPACT_THRESHOLD = 4; -const OVERFLOW_THRESHOLD = 6; -const MAX_VISIBLE_WITH_OVERFLOW = 5; - /** - * Compact right-aligned row of clickable bot pills. - * Only renders pills for bots that are currently typing (actively working). + * Single collected active-agent pill. The dropdown exposes the individual + * active agents while keeping the composer area visually quiet. */ export function BotActivityBar({ agents, onOpenAgentSession, openAgentSessionPubkey, + profiles, typingBotPubkeys, }: BotActivityBarProps) { - if (typingBotPubkeys.length === 0) { - return null; - } + const [isOpen, setIsOpen] = React.useState(false); + const [contentSide, setContentSide] = React.useState<"top" | "bottom">( + "bottom", + ); + const closeTimeoutRef = React.useRef | null>( + null, + ); + const typingSet = React.useMemo( + () => new Set(typingBotPubkeys.map((pubkey) => pubkey.toLowerCase())), + [typingBotPubkeys], + ); + const typingAgents = React.useMemo( + () => agents.filter((agent) => typingSet.has(agent.pubkey.toLowerCase())), + [agents, typingSet], + ); + const typingAgentKey = typingAgents + .map((agent) => agent.pubkey.toLowerCase()) + .join("|"); - const typingSet = new Set( - typingBotPubkeys.map((pubkey) => pubkey.toLowerCase()), + const clearCloseTimeout = React.useCallback(() => { + if (closeTimeoutRef.current) { + window.clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + }, []); + + const openAgentList = React.useCallback(() => { + clearCloseTimeout(); + setIsOpen(true); + }, [clearCloseTimeout]); + + const openAgentListFromTrigger = React.useCallback( + (event: React.PointerEvent) => { + const triggerRect = event.currentTarget.getBoundingClientRect(); + const availableBelow = window.innerHeight - triggerRect.bottom; + setContentSide( + availableBelow < AGENT_LIST_HEIGHT_ESTIMATE_PX ? "top" : "bottom", + ); + openAgentList(); + }, + [openAgentList], ); - const typingAgents = agents.filter((agent) => - typingSet.has(agent.pubkey.toLowerCase()), - ); + const scheduleCloseAgentList = React.useCallback(() => { + clearCloseTimeout(); + closeTimeoutRef.current = window.setTimeout(() => { + setIsOpen(false); + closeTimeoutRef.current = null; + }, 250); + }, [clearCloseTimeout]); + + React.useEffect(() => { + return clearCloseTimeout; + }, [clearCloseTimeout]); if (typingAgents.length === 0) { return null; } - const { hiddenAgents, visibleAgents } = splitVisibleAgents( - typingAgents, - openAgentSessionPubkey, - ); - const isCompact = typingAgents.length >= COMPACT_THRESHOLD; - - return ( -
- {visibleAgents.map((agent) => { - const isSelected = - openAgentSessionPubkey?.toLowerCase() === agent.pubkey.toLowerCase(); - return ( - - - - - - {agent.name} is working — click to view activity - - - ); - })} - - {hiddenAgents.length > 0 ? ( - - - - - - - More agents working - - {hiddenAgents.map((agent) => ( - onOpenAgentSession(agent.pubkey)} - > - - {agent.name} - - - ))} - - - ) : null} -
- ); -} - -function splitVisibleAgents( - typingAgents: ManagedAgent[], - openAgentSessionPubkey: string | null, -): { visibleAgents: ManagedAgent[]; hiddenAgents: ManagedAgent[] } { - if (typingAgents.length < OVERFLOW_THRESHOLD) { - return { visibleAgents: typingAgents, hiddenAgents: [] }; - } - const selectedAgent = openAgentSessionPubkey ? typingAgents.find( (agent) => agent.pubkey.toLowerCase() === openAgentSessionPubkey.toLowerCase(), ) : null; + const label = + typingAgents.length === 1 + ? "1 active agent" + : `${typingAgents.length} active agents`; - const visibleAgents = typingAgents.slice(0, MAX_VISIBLE_WITH_OVERFLOW); - - if ( - selectedAgent && - !visibleAgents.some((agent) => agent.pubkey === selectedAgent.pubkey) - ) { - visibleAgents[visibleAgents.length - 1] = selectedAgent; - } - - const visibleSet = new Set(visibleAgents.map((agent) => agent.pubkey)); - const hiddenAgents = typingAgents.filter( - (agent) => !visibleSet.has(agent.pubkey), + return ( +
+ + + + + event.preventDefault()} + onPointerEnter={openAgentList} + onPointerLeave={scheduleCloseAgentList} + side={contentSide} + sideOffset={8} + > +
+ Active agents +
+ {typingAgents.map((agent) => ( + + ))} +
+
+
); - - return { visibleAgents, hiddenAgents }; } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index ef60c74cf..927656007 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -256,6 +256,17 @@ export const ChannelPane = React.memo(function ChannelPane({ : "No messages yet" : "No channel selected" } + conversationFooter={ + botTypingPubkeys.length > 0 ? ( + + ) : null + } isLoading={isTimelineLoading} messages={messages} onDelete={onDelete} @@ -277,6 +288,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onCancelEdit={onCancelEdit} onEditSave={onEditSave} onSend={onSendMessage} + profiles={profiles} placeholder={ activeChannel?.archivedAt ? "Archived channels are read-only." @@ -296,14 +308,6 @@ export const ChannelPane = React.memo(function ChannelPane({ profiles={profiles} typingPubkeys={typingPubkeys} /> -
- -
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 0fd3de8dc..5110662ac 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -128,14 +128,10 @@ export function ChannelScreen({ () => collectMessageAuthorPubkeys(resolvedMessages), [resolvedMessages], ); - const latestMessageEvent = React.useMemo( - () => resolvedMessages[resolvedMessages.length - 1] ?? null, - [resolvedMessages], - ); const typingEntries = useChannelTyping( activeChannel, currentPubkey, - latestMessageEvent, + resolvedMessages, ); const mainTypingPubkeys = React.useMemo( () => @@ -175,11 +171,15 @@ export function ChannelScreen({ humanTypingPubkeys: mainTypingPubkeys.filter( (pk) => !localAgentSet.has(pk.toLowerCase()), ), - botTypingPubkeys: mainTypingPubkeys.filter((pk) => - localAgentSet.has(pk.toLowerCase()), - ), + botTypingPubkeys: [ + ...new Set( + typingEntries + .map((entry) => entry.pubkey) + .filter((pk) => localAgentSet.has(pk.toLowerCase())), + ), + ], }; - }, [mainTypingPubkeys, managedAgentsQuery.data]); + }, [mainTypingPubkeys, managedAgentsQuery.data, typingEntries]); const messageProfiles = React.useMemo(() => { const base = mergeCurrentProfileIntoLookup( diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index c5fe185e1..97e6d528c 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import type { TimelineMessage } from "@/features/messages/types"; -import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds"; @@ -258,17 +257,6 @@ export const MessageRow = React.memo( const messageBodyNode = ( <> {renderBody()} - { - void handleReactionSelect(emoji).catch(() => { - return; - }); - }} - /> {reactionErrorMessage ? (

{reactionErrorMessage} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 0d1e4774d..c5fa46976 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -19,6 +19,7 @@ type MessageTimelineProps = { isLoading?: boolean; emptyTitle?: string; emptyDescription?: string; + conversationFooter?: React.ReactNode; activeReplyTargetId?: string | null; currentPubkey?: string; fetchOlder?: () => Promise; @@ -51,6 +52,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ isLoading = false, emptyTitle = "No messages yet", emptyDescription = "Send the first message to start the thread.", + conversationFooter, activeReplyTargetId = null, currentPubkey, fetchOlder, @@ -199,6 +201,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({ /> ) : null} + {conversationFooter ? ( +

+ {conversationFooter} +
+ ) : null} +
diff --git a/desktop/src/features/messages/useChannelTyping.ts b/desktop/src/features/messages/useChannelTyping.ts index 789f04ac8..75d50aaa5 100644 --- a/desktop/src/features/messages/useChannelTyping.ts +++ b/desktop/src/features/messages/useChannelTyping.ts @@ -67,7 +67,7 @@ function getTypingStateKey(pubkey: string, threadHeadId: string | null) { export function useChannelTyping( channel: Channel | null, currentPubkey?: string, - latestMessageEvent?: RelayEvent | null, + completionEvents: RelayEvent[] = [], ) { const channelId = channel?.id ?? null; const channelType = channel?.channelType ?? null; @@ -131,38 +131,55 @@ export function useChannelTyping( }, [channelId]); useEffect(() => { - if ( - !channelId || - !latestMessageEvent || - !isTypingCompletionEvent(latestMessageEvent) - ) { + if (!channelId || completionEvents.length === 0) { return; } - if (getChannelIdFromTags(latestMessageEvent.tags) !== channelId) { + const completionKeys = new Set(); + for (const event of completionEvents) { + if ( + !isTypingCompletionEvent(event) || + getChannelIdFromTags(event.tags) !== channelId + ) { + continue; + } + + const authorPubkey = event.pubkey.toLowerCase(); + const threadHeadId = getTypingScopeId(event); + const typingKey = getTypingStateKey(authorPubkey, threadHeadId); + latestMessageCreatedAtByPubkeyRef.current[typingKey] = Math.max( + latestMessageCreatedAtByPubkeyRef.current[typingKey] ?? 0, + event.created_at, + ); + completionKeys.add(typingKey); + } + + if (completionKeys.size === 0) { return; } - const authorPubkey = latestMessageEvent.pubkey.toLowerCase(); - const threadHeadId = getTypingScopeId(latestMessageEvent); - const typingKey = getTypingStateKey(authorPubkey, threadHeadId); - latestMessageCreatedAtByPubkeyRef.current[typingKey] = Math.max( - latestMessageCreatedAtByPubkeyRef.current[typingKey] ?? 0, - latestMessageEvent.created_at, - ); - typingSuppressUntilByPubkeyRef.current[typingKey] = - Date.now() + TYPING_POST_MESSAGE_SUPPRESS_MS; setTypingByPubkey((current) => { const next = pruneTypingState(current); - if (!(typingKey in next)) { + let updated: TypingState | null = null; + + for (const typingKey of completionKeys) { + if (!(typingKey in next)) { + continue; + } + + updated ??= { ...next }; + delete updated[typingKey]; + typingSuppressUntilByPubkeyRef.current[typingKey] = + Date.now() + TYPING_POST_MESSAGE_SUPPRESS_MS; + } + + if (!updated) { return next; } - const updated = { ...next }; - delete updated[typingKey]; return updated; }); - }, [channelId, latestMessageEvent]); + }, [channelId, completionEvents]); useEffect(() => { if (!channelId || channelType === "forum") {