feat(composer): freeze pill strip on hover, typing-fallback pills, faster label decay

Composer activity strip (BotActivityBar.tsx):
- Centralize hover popover state at the strip level (useStripHoverPopover):
  ONE active pill and ONE timer for the whole strip, replacing per-pill
  timers that raced each other (double-open on pill-to-pill travel, stuck
  cards when the cursor clipped an open card)
- Freeze pill order, membership, layout animation, and pill widths while
  the cursor is over the bar or a hover card is showing; queued reorders
  apply (animated) after the hold releases
- Pin the hovered pill's rendered width so label swaps can't shift
  neighboring pills under the cursor
- Open on keyboard focus only for :focus-visible, and preventDefault on
  the card's close auto-focus, fixing the re-open loop when Radix
  returned focus to the trigger
- Typing-fallback-only agents (working source "typing", no observer turn)
  now render a passive status pill: borderless, no max-width cap, no hover
  card, no click-through, not focusable — there is no transcript or
  session behind the typing signal to open

Pill label decay (composerLiveActivity.ts):
- Shorten ACTIVITY_PILL_STALE_MS 15s → 6s so pauses in the event stream
  cycle the pill back to the generic label promptly; tick 5s → 1s
- Generic label is now agent-named ("<name> is working…") instead of the
  bare "Working…"

Debug harness (features/agents/debug):
- Add per-agent "Typing" toggle simulating the basic "is typing"
  situation: a synthetic channel-scoped typing entry with no observer
  turn, exercising the typing-fallback path (useChannelActivityTyping →
  reportChannelBotTyping → passive pill); independent of Working/Progress
  so observer-over-typing precedence is testable; "End all turns" also
  clears typing
- useDebugHarnessTypingEntries appends the synthetic entries in
  useChannelActivityTyping (dev-only, reference-stable when idle)

Tests:
- channels.spec.ts: e2e coverage for hover-freeze — card freezes pill
  order with queued reorder applying on close, and bar-hover freeze
  without an open card
- composerLiveActivity.test.mjs: freshness fixtures updated for the 6s
  stale window

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-05 16:41:56 -07:00
committed by Carl
parent facf4284b9
commit ee7f183e26
4 changed files with 544 additions and 84 deletions
@@ -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<ReturnType<typeof setTimeout> | 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<string | null>(null);
// Ref mirror so pointer handlers can branch on open-vs-closed synchronously.
const activeRef = React.useRef<string | null>(null);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | 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<HTMLButtonElement | null>(null);
const [pinnedWidth, setPinnedWidth] = React.useState<number | null>(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 = (
<>
<UserAvatar
avatarUrl={avatarUrl}
className="!h-[18px] !w-[18px] shrink-0 text-3xs"
displayName={agent.name}
size="xs"
/>
<span className="relative block h-3.5 min-w-0 flex-1 overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={{ y: 0 }}
className="flex h-full items-center"
exit={{ y: "-110%" }}
initial={shouldReduceMotion ? false : { y: "110%" }}
key={display.id}
transition={
shouldReduceMotion
? { duration: 0 }
: { type: "tween", duration: 0.28, ease: "easeOut" }
}
>
<Shimmer className="block min-w-0 truncate">
{display.label}
</Shimmer>
</motion.span>
</AnimatePresence>
</span>
</>
);
// 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 (
<div
className="inline-flex h-7 min-w-0 items-center gap-1.5 rounded-full bg-background px-2 text-xs font-semibold leading-none text-muted-foreground"
data-testid="bot-activity-composer-typing"
>
{pillContent}
</div>
);
}
return (
<Popover onOpenChange={setOpen} open={open}>
<Popover
onOpenChange={(nextOpen) => {
// Radix only drives closes here (escape / outside dismiss) — trigger
// clicks are preventDefault'ed and hover opens are controlled.
if (!nextOpen) {
hover.closeNow();
}
}}
open={open}
>
<PopoverTrigger asChild>
<button
aria-label={`${agent.name} is working. View activity.`}
className="inline-flex h-7 min-w-0 max-w-36 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"
className="inline-flex h-7 min-w-0 max-w-49 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}
onBlur={hover.scheduleClose}
onClick={(event) => {
// The popover is a hover preview; clicking goes straight to the
// agent's runtime in the aux panel. preventDefault stops Radix's
@@ -170,37 +323,21 @@ function BotActivityAgentPill({
event.preventDefault();
openSession();
}}
onFocus={() => setOpen(true)}
onMouseEnter={openWithDelay}
onMouseLeave={closeWithDelay}
onFocus={(event) => {
// Keyboard focus only (:focus-visible). Plain focus also lands
// here when Radix returns focus to the trigger as the card
// closes — opening on that re-opened the card in a loop.
if (event.currentTarget.matches(":focus-visible")) {
hover.openNow(pillKey);
}
}}
onMouseEnter={() => hover.enterTrigger(pillKey)}
onMouseLeave={hover.scheduleClose}
ref={triggerRef}
style={pinnedWidth === null ? undefined : { width: pinnedWidth }}
type="button"
>
<UserAvatar
avatarUrl={avatarUrl}
className="!h-[18px] !w-[18px] shrink-0 text-3xs"
displayName={agent.name}
size="xs"
/>
<span className="relative block h-3.5 min-w-0 flex-1 overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={{ y: 0 }}
className="flex h-full items-center"
exit={{ y: "-110%" }}
initial={shouldReduceMotion ? false : { y: "110%" }}
key={display.id}
transition={
shouldReduceMotion
? { duration: 0 }
: { type: "tween", duration: 0.28, ease: "easeOut" }
}
>
<Shimmer className="block min-w-0 truncate">
{display.label}
</Shimmer>
</motion.span>
</AnimatePresence>
</span>
{pillContent}
</button>
</PopoverTrigger>
<PopoverContent
@@ -208,8 +345,13 @@ function BotActivityAgentPill({
className={cn(
liveActivityEnabled ? "w-80 overflow-hidden p-0" : "w-64 p-1",
)}
onMouseEnter={clearHoverTimer}
onMouseLeave={closeWithDelay}
onCloseAutoFocus={(event) => {
// 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 (
<motion.div
animate={{ opacity: isMoving ? [1, 0.35, 1] : 1, scale: 1 }}
className="flex min-w-0"
exit={{ opacity: 0, scale: 0.9 }}
initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.9 }}
layout={!shouldReduceMotion}
layout={!shouldReduceMotion && !freezeLayout}
onLayoutAnimationComplete={() => 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<BotActivityAgent[]>(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 (
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
// biome-ignore lint/a11y/noStaticElementInteractions: hover-only hold — keyboard focus drives the same hold via the pill triggers.
<div
className="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible"
data-testid="bot-activity-strip"
onMouseEnter={() => setBarHovered(true)}
onMouseLeave={() => setBarHovered(false)}
>
<AnimatePresence initial={false}>
{orderedAgents.map((agent) => (
<AnimatedPillSlot
freezeLayout={holdActive}
key={agent.pubkey}
shouldReduceMotion={Boolean(shouldReduceMotion)}
>
@@ -369,9 +570,11 @@ export function BotActivityComposerAction({
}
channelId={channelId}
holdLabelSwap={isMoving}
hover={hover}
liveActivityEnabled={liveActivityEnabled}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
pinWidth={holdActive}
profiles={profiles}
/>
)}
@@ -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,
@@ -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 "<name> 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 "<name> is working…" label.
* Deliberately no rotation through recent actions: one headline, then decay.
*/
export function deriveActivityPillLabel({
channelId,
+255
View File
@@ -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,
}) => {