diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 72a0c755f..a4b4054d4 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { Loader2 } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { getAgentTranscript, subscribeAgentObserverStore, @@ -34,10 +35,11 @@ type BotActivityBarProps = { const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 180; -/** 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…"; +/** + * Re-render cadence for the pill label's staleness check. One second so the + * decay to the generic label lands promptly after the stale window elapses. + */ +const PILL_LABEL_TICK_MS = 1_000; /** Ticker key for the generic label so decay/recovery animate as one swap. */ const GENERIC_LABEL_ID = "generic-working"; /** @@ -51,39 +53,103 @@ const PILL_REORDER_DURATION_S = 0.9; const subscribeToObserverStore = (onStoreChange: () => void) => subscribeAgentObserverStore(onStoreChange); -/** Hover-intent popover state shared by every activity pill. */ -function useHoverPopover() { - const [open, setOpen] = React.useState(false); - const hoverTimerRef = React.useRef | null>( - null, - ); +/** + * Strip-level hover popover state: ONE active pill and ONE timer for the + * whole composer strip. + * + * Hover state used to live per pill, which raced N independent open/close + * timers against each other: traveling pill A → pill B opened B's card at + * +150ms while A's close fired at +180ms (brief double-open), and a cursor + * that clipped A's open card on the way cancelled A's close entirely, + * leaving the card stuck. Centralizing means at most one card can ever be + * open and pill-to-pill travel is a single deterministic switch. + */ +type StripHoverPopover = { + /** Pill key (lowercased pubkey) whose hover card is open, or null. */ + activePubkey: string | null; + /** Cancel any pending open/close without touching the current card. */ + cancelPending: () => void; + /** Close immediately (click-through to the session, Radix dismiss). */ + closeNow: () => void; + /** Pointer entered a pill trigger. */ + enterTrigger: (pubkey: string) => void; + /** Open a pill's card immediately (keyboard focus). */ + openNow: (pubkey: string) => void; + /** Pointer left a trigger or the open card: close after the grace delay. */ + scheduleClose: () => void; +}; - const clearHoverTimer = React.useCallback(() => { - if (hoverTimerRef.current !== null) { - clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = null; +function useStripHoverPopover(): StripHoverPopover { + const [activePubkey, setActivePubkey] = React.useState(null); + // Ref mirror so pointer handlers can branch on open-vs-closed synchronously. + const activeRef = React.useRef(null); + const timerRef = React.useRef | null>(null); + + const cancelPending = React.useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; } }, []); - const openWithDelay = React.useCallback(() => { - clearHoverTimer(); - hoverTimerRef.current = setTimeout(() => { - setOpen(true); - }, HOVER_OPEN_DELAY_MS); - }, [clearHoverTimer]); + const setActive = React.useCallback( + (pubkey: string | null) => { + cancelPending(); + activeRef.current = pubkey; + setActivePubkey(pubkey); + }, + [cancelPending], + ); - const closeWithDelay = React.useCallback(() => { - clearHoverTimer(); - hoverTimerRef.current = setTimeout(() => { - setOpen(false); + const enterTrigger = React.useCallback( + (pubkey: string) => { + // With a card up (or in its close grace period), reaching another pill + // switches the card right away — re-arming the intent delay here is + // what raced the per-pill timers. From a fully closed strip, keep the + // hover-intent delay; the single timer means the pending target simply + // follows the cursor. + if (activeRef.current !== null) { + setActive(pubkey); + return; + } + cancelPending(); + timerRef.current = setTimeout(() => { + setActive(pubkey); + }, HOVER_OPEN_DELAY_MS); + }, + [cancelPending, setActive], + ); + + const scheduleClose = React.useCallback(() => { + cancelPending(); + if (activeRef.current === null) { + // Nothing open — leaving just abandons the pending hover intent. + return; + } + timerRef.current = setTimeout(() => { + setActive(null); }, HOVER_CLOSE_DELAY_MS); - }, [clearHoverTimer]); + }, [cancelPending, setActive]); + + const openNow = React.useCallback( + (pubkey: string) => setActive(pubkey), + [setActive], + ); + + const closeNow = React.useCallback(() => setActive(null), [setActive]); React.useEffect(() => { - return () => clearHoverTimer(); - }, [clearHoverTimer]); + return () => cancelPending(); + }, [cancelPending]); - return { clearHoverTimer, closeWithDelay, open, openWithDelay, setOpen }; + return { + activePubkey, + cancelPending, + closeNow, + enterTrigger, + openNow, + scheduleClose, + }; } /** @@ -96,15 +162,22 @@ function useHoverPopover() { * 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. + * + * Typing-fallback-only agents (working source "typing", no observer turn) + * render a passive, borderless, width-uncapped status instead: no hover + * card, no click-through, not focusable — there is no transcript or session + * behind the signal to open. */ function BotActivityAgentPill({ agent, avatarUrl, channelId, holdLabelSwap, + hover, liveActivityEnabled, onOpenAgentSession, openAgentSessionPubkey, + pinWidth, profiles, }: { agent: BotActivityAgent; @@ -112,14 +185,22 @@ function BotActivityAgentPill({ channelId: string | null; /** Defer label swaps while the pill's slot is mid layout animation. */ holdLabelSwap: boolean; + /** Strip-level hover popover state shared by every pill. */ + hover: StripHoverPopover; liveActivityEnabled: boolean; onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; openAgentSessionPubkey: string | null; + /** Freeze the pill's rendered width (a hover card is showing). */ + pinWidth: boolean; profiles?: UserProfileLookup; }) { - const { clearHoverTimer, closeWithDelay, open, openWithDelay, setOpen } = - useHoverPopover(); + const pillKey = agent.pubkey.toLowerCase(); + const open = hover.activePubkey === pillKey; const shouldReduceMotion = useReducedMotion(); + // Typing-fallback-only agents (no observer turn) render borderless and + // uncapped — the basic "is typing" presentation. + const typingOnly = + useAgentWorking(agent.pubkey, channelId).source === "typing"; const transcript = useAgentTranscript(true, agent.pubkey); const now = useNow(PILL_LABEL_TICK_MS); const headline = React.useMemo( @@ -127,7 +208,9 @@ function BotActivityAgentPill({ [channelId, now, transcript], ); const activeId = headline?.id ?? GENERIC_LABEL_ID; - const activeLabel = headline?.label ?? GENERIC_WORKING_LABEL; + // No fresh action headline (see deriveActivityPillLabel) — decay to the + // agent-named generic working label. + const activeLabel = headline?.label ?? `${agent.name} is working…`; // 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 @@ -149,20 +232,90 @@ function BotActivityAgentPill({ const isSessionOpen = openAgentSessionPubkey?.toLowerCase() === agent.pubkey.toLowerCase(); + // While a hover card is showing, the pill must not resize: label swaps + // keep animating, but a longer/shorter label truncating inside a FROZEN + // width can no longer shift the neighboring pills under the cursor. The + // width is measured once when the hold starts and dropped on release. + const triggerRef = React.useRef(null); + const [pinnedWidth, setPinnedWidth] = React.useState(null); + React.useLayoutEffect(() => { + if (!pinWidth) { + setPinnedWidth(null); + return; + } + const node = triggerRef.current; + if (node !== null) { + setPinnedWidth(node.getBoundingClientRect().width); + } + }, [pinWidth]); + const openSession = () => { - clearHoverTimer(); - setOpen(false); + hover.closeNow(); onOpenAgentSession(agent.pubkey, channelId); }; + const pillContent = ( + <> + + + + + + {display.label} + + + + + + ); + + // Typing-fallback-only: a passive status readout — no hover card, no + // click-through, not focusable. There is no observer turn (and so no + // transcript or session) behind it to open. + if (typingOnly) { + return ( +
+ {pillContent} +
+ ); + } + return ( - + { + // Radix only drives closes here (escape / outside dismiss) — trigger + // clicks are preventDefault'ed and hover opens are controlled. + if (!nextOpen) { + hover.closeNow(); + } + }} + open={open} + > { + // A hover preview must not yank focus back to the trigger on + // close: the trigger's focus handler would re-open the card. + event.preventDefault(); + }} + onMouseEnter={hover.cancelPending} + onMouseLeave={hover.scheduleClose} onOpenAutoFocus={(event) => event.preventDefault()} side="top" sideOffset={8} @@ -257,25 +399,39 @@ function BotActivityAgentPill({ * 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. + * swap. Enter/exit use the same scale+fade treatment. While a hover card is + * showing (`freezeLayout`), layout animation is disabled entirely so nothing + * can slide under the cursor. */ function AnimatedPillSlot({ children, + freezeLayout, shouldReduceMotion, }: { /** Render prop so the pill can defer label swaps while its slot moves. */ children: (isMoving: boolean) => React.ReactNode; + /** Disable slot layout animation while a hover card is showing. */ + freezeLayout: boolean; shouldReduceMotion: boolean; }) { const [isMoving, setIsMoving] = React.useState(false); + // Freezing mid-flight cuts the layout animation short, and + // onLayoutAnimationComplete never fires for a cancelled run — clear the + // moving flag here so the pill's label-swap hold can't get stuck. + React.useEffect(() => { + if (freezeLayout) { + setIsMoving(false); + } + }, [freezeLayout]); + return ( setIsMoving(false)} onLayoutAnimationStart={() => setIsMoving(true)} transition={ @@ -299,11 +455,14 @@ function AnimatedPillSlot({ } /** - * Composer status strip for working agents: one pill per working agent, each - * 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. + * Composer status strip for working agents: one pill per working agent, + * sharing one strip-level hover popover (at most one card open). Pills are + * ordered most-recently-active first (left-most) and animate to their new + * slot as the order changes — except while the cursor is over the bar or a + * hover card is showing, when order, membership, layout animation, and pill + * widths are all frozen so nothing can move out from under (or slide under) + * the cursor. 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, @@ -315,6 +474,14 @@ export function BotActivityComposerAction({ }: BotActivityBarProps) { const liveActivityEnabled = useFeatureEnabled("composerLiveActivity"); const shouldReduceMotion = useReducedMotion(); + const hover = useStripHoverPopover(); + // The freeze engages as soon as the cursor is anywhere over the bar — not + // only once a card opens — so pills can't move (or resize) under a cursor + // that is still traveling toward one. It also holds while a card is open + // with the cursor off the bar (over the card itself, or during the close + // grace period). + const [barHovered, setBarHovered] = React.useState(false); + const holdActive = barHovered || hover.activePubkey !== null; const workingAgents = React.useMemo(() => { const workingSet = new Set( @@ -340,7 +507,7 @@ export function BotActivityComposerAction({ subscribeToObserverStore, getOrderSnapshot, ); - const orderedAgents = React.useMemo(() => { + const liveOrderedAgents = React.useMemo(() => { const byPubkey = new Map( workingAgents.map((agent) => [agent.pubkey.toLowerCase(), agent]), ); @@ -349,15 +516,49 @@ export function BotActivityComposerAction({ : orderKey.split(",").flatMap((pubkey) => byPubkey.get(pubkey) ?? []); }, [orderKey, workingAgents]); + // While the hold is active, the strip must not move under the cursor: + // pill order AND membership are frozen at their last hold-free values. + // Reorders queue up behind the hold and apply when it releases; an agent + // that finishes mid-hold keeps its pill until then. Without this, a + // reorder slides the hovered pill away (firing a spurious mouseleave that + // closes the card) or slides a different pill under the cursor (opening + // the wrong one). + // + // The queued order is adopted in an effect (a commit AFTER the release + // render) on purpose: the release render still shows the frozen order but + // re-enables slot layout animation, so when the live order lands next + // commit the pills ANIMATE from their frozen slots into the new layout + // instead of snapping. + const [orderedAgents, setOrderedAgents] = + React.useState(liveOrderedAgents); + React.useEffect(() => { + if (holdActive) { + return; + } + setOrderedAgents((current) => + current.length === liveOrderedAgents.length && + current.every((agent, index) => agent === liveOrderedAgents[index]) + ? current + : liveOrderedAgents, + ); + }, [holdActive, liveOrderedAgents]); + if (orderedAgents.length === 0) { return null; } return ( -
+ // biome-ignore lint/a11y/noStaticElementInteractions: hover-only hold — keyboard focus drives the same hold via the pill triggers. +
setBarHovered(true)} + onMouseLeave={() => setBarHovered(false)} + > {orderedAgents.map((agent) => ( @@ -369,9 +570,11 @@ export function BotActivityComposerAction({ } channelId={channelId} holdLabelSwap={isMoving} + hover={hover} liveActivityEnabled={liveActivityEnabled} onOpenAgentSession={onOpenAgentSession} openAgentSessionPubkey={openAgentSessionPubkey} + pinWidth={holdActive} profiles={profiles} /> )} diff --git a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs index 723eeadda..4652346c7 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs +++ b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs @@ -67,7 +67,7 @@ test("deriveActivityPillLabel returns the newest fresh headline, no rotation", ( const headline = deriveActivityPillLabel({ channelId: CHANNEL, now: NOW, - transcript: [thought("Reading files", secondsBeforeNow(8)), editing], + transcript: [thought("Reading files", secondsBeforeNow(4)), editing], }); assert.deepEqual(headline, { id: editing.id, label: "Editing ChannelPane" }); }); @@ -93,7 +93,7 @@ test("deriveActivityPillLabel honors a custom staleness window", () => { }); test("deriveActivityPillLabel ignores other-channel items", () => { - const inChannel = thought("In-channel work", secondsBeforeNow(10)); + const inChannel = thought("In-channel work", secondsBeforeNow(3)); const headline = deriveActivityPillLabel({ channelId: CHANNEL, now: NOW, @@ -106,7 +106,7 @@ test("deriveActivityPillLabel ignores other-channel items", () => { }); test("deriveActivityPillLabel lets spine work headline over fresher metadata reads", () => { - const realWork = thought("Real work", secondsBeforeNow(10)); + const realWork = thought("Real work", secondsBeforeNow(4)); const headline = deriveActivityPillLabel({ channelId: CHANNEL, now: NOW, @@ -135,7 +135,7 @@ test("deriveActivityPillLabel returns null for an empty transcript", () => { }); test("deriveActivityPillLabel never headlines usage/commands meta frames", () => { - const realWork = thought("Real work", secondsBeforeNow(10)); + const realWork = thought("Real work", secondsBeforeNow(4)); const headline = deriveActivityPillLabel({ channelId: CHANNEL, now: NOW, diff --git a/desktop/src/features/channels/ui/composerLiveActivity.ts b/desktop/src/features/channels/ui/composerLiveActivity.ts index 02e19c122..390ce9456 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.ts +++ b/desktop/src/features/channels/ui/composerLiveActivity.ts @@ -7,9 +7,11 @@ import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; /** * How long the latest action headline stays on a working agent's composer - * pill before the label decays to the generic working state. + * pill before the label decays to the generic working state. Kept short so + * any pause in the event stream (a long tool call, a thinking gap) cycles + * the pill back to " is working…" until the next action lands. */ -export const ACTIVITY_PILL_STALE_MS = 15_000; +export const ACTIVITY_PILL_STALE_MS = 6_000; /** * Lifecycle meta-frames that must never headline the pill. They pass the @@ -37,8 +39,8 @@ export type ActivityPillHeadline = { * 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. + * the pill then falls back to its generic " is working…" label. + * Deliberately no rotation through recent actions: one headline, then decay. */ export function deriveActivityPillLabel({ channelId, diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index b4fee9850..c5cf0ef83 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2109,6 +2109,261 @@ test("composer does not shift when the activity row mounts and clears", async ({ expect((await composerBox()).y).toBeCloseTo(idleComposerTop, 0); }); +// ── Composer pill hover freeze ─────────────────────────────────────────────── +// Working pills reorder most-recently-active-first. While the cursor is +// anywhere over the activity bar OR a hover card is showing, the strip must +// not move under the cursor: pill order, membership, and widths are frozen, +// and the queued reorder applies (animated) only after the hold releases +// (BotActivityBar strip-level hover popover + bar-hover freeze). + +const PILL_AGENT_NOVA = "cc".repeat(32); +const PILL_AGENT_ASTRA = "dd".repeat(32); +const PILL_AGENT_SEEDS = [ + { + pubkey: PILL_AGENT_NOVA, + name: "nova", + status: "running" as const, + channelNames: ["agents"], + }, + { + pubkey: PILL_AGENT_ASTRA, + name: "astra", + status: "running" as const, + channelNames: ["agents"], + }, +]; + +/** + * Seed one channel-scoped assistant-message observer event. Its transcript + * timestamp drives the strip's most-recent-first pill order, so tests pass + * explicit future `atMs` stamps to make the order deterministic. + */ +async function seedPillActivityMessage( + page: import("@playwright/test").Page, + input: { agentPubkey: string; atMs: number; seq: number; text: string }, +) { + await page.evaluate( + ({ agentPubkey, atMs, channelId, seq, text }) => { + const sessionId = `seed-session-${agentPubkey.slice(0, 6)}`; + window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({ + agentPubkey, + events: [ + { + seq, + timestamp: new Date(atMs).toISOString(), + kind: "acp_read", + agentIndex: 0, + channelId, + sessionId, + turnId: `seed-turn-${agentPubkey.slice(0, 6)}`, + payload: { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + messageId: `seed-msg-${agentPubkey.slice(0, 6)}-${seq}`, + content: { type: "text", text }, + }, + }, + }, + }, + ], + }); + }, + { ...input, channelId: AGENTS_CHANNEL_ID }, + ); +} + +/** + * Open #agents with two seeded working agents. Activity messages stamp nova + * as most recent, so the strip renders [nova, astra]. Returns the shared + * trigger locator and the timestamp base used for the seeds. + */ +async function openAgentsChannelWithTwoWorkingPills( + page: import("@playwright/test").Page, +) { + await page.goto("/"); + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function", + null, + { timeout: 10_000 }, + ); + + const baseMs = Date.now(); + await page.evaluate( + ({ agents, channelId }) => { + for (const [index, agentPubkey] of agents.entries()) { + window.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey, + channelId, + turnId: `pill-turn-${index}`, + }); + } + }, + { + agents: [PILL_AGENT_ASTRA, PILL_AGENT_NOVA], + channelId: AGENTS_CHANNEL_ID, + }, + ); + // Future-stamped messages outrank the just-seeded turn events regardless + // of turn-seed timing: nova (+2s) newest, astra (+1s) second. + await seedPillActivityMessage(page, { + agentPubkey: PILL_AGENT_ASTRA, + atMs: baseMs + 1_000, + seq: baseMs + 10_001, + text: "Astra: reviewing the composer wiring", + }); + await seedPillActivityMessage(page, { + agentPubkey: PILL_AGENT_NOVA, + atMs: baseMs + 2_000, + seq: baseMs + 10_002, + text: "Nova: tracing the observer store", + }); + + const triggers = page.getByTestId("bot-activity-composer-trigger"); + await expect(triggers).toHaveCount(2); + await expect(triggers.first()).toHaveAttribute( + "aria-label", + "nova is working. View activity.", + ); + return { baseMs, triggers }; +} + +test("hover card freezes pill order; queued reorder applies on close", async ({ + page, +}) => { + await installMockBridge(page, { managedAgents: PILL_AGENT_SEEDS }); + const { baseMs, triggers } = await openAgentsChannelWithTwoWorkingPills(page); + + const astraPill = page.getByRole("button", { + name: "astra is working. View activity.", + }); + await astraPill.hover(); + const feed = page.getByTestId("composer-live-activity-feed"); + await expect(feed).toBeVisible(); + await expect(page.getByTestId("composer-live-activity-open")).toHaveAttribute( + "aria-label", + /^Open astra's full activity/, + ); + const heldBox = await astraPill.boundingBox(); + if (!heldBox) { + throw new Error("Hovered pill is not visible."); + } + + // Newest activity for astra would move her pill to the front — but a hover + // card is showing, so the strip must not move. The dwell covers the close + // grace (180ms) plus the reorder spring (0.9s) that would have played. + await seedPillActivityMessage(page, { + agentPubkey: PILL_AGENT_ASTRA, + atMs: baseMs + 5_000, + seq: baseMs + 10_003, + text: "Astra: verifying the turn-resurrection path", + }); + await page.waitForTimeout(1_200); + await expect(feed).toBeVisible(); + await expect(triggers.first()).toHaveAttribute( + "aria-label", + "nova is working. View activity.", + ); + const stillHeldBox = await astraPill.boundingBox(); + if (!stillHeldBox) { + throw new Error("Hovered pill is not visible after the reorder attempt."); + } + // Frozen order + pinned width: the hovered pill's slot and size held even + // though its label swapped to the new (longer) message text. + expect(stillHeldBox.x).toBeCloseTo(heldBox.x, 0); + expect(stillHeldBox.width).toBeCloseTo(heldBox.width, 0); + + // Release the hover: the card closes and the queued reorder applies. + await page.getByTestId("chat-title").hover(); + await expect(feed).toBeHidden(); + await expect(triggers.first()).toHaveAttribute( + "aria-label", + "astra is working. View activity.", + ); +}); + +test("hovering the bar itself freezes pill order without opening a card", async ({ + page, +}) => { + await installMockBridge(page, { managedAgents: PILL_AGENT_SEEDS }); + const { baseMs, triggers } = await openAgentsChannelWithTwoWorkingPills(page); + + // Park the cursor on the bar's empty right side — over the strip, not a + // pill. This alone must engage the freeze; no card opens. + const strip = page.getByTestId("bot-activity-strip"); + const stripBox = await strip.boundingBox(); + if (!stripBox) { + throw new Error("Activity strip is not visible."); + } + await strip.hover({ + position: { x: stripBox.width - 8, y: stripBox.height / 2 }, + }); + await page.waitForTimeout(400); + await expect(page.getByTestId("composer-live-activity-feed")).toBeHidden(); + + // Newest activity for astra would move her pill to the front — but the + // cursor is over the bar, so the order stays frozen. + await seedPillActivityMessage(page, { + agentPubkey: PILL_AGENT_ASTRA, + atMs: baseMs + 5_000, + seq: baseMs + 10_003, + text: "Astra: verifying the turn-resurrection path", + }); + await page.waitForTimeout(1_200); + await expect(triggers.first()).toHaveAttribute( + "aria-label", + "nova is working. View activity.", + ); + + // Leaving the bar releases the hold: the queued reorder applies. + await page.getByTestId("chat-title").hover(); + await expect(triggers.first()).toHaveAttribute( + "aria-label", + "astra is working. View activity.", + ); +}); + +test("hovering across pills switches a single live-activity card", async ({ + page, +}) => { + await installMockBridge(page, { managedAgents: PILL_AGENT_SEEDS }); + await openAgentsChannelWithTwoWorkingPills(page); + + const novaPill = page.getByRole("button", { + name: "nova is working. View activity.", + }); + const astraPill = page.getByRole("button", { + name: "astra is working. View activity.", + }); + const feed = page.getByTestId("composer-live-activity-feed"); + const openOverlay = page.getByTestId("composer-live-activity-open"); + + await novaPill.hover(); + await expect(feed).toBeVisible(); + await expect(openOverlay).toHaveAttribute( + "aria-label", + /^Open nova's full activity/, + ); + + // Traveling to the other pill swaps the card immediately — one card, no + // double-open, no stuck card from the old pill's close timer. Wait for the + // old card's ~150ms exit animation with retrying count assertions first; + // attribute assertions would hit a non-retrying strict-mode violation + // while both cards are briefly mounted. + await astraPill.hover(); + await expect(openOverlay).toHaveCount(1); + await expect(feed).toHaveCount(1); + await expect(openOverlay).toHaveAttribute( + "aria-label", + /^Open astra's full activity/, + ); +}); + test("members sidebar exposes view-activity for a viewer-owned relay agent", async ({ page, }) => {