Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions desktop/src/features/agents/activeAgentTurnsStore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
41 changes: 34 additions & 7 deletions desktop/src/features/agents/activeAgentTurnsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};

Expand Down Expand Up @@ -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) {
Expand All @@ -483,6 +494,7 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] {
summaries.set(turn.channelId, {
anchorAt,
agentPubkeys: new Set([agentKey]),
agentAnchorAts: new Map([[agentKey, anchorAt]]),
});
continue;
}
Expand All @@ -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;
Expand Down
18 changes: 15 additions & 3 deletions desktop/src/features/agents/agentWorkingSignal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -245,23 +249,30 @@ 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) {
byChannel.set(channelId, {
...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;
}
Expand All @@ -271,6 +282,7 @@ export function getWorkingChannels(): WorkingChannelSummary[] {
anchorAt,
agentCount: entries.size,
agentPubkeys: [...entries.keys()],
agentAnchorAts,
source: "typing",
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import test from "node:test";

import {
getActivityHeadline,
getLatestActivityHeadline,
isMeaningfulItem,
isSpineItem,
shouldShowTranscriptRowTimestamp,
Expand Down Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ describe("resolveActiveWorkingChannelNames", () => {
anchorAt: 0,
agentCount: 2,
agentPubkeys: ["AAAA", "bbbb"],
agentAnchorAts: { aaaa: 0, bbbb: 0 },
},
[
{ pubkey: "aaaa", name: "Ned" },
Expand All @@ -28,6 +29,7 @@ describe("resolveActiveWorkingChannelNames", () => {
anchorAt: 0,
agentCount: 2,
agentPubkeys: ["AAAA", "cccc"],
agentAnchorAts: { aaaa: 0, cccc: 0 },
},
[{ pubkey: "aaaa", name: "Ned" }],
);
Expand Down
37 changes: 29 additions & 8 deletions desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
Expand All @@ -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>
Expand All @@ -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
Expand All @@ -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)}
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/features/sidebar/ui/SidebarSection.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
Loading
Loading