feat(composer): flatten agent activity UI into per-agent status pills

- BotActivityBar.tsx: replace the combined "agents working" trigger with one
  pill per working agent (avatar + latest action summary in a rounded-full
  chip, max-w-30 with truncation so pill widths stay uniform); extract
  BotActivityAgentPill with per-pill hover popover state (useHoverPopover)
  and remove the tab strip, tab selection state, and the 5-headline
  rotation interval
- Pill label shows the agent's single latest action headline and decays to
  a generic "Working…" state once activity goes quiet (15s staleness
  window, 5s useNow tick); deliberately no rotation between recent actions
- Hovering a pill shows the live activity feed as the popover surface
  itself (w-80 p-0, edge-to-edge, no inset bordered box); clicking the
  pill opens the agent's full runtime in the auxiliary panel
  (preventDefault stops Radix's composed trigger toggle from racing the
  hover/focus open state, which made click-to-open flaky)
- composerLiveActivity.ts: add deriveActivityPillLabel pure helper
  (channel-scoped spine-first scan + staleness decay,
  ACTIVITY_PILL_STALE_MS = 15s) and remove the now-unused
  resolveSelectedActivityAgent
- composerLiveActivity.test.mjs: replace resolveSelectedActivityAgent
  tests with 7 deriveActivityPillLabel cases (fresh headline wins,
  staleness decay, custom window, channel scoping, spine-over-metadata,
  metadata fallback, empty transcript)
- ComposerLiveActivityFeed.tsx: overlay button rounding rounded-lg →
  rounded-[inherit] so the hover tint follows the popover's rounded-xl
  clip when the feed renders edge-to-edge
- ChannelComposerActivityRow.tsx / ChannelPane.tsx: drop the removed
  variant prop (the toolbar variant was unused; both call sites rendered
  inline)
- channels.spec.ts: activity-indicator test now hovers the pill for the
  legacy preview popover and clicks the pill for direct aux-panel open
- Flag-off (composerLiveActivity disabled) hover popover keeps the legacy
  "View activity" item; pills stay h-7 inside the fixed h-8.5 activity row
  so the composer-shift guard keeps passing

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-06 16:59:36 -07:00
parent a89d0cef92
commit d1d1eba64f
7 changed files with 307 additions and 353 deletions
@@ -2,21 +2,16 @@ import * as React from "react";
import { Loader2 } from "lucide-react";
import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
import {
getActivityHeadline,
isMeaningfulItem,
isSpineItem,
} 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 { Button } from "@/shared/ui/button";
import { useNow } from "@/shared/lib/useNow";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Shimmer } from "@/shared/ui/Shimmer";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { ComposerLiveActivityFeed } from "./ComposerLiveActivityFeed";
import { resolveSelectedActivityAgent } from "./composerLiveActivity";
import { deriveActivityPillLabel } from "./composerLiveActivity";
export type BotActivityAgent = Pick<ManagedAgent, "pubkey" | "name" | "status">;
@@ -27,101 +22,22 @@ type BotActivityBarProps = {
openAgentSessionPubkey: string | null;
profiles?: UserProfileLookup;
workingBotPubkeys: string[];
variant?: "toolbar" | "inline";
};
const HOVER_OPEN_DELAY_MS = 150;
const HOVER_CLOSE_DELAY_MS = 180;
const HEADLINE_ROTATION_MS = 2200;
/** Re-render cadence for the pill label's staleness check. */
const PILL_LABEL_TICK_MS = 5_000;
/** Shown when no fresh action headline exists (see deriveActivityPillLabel). */
const GENERIC_WORKING_LABEL = "Working…";
export function BotActivityComposerAction({
agents,
channelId = null,
onOpenAgentSession,
openAgentSessionPubkey,
profiles,
workingBotPubkeys,
variant = "toolbar",
}: BotActivityBarProps) {
/** Hover-intent popover state shared by every activity pill. */
function useHoverPopover() {
const [open, setOpen] = React.useState(false);
const liveActivityEnabled = useFeatureEnabled("composerLiveActivity");
// Which working agent's feed the live-activity preview shows. Explicit tab
// selection first; falls back to the open session pane, then the first
// working agent (see resolveSelectedActivityAgent).
const [selectedActivityPubkey, setSelectedActivityPubkey] = React.useState<
string | null
>(null);
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const workingAgents = React.useMemo(() => {
const workingSet = new Set(
workingBotPubkeys.map((pubkey) => pubkey.toLowerCase()),
);
return agents.filter((agent) => workingSet.has(agent.pubkey.toLowerCase()));
}, [agents, workingBotPubkeys]);
const singleWorkingAgent =
workingAgents.length === 1 ? (workingAgents[0] ?? null) : null;
const selectedActivityAgent = React.useMemo(
() =>
liveActivityEnabled
? resolveSelectedActivityAgent({
openAgentSessionPubkey,
selectedPubkey: selectedActivityPubkey,
workingAgents,
})
: null,
[
liveActivityEnabled,
openAgentSessionPubkey,
selectedActivityPubkey,
workingAgents,
],
);
const transcript = useAgentTranscript(
Boolean(singleWorkingAgent),
singleWorkingAgent?.pubkey,
);
const activityHeadlines = React.useMemo(() => {
if (!singleWorkingAgent) {
return [];
}
const seen = new Set<string>();
const headlines: string[] = [];
const scopedTranscript = channelId
? transcript.filter((item) => item.channelId === channelId)
: transcript;
// Two-tier scan: spine items first (reads recede when real work is present).
// If no spine headlines are found (session start / idle), fall back to all
// meaningful items so the bar isn't left empty.
const passFilter: (item: (typeof scopedTranscript)[number]) => boolean =
scopedTranscript.some(isSpineItem) ? isSpineItem : isMeaningfulItem;
for (let i = scopedTranscript.length - 1; i >= 0; i--) {
const item = scopedTranscript[i];
if (!passFilter(item)) {
continue;
}
const headline = getActivityHeadline(item);
if (!headline || seen.has(headline)) {
continue;
}
seen.add(headline);
headlines.unshift(headline);
if (headlines.length >= 5) {
break;
}
}
return headlines;
}, [channelId, singleWorkingAgent, transcript]);
const [headlineIndex, setHeadlineIndex] = React.useState(0);
const clearHoverTimer = React.useCallback(() => {
if (hoverTimerRef.current !== null) {
clearTimeout(hoverTimerRef.current);
@@ -143,217 +59,177 @@ export function BotActivityComposerAction({
}, HOVER_CLOSE_DELAY_MS);
}, [clearHoverTimer]);
const keepOpen = React.useCallback(() => {
clearHoverTimer();
}, [clearHoverTimer]);
React.useEffect(() => {
return () => clearHoverTimer();
}, [clearHoverTimer]);
React.useEffect(() => {
if (activityHeadlines.length <= 1) {
return;
}
return { clearHoverTimer, closeWithDelay, open, openWithDelay, setOpen };
}
const interval = window.setInterval(() => {
setHeadlineIndex((current) => (current + 1) % activityHeadlines.length);
}, HEADLINE_ROTATION_MS);
/**
* One working agent's status pill: avatar + the agent's latest action summary
* (decaying to a generic working label when activity goes quiet). Hovering
* shows the agent's live activity feed as the popover surface itself — flat,
* no inset box, no tab strip — while clicking the pill opens the agent's full
* runtime in the auxiliary panel. With the `composerLiveActivity` preview
* flag off, the hover popover keeps the legacy "View activity" item instead.
*/
function BotActivityAgentPill({
agent,
avatarUrl,
channelId,
liveActivityEnabled,
onOpenAgentSession,
openAgentSessionPubkey,
profiles,
}: {
agent: BotActivityAgent;
avatarUrl: string | null;
channelId: string | null;
liveActivityEnabled: boolean;
onOpenAgentSession: (pubkey: string, channelId?: string | null) => void;
openAgentSessionPubkey: string | null;
profiles?: UserProfileLookup;
}) {
const { clearHoverTimer, closeWithDelay, open, openWithDelay, setOpen } =
useHoverPopover();
const transcript = useAgentTranscript(true, agent.pubkey);
const now = useNow(PILL_LABEL_TICK_MS);
const headline = React.useMemo(
() => deriveActivityPillLabel({ channelId, now, transcript }),
[channelId, now, transcript],
);
const label = headline ?? GENERIC_WORKING_LABEL;
const isSessionOpen =
openAgentSessionPubkey?.toLowerCase() === agent.pubkey.toLowerCase();
return () => window.clearInterval(interval);
}, [activityHeadlines.length]);
if (workingAgents.length === 0) {
return null;
}
const agentAvatarUrl = (agent: BotActivityAgent) =>
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null;
const selectedPubkey = openAgentSessionPubkey?.toLowerCase() ?? null;
const triggerLabel =
workingAgents.length === 1
? `${workingAgents[0]?.name ?? "Agent"} is working`
: `${workingAgents.length} agents working`;
const isInline = variant === "inline";
const visibleStatusLabel =
workingAgents.length === 1
? `${workingAgents[0]?.name ?? "Agent"}: ${
activityHeadlines[headlineIndex % activityHeadlines.length] ??
"Working"
}`
: `${workingAgents[0]?.name ?? "Agent"} +${workingAgents.length - 1}`;
const openSession = () => {
clearHoverTimer();
setOpen(false);
onOpenAgentSession(agent.pubkey, channelId);
};
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<button
aria-label={`${triggerLabel}. View activity.`}
className={cn(
"inline-flex items-center justify-center rounded-full border border-border/60 bg-background font-medium text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary",
isInline
? "min-w-0 gap-1.5 overflow-visible border-transparent bg-transparent px-0 text-xs font-normal leading-normal shadow-none hover:border-transparent hover:bg-transparent data-[state=open]:border-transparent data-[state=open]:bg-transparent"
: "h-9 min-w-9 gap-1.5 px-2 text-xs",
)}
aria-label={`${agent.name} is working. View activity.`}
className="inline-flex h-7 min-w-0 max-w-30 items-center gap-1.5 rounded-full border border-border/60 bg-background px-2 text-xs font-semibold leading-none text-muted-foreground shadow-xs transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary"
data-testid="bot-activity-composer-trigger"
onBlur={closeWithDelay}
onClick={() => {
clearHoverTimer();
setOpen((current) => !current);
onClick={(event) => {
// The popover is a hover preview; clicking goes straight to the
// agent's runtime in the aux panel. preventDefault stops Radix's
// composed trigger toggle from fighting over the popover state.
event.preventDefault();
openSession();
}}
onFocus={() => setOpen(true)}
onMouseEnter={openWithDelay}
onMouseLeave={closeWithDelay}
type="button"
>
<span className="flex h-4.5 items-center overflow-visible -space-x-1">
{workingAgents.slice(0, 2).map((agent) => (
<UserAvatar
avatarUrl={agentAvatarUrl(agent)}
className={cn(
"border border-background",
isInline ? "!h-4.5 !w-4.5 text-3xs" : "shrink-0",
)}
displayName={agent.name}
fallbackDelayMs={isInline ? 0 : undefined}
key={agent.pubkey}
size="xs"
/>
))}
<UserAvatar
avatarUrl={avatarUrl}
className="!h-[18px] !w-[18px] shrink-0 text-3xs"
displayName={agent.name}
size="xs"
/>
<span className="min-w-0 flex-1 overflow-hidden">
<Shimmer className="block truncate">{label}</Shimmer>
</span>
{workingAgents.length > 2 ? (
<span className="text-2xs leading-none">
+{workingAgents.length - 2}
</span>
) : null}
<span
className={cn(
isInline
? "flex h-4.5 min-w-0 flex-1 items-center overflow-visible leading-none"
: "sr-only",
)}
>
{isInline ? (
<Shimmer className="-my-px truncate py-px">
{visibleStatusLabel}
</Shimmer>
) : (
"working"
)}
</span>
{isInline ? null : (
<Loader2 className="h-4 w-4 shrink-0 animate-spin opacity-70" />
)}
</button>
</PopoverTrigger>
<PopoverContent
align={isInline ? "start" : "end"}
align="start"
className={cn(
liveActivityEnabled ? "flex w-80 flex-col gap-1.5 p-2" : "w-64 p-1",
liveActivityEnabled ? "w-80 overflow-hidden p-0" : "w-64 p-1",
)}
onMouseEnter={keepOpen}
onMouseEnter={clearHoverTimer}
onMouseLeave={closeWithDelay}
onOpenAutoFocus={(event) => event.preventDefault()}
side="top"
sideOffset={8}
>
{liveActivityEnabled ? (
<>
{selectedActivityAgent ? (
<ComposerLiveActivityFeed
agent={selectedActivityAgent}
channelId={channelId}
className="h-48 rounded-lg border border-border/60 bg-background"
onOpenAgentSession={(pubkey) => {
clearHoverTimer();
setOpen(false);
onOpenAgentSession(pubkey, channelId);
}}
profiles={profiles}
/>
) : null}
<div
aria-label="Working agents"
className="flex w-full flex-wrap items-center justify-start gap-1.5"
role="tablist"
>
{workingAgents.map((agent) => {
const isSelected =
selectedActivityAgent?.pubkey.toLowerCase() ===
agent.pubkey.toLowerCase();
return (
<Button
aria-selected={isSelected}
className="max-w-full shrink-0 rounded-full px-2"
data-testid={`bot-activity-composer-item-${agent.pubkey}`}
key={agent.pubkey}
onClick={() =>
setSelectedActivityPubkey(agent.pubkey.toLowerCase())
}
role="tab"
size="sm"
type="button"
variant={isSelected ? "secondary" : "ghost"}
>
<UserAvatar
avatarUrl={agentAvatarUrl(agent)}
className="!h-[18px] !w-[18px] shrink-0 text-3xs"
displayName={agent.name}
size="xs"
/>
<span className="max-w-28 truncate">{agent.name}</span>
</Button>
);
})}
</div>
</>
<ComposerLiveActivityFeed
agent={agent}
channelId={channelId}
className="h-48 rounded-[inherit]"
onOpenAgentSession={() => openSession()}
profiles={profiles}
/>
) : (
<>
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
Agents working
</div>
<div className="mt-1 flex flex-col gap-1">
{workingAgents.map((agent) => {
const isSelected =
selectedPubkey === agent.pubkey.toLowerCase();
return (
<button
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors",
isSelected
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`bot-activity-composer-item-${agent.pubkey}`}
key={agent.pubkey}
onClick={() => {
clearHoverTimer();
setOpen(false);
onOpenAgentSession(agent.pubkey, channelId);
}}
type="button"
>
<UserAvatar
avatarUrl={agentAvatarUrl(agent)}
className="shrink-0"
displayName={agent.name}
size="sm"
/>
<span className="min-w-0 flex-1 truncate">
{agent.name}
</span>
<span className="shrink-0 whitespace-nowrap text-xs font-medium opacity-80">
View activity
</span>
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground/70" />
</button>
);
})}
</div>
</>
<button
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors",
isSessionOpen
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`bot-activity-composer-item-${agent.pubkey}`}
onClick={openSession}
type="button"
>
<UserAvatar
avatarUrl={avatarUrl}
className="shrink-0"
displayName={agent.name}
size="sm"
/>
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
<span className="shrink-0 whitespace-nowrap text-xs font-medium opacity-80">
View activity
</span>
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground/70" />
</button>
)}
</PopoverContent>
</Popover>
);
}
/**
* Composer status strip for working agents: one pill per working agent, each
* owning its hover popover. Pills shrink and truncate their labels when
* several agents work at once so the strip never wraps the fixed-height row.
*/
export function BotActivityComposerAction({
agents,
channelId = null,
onOpenAgentSession,
openAgentSessionPubkey,
profiles,
workingBotPubkeys,
}: BotActivityBarProps) {
const liveActivityEnabled = useFeatureEnabled("composerLiveActivity");
const workingAgents = React.useMemo(() => {
const workingSet = new Set(
workingBotPubkeys.map((pubkey) => pubkey.toLowerCase()),
);
return agents.filter((agent) => workingSet.has(agent.pubkey.toLowerCase()));
}, [agents, workingBotPubkeys]);
if (workingAgents.length === 0) {
return null;
}
return (
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
{workingAgents.map((agent) => (
<BotActivityAgentPill
agent={agent}
avatarUrl={profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null}
channelId={channelId}
key={agent.pubkey}
liveActivityEnabled={liveActivityEnabled}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
/>
))}
</div>
);
}
@@ -54,7 +54,6 @@ export function ChannelComposerActivityRow({
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
variant="inline"
workingBotPubkeys={workingBotPubkeys}
/>
</div>
@@ -905,7 +905,6 @@ export const ChannelPane = React.memo(function ChannelPane({
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
workingBotPubkeys={threadComposerBotTypingPubkeys}
variant="inline"
/>
) : null
}
@@ -63,7 +63,7 @@ export function ComposerLiveActivityFeed({
>
<button
aria-label={openLabel}
className="absolute inset-0 z-10 cursor-pointer rounded-lg transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
className="absolute inset-0 z-10 cursor-pointer rounded-[inherit] transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
data-testid="composer-live-activity-open"
onClick={() => onOpenAgentSession(agent.pubkey)}
type="button"
@@ -2,61 +2,115 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
deriveActivityPillLabel,
deriveLastLiveAt,
resolveSelectedActivityAgent,
} from "./composerLiveActivity.ts";
const alice = { pubkey: "ALICE-pubkey", name: "Alice" };
const bob = { pubkey: "bob-pubkey", name: "Bob" };
test("explicit selection wins, case-insensitively", () => {
const agent = resolveSelectedActivityAgent({
openAgentSessionPubkey: "bob-pubkey",
selectedPubkey: "alice-PUBKEY",
workingAgents: [alice, bob],
});
assert.equal(agent, alice);
});
test("falls back to the open session agent", () => {
const agent = resolveSelectedActivityAgent({
openAgentSessionPubkey: "bob-pubkey",
selectedPubkey: null,
workingAgents: [alice, bob],
});
assert.equal(agent, bob);
});
test("falls back to the first working agent", () => {
const agent = resolveSelectedActivityAgent({
openAgentSessionPubkey: "gone-pubkey",
selectedPubkey: "also-gone",
workingAgents: [alice, bob],
});
assert.equal(agent, alice);
});
test("selection that stopped working falls through", () => {
const agent = resolveSelectedActivityAgent({
openAgentSessionPubkey: null,
selectedPubkey: "bob-pubkey",
workingAgents: [alice],
});
assert.equal(agent, alice);
});
test("returns null with no working agents", () => {
const agent = resolveSelectedActivityAgent({
openAgentSessionPubkey: null,
selectedPubkey: null,
workingAgents: [],
});
assert.equal(agent, null);
});
const CHANNEL = "channel-1";
const OTHER_CHANNEL = "channel-2";
const NOW = Date.parse("2026-07-23T00:01:00.000Z");
/** Thought item: spine, headlined by its title. */
const thought = (title, timestamp, channelId = CHANNEL) => ({
id: `thought-${title}-${timestamp}`,
type: "thought",
renderClass: "thought",
title,
text: "",
timestamp,
channelId,
});
/** Metadata item: meaningful but NOT spine — recedes when real work exists. */
const metadata = (title, timestamp, channelId = CHANNEL) => ({
id: `metadata-${title}-${timestamp}`,
type: "metadata",
renderClass: "raw-rail",
title,
sections: [],
timestamp,
acpSource: "prompt_context",
channelId,
});
const secondsBeforeNow = (seconds) =>
new Date(NOW - seconds * 1000).toISOString();
test("deriveActivityPillLabel returns the newest fresh headline, no rotation", () => {
const label = deriveActivityPillLabel({
channelId: CHANNEL,
now: NOW,
transcript: [
thought("Reading files", secondsBeforeNow(8)),
thought("Editing ChannelPane", secondsBeforeNow(2)),
],
});
assert.equal(label, "Editing ChannelPane");
});
test("deriveActivityPillLabel decays to null once the newest headline is stale", () => {
const label = deriveActivityPillLabel({
channelId: CHANNEL,
now: NOW,
transcript: [thought("Editing ChannelPane", secondsBeforeNow(30))],
});
assert.equal(label, null);
});
test("deriveActivityPillLabel honors a custom staleness window", () => {
const transcript = [thought("Editing ChannelPane", secondsBeforeNow(30))];
const label = deriveActivityPillLabel({
channelId: CHANNEL,
now: NOW,
staleAfterMs: 60_000,
transcript,
});
assert.equal(label, "Editing ChannelPane");
});
test("deriveActivityPillLabel ignores other-channel items", () => {
const label = deriveActivityPillLabel({
channelId: CHANNEL,
now: NOW,
transcript: [
thought("In-channel work", secondsBeforeNow(10)),
thought("Other-channel work", secondsBeforeNow(1), OTHER_CHANNEL),
],
});
assert.equal(label, "In-channel work");
});
test("deriveActivityPillLabel lets spine work headline over fresher metadata reads", () => {
const label = deriveActivityPillLabel({
channelId: CHANNEL,
now: NOW,
transcript: [
thought("Real work", secondsBeforeNow(10)),
metadata("Prompt context", secondsBeforeNow(1)),
],
});
assert.equal(label, "Real work");
});
test("deriveActivityPillLabel falls back to metadata when no spine items exist", () => {
const label = deriveActivityPillLabel({
channelId: CHANNEL,
now: NOW,
transcript: [metadata("Prompt context", secondsBeforeNow(5))],
});
assert.equal(label, "Prompt context");
});
test("deriveActivityPillLabel returns null for an empty transcript", () => {
const label = deriveActivityPillLabel({
channelId: CHANNEL,
now: NOW,
transcript: [],
});
assert.equal(label, null);
});
test("deriveLastLiveAt prefers the newest channel-scoped transcript item", () => {
const lastLiveAt = deriveLastLiveAt({
activeTurns: [],
@@ -1,34 +1,58 @@
/**
* Selection logic for the composer live-activity preview.
*
* The popover shows ONE working agent's feed at a time. Resolution order:
* explicit tab selection, then the agent whose session pane is already open,
* then the first working agent. A selection that stops working (its agent
* leaves the list) silently falls through to the next candidate rather than
* pinning a dead tab.
*/
export function resolveSelectedActivityAgent<T extends { pubkey: string }>({
openAgentSessionPubkey,
selectedPubkey,
workingAgents,
}: {
openAgentSessionPubkey: string | null;
selectedPubkey: string | null;
workingAgents: readonly T[];
}): T | null {
const findByPubkey = (pubkey: string | null) =>
pubkey
? (workingAgents.find(
(agent) => agent.pubkey.toLowerCase() === pubkey.toLowerCase(),
) ?? null)
: null;
import {
getActivityHeadline,
isMeaningfulItem,
isSpineItem,
} from "@/features/agents/ui/agentSessionTranscriptPresentation";
import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes";
return (
findByPubkey(selectedPubkey) ??
findByPubkey(openAgentSessionPubkey) ??
workingAgents[0] ??
null
);
/**
* How long the latest action headline stays on a working agent's composer
* pill before the label decays to the generic working state.
*/
export const ACTIVITY_PILL_STALE_MS = 15_000;
/**
* Latest fresh action headline for a working agent's composer pill.
*
* Channel-scoped, two-tier scan (spine items headline over metadata reads,
* mirroring the session transcript's noise gate), newest wins. Returns null
* when there is no headline or the newest one is older than `staleAfterMs`;
* the pill then falls back to its generic "Working…" label. Deliberately no
* rotation through recent actions: one headline, then decay.
*/
export function deriveActivityPillLabel({
channelId,
now,
staleAfterMs = ACTIVITY_PILL_STALE_MS,
transcript,
}: {
channelId: string | null;
now: number;
staleAfterMs?: number;
transcript: readonly TranscriptItem[];
}): string | null {
const scoped = channelId
? transcript.filter((item) => item.channelId === channelId)
: transcript;
const passFilter = scoped.some(isSpineItem) ? isSpineItem : isMeaningfulItem;
for (let index = scoped.length - 1; index >= 0; index -= 1) {
const item = scoped[index];
if (!item || !passFilter(item)) {
continue;
}
const headline = getActivityHeadline(item);
if (!headline) {
continue;
}
const millis = Date.parse(item.timestamp);
if (!Number.isNaN(millis) && now - millis > staleAfterMs) {
return null;
}
return headline;
}
return null;
}
/**