diff --git a/desktop/src/features/agents/ui/useObserverEvents.ts b/desktop/src/features/agents/ui/useObserverEvents.ts index 73603a029..77c56cd23 100644 --- a/desktop/src/features/agents/ui/useObserverEvents.ts +++ b/desktop/src/features/agents/ui/useObserverEvents.ts @@ -52,6 +52,37 @@ export function useAgentTranscript( return React.useSyncExternalStore(subscribeToStore, getSnapshot); } +/** + * Live transcripts for several agents at once, index-aligned with + * `agentPubkeys`. The snapshot is cached so `useSyncExternalStore` only sees + * a new array when at least one underlying per-agent transcript changed — + * required to avoid render loops. + */ +export function useAgentTranscripts( + enabled: boolean, + agentPubkeys: readonly string[], +): TranscriptItem[][] { + const cacheRef = React.useRef(null); + + const getSnapshot = React.useCallback(() => { + const next = agentPubkeys.map((pubkey) => + getAgentTranscript(pubkey, enabled), + ); + const cached = cacheRef.current; + if ( + cached && + cached.length === next.length && + cached.every((transcript, index) => transcript === next[index]) + ) { + return cached; + } + cacheRef.current = next; + return next; + }, [agentPubkeys, enabled]); + + return React.useSyncExternalStore(subscribeToStore, getSnapshot); +} + const ARCHIVED_EVENTS_PAGE_SIZE = 50; /** diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d7f7146f4..c0de3b359 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -9,10 +9,12 @@ import { } from "@/features/agents/ui/agentSessionTranscriptPresentation"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; +import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Shimmer } from "@/shared/ui/Shimmer"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { ComposerLiveActivityFeed } from "./ComposerLiveActivityFeed"; export type BotActivityAgent = Pick; @@ -40,6 +42,7 @@ export function BotActivityComposerAction({ variant = "toolbar", }: BotActivityBarProps) { const [open, setOpen] = React.useState(false); + const liveActivityEnabled = useFeatureEnabled("composerLiveActivity"); const hoverTimerRef = React.useRef | null>( null, ); @@ -217,24 +220,50 @@ export function BotActivityComposerAction({ event.preventDefault()} side="top" sideOffset={8} > -
+
Agents working
-
+ {liveActivityEnabled ? ( + { + clearHoverTimer(); + setOpen(false); + onOpenAgentSession(pubkey, channelId); + }} + profiles={profiles} + /> + ) : null} +
{workingAgents.map((agent) => { const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); return ( ); })} diff --git a/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx b/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx new file mode 100644 index 000000000..e9aa14507 --- /dev/null +++ b/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx @@ -0,0 +1,144 @@ +import * as React from "react"; + +import { AgentSessionTranscriptVariantProvider } from "@/features/agents/ui/agentSessionTranscriptContext"; +import { TranscriptActivityItem } from "@/features/agents/ui/activityRenderClasses/TranscriptActivityItem"; +import { useAgentTranscripts } from "@/features/agents/ui/useObserverEvents"; +import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; +import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + groupLiveActivity, + mergeLiveActivity, + type LiveActivityAgent, +} from "./composerLiveActivity"; + +/** + * Merged multi-agent live activity preview for the composer popover. + * + * Interleaves the working agents' transcripts (scoped to the current channel) + * into one chronological stream, badges each same-agent run with the agent's + * identity, and auto-tails streaming updates. Rendering reuses the compact + * transcript presenters from the full activity view. + */ +export function ComposerLiveActivityFeed({ + agents, + channelId, + className, + onOpenAgentSession, + profiles, +}: { + agents: LiveActivityAgent[]; + channelId: string | null; + className?: string; + onOpenAgentSession: (pubkey: string) => void; + profiles?: UserProfileLookup; +}) { + const agentPubkeys = React.useMemo( + () => agents.map((agent) => agent.pubkey), + [agents], + ); + const transcripts = useAgentTranscripts(agents.length > 0, agentPubkeys); + const entries = React.useMemo( + () => mergeLiveActivity(agents, transcripts, channelId), + [agents, channelId, transcripts], + ); + const groups = React.useMemo(() => groupLiveActivity(entries), [entries]); + + const scrollContainerRef = React.useRef(null); + const contentRef = React.useRef(null); + // Stable identity per entries snapshot — useAnchoredScroll keys effects on + // `messages`, so a fresh array every render would re-run them needlessly. + const anchoredMessages = React.useMemo( + () => entries.map((entry) => ({ id: entry.key })), + [entries], + ); + const anchoredScroll = useAnchoredScroll({ + channelId: `composer-live-activity:${channelId ?? "all"}`, + contentRef, + isLoading: false, + messages: anchoredMessages, + scrollContainerRef, + }); + + const agentAvatarUrl = (agent: LiveActivityAgent) => + profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null; + + if (entries.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + {groups.map((group) => ( +
+ +
+ {group.entries.map((entry) => ( +
+ +
+ ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs new file mode 100644 index 000000000..fb62d5f52 --- /dev/null +++ b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + groupLiveActivity, + mergeLiveActivity, +} from "./composerLiveActivity.ts"; + +const CHANNEL = "channel-1"; +const OTHER_CHANNEL = "channel-2"; + +const alice = { pubkey: "alice-pubkey", name: "Alice" }; +const bob = { pubkey: "bob-pubkey", name: "Bob" }; + +function makeItem(overrides = {}) { + return { + id: "item:1", + type: "tool", + title: "Read file", + toolName: "read_file", + buzzToolName: "read_file", + status: "completed", + args: {}, + result: "", + isError: false, + renderClass: "tool-run", + channelId: CHANNEL, + timestamp: "2026-07-07T00:00:00.000Z", + startedAt: "2026-07-07T00:00:00.000Z", + completedAt: null, + ...overrides, + }; +} + +test("mergeLiveActivity interleaves per-agent transcripts chronologically", () => { + const merged = mergeLiveActivity( + [alice, bob], + [ + [ + makeItem({ id: "a1", timestamp: "2026-07-07T00:00:01.000Z" }), + makeItem({ id: "a2", timestamp: "2026-07-07T00:00:04.000Z" }), + ], + [ + makeItem({ id: "b1", timestamp: "2026-07-07T00:00:02.000Z" }), + makeItem({ id: "b2", timestamp: "2026-07-07T00:00:03.000Z" }), + ], + ], + CHANNEL, + ); + + assert.deepEqual( + merged.map((entry) => entry.key), + ["alice-pubkey:a1", "bob-pubkey:b1", "bob-pubkey:b2", "alice-pubkey:a2"], + ); +}); + +test("mergeLiveActivity scopes to the requested channel", () => { + const merged = mergeLiveActivity( + [alice], + [ + [ + makeItem({ id: "in-scope" }), + makeItem({ id: "out-of-scope", channelId: OTHER_CHANNEL }), + ], + ], + CHANNEL, + ); + + assert.deepEqual( + merged.map((entry) => entry.item.id), + ["in-scope"], + ); +}); + +test("mergeLiveActivity drops suppressed tool items", () => { + const merged = mergeLiveActivity( + [alice], + [ + [ + makeItem({ id: "visible" }), + makeItem({ id: "hidden", renderClass: "suppressed" }), + ], + ], + CHANNEL, + ); + + assert.deepEqual( + merged.map((entry) => entry.item.id), + ["visible"], + ); +}); + +test("mergeLiveActivity keeps ties in agent order (stable merge)", () => { + const timestamp = "2026-07-07T00:00:05.000Z"; + const merged = mergeLiveActivity( + [alice, bob], + [[makeItem({ id: "a1", timestamp })], [makeItem({ id: "b1", timestamp })]], + CHANNEL, + ); + + assert.deepEqual( + merged.map((entry) => entry.agent.pubkey), + ["alice-pubkey", "bob-pubkey"], + ); +}); + +test("mergeLiveActivity caps the merged stream at the preview limit", () => { + const items = Array.from({ length: 120 }, (_, index) => + makeItem({ + id: `a${index}`, + timestamp: new Date(Date.UTC(2026, 6, 7, 0, 0, index)).toISOString(), + }), + ); + const merged = mergeLiveActivity([alice], [items], CHANNEL); + + assert.equal(merged.length, 80); + assert.equal(merged[0].item.id, "a40"); + assert.equal(merged.at(-1).item.id, "a119"); +}); + +test("groupLiveActivity groups consecutive same-agent runs", () => { + const merged = mergeLiveActivity( + [alice, bob], + [ + [ + makeItem({ id: "a1", timestamp: "2026-07-07T00:00:01.000Z" }), + makeItem({ id: "a2", timestamp: "2026-07-07T00:00:02.000Z" }), + makeItem({ id: "a3", timestamp: "2026-07-07T00:00:09.000Z" }), + ], + [makeItem({ id: "b1", timestamp: "2026-07-07T00:00:05.000Z" })], + ], + CHANNEL, + ); + + const groups = groupLiveActivity(merged); + assert.deepEqual( + groups.map((group) => ({ + agent: group.agent.pubkey, + ids: group.entries.map((entry) => entry.item.id), + })), + [ + { agent: "alice-pubkey", ids: ["a1", "a2"] }, + { agent: "bob-pubkey", ids: ["b1"] }, + { agent: "alice-pubkey", ids: ["a3"] }, + ], + ); +}); diff --git a/desktop/src/features/channels/ui/composerLiveActivity.ts b/desktop/src/features/channels/ui/composerLiveActivity.ts new file mode 100644 index 000000000..6bad61f2d --- /dev/null +++ b/desktop/src/features/channels/ui/composerLiveActivity.ts @@ -0,0 +1,110 @@ +import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; +import { scopeByChannel } from "@/features/agents/ui/agentSessionPanelLayout"; +import { isMeaningfulItem } from "@/features/agents/ui/agentSessionTranscriptPresentation"; + +/** Identity shown on merged feed rows. */ +export type LiveActivityAgent = { + pubkey: string; + name: string; +}; + +export type LiveActivityEntry = { + agent: LiveActivityAgent; + item: TranscriptItem; + /** Unique across agents — transcript item ids are only per-agent unique. */ + key: string; +}; + +/** Consecutive run of entries from the same agent in the merged stream. */ +export type LiveActivityGroup = { + agent: LiveActivityAgent; + entries: LiveActivityEntry[]; + key: string; +}; + +/** + * Cap on merged entries kept for the preview window. The composer popover is + * a peek, not the archive — click-through opens the full activity view. + */ +const MERGED_ENTRY_LIMIT = 80; + +function parseTimestamp(timestamp: string): number { + const parsed = Date.parse(timestamp); + return Number.isNaN(parsed) ? 0 : parsed; +} + +/** + * Merge several per-agent transcripts into one interleaved, chronological + * stream scoped to a channel. Inputs are index-aligned: `transcripts[i]` + * belongs to `agents[i]`. Each per-agent transcript is already chronological, + * so this is a stable k-way merge on item timestamps (ties keep agent order). + */ +export function mergeLiveActivity( + agents: readonly LiveActivityAgent[], + transcripts: readonly TranscriptItem[][], + channelId: string | null, +): LiveActivityEntry[] { + const cursors = agents.map((agent, index) => ({ + agent, + items: scopeByChannel(transcripts[index] ?? [], channelId).filter( + isMeaningfulItem, + ), + position: 0, + })); + + const merged: LiveActivityEntry[] = []; + for (;;) { + let best: (typeof cursors)[number] | null = null; + let bestTime = Number.POSITIVE_INFINITY; + for (const cursor of cursors) { + const item = cursor.items[cursor.position]; + if (!item) { + continue; + } + const time = parseTimestamp(item.timestamp); + if (time < bestTime) { + best = cursor; + bestTime = time; + } + } + if (!best) { + break; + } + const item = best.items[best.position]; + if (item) { + merged.push({ + agent: best.agent, + item, + key: `${best.agent.pubkey}:${item.id}`, + }); + } + best.position += 1; + } + + return merged.length > MERGED_ENTRY_LIMIT + ? merged.slice(merged.length - MERGED_ENTRY_LIMIT) + : merged; +} + +/** + * Group consecutive same-agent entries so the feed can render one agent + * badge per run instead of per row. + */ +export function groupLiveActivity( + entries: readonly LiveActivityEntry[], +): LiveActivityGroup[] { + const groups: LiveActivityGroup[] = []; + for (const entry of entries) { + const current = groups[groups.length - 1]; + if (current && current.agent.pubkey === entry.agent.pubkey) { + current.entries.push(entry); + continue; + } + groups.push({ + agent: entry.agent, + entries: [entry], + key: entry.key, + }); + } + return groups; +} diff --git a/preview-features.json b/preview-features.json index bc5f6d16c..dcdc7f883 100644 --- a/preview-features.json +++ b/preview-features.json @@ -40,6 +40,14 @@ "platforms": [ "desktop" ] + }, + { + "id": "composerLiveActivity", + "name": "Composer live activity", + "description": "Merged live agent activity feed in the agents-working popover below the chat composer", + "platforms": [ + "desktop" + ] } ] }