diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 6d49c0435e..8d023dd961 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -1,171 +1,166 @@ +import * as React from "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 { - 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"; +export type BotActivityAgent = Pick; + type BotActivityBarProps = { - agents: ManagedAgent[]; + agents: BotActivityAgent[]; 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; +const HOVER_OPEN_DELAY_MS = 150; +const HOVER_CLOSE_DELAY_MS = 180; -/** - * Compact right-aligned row of clickable bot pills. - * Only renders pills for bots that are currently typing (actively working). - */ -export function BotActivityBar({ +export function BotActivityComposerAction({ agents, onOpenAgentSession, openAgentSessionPubkey, profiles, typingBotPubkeys, }: BotActivityBarProps) { - if (typingBotPubkeys.length === 0) { - return null; - } - - const typingSet = new Set( - typingBotPubkeys.map((pubkey) => pubkey.toLowerCase()), + const [open, setOpen] = React.useState(false); + const hoverTimerRef = React.useRef | null>( + null, ); - const typingAgents = agents.filter((agent) => - typingSet.has(agent.pubkey.toLowerCase()), - ); + const typingAgents = React.useMemo(() => { + const typingSet = new Set( + typingBotPubkeys.map((pubkey) => pubkey.toLowerCase()), + ); + + return agents.filter((agent) => typingSet.has(agent.pubkey.toLowerCase())); + }, [agents, typingBotPubkeys]); + + const clearHoverTimer = React.useCallback(() => { + if (hoverTimerRef.current !== null) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + }, []); + + const openWithDelay = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(true); + }, HOVER_OPEN_DELAY_MS); + }, [clearHoverTimer]); + + const closeWithDelay = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(false); + }, HOVER_CLOSE_DELAY_MS); + }, [clearHoverTimer]); + + const keepOpen = React.useCallback(() => { + clearHoverTimer(); + }, [clearHoverTimer]); + + React.useEffect(() => { + return () => clearHoverTimer(); + }, [clearHoverTimer]); if (typingAgents.length === 0) { return null; } - const { hiddenAgents, visibleAgents } = splitVisibleAgents( - typingAgents, - openAgentSessionPubkey, - ); - const isCompact = typingAgents.length >= COMPACT_THRESHOLD; - const agentAvatarUrl = (agent: ManagedAgent) => + const agentAvatarUrl = (agent: BotActivityAgent) => profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null; + const selectedPubkey = openAgentSessionPubkey?.toLowerCase() ?? null; + const triggerLabel = + typingAgents.length === 1 + ? `${typingAgents[0]?.name ?? "Agent"} is working` + : `${typingAgents.length} agents working`; return ( -
- {visibleAgents.map((agent) => { - const isSelected = - openAgentSessionPubkey?.toLowerCase() === agent.pubkey.toLowerCase(); - return ( - - + + + + + event.preventDefault()} + side="top" + sideOffset={8} + > +
+ Agents working +
+
+ {typingAgents.map((agent) => { + const isSelected = selectedPubkey === 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 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 { visibleAgents, hiddenAgents }; -} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index dc3231ed28..fb513c669a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -9,7 +9,10 @@ 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"; -import { BotActivityBar } from "@/features/channels/ui/BotActivityBar"; +import { + BotActivityComposerAction, + type BotActivityAgent, +} from "@/features/channels/ui/BotActivityBar"; import { Button } from "@/shared/ui/button"; import type { useChannelFind } from "@/features/search/useChannelFind"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; @@ -51,47 +54,9 @@ 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; + activityAgents: BotActivityAgent[]; agentSessionAgents: ManagedAgent[]; botTypingEntries: TypingIndicatorEntry[]; channelFind: ReturnType; @@ -157,6 +122,7 @@ type ChannelPaneProps = { export const ChannelPane = React.memo(function ChannelPane({ activeChannel, + activityAgents, agentSessionAgents, botTypingEntries, channelFind, @@ -270,53 +236,19 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel.channelType === "forum" || isSending; const hasTypingActivity = typingPubkeys.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 composerBotTypingPubkeys = React.useMemo(() => { + const pubkeys: string[] = []; + for (const entry of botTypingEntries) { + if ( + !pubkeys.some( + (pubkey) => pubkey.toLowerCase() === entry.pubkey.toLowerCase(), + ) + ) { + pubkeys.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; + } + return pubkeys; + }, [botTypingEntries]); const selectedAgent = React.useMemo( () => @@ -349,7 +281,6 @@ export const ChannelPane = React.memo(function ChannelPane({ fetchOlder={fetchOlder} hasOlderMessages={hasOlderMessages} isFetchingOlder={isFetchingOlder} - messageFooters={messageActivityFooters} personaLookup={personaLookup} profiles={profiles} emptyDescription={ @@ -386,19 +317,6 @@ export const ChannelPane = React.memo(function ChannelPane({ /> ) : null} - {hasBotActivity ? ( -
-
- -
-
- ) : null} {isNonMemberView ? (
+ } placeholder={ activeChannel?.archivedAt ? "Archived channels are read-only." diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 1c7521b3c6..2f7e78421e 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -58,6 +58,7 @@ type MessageComposerProps = { id: string; } | null; showTopBorder?: boolean; + toolbarExtraActions?: React.ReactNode; typingParentEventId?: string | null; typingRootEventId?: string | null; }; @@ -77,6 +78,7 @@ export function MessageComposer({ profiles, replyTarget = null, showTopBorder = false, + toolbarExtraActions, typingParentEventId = null, typingRootEventId = null, }: MessageComposerProps) { @@ -663,6 +665,7 @@ export function MessageComposer({ *:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-1" + ? "max-w-none break-words text-sm leading-5 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-0.5" : compact ? "max-w-none break-words text-[15px] leading-6 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-1.5" : "max-w-none break-words text-sm leading-7 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-3",