mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(sidebar): show per-agent activity in channel popover
Integrate joelbrilliant’s channel activity popover work on top of the composer live-activity branch, preserving per-agent elapsed ages, channel-scoped observer headlines, and activity-panel click-through. Co-authored-by: joelbrilliant <joelbrilliant1@gmail.com> Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
co-authored by
joelbrilliant
parent
cb8118e9ce
commit
617a4ab4ff
@@ -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", () => {
|
||||
|
||||
@@ -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<string, number>;
|
||||
agentNames?: string[];
|
||||
};
|
||||
|
||||
@@ -469,7 +476,11 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] {
|
||||
|
||||
const summaries = new Map<
|
||||
string,
|
||||
{ anchorAt: number; agentPubkeys: Set<string> }
|
||||
{
|
||||
anchorAt: number;
|
||||
agentPubkeys: Set<string>;
|
||||
agentAnchorAts: Map<string, number>;
|
||||
}
|
||||
>();
|
||||
|
||||
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<string, number> = {};
|
||||
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;
|
||||
|
||||
@@ -235,7 +235,11 @@ export function getWorkingChannels(): WorkingChannelSummary[] {
|
||||
|
||||
const byChannel = new Map<string, WorkingChannelSummary>();
|
||||
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<string, number> = {};
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
getActivityHeadline,
|
||||
getLatestActivityHeadline,
|
||||
isMeaningfulItem,
|
||||
isSpineItem,
|
||||
shouldShowTranscriptRowTimestamp,
|
||||
@@ -430,3 +431,36 @@ 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"),
|
||||
"Send Message · 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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" }],
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
className="flex w-full min-w-0 items-start gap-2.5 border-t border-border/50 px-3 py-3 text-left transition-colors first:border-t-0 hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-hidden"
|
||||
@@ -175,9 +188,13 @@ function WorkingAgentRow({
|
||||
{elapsed}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-xs leading-4 text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary/70" />
|
||||
Working
|
||||
<span
|
||||
className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs leading-4 text-muted-foreground"
|
||||
data-testid={`channel-activity-agent-status-${pubkey}`}
|
||||
title={secondary}
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-primary/70" />
|
||||
<span className="min-w-0 truncate">{secondary}</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -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 (
|
||||
<WorkingAgentRow
|
||||
anchorAt={anchorAt}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
elapsed={elapsed}
|
||||
channelId={channelId}
|
||||
key={pubkey}
|
||||
name={name}
|
||||
onOpen={() => onOpen(pubkey, channelId)}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user