From 4435090f028d8680b10350e974641e5378f87618 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 6 Jul 2026 19:08:52 -0700 Subject: [PATCH 1/2] feat(composer): merged live activity feed in agents-working popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the plain agent list in the below-composer 'Agents working' popover with an h-48 merged live activity preview. Per-agent observer transcripts for the channel's working agents are interleaved into one chronological stream (stable k-way merge on item timestamps), rendered with the compact transcript presenters from the agent activity view, and badged per consecutive same-agent run. - composerLiveActivity.ts: channel-scoped merge + consecutive-run grouping helpers, capped at 80 entries for the preview window - ComposerLiveActivityFeed.tsx: compactPreview-variant feed with auto-tail via useAnchoredScroll (memoized entry snapshot so streaming updates re-anchor without effect churn) and explicit agent-badge click-through buttons — no nested interactive wrappers - useObserverEvents.ts: useAgentTranscripts hook for index-aligned multi-agent snapshots with referential caching to keep useSyncExternalStore stable - BotActivityBar.tsx: popover widened to w-80, feed above a compact per-agent footer that keeps the existing click-through rows Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/agents/ui/useObserverEvents.ts | 31 ++++ .../features/channels/ui/BotActivityBar.tsx | 24 ++- .../channels/ui/ComposerLiveActivityFeed.tsx | 144 +++++++++++++++++ .../channels/ui/composerLiveActivity.test.mjs | 147 ++++++++++++++++++ .../channels/ui/composerLiveActivity.ts | 110 +++++++++++++ 5 files changed, 450 insertions(+), 6 deletions(-) create mode 100644 desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx create mode 100644 desktop/src/features/channels/ui/composerLiveActivity.test.mjs create mode 100644 desktop/src/features/channels/ui/composerLiveActivity.ts 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..5b70c6fc3 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -13,6 +13,7 @@ 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; @@ -217,24 +218,35 @@ export function BotActivityComposerAction({ event.preventDefault()} side="top" sideOffset={8} > -
+
Agents working
-
+ { + clearHoverTimer(); + setOpen(false); + onOpenAgentSession(pubkey, channelId); + }} + profiles={profiles} + /> +
{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; +} From 5fffca1a6d6b73d2b7794dd9e96fc6f17687bee1 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 00:04:37 -0700 Subject: [PATCH 2/2] feat(composer): gate live activity feed behind preview experiment Add a 'Composer live activity' entry to preview-features.json and gate the merged live activity feed in the agents-working popover on useFeatureEnabled("composerLiveActivity"). When the experiment is off, the popover renders exactly the pre-feed layout (w-64, plain agent list with sm avatars) so prod behavior is unchanged by default. The existing agents-working/typing composer affordance is untouched either way. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/channels/ui/BotActivityBar.tsx | 56 +++++++++++++------ preview-features.json | 8 +++ 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 5b70c6fc3..c0de3b359 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -9,6 +9,7 @@ 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"; @@ -41,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, ); @@ -218,35 +220,50 @@ export function BotActivityComposerAction({ event.preventDefault()} side="top" sideOffset={8} > -
+
Agents working
- { - clearHoverTimer(); - setOpen(false); - onOpenAgentSession(pubkey, channelId); - }} - profiles={profiles} - /> -
+ {liveActivityEnabled ? ( + { + clearHoverTimer(); + setOpen(false); + onOpenAgentSession(pubkey, channelId); + }} + profiles={profiles} + /> + ) : null} +
{workingAgents.map((agent) => { const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); return ( ); })} 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" + ] } ] }