diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index d78d1d21af..c1f29f9712 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -286,6 +286,18 @@ describe("activeAgentTurnsStore", () => { summaries[0].anchorAt, getActiveTurnsForAgent(AGENT)[0].anchorAt, ); + assert.equal( + summaries[0].agentAnchorAts[AGENT], + getActiveTurnsForAgent(AGENT)[0].anchorAt, + ); + assert.equal( + summaries[0].agentAnchorAts[AGENT_2], + getActiveTurnsForAgent(AGENT_2)[0].anchorAt, + ); + assert.notEqual( + summaries[0].agentAnchorAts[AGENT], + summaries[0].agentAnchorAts[AGENT_2], + ); }); it("removes a channel summary when the last active turn ends", () => { diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 3af41ae078..8da8644244 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -53,9 +53,16 @@ export type ActiveTurnSummary = { /** One channel with active agent work, aggregated across agents. */ export type ActiveChannelTurnSummary = { channelId: string; + /** Earliest live-turn anchor in the channel (channel-level badge age). */ anchorAt: number; agentCount: number; agentPubkeys: string[]; + /** + * Per-agent earliest desktop-clock anchor for turns in this channel. + * Keys are normalized pubkeys matching `agentPubkeys`. Used so multi-agent + * surfaces can show distinct elapsed ages instead of one shared channel age. + */ + agentAnchorAts: Record; agentNames?: string[]; }; @@ -469,7 +476,11 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] { const summaries = new Map< string, - { anchorAt: number; agentPubkeys: Set } + { + anchorAt: number; + agentPubkeys: Set; + agentAnchorAts: Map; + } >(); for (const [agentKey, agentTurns] of activeTurnsByAgent) { @@ -483,6 +494,7 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] { summaries.set(turn.channelId, { anchorAt, agentPubkeys: new Set([agentKey]), + agentAnchorAts: new Map([[agentKey, anchorAt]]), }); continue; } @@ -491,16 +503,31 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] { if (anchorAt < summary.anchorAt) { summary.anchorAt = anchorAt; } + const priorAgentAnchor = summary.agentAnchorAts.get(agentKey); + if (priorAgentAnchor === undefined || anchorAt < priorAgentAnchor) { + summary.agentAnchorAts.set(agentKey, anchorAt); + } } } const result = [...summaries.entries()] - .map(([channelId, summary]) => ({ - channelId, - anchorAt: summary.anchorAt, - agentCount: summary.agentPubkeys.size, - agentPubkeys: [...summary.agentPubkeys].sort(), - })) + .map(([channelId, summary]) => { + const agentPubkeys = [...summary.agentPubkeys].sort(); + const agentAnchorAts: Record = {}; + for (const pubkey of agentPubkeys) { + const agentAnchor = summary.agentAnchorAts.get(pubkey); + if (agentAnchor !== undefined) { + agentAnchorAts[pubkey] = agentAnchor; + } + } + return { + channelId, + anchorAt: summary.anchorAt, + agentCount: agentPubkeys.length, + agentPubkeys, + agentAnchorAts, + }; + }) .sort((a, b) => a.channelId.localeCompare(b.channelId)); cachedChannelTurnSummaries = result; return result; diff --git a/desktop/src/features/agents/agentWorkingSignal.ts b/desktop/src/features/agents/agentWorkingSignal.ts index c09c307853..9d4705795a 100644 --- a/desktop/src/features/agents/agentWorkingSignal.ts +++ b/desktop/src/features/agents/agentWorkingSignal.ts @@ -235,7 +235,11 @@ export function getWorkingChannels(): WorkingChannelSummary[] { const byChannel = new Map(); for (const summary of getActiveTurnsByChannel()) { - byChannel.set(summary.channelId, { ...summary, source: "observer" }); + byChannel.set(summary.channelId, { + ...summary, + agentAnchorAts: { ...summary.agentAnchorAts }, + source: "observer", + }); } for (const [channelId, entries] of typingByChannel) { @@ -245,9 +249,13 @@ export function getWorkingChannels(): WorkingChannelSummary[] { existing.agentPubkeys.map((pubkey) => normalizePubkey(pubkey)), ); const merged = [...existing.agentPubkeys]; - for (const pubkey of entries.keys()) { + const agentAnchorAts = { ...existing.agentAnchorAts }; + for (const [pubkey, since] of entries) { if (!known.has(pubkey)) { merged.push(pubkey); + agentAnchorAts[pubkey] = since; + } else if (agentAnchorAts[pubkey] === undefined) { + agentAnchorAts[pubkey] = since; } } if (merged.length !== existing.agentPubkeys.length) { @@ -255,13 +263,16 @@ export function getWorkingChannels(): WorkingChannelSummary[] { ...existing, agentPubkeys: merged, agentCount: merged.length, + agentAnchorAts, }); } continue; } let anchorAt = Number.POSITIVE_INFINITY; - for (const since of entries.values()) { + const agentAnchorAts: Record = {}; + for (const [pubkey, since] of entries) { + agentAnchorAts[pubkey] = since; if (since < anchorAt) { anchorAt = since; } @@ -271,6 +282,7 @@ export function getWorkingChannels(): WorkingChannelSummary[] { anchorAt, agentCount: entries.size, agentPubkeys: [...entries.keys()], + agentAnchorAts, source: "typing", }); } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index 9ccf93bf9f..ab019c3c1e 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { getActivityHeadline, + getLatestActivityHeadline, isMeaningfulItem, isSpineItem, shouldShowTranscriptRowTimestamp, @@ -430,3 +431,33 @@ test("shouldShowTranscriptRowTimestamp: compact preview stays dense", () => { false, ); }); + +test("getLatestActivityHeadline prefers latest spine work and scopes by channel", () => { + const items = [ + makeMessage({ + id: "msg:old", + text: "Old reply", + channelId: "chan-a", + timestamp: "2026-06-14T19:00:00.000Z", + }), + makeTool({ + id: "tool:1", + channelId: "chan-a", + timestamp: "2026-06-14T19:00:10.000Z", + startedAt: "2026-06-14T19:00:10.000Z", + }), + makeMessage({ + id: "msg:other", + text: "Other channel", + channelId: "chan-b", + timestamp: "2026-06-14T19:00:20.000Z", + }), + ]; + assert.equal(getLatestActivityHeadline(items, "chan-a"), "Sent abc"); + assert.equal(getLatestActivityHeadline(items, "chan-b"), "Other channel"); + assert.equal(getLatestActivityHeadline(items, "chan-missing"), null); +}); + +test("getLatestActivityHeadline returns null for empty transcripts", () => { + assert.equal(getLatestActivityHeadline([]), null); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts index d07449da38..af68950600 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts @@ -153,3 +153,37 @@ export function isSpineItem(item: TranscriptItem): boolean { if (!isMeaningfulItem(item)) return false; return item.type !== "metadata"; } + +/** + * Latest meaningful activity headline for a transcript, optionally scoped to + * one channel. Prefers spine work; falls back to any meaningful item so early + * session context can still surface. Returns null when nothing qualifies. + */ +export function getLatestActivityHeadline( + items: readonly TranscriptItem[], + channelId?: string | null, +): string | null { + const scoped = + channelId && channelId.length > 0 + ? items.filter((item) => item.channelId === channelId) + : items; + if (scoped.length === 0) { + return null; + } + + const passFilter: (item: TranscriptItem) => boolean = scoped.some(isSpineItem) + ? isSpineItem + : isMeaningfulItem; + + for (let i = scoped.length - 1; i >= 0; i--) { + const item = scoped[i]; + if (!item || !passFilter(item)) { + continue; + } + const headline = getActivityHeadline(item); + if (headline) { + return headline; + } + } + return null; +} diff --git a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.test.mjs b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.test.mjs index 5f022df53a..964672387d 100644 --- a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.test.mjs +++ b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.test.mjs @@ -11,6 +11,7 @@ describe("resolveActiveWorkingChannelNames", () => { anchorAt: 0, agentCount: 2, agentPubkeys: ["AAAA", "bbbb"], + agentAnchorAts: { aaaa: 0, bbbb: 0 }, }, [ { pubkey: "aaaa", name: "Ned" }, @@ -28,6 +29,7 @@ describe("resolveActiveWorkingChannelNames", () => { anchorAt: 0, agentCount: 2, agentPubkeys: ["AAAA", "cccc"], + agentAnchorAts: { aaaa: 0, cccc: 0 }, }, [{ pubkey: "aaaa", name: "Ned" }], ); diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index 1c86ac4c36..90277db87d 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -4,7 +4,9 @@ import { Clock, Loader2, MailOpen } from "lucide-react"; import { useAppShell } from "@/app/AppShellContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore"; +import { getLatestActivityHeadline } from "@/features/agents/ui/agentSessionTranscriptPresentation"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { buildInboxItems, type InboxItem } from "@/features/home/lib/inbox"; import { getGroupedInboxItemIds } from "@/features/home/useHomeInboxReadState"; @@ -141,18 +143,29 @@ function ThreadPreviewRow({ } function WorkingAgentRow({ + anchorAt, avatarUrl, - elapsed, + channelId, name, onOpen, pubkey, }: { + anchorAt: number; avatarUrl: string | null; - elapsed: string; + channelId: string; name: string; onOpen: () => void; pubkey: string; }) { + const now = useNow(1000); + const elapsed = formatElapsed(Math.max(0, now - anchorAt)); + const transcript = useAgentTranscript(true, pubkey); + const headline = React.useMemo( + () => getLatestActivityHeadline(transcript, channelId), + [channelId, transcript], + ); + const secondary = headline ? `Working · ${headline}` : "Working"; + return ( @@ -195,8 +212,6 @@ function WorkingAgentRows({ onOpen: (pubkey: string, channelId: string) => void; profiles?: UserProfileLookup; }) { - const now = useNow(1000); - const elapsed = formatElapsed(now - activeWorking.anchorAt); const alignedAgentNames = activeWorking.agentNames?.length === activeWorking.agentPubkeys.length ? activeWorking.agentNames @@ -208,10 +223,16 @@ function WorkingAgentRows({ profile?.displayName?.trim() || alignedAgentNames?.[index] || `Agent ${truncatePubkey(pubkey)}`; + const agentKey = normalizePubkey(pubkey); + const anchorAt = + activeWorking.agentAnchorAts?.[agentKey] ?? + activeWorking.agentAnchorAts?.[pubkey] ?? + activeWorking.anchorAt; return ( onOpen(pubkey, channelId)} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.test.mjs b/desktop/src/features/sidebar/ui/SidebarSection.test.mjs index 0a1990579a..09214fa4fb 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.test.mjs +++ b/desktop/src/features/sidebar/ui/SidebarSection.test.mjs @@ -12,6 +12,12 @@ function summary(agentNames, agentCount = agentNames.length) { { length: agentCount }, (_, index) => `agent-${index}-pubkey`, ), + agentAnchorAts: Object.fromEntries( + Array.from({ length: agentCount }, (_, index) => [ + `agent-${index}-pubkey`, + 0, + ]), + ), agentNames, }; } diff --git a/desktop/tests/e2e/channel-activity-popover.spec.ts b/desktop/tests/e2e/channel-activity-popover.spec.ts index a62d064d76..5b615a6066 100644 --- a/desktop/tests/e2e/channel-activity-popover.spec.ts +++ b/desktop/tests/e2e/channel-activity-popover.spec.ts @@ -226,18 +226,61 @@ async function seedChannelActivity( ); await page.evaluate( ({ agentPubkey, channelId }) => { + const now = Date.now(); ( window as Window & { __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { agentPubkey: string; channelId: string; turnId: string; + atMs?: number; + }) => void; + __BUZZ_E2E_SEED_OBSERVER_EVENTS__?: (input: { + agentPubkey: string; + events: Array>; }) => void; } ).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ agentPubkey, channelId, turnId: "channel-hover-preview", + atMs: now - 92_000, + }); + ( + window as Window & { + __BUZZ_E2E_SEED_OBSERVER_EVENTS__?: (input: { + agentPubkey: string; + events: Array>; + }) => void; + } + ).__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({ + agentPubkey, + events: [ + { + seq: now, + timestamp: new Date(now - 2_000).toISOString(), + kind: "acp_read", + agentIndex: 0, + channelId, + sessionId: "channel-hover-session", + turnId: "channel-hover-preview", + payload: { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "channel-hover-session", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "channel-hover-message", + content: { + type: "text", + text: "Updated the channel activity preview", + }, + }, + }, + }, + }, + ], }); }, { agentPubkey: AGENT_PUBKEY, channelId: CHANNEL_GENERAL }, @@ -338,8 +381,8 @@ test.describe("channel activity hover preview", () => { popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`), ).toContainText("Charlie"); await expect( - popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`), - ).toContainText("Working"); + popover.getByTestId(`channel-activity-agent-status-${AGENT_PUBKEY}`), + ).toHaveText("Working · Updated the channel activity preview"); const orderedRows = popover.locator( '[data-testid^="channel-activity-agent-"], [data-testid^="channel-activity-item-"]', );