From d1d1eba64f88a44d412ea7a511c8bfd491f30d8a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 26 Jul 2026 21:04:01 -0700 Subject: [PATCH] feat(composer): flatten agent activity UI into per-agent status pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Signed-off-by: Taylor Ho --- .../features/channels/ui/BotActivityBar.tsx | 414 ++++++------------ .../ui/ChannelComposerActivityRow.tsx | 1 - .../src/features/channels/ui/ChannelPane.tsx | 1 - .../channels/ui/ComposerLiveActivityFeed.tsx | 2 +- .../channels/ui/composerLiveActivity.test.mjs | 152 ++++--- .../channels/ui/composerLiveActivity.ts | 84 ++-- desktop/tests/e2e/channels.spec.ts | 6 +- 7 files changed, 307 insertions(+), 353 deletions(-) diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index cd3dded72..18b26fb38 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -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; @@ -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 | 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(); - 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 ( event.preventDefault()} side="top" sideOffset={8} > {liveActivityEnabled ? ( - <> - {selectedActivityAgent ? ( - { - clearHoverTimer(); - setOpen(false); - onOpenAgentSession(pubkey, channelId); - }} - profiles={profiles} - /> - ) : null} -
- {workingAgents.map((agent) => { - const isSelected = - selectedActivityAgent?.pubkey.toLowerCase() === - agent.pubkey.toLowerCase(); - - return ( - - ); - })} -
- + openSession()} + profiles={profiles} + /> ) : ( - <> -
- Agents working -
-
- {workingAgents.map((agent) => { - const isSelected = - selectedPubkey === agent.pubkey.toLowerCase(); - - return ( - - ); - })} -
- + )}
); } + +/** + * 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 ( +
+ {workingAgents.map((agent) => ( + + ))} +
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx b/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx index a82170f01..71cd5e4c6 100644 --- a/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx +++ b/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx @@ -54,7 +54,6 @@ export function ChannelComposerActivityRow({ onOpenAgentSession={onOpenAgentSession} openAgentSessionPubkey={openAgentSessionPubkey} profiles={profiles} - variant="inline" workingBotPubkeys={workingBotPubkeys} /> diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 96f32a340..c8938049d 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -905,7 +905,6 @@ export const ChannelPane = React.memo(function ChannelPane({ openAgentSessionPubkey={openAgentSessionPubkey} profiles={profiles} workingBotPubkeys={threadComposerBotTypingPubkeys} - variant="inline" /> ) : null } diff --git a/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx b/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx index 4940e8f12..eeaea1523 100644 --- a/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx +++ b/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx @@ -63,7 +63,7 @@ export function ComposerLiveActivityFeed({ >