diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 5cdecb8593..754762f863 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -1,6 +1,11 @@ import * as React from "react"; import { Loader2 } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + getAgentTranscript, + subscribeAgentObserverStore, +} from "@/features/agents/observerRelayStore"; import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; @@ -16,7 +21,10 @@ import { import { Shimmer } from "@/shared/ui/Shimmer"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { ComposerLiveActivityFeed } from "./ComposerLiveActivityFeed"; -import { deriveActivityPillLabel } from "./composerLiveActivity"; +import { + deriveActivityPillLabel, + deriveAgentActivityOrder, +} from "./composerLiveActivity"; export type BotActivityAgent = Pick; @@ -34,6 +42,18 @@ const HOVER_CLOSE_DELAY_MS = 180; const PILL_LABEL_TICK_MS = 5_000; /** Shown when no fresh action headline exists (see deriveActivityPillLabel). */ const GENERIC_WORKING_LABEL = "Working…"; +/** Ticker key for the generic label so decay/recovery animate as one swap. */ +const GENERIC_LABEL_ID = "generic-working"; +/** + * Perceptual duration shared by the pill reorder spring and the opacity dip + * keyframes so the fade lands together with the layout switch. + */ +const PILL_REORDER_DURATION_S = 0.9; + +// Stable subscribe reference for useSyncExternalStore (same pattern as +// useObserverEvents). +const subscribeToObserverStore = (onStoreChange: () => void) => + subscribeAgentObserverStore(onStoreChange); /** Hover-intent popover state shared by every activity pill. */ function useHoverPopover() { @@ -72,16 +92,20 @@ function useHoverPopover() { /** * 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. + * (decaying to a generic working label when activity goes quiet). Label + * updates play as a clipped ticker — the new text pushes the old text up and + * out — deferred until the pill's slot settles when a reorder is in flight. + * 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, + holdLabelSwap, liveActivityEnabled, onOpenAgentSession, openAgentSessionPubkey, @@ -90,6 +114,8 @@ function BotActivityAgentPill({ agent: BotActivityAgent; avatarUrl: string | null; channelId: string | null; + /** Defer label swaps while the pill's slot is mid layout animation. */ + holdLabelSwap: boolean; liveActivityEnabled: boolean; onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; openAgentSessionPubkey: string | null; @@ -97,13 +123,33 @@ function BotActivityAgentPill({ }) { const { clearHoverTimer, closeWithDelay, open, openWithDelay, setOpen } = useHoverPopover(); + const shouldReduceMotion = useReducedMotion(); 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 activeId = headline?.id ?? GENERIC_LABEL_ID; + const activeLabel = headline?.label ?? GENERIC_WORKING_LABEL; + // The rendered label lags the derived one while the pill is moving: the + // push-up ticker plays after the slot settles (or immediately when idle). + // Keyed by transcript item id, not label text — a NEW action swaps, while + // streamed chunks growing the same item update text in place. + const [display, setDisplay] = React.useState({ + id: activeId, + label: activeLabel, + }); + React.useEffect(() => { + if (holdLabelSwap) { + return; + } + setDisplay((current) => + current.id === activeId && current.label === activeLabel + ? current + : { id: activeId, label: activeLabel }, + ); + }, [holdLabelSwap, activeId, activeLabel]); const isSessionOpen = openAgentSessionPubkey?.toLowerCase() === agent.pubkey.toLowerCase(); @@ -118,7 +164,7 @@ function BotActivityAgentPill({ @@ -193,10 +256,58 @@ function BotActivityAgentPill({ ); } +/** + * Layout slot for one pill in the composer strip. While a pill travels to a + * new slot (layout animation) its opacity dips and recovers via keyframes + * that run for the same duration as the slot spring, so the fade and the + * move finish together and reorders read as a shuffle instead of a hard + * swap. Enter/exit use the same scale+fade treatment. + */ +function AnimatedPillSlot({ + children, + shouldReduceMotion, +}: { + /** Render prop so the pill can defer label swaps while its slot moves. */ + children: (isMoving: boolean) => React.ReactNode; + shouldReduceMotion: boolean; +}) { + const [isMoving, setIsMoving] = React.useState(false); + + return ( + setIsMoving(false)} + onLayoutAnimationStart={() => setIsMoving(true)} + transition={ + shouldReduceMotion + ? { duration: 0 } + : { + type: "spring", + duration: PILL_REORDER_DURATION_S, + bounce: 0.15, + opacity: { + type: "tween", + duration: PILL_REORDER_DURATION_S, + ease: "linear", + }, + } + } + > + {children(isMoving)} + + ); +} + /** * 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. + * owning its hover popover. Pills are ordered most-recently-active first + * (left-most) and animate to their new slot as the order changes. 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, @@ -207,6 +318,7 @@ export function BotActivityComposerAction({ workingBotPubkeys, }: BotActivityBarProps) { const liveActivityEnabled = useFeatureEnabled("composerLiveActivity"); + const shouldReduceMotion = useReducedMotion(); const workingAgents = React.useMemo(() => { const workingSet = new Set( @@ -216,24 +328,60 @@ export function BotActivityComposerAction({ return agents.filter((agent) => workingSet.has(agent.pubkey.toLowerCase())); }, [agents, workingBotPubkeys]); - if (workingAgents.length === 0) { + // Most-recent-first pill order. The snapshot is a joined string so + // useSyncExternalStore only re-renders this strip when the ORDER actually + // changes, not on every observer store write. + const getOrderSnapshot = React.useCallback( + () => + deriveAgentActivityOrder({ + channelId, + getTranscript: (pubkey) => getAgentTranscript(pubkey, true), + pubkeys: workingAgents.map((agent) => agent.pubkey.toLowerCase()), + }).join(","), + [channelId, workingAgents], + ); + const orderKey = React.useSyncExternalStore( + subscribeToObserverStore, + getOrderSnapshot, + ); + const orderedAgents = React.useMemo(() => { + const byPubkey = new Map( + workingAgents.map((agent) => [agent.pubkey.toLowerCase(), agent]), + ); + return orderKey === "" + ? [] + : orderKey.split(",").flatMap((pubkey) => byPubkey.get(pubkey) ?? []); + }, [orderKey, workingAgents]); + + if (orderedAgents.length === 0) { return null; } return (
- {workingAgents.map((agent) => ( - - ))} + + {orderedAgents.map((agent) => ( + + {(isMoving) => ( + + )} + + ))} +
); } diff --git a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs index ae6519a1f8..723eeadda7 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs +++ b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { deriveActivityPillLabel, + deriveAgentActivityOrder, deriveLastLiveAt, } from "./composerLiveActivity.ts"; @@ -10,6 +11,7 @@ const CHANNEL = "channel-1"; const OTHER_CHANNEL = "channel-2"; const NOW = Date.parse("2026-07-23T00:01:00.000Z"); +const NOW_ISO = new Date(NOW).toISOString(); /** Thought item: spine, headlined by its title. */ const thought = (title, timestamp, channelId = CHANNEL) => ({ @@ -34,81 +36,197 @@ const metadata = (title, timestamp, channelId = CHANNEL) => ({ channelId, }); +/** Lifecycle meta-frame (usage tick / commands): spine, but never a headline. */ +const lifecycleMeta = (acpSource, timestamp, channelId = CHANNEL) => ({ + id: `lifecycle-${acpSource}-${timestamp}`, + type: "lifecycle", + renderClass: "status", + title: acpSource === "usage_update" ? "Usage" : "Commands", + text: "Tokens: 1200/8192", + timestamp, + acpSource, + channelId, +}); + +/** Streaming assistant message: headline is the (growing) first line. */ +const assistantMessage = (id, text, timestamp, channelId = CHANNEL) => ({ + id, + type: "message", + role: "assistant", + title: "", + text, + timestamp, + channelId, +}); + const secondsBeforeNow = (seconds) => new Date(NOW - seconds * 1000).toISOString(); test("deriveActivityPillLabel returns the newest fresh headline, no rotation", () => { - const label = deriveActivityPillLabel({ + const editing = thought("Editing ChannelPane", secondsBeforeNow(2)); + const headline = deriveActivityPillLabel({ channelId: CHANNEL, now: NOW, - transcript: [ - thought("Reading files", secondsBeforeNow(8)), - thought("Editing ChannelPane", secondsBeforeNow(2)), - ], + transcript: [thought("Reading files", secondsBeforeNow(8)), editing], }); - assert.equal(label, "Editing ChannelPane"); + assert.deepEqual(headline, { id: editing.id, label: "Editing ChannelPane" }); }); test("deriveActivityPillLabel decays to null once the newest headline is stale", () => { - const label = deriveActivityPillLabel({ + const headline = deriveActivityPillLabel({ channelId: CHANNEL, now: NOW, transcript: [thought("Editing ChannelPane", secondsBeforeNow(30))], }); - assert.equal(label, null); + assert.equal(headline, null); }); test("deriveActivityPillLabel honors a custom staleness window", () => { - const transcript = [thought("Editing ChannelPane", secondsBeforeNow(30))]; - const label = deriveActivityPillLabel({ + const editing = thought("Editing ChannelPane", secondsBeforeNow(30)); + const headline = deriveActivityPillLabel({ channelId: CHANNEL, now: NOW, staleAfterMs: 60_000, - transcript, + transcript: [editing], }); - assert.equal(label, "Editing ChannelPane"); + assert.deepEqual(headline, { id: editing.id, label: "Editing ChannelPane" }); }); test("deriveActivityPillLabel ignores other-channel items", () => { - const label = deriveActivityPillLabel({ + const inChannel = thought("In-channel work", secondsBeforeNow(10)); + const headline = deriveActivityPillLabel({ channelId: CHANNEL, now: NOW, transcript: [ - thought("In-channel work", secondsBeforeNow(10)), + inChannel, thought("Other-channel work", secondsBeforeNow(1), OTHER_CHANNEL), ], }); - assert.equal(label, "In-channel work"); + assert.deepEqual(headline, { id: inChannel.id, label: "In-channel work" }); }); test("deriveActivityPillLabel lets spine work headline over fresher metadata reads", () => { - const label = deriveActivityPillLabel({ + const realWork = thought("Real work", secondsBeforeNow(10)); + const headline = deriveActivityPillLabel({ channelId: CHANNEL, now: NOW, - transcript: [ - thought("Real work", secondsBeforeNow(10)), - metadata("Prompt context", secondsBeforeNow(1)), - ], + transcript: [realWork, metadata("Prompt context", secondsBeforeNow(1))], }); - assert.equal(label, "Real work"); + assert.deepEqual(headline, { id: realWork.id, label: "Real work" }); }); test("deriveActivityPillLabel falls back to metadata when no spine items exist", () => { - const label = deriveActivityPillLabel({ + const context = metadata("Prompt context", secondsBeforeNow(5)); + const headline = deriveActivityPillLabel({ channelId: CHANNEL, now: NOW, - transcript: [metadata("Prompt context", secondsBeforeNow(5))], + transcript: [context], }); - assert.equal(label, "Prompt context"); + assert.deepEqual(headline, { id: context.id, label: "Prompt context" }); }); test("deriveActivityPillLabel returns null for an empty transcript", () => { - const label = deriveActivityPillLabel({ + const headline = deriveActivityPillLabel({ channelId: CHANNEL, now: NOW, transcript: [], }); - assert.equal(label, null); + assert.equal(headline, null); +}); + +test("deriveActivityPillLabel never headlines usage/commands meta frames", () => { + const realWork = thought("Real work", secondsBeforeNow(10)); + const headline = deriveActivityPillLabel({ + channelId: CHANNEL, + now: NOW, + transcript: [ + realWork, + lifecycleMeta("usage_update", secondsBeforeNow(2)), + lifecycleMeta("available_commands_update", secondsBeforeNow(1)), + ], + }); + // Meta frames are skipped entirely — the older real action still headlines. + assert.deepEqual(headline, { id: realWork.id, label: "Real work" }); +}); + +test("deriveActivityPillLabel returns null when only meta frames are fresh", () => { + const headline = deriveActivityPillLabel({ + channelId: CHANNEL, + now: NOW, + transcript: [lifecycleMeta("usage_update", secondsBeforeNow(1))], + }); + assert.equal(headline, null); +}); + +test("deriveActivityPillLabel keeps a stable id while a message streams", () => { + const first = deriveActivityPillLabel({ + channelId: CHANNEL, + now: NOW, + transcript: [assistantMessage("msg-1", "Pass 1: reading", NOW_ISO)], + }); + const extended = deriveActivityPillLabel({ + channelId: CHANNEL, + now: NOW, + transcript: [ + assistantMessage("msg-1", "Pass 1: reading the composer wiring", NOW_ISO), + ], + }); + // Same item id: the pill updates text in place instead of re-animating. + assert.equal(first.id, "msg-1"); + assert.equal(extended.id, "msg-1"); + assert.equal(extended.label, "Pass 1: reading the composer wiring"); +}); + +test("deriveAgentActivityOrder puts the most recently active agent first", () => { + const transcripts = new Map([ + ["alpha", [thought("Older work", secondsBeforeNow(20))]], + ["beta", [thought("Newer work", secondsBeforeNow(2))]], + ]); + const order = deriveAgentActivityOrder({ + channelId: CHANNEL, + getTranscript: (pubkey) => transcripts.get(pubkey) ?? [], + pubkeys: ["alpha", "beta"], + }); + assert.deepEqual(order, ["beta", "alpha"]); +}); + +test("deriveAgentActivityOrder keeps roster order for agents with no activity", () => { + const transcripts = new Map([ + ["gamma", [thought("Only worker", secondsBeforeNow(5))]], + ]); + const order = deriveAgentActivityOrder({ + channelId: CHANNEL, + getTranscript: (pubkey) => transcripts.get(pubkey) ?? [], + pubkeys: ["alpha", "beta", "gamma"], + }); + assert.deepEqual(order, ["gamma", "alpha", "beta"]); +}); + +test("deriveAgentActivityOrder ignores other-channel activity", () => { + const transcripts = new Map([ + ["alpha", [thought("In-channel", secondsBeforeNow(30))]], + ["beta", [thought("Elsewhere", secondsBeforeNow(1), OTHER_CHANNEL)]], + ]); + const order = deriveAgentActivityOrder({ + channelId: CHANNEL, + getTranscript: (pubkey) => transcripts.get(pubkey) ?? [], + pubkeys: ["alpha", "beta"], + }); + assert.deepEqual(order, ["alpha", "beta"]); +}); + +test("deriveAgentActivityOrder is stable for identical timestamps", () => { + const sameTime = secondsBeforeNow(3); + const transcripts = new Map([ + ["alpha", [thought("Tied A", sameTime)]], + ["beta", [thought("Tied B", sameTime)]], + ]); + const order = deriveAgentActivityOrder({ + channelId: CHANNEL, + getTranscript: (pubkey) => transcripts.get(pubkey) ?? [], + pubkeys: ["alpha", "beta"], + }); + assert.deepEqual(order, ["alpha", "beta"]); }); test("deriveLastLiveAt prefers the newest channel-scoped transcript item", () => { diff --git a/desktop/src/features/channels/ui/composerLiveActivity.ts b/desktop/src/features/channels/ui/composerLiveActivity.ts index 6e6252b073..02e19c1221 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.ts +++ b/desktop/src/features/channels/ui/composerLiveActivity.ts @@ -11,6 +11,26 @@ import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; */ export const ACTIVITY_PILL_STALE_MS = 15_000; +/** + * Lifecycle meta-frames that must never headline the pill. They pass the + * spine filter (meaningful lifecycle items) but would surface as bare + * "Usage" / "Commands" labels that read like phantom activity. + */ +const PILL_HEADLINE_EXCLUDED_SOURCES = new Set([ + "usage_update", + "available_commands_update", +]); + +export type ActivityPillHeadline = { + /** + * Transcript item id backing the headline. The pill keys its ticker swap + * on this id, so a NEW action animates while streamed chunks that grow the + * same item (message first line, thought text) update text in place. + */ + id: string; + label: string; +}; + /** * Latest fresh action headline for a working agent's composer pill. * @@ -30,7 +50,7 @@ export function deriveActivityPillLabel({ now: number; staleAfterMs?: number; transcript: readonly TranscriptItem[]; -}): string | null { +}): ActivityPillHeadline | null { const scoped = channelId ? transcript.filter((item) => item.channelId === channelId) : transcript; @@ -41,6 +61,13 @@ export function deriveActivityPillLabel({ if (!item || !passFilter(item)) { continue; } + if ( + item.type === "lifecycle" && + item.acpSource !== undefined && + PILL_HEADLINE_EXCLUDED_SOURCES.has(item.acpSource) + ) { + continue; + } const headline = getActivityHeadline(item); if (!headline) { continue; @@ -49,12 +76,54 @@ export function deriveActivityPillLabel({ if (!Number.isNaN(millis) && now - millis > staleAfterMs) { return null; } - return headline; + return { id: item.id, label: headline }; } return null; } +/** + * Working-agent pill order for the composer strip: agents with the newest + * channel-scoped transcript activity first (left-most). Agents with no + * recorded activity keep their incoming (roster) order at the end — the + * sort is stable, so ties never reshuffle. + * + * `getTranscript` is injected so the helper stays pure and unit-testable; + * callers pass the observer store's cached reader. + */ +export function deriveAgentActivityOrder({ + channelId, + getTranscript, + pubkeys, +}: { + channelId: string | null; + getTranscript: (pubkey: string) => readonly TranscriptItem[]; + pubkeys: readonly string[]; +}): string[] { + const lastActivityAt = new Map(); + + for (const pubkey of pubkeys) { + const transcript = getTranscript(pubkey); + for (let index = transcript.length - 1; index >= 0; index -= 1) { + const item = transcript[index]; + if (!item || (channelId && item.channelId !== channelId)) { + continue; + } + const millis = Date.parse(item.timestamp); + if (!Number.isNaN(millis)) { + lastActivityAt.set(pubkey, millis); + break; + } + } + } + + return [...pubkeys].sort( + (a, b) => + (lastActivityAt.get(b) ?? Number.NEGATIVE_INFINITY) - + (lastActivityAt.get(a) ?? Number.NEGATIVE_INFINITY), + ); +} + /** * Latest activity timestamp (ms) for the composer preview's "Last live" pill. * diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index 5a0723cdaa..7327bb511a 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -238,6 +238,15 @@ -webkit-text-fill-color: transparent; } +/* Truncated hosts (Tailwind `truncate`): ellipsize the overlay copy the same + way as the base text so the highlight sweeps the ellipsis, instead of + revealing the clipped characters at the right edge. `white-space: nowrap` + is inherited from the host; overflow rules are not, so restate them. */ +.buzz-shimmer.truncate::before { + overflow: hidden; + text-overflow: ellipsis; +} + @keyframes buzz-shimmer { 0%, 100% { diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 16860d7c66..b15b6d44d8 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2060,19 +2060,15 @@ test("shows and clears activity indicators for active channel agents", async ({ }); }, TEST_IDENTITIES.alice.pubkey); - await expect(page.getByTestId("bot-activity-composer-trigger")).toBeVisible(); - await expect( - page.getByTestId("bot-activity-composer-trigger"), - ).not.toContainText("View activity"); - // Hovering the pill opens the preview popover with the activity item. - await page.getByTestId("bot-activity-composer-trigger").hover(); - const aliceActivityItem = page.getByTestId( - `bot-activity-composer-item-${TEST_IDENTITIES.alice.pubkey}`, - ); - await expect(aliceActivityItem).toBeVisible(); - await expect(aliceActivityItem).toContainText("View activity"); - // Clicking the pill itself opens the agent's runtime in the aux panel. - await page.getByTestId("bot-activity-composer-trigger").click(); + const activityPill = page.getByTestId("bot-activity-composer-trigger"); + await expect(activityPill).toBeVisible(); + await expect(activityPill).not.toContainText("View activity"); + // E2E seeds every preview feature on, so the composerLiveActivity path is + // what renders here: hovering the pill opens the live-activity preview. + await activityPill.hover(); + await expect(page.getByTestId("composer-live-activity-feed")).toBeVisible(); + // Clicking the pill promotes the agent's runtime into the aux panel. + await activityPill.click(); await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible(); await expect(page.getByTestId("agent-session-thread-panel")).toContainText( "alice",