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:
Taylor Ho
2026-08-05 17:43:43 -07:00
committed by Carl
co-authored by joelbrilliant
parent cb8118e9ce
commit 617a4ab4ff
8 changed files with 166 additions and 18 deletions
@@ -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,
};
}