diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index cef73f602f..f12f64fb57 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -5,6 +5,7 @@ import { syncAgentTurnsFromEvents, getActiveTurnsForAgent, getActiveTurnsByChannel, + getActiveAgentPubkeysForChannel, resetActiveAgentTurnsStore, subscribeActiveAgentTurns, } from "./activeAgentTurnsStore.ts"; @@ -205,6 +206,21 @@ describe("activeAgentTurnsStore", () => { ); }); + it("lists active agent pubkeys for a channel", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "agent-1", channelId: "shared" }), + ]); + syncAgentTurnsFromEvents(AGENT_2, [ + makeEvent({ seq: 1, turnId: "agent-2", channelId: "shared" }), + ]); + + assert.deepEqual(getActiveAgentPubkeysForChannel("shared"), [ + AGENT, + AGENT_2, + ]); + assert.deepEqual(getActiveAgentPubkeysForChannel("other"), []); + }); + it("removes a channel summary when the last active turn ends", () => { syncAgentTurnsFromEvents(AGENT, [ makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }), diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 6e04fc0b36..2b1ec9d699 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -76,6 +76,7 @@ const clockOffsetByAgent = new Map(); // Only regenerated when the underlying turn map for an agent actually changes. const cachedTurnSummaries = new Map(); let cachedChannelTurnSummaries: ActiveChannelTurnSummary[] | null = null; +const cachedAgentPubkeysByChannel = new Map(); // Composite watermark per agent: the newest observer event processed, by // (timestamp, seq) ordering. An event is processed only if it is strictly @@ -96,6 +97,7 @@ let pruneInterval: ReturnType | null = null; function invalidateCache(agentKey: string) { cachedTurnSummaries.delete(agentKey); cachedChannelTurnSummaries = null; + cachedAgentPubkeysByChannel.clear(); } function notifyListeners() { @@ -437,6 +439,7 @@ export function getActiveTurnsForAgent( const EMPTY_TURNS: ActiveTurnSummary[] = []; const EMPTY_CHANNEL_TURNS: ActiveChannelTurnSummary[] = []; +const EMPTY_AGENT_PUBKEYS: string[] = []; /** * Returns active working channels across all tracked agents, sorted by @@ -484,6 +487,30 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] { return result; } +export function getActiveAgentPubkeysForChannel( + channelId: string | null | undefined, +): string[] { + if (!channelId || activeTurnsByAgent.size === 0) return EMPTY_AGENT_PUBKEYS; + + const cached = cachedAgentPubkeysByChannel.get(channelId); + if (cached) return cached; + + const pubkeys: string[] = []; + for (const [agentKey, agentTurns] of activeTurnsByAgent) { + for (const turn of agentTurns.values()) { + if (turn.channelId === channelId) { + pubkeys.push(agentKey); + break; + } + } + } + + if (pubkeys.length === 0) return EMPTY_AGENT_PUBKEYS; + pubkeys.sort((a, b) => a.localeCompare(b)); + cachedAgentPubkeysByChannel.set(channelId, pubkeys); + return pubkeys; +} + /** * Synchronize the active-turns store with the latest observer events for a * given agent. @@ -524,6 +551,17 @@ export function useActiveAgentTurnsByChannel(): ActiveChannelTurnSummary[] { ); } +export function useActiveAgentPubkeysForChannel( + channelId: string | null | undefined, +): string[] { + const getSnapshot = React.useCallback( + () => getActiveAgentPubkeysForChannel(channelId), + [channelId], + ); + + return React.useSyncExternalStore(subscribeActiveAgentTurns, getSnapshot); +} + /** * Bridge hook: processes observer events into the active-turns store. * Should be called by a parent component that has access to the observer events. @@ -551,6 +589,7 @@ export function resetActiveAgentTurnsStore() { clockOffsetByAgent.clear(); cachedTurnSummaries.clear(); cachedChannelTurnSummaries = null; + cachedAgentPubkeysByChannel.clear(); terminalAtByAgent.clear(); notifyListeners(); } diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index f6e3907308..09ecf3d73d 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -1,14 +1,21 @@ import * as React from "react"; import { Loader2 } from "lucide-react"; +import { + useActiveAgentPubkeysForChannel, + useActiveAgentTurns, +} from "@/features/agents/activeAgentTurnsStore"; import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; import { getActivityHeadline, isMeaningfulItem, } from "@/features/agents/ui/agentSessionTranscriptPresentation"; +import { TranscriptActivityItem } from "@/features/agents/ui/activityRenderClasses/TranscriptActivityItem"; +import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Shimmer } from "@/shared/ui/Shimmer"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -28,6 +35,77 @@ type BotActivityBarProps = { const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 180; const HEADLINE_ROTATION_MS = 2200; +const PREVIEW_ITEM_LIMIT = 8; + +type ComposerPreviewItem = { + agent: BotActivityAgent; + item: TranscriptItem; + sortKey: string; +}; + +function useAgentActivityPreviewItems({ + agent, + channelId, + enabled, +}: { + agent: BotActivityAgent; + channelId?: string | null; + enabled: boolean; +}): ComposerPreviewItem[] { + const transcript = useAgentTranscript(enabled, agent.pubkey); + + return React.useMemo(() => { + if (!enabled) { + return []; + } + + const scopedTranscript = channelId + ? transcript.filter((item) => item.channelId === channelId) + : transcript; + + return scopedTranscript.filter(isMeaningfulItem).map((item) => ({ + agent, + item, + sortKey: getPreviewItemSortKey(item), + })); + }, [agent, channelId, enabled, transcript]); +} + +function getPreviewItemSortKey(item: TranscriptItem) { + return `${item.timestamp}:${item.id}`; +} + +function comparePreviewItems( + left: ComposerPreviewItem, + right: ComposerPreviewItem, +) { + const leftTime = Date.parse(left.item.timestamp); + const rightTime = Date.parse(right.item.timestamp); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) { + const timeDiff = leftTime - rightTime; + if (timeDiff !== 0) { + return timeDiff; + } + } + + return left.sortKey.localeCompare(right.sortKey); +} + +function previewItemsEqual( + left: ComposerPreviewItem[], + right: ComposerPreviewItem[], +) { + if (left.length !== right.length) { + return false; + } + + return left.every( + (item, index) => + item.item === right[index]?.item && + item.agent.pubkey === right[index]?.agent.pubkey && + item.sortKey === right[index]?.sortKey, + ); +} export function BotActivityComposerAction({ agents, @@ -50,12 +128,79 @@ export function BotActivityComposerAction({ return agents.filter((agent) => typingSet.has(agent.pubkey.toLowerCase())); }, [agents, typingBotPubkeys]); + const activeAgentPubkeys = useActiveAgentPubkeysForChannel(channelId); + const activeAgents = React.useMemo(() => { + if (activeAgentPubkeys.length === 0) { + return []; + } + + const activeSet = new Set(activeAgentPubkeys); + return agents.filter((agent) => + activeSet.has(normalizePubkey(agent.pubkey)), + ); + }, [activeAgentPubkeys, agents]); + const workingAgents = React.useMemo(() => { + const byPubkey = new Map(); + for (const agent of typingAgents) { + byPubkey.set(normalizePubkey(agent.pubkey), agent); + } + for (const agent of activeAgents) { + byPubkey.set(normalizePubkey(agent.pubkey), agent); + } + return [...byPubkey.values()]; + }, [activeAgents, typingAgents]); const singleTypingAgent = - typingAgents.length === 1 ? (typingAgents[0] ?? null) : null; + workingAgents.length === 1 ? (workingAgents[0] ?? null) : null; const transcript = useAgentTranscript( Boolean(singleTypingAgent), singleTypingAgent?.pubkey, ); + const [previewItemsByAgent, setPreviewItemsByAgent] = React.useState< + Record + >({}); + const typingAgentKeys = React.useMemo( + () => new Set(workingAgents.map((agent) => normalizePubkey(agent.pubkey))), + [workingAgents], + ); + const previewItems = React.useMemo( + () => + workingAgents + .flatMap( + (agent) => previewItemsByAgent[normalizePubkey(agent.pubkey)] ?? [], + ) + .sort(comparePreviewItems) + .slice(-PREVIEW_ITEM_LIMIT), + [previewItemsByAgent, workingAgents], + ); + const handlePreviewItemsChange = React.useCallback( + (agentPubkey: string, items: ComposerPreviewItem[]) => { + const key = normalizePubkey(agentPubkey); + setPreviewItemsByAgent((current) => { + if (previewItemsEqual(current[key] ?? [], items)) { + return current; + } + + return { ...current, [key]: items }; + }); + }, + [], + ); + + React.useEffect(() => { + setPreviewItemsByAgent((current) => { + let changed = false; + const next: Record = {}; + for (const [key, items] of Object.entries(current)) { + if (typingAgentKeys.has(key)) { + next[key] = items; + } else { + changed = true; + } + } + return changed ? next : current; + }); + }, [typingAgentKeys]); + const activityHeadlines = React.useMemo(() => { if (!singleTypingAgent) { return []; @@ -129,131 +274,291 @@ export function BotActivityComposerAction({ return () => window.clearInterval(interval); }, [activityHeadlines.length]); - if (typingAgents.length === 0) { + if (workingAgents.length === 0) { return null; } 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`; + workingAgents.length === 1 + ? `${workingAgents[0]?.name ?? "Agent"} is working` + : `${workingAgents.length} agents working`; const isInline = variant === "inline"; const visibleStatusLabel = - typingAgents.length === 1 - ? `${typingAgents[0]?.name ?? "Agent"}: ${ + workingAgents.length === 1 + ? `${workingAgents[0]?.name ?? "Agent"}: ${ activityHeadlines[headlineIndex % activityHeadlines.length] ?? "Working" }` - : `${typingAgents[0]?.name ?? "Agent"} +${typingAgents.length - 1}`; + : `${workingAgents[0]?.name ?? "Agent"} +${workingAgents.length - 1}`; return ( - - - - - event.preventDefault()} - side="top" - sideOffset={8} - > -
- Agents working -
-
- {typingAgents.map((agent) => { - const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); - - return ( - - ); - })} -
-
-
+ ))} + + {workingAgents.length > 2 ? ( + + +{workingAgents.length - 2} + + ) : null} + + {isInline ? ( + + {visibleStatusLabel} + + ) : ( + "working" + )} + + {isInline ? null : ( + + )} + + + event.preventDefault()} + side="top" + sideOffset={8} + > +
+
+ Agents working +
+ +
+ { + clearHoverTimer(); + setOpen(false); + onOpenAgentSession(pubkey); + }} + openAgentSessionPubkey={openAgentSessionPubkey} + previewItems={previewItems} + profiles={profiles} + typingAgents={workingAgents} + /> +
+ + + ); +} + +function AgentActivityPreviewCollector({ + agent, + channelId, + onItemsChange, +}: { + agent: BotActivityAgent; + channelId?: string | null; + onItemsChange: (agentPubkey: string, items: ComposerPreviewItem[]) => void; +}) { + const activeTurns = useActiveAgentTurns(agent.pubkey); + const hasActiveChannelTurn = channelId + ? activeTurns.some((turn) => turn.channelId === channelId) + : activeTurns.length > 0; + const items = useAgentActivityPreviewItems({ + agent, + channelId, + enabled: hasActiveChannelTurn, + }); + + React.useEffect(() => { + onItemsChange(agent.pubkey, items); + }, [agent.pubkey, items, onItemsChange]); + + return null; +} + +function ComposerActivityPreview({ + agentAvatarUrl, + onOpenAgentSession, + openAgentSessionPubkey, + previewItems, + profiles, + typingAgents, +}: { + agentAvatarUrl: (agent: BotActivityAgent) => string | null; + onOpenAgentSession: (pubkey: string) => void; + openAgentSessionPubkey: string | null; + previewItems: ComposerPreviewItem[]; + profiles?: UserProfileLookup; + typingAgents: BotActivityAgent[]; +}) { + const tailRef = React.useRef(null); + const latestTailKey = + previewItems.length > 0 + ? (previewItems[previewItems.length - 1]?.sortKey ?? null) + : null; + const selectedPubkey = openAgentSessionPubkey + ? normalizePubkey(openAgentSessionPubkey) + : null; + + React.useEffect(() => { + if (!latestTailKey) { + return; + } + + tailRef.current?.scrollIntoView({ block: "end" }); + }, [latestTailKey]); + + if (previewItems.length === 0) { + return ( +
+ {typingAgents.map((agent) => { + const isSelected = selectedPubkey === normalizePubkey(agent.pubkey); + + return ( + + ); + })} +
+ ); + } + + return ( +
+
+ {previewItems.map(({ agent, item, sortKey }) => ( +
+ +
+
+ + + + {getActivityHeadline(item) ?? item.title} + +
+
+ +
+
+
+ ))} + +
); } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index f89245e535..6a063e19c0 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -62,6 +62,7 @@ export const ChannelPane = React.memo(function ChannelPane({ activityAgents = agentSessionAgents, botTypingEntries, channelFind, + channelHasActiveAgentTurns = false, channelManagementOpen = false, currentPubkey, editTarget = null, @@ -337,7 +338,9 @@ export const ChannelPane = React.memo(function ChannelPane({ } return pubkeys; }, [botTypingEntries]); - const hasComposerBotActivity = composerBotTypingPubkeys.length > 0; + const hasComposerBotActivity = + composerBotTypingPubkeys.length > 0 || + (channelHasActiveAgentTurns && activityAgents.length > 0); const threadComposerBotTypingPubkeys = React.useMemo(() => { if (!openThreadHeadId) { return []; diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 02b441ff65..91c1b0b39f 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -20,6 +20,7 @@ export type ChannelPaneProps = { agentSessionAgents: ChannelAgentSessionAgent[]; botTypingEntries: TypingIndicatorEntry[]; channelFind: ReturnType; + channelHasActiveAgentTurns?: boolean; channelManagementOpen?: boolean; currentPubkey?: string; editTarget?: { diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 5197c6717f..ef4748d4a6 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -19,6 +19,10 @@ import { ForumView, } from "@/features/channels/ui/ChannelScreenLazyViews"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; +import { + useActiveAgentTurnsBridge, + useActiveAgentTurnsByChannel, +} from "@/features/agents/activeAgentTurnsStore"; import { useManagedAgentsQuery, usePersonasQuery, @@ -360,6 +364,15 @@ export function ChannelScreen({ relayAgents, typingEntries, }); + const activeAgentChannelTurns = useActiveAgentTurnsByChannel(); + const channelHasActiveAgentTurns = React.useMemo( + () => + Boolean(activeChannelId) && + activeAgentChannelTurns.some( + (turn) => turn.channelId === activeChannelId, + ), + [activeAgentChannelTurns, activeChannelId], + ); const observerBridgeAgents = React.useMemo(() => { if ( !profilePanelPubkey || @@ -383,6 +396,7 @@ export function ChannelScreen({ ]; }, [managedAgents, openAgentSessionPubkey, profilePanelPubkey]); useManagedAgentObserverBridge(observerBridgeAgents); + useActiveAgentTurnsBridge(observerBridgeAgents); const messageProfiles = React.useMemo(() => { const base = mergeCurrentProfileIntoLookup( @@ -852,6 +866,7 @@ export function ChannelScreen({ agentSessionAgents={agentSessionAgents} botTypingEntries={botTypingEntries} channelFind={channelFind} + channelHasActiveAgentTurns={channelHasActiveAgentTurns} channelManagementOpen={channelManagementOpen} currentPubkey={currentPubkey} canResetThreadPanelWidth={canResetThreadPanelWidth}