mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user