mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
feat(composer): sync pill reorder motion and add per-action label ticker
- BotActivityBar.tsx: extract AnimatedPillSlot around each agent pill —
reorders animate with a 0.9s spring (bounce 0.15, subtler overshoot) and
an opacity dip via [1, 0.35, 1] keyframes running the same duration with
linear easing, so the fade lands together with the layout switch instead
of trailing it
- Label updates render as a clipped push-up ticker (h-3.5 overflow-hidden
container; the new text pushes the old text up and out, 0.28s tween,
instant under reduced motion); swaps are deferred while the pill's slot
is mid layout animation (AnimatedPillSlot passes isMoving via render
prop) and play once the spring settles, otherwise immediately
- Ticker keys on the backing transcript item id, not the label string: a
genuinely new action animates, while streamed chunks that grow the same
item (message first line, thought text) update text in place — fixes
phantom re-animations of visually identical truncated text
- composerLiveActivity.ts: deriveActivityPillLabel now returns
ActivityPillHeadline {id, label} and skips usage_update /
available_commands_update lifecycle meta frames, which previously
headlined the pill as bare "Usage"/"Commands" between real actions
- animations.css: .buzz-shimmer.truncate::before ellipsizes the glow
overlay copy the same way as the base text, so the shimmer sweeps the
ellipsis instead of revealing the clipped rightmost characters
- Pill max width raised from max-w-30 (120px) to max-w-36 (144px)
- channels.spec.ts: activity-indicator test now asserts the flag-ON
reality (E2E seeds every preview feature, so composerLiveActivity always
renders): hovering the pill opens the live-activity feed popover and
clicking opens the agent session in the aux panel — replaces the legacy
"View activity" item assertions, which only ever passed via a popover
toggle race in the pre-pill trigger
- composerLiveActivity.test.mjs: object-shape assertions plus new cases —
meta frames never headline, meta-only transcripts stay generic, and the
headline id stays stable while a message streams (19 tests passing)
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
|
||||
import {
|
||||
getAgentTranscript,
|
||||
subscribeAgentObserverStore,
|
||||
} from "@/features/agents/observerRelayStore";
|
||||
import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
@@ -16,7 +21,10 @@ import {
|
||||
import { Shimmer } from "@/shared/ui/Shimmer";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { ComposerLiveActivityFeed } from "./ComposerLiveActivityFeed";
|
||||
import { deriveActivityPillLabel } from "./composerLiveActivity";
|
||||
import {
|
||||
deriveActivityPillLabel,
|
||||
deriveAgentActivityOrder,
|
||||
} from "./composerLiveActivity";
|
||||
|
||||
export type BotActivityAgent = Pick<ManagedAgent, "pubkey" | "name" | "status">;
|
||||
|
||||
@@ -34,6 +42,18 @@ const HOVER_CLOSE_DELAY_MS = 180;
|
||||
const PILL_LABEL_TICK_MS = 5_000;
|
||||
/** Shown when no fresh action headline exists (see deriveActivityPillLabel). */
|
||||
const GENERIC_WORKING_LABEL = "Working…";
|
||||
/** Ticker key for the generic label so decay/recovery animate as one swap. */
|
||||
const GENERIC_LABEL_ID = "generic-working";
|
||||
/**
|
||||
* Perceptual duration shared by the pill reorder spring and the opacity dip
|
||||
* keyframes so the fade lands together with the layout switch.
|
||||
*/
|
||||
const PILL_REORDER_DURATION_S = 0.9;
|
||||
|
||||
// Stable subscribe reference for useSyncExternalStore (same pattern as
|
||||
// useObserverEvents).
|
||||
const subscribeToObserverStore = (onStoreChange: () => void) =>
|
||||
subscribeAgentObserverStore(onStoreChange);
|
||||
|
||||
/** Hover-intent popover state shared by every activity pill. */
|
||||
function useHoverPopover() {
|
||||
@@ -72,16 +92,20 @@ function useHoverPopover() {
|
||||
|
||||
/**
|
||||
* One working agent's status pill: avatar + the agent's latest action summary
|
||||
* (decaying to a generic working label when activity goes quiet). Hovering
|
||||
* shows the agent's live activity feed as the popover surface itself — flat,
|
||||
* no inset box, no tab strip — while clicking the pill opens the agent's full
|
||||
* runtime in the auxiliary panel. With the `composerLiveActivity` preview
|
||||
* flag off, the hover popover keeps the legacy "View activity" item instead.
|
||||
* (decaying to a generic working label when activity goes quiet). Label
|
||||
* updates play as a clipped ticker — the new text pushes the old text up and
|
||||
* out — deferred until the pill's slot settles when a reorder is in flight.
|
||||
* Hovering shows the agent's live activity feed as the popover surface
|
||||
* itself — flat, no inset box, no tab strip — while clicking the pill opens
|
||||
* the agent's full runtime in the auxiliary panel. With the
|
||||
* `composerLiveActivity` preview flag off, the hover popover keeps the
|
||||
* legacy "View activity" item instead.
|
||||
*/
|
||||
function BotActivityAgentPill({
|
||||
agent,
|
||||
avatarUrl,
|
||||
channelId,
|
||||
holdLabelSwap,
|
||||
liveActivityEnabled,
|
||||
onOpenAgentSession,
|
||||
openAgentSessionPubkey,
|
||||
@@ -90,6 +114,8 @@ function BotActivityAgentPill({
|
||||
agent: BotActivityAgent;
|
||||
avatarUrl: string | null;
|
||||
channelId: string | null;
|
||||
/** Defer label swaps while the pill's slot is mid layout animation. */
|
||||
holdLabelSwap: boolean;
|
||||
liveActivityEnabled: boolean;
|
||||
onOpenAgentSession: (pubkey: string, channelId?: string | null) => void;
|
||||
openAgentSessionPubkey: string | null;
|
||||
@@ -97,13 +123,33 @@ function BotActivityAgentPill({
|
||||
}) {
|
||||
const { clearHoverTimer, closeWithDelay, open, openWithDelay, setOpen } =
|
||||
useHoverPopover();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const transcript = useAgentTranscript(true, agent.pubkey);
|
||||
const now = useNow(PILL_LABEL_TICK_MS);
|
||||
const headline = React.useMemo(
|
||||
() => deriveActivityPillLabel({ channelId, now, transcript }),
|
||||
[channelId, now, transcript],
|
||||
);
|
||||
const label = headline ?? GENERIC_WORKING_LABEL;
|
||||
const activeId = headline?.id ?? GENERIC_LABEL_ID;
|
||||
const activeLabel = headline?.label ?? GENERIC_WORKING_LABEL;
|
||||
// The rendered label lags the derived one while the pill is moving: the
|
||||
// push-up ticker plays after the slot settles (or immediately when idle).
|
||||
// Keyed by transcript item id, not label text — a NEW action swaps, while
|
||||
// streamed chunks growing the same item update text in place.
|
||||
const [display, setDisplay] = React.useState({
|
||||
id: activeId,
|
||||
label: activeLabel,
|
||||
});
|
||||
React.useEffect(() => {
|
||||
if (holdLabelSwap) {
|
||||
return;
|
||||
}
|
||||
setDisplay((current) =>
|
||||
current.id === activeId && current.label === activeLabel
|
||||
? current
|
||||
: { id: activeId, label: activeLabel },
|
||||
);
|
||||
}, [holdLabelSwap, activeId, activeLabel]);
|
||||
const isSessionOpen =
|
||||
openAgentSessionPubkey?.toLowerCase() === agent.pubkey.toLowerCase();
|
||||
|
||||
@@ -118,7 +164,7 @@ function BotActivityAgentPill({
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-label={`${agent.name} is working. View activity.`}
|
||||
className="inline-flex h-7 min-w-0 max-w-30 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-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"
|
||||
data-testid="bot-activity-composer-trigger"
|
||||
onBlur={closeWithDelay}
|
||||
onClick={(event) => {
|
||||
@@ -139,8 +185,25 @@ function BotActivityAgentPill({
|
||||
displayName={agent.name}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
<Shimmer className="block truncate">{label}</Shimmer>
|
||||
<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>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
@@ -193,10 +256,58 @@ function BotActivityAgentPill({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout slot for one pill in the composer strip. While a pill travels to a
|
||||
* new slot (layout animation) its opacity dips and recovers via keyframes
|
||||
* that run for the same duration as the slot spring, so the fade and the
|
||||
* move finish together and reorders read as a shuffle instead of a hard
|
||||
* swap. Enter/exit use the same scale+fade treatment.
|
||||
*/
|
||||
function AnimatedPillSlot({
|
||||
children,
|
||||
shouldReduceMotion,
|
||||
}: {
|
||||
/** Render prop so the pill can defer label swaps while its slot moves. */
|
||||
children: (isMoving: boolean) => React.ReactNode;
|
||||
shouldReduceMotion: boolean;
|
||||
}) {
|
||||
const [isMoving, setIsMoving] = React.useState(false);
|
||||
|
||||
return (
|
||||
<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}
|
||||
onLayoutAnimationComplete={() => setIsMoving(false)}
|
||||
onLayoutAnimationStart={() => setIsMoving(true)}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
type: "spring",
|
||||
duration: PILL_REORDER_DURATION_S,
|
||||
bounce: 0.15,
|
||||
opacity: {
|
||||
type: "tween",
|
||||
duration: PILL_REORDER_DURATION_S,
|
||||
ease: "linear",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
{children(isMoving)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer status strip for working agents: one pill per working agent, each
|
||||
* owning its hover popover. Pills shrink and truncate their labels when
|
||||
* several agents work at once so the strip never wraps the fixed-height row.
|
||||
* owning its hover popover. Pills are ordered most-recently-active first
|
||||
* (left-most) and animate to their new slot as the order changes. Pills
|
||||
* shrink and truncate their labels when several agents work at once so the
|
||||
* strip never wraps the fixed-height row.
|
||||
*/
|
||||
export function BotActivityComposerAction({
|
||||
agents,
|
||||
@@ -207,6 +318,7 @@ export function BotActivityComposerAction({
|
||||
workingBotPubkeys,
|
||||
}: BotActivityBarProps) {
|
||||
const liveActivityEnabled = useFeatureEnabled("composerLiveActivity");
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const workingAgents = React.useMemo(() => {
|
||||
const workingSet = new Set(
|
||||
@@ -216,24 +328,60 @@ export function BotActivityComposerAction({
|
||||
return agents.filter((agent) => workingSet.has(agent.pubkey.toLowerCase()));
|
||||
}, [agents, workingBotPubkeys]);
|
||||
|
||||
if (workingAgents.length === 0) {
|
||||
// Most-recent-first pill order. The snapshot is a joined string so
|
||||
// useSyncExternalStore only re-renders this strip when the ORDER actually
|
||||
// changes, not on every observer store write.
|
||||
const getOrderSnapshot = React.useCallback(
|
||||
() =>
|
||||
deriveAgentActivityOrder({
|
||||
channelId,
|
||||
getTranscript: (pubkey) => getAgentTranscript(pubkey, true),
|
||||
pubkeys: workingAgents.map((agent) => agent.pubkey.toLowerCase()),
|
||||
}).join(","),
|
||||
[channelId, workingAgents],
|
||||
);
|
||||
const orderKey = React.useSyncExternalStore(
|
||||
subscribeToObserverStore,
|
||||
getOrderSnapshot,
|
||||
);
|
||||
const orderedAgents = React.useMemo(() => {
|
||||
const byPubkey = new Map(
|
||||
workingAgents.map((agent) => [agent.pubkey.toLowerCase(), agent]),
|
||||
);
|
||||
return orderKey === ""
|
||||
? []
|
||||
: orderKey.split(",").flatMap((pubkey) => byPubkey.get(pubkey) ?? []);
|
||||
}, [orderKey, workingAgents]);
|
||||
|
||||
if (orderedAgents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
|
||||
{workingAgents.map((agent) => (
|
||||
<BotActivityAgentPill
|
||||
agent={agent}
|
||||
avatarUrl={profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null}
|
||||
channelId={channelId}
|
||||
key={agent.pubkey}
|
||||
liveActivityEnabled={liveActivityEnabled}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
profiles={profiles}
|
||||
/>
|
||||
))}
|
||||
<AnimatePresence initial={false}>
|
||||
{orderedAgents.map((agent) => (
|
||||
<AnimatedPillSlot
|
||||
key={agent.pubkey}
|
||||
shouldReduceMotion={Boolean(shouldReduceMotion)}
|
||||
>
|
||||
{(isMoving) => (
|
||||
<BotActivityAgentPill
|
||||
agent={agent}
|
||||
avatarUrl={
|
||||
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null
|
||||
}
|
||||
channelId={channelId}
|
||||
holdLabelSwap={isMoving}
|
||||
liveActivityEnabled={liveActivityEnabled}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
profiles={profiles}
|
||||
/>
|
||||
)}
|
||||
</AnimatedPillSlot>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
deriveActivityPillLabel,
|
||||
deriveAgentActivityOrder,
|
||||
deriveLastLiveAt,
|
||||
} from "./composerLiveActivity.ts";
|
||||
|
||||
@@ -10,6 +11,7 @@ const CHANNEL = "channel-1";
|
||||
const OTHER_CHANNEL = "channel-2";
|
||||
|
||||
const NOW = Date.parse("2026-07-23T00:01:00.000Z");
|
||||
const NOW_ISO = new Date(NOW).toISOString();
|
||||
|
||||
/** Thought item: spine, headlined by its title. */
|
||||
const thought = (title, timestamp, channelId = CHANNEL) => ({
|
||||
@@ -34,81 +36,197 @@ const metadata = (title, timestamp, channelId = CHANNEL) => ({
|
||||
channelId,
|
||||
});
|
||||
|
||||
/** Lifecycle meta-frame (usage tick / commands): spine, but never a headline. */
|
||||
const lifecycleMeta = (acpSource, timestamp, channelId = CHANNEL) => ({
|
||||
id: `lifecycle-${acpSource}-${timestamp}`,
|
||||
type: "lifecycle",
|
||||
renderClass: "status",
|
||||
title: acpSource === "usage_update" ? "Usage" : "Commands",
|
||||
text: "Tokens: 1200/8192",
|
||||
timestamp,
|
||||
acpSource,
|
||||
channelId,
|
||||
});
|
||||
|
||||
/** Streaming assistant message: headline is the (growing) first line. */
|
||||
const assistantMessage = (id, text, timestamp, channelId = CHANNEL) => ({
|
||||
id,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
title: "",
|
||||
text,
|
||||
timestamp,
|
||||
channelId,
|
||||
});
|
||||
|
||||
const secondsBeforeNow = (seconds) =>
|
||||
new Date(NOW - seconds * 1000).toISOString();
|
||||
|
||||
test("deriveActivityPillLabel returns the newest fresh headline, no rotation", () => {
|
||||
const label = deriveActivityPillLabel({
|
||||
const editing = thought("Editing ChannelPane", secondsBeforeNow(2));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [
|
||||
thought("Reading files", secondsBeforeNow(8)),
|
||||
thought("Editing ChannelPane", secondsBeforeNow(2)),
|
||||
],
|
||||
transcript: [thought("Reading files", secondsBeforeNow(8)), editing],
|
||||
});
|
||||
assert.equal(label, "Editing ChannelPane");
|
||||
assert.deepEqual(headline, { id: editing.id, label: "Editing ChannelPane" });
|
||||
});
|
||||
|
||||
test("deriveActivityPillLabel decays to null once the newest headline is stale", () => {
|
||||
const label = deriveActivityPillLabel({
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [thought("Editing ChannelPane", secondsBeforeNow(30))],
|
||||
});
|
||||
assert.equal(label, null);
|
||||
assert.equal(headline, null);
|
||||
});
|
||||
|
||||
test("deriveActivityPillLabel honors a custom staleness window", () => {
|
||||
const transcript = [thought("Editing ChannelPane", secondsBeforeNow(30))];
|
||||
const label = deriveActivityPillLabel({
|
||||
const editing = thought("Editing ChannelPane", secondsBeforeNow(30));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
staleAfterMs: 60_000,
|
||||
transcript,
|
||||
transcript: [editing],
|
||||
});
|
||||
assert.equal(label, "Editing ChannelPane");
|
||||
assert.deepEqual(headline, { id: editing.id, label: "Editing ChannelPane" });
|
||||
});
|
||||
|
||||
test("deriveActivityPillLabel ignores other-channel items", () => {
|
||||
const label = deriveActivityPillLabel({
|
||||
const inChannel = thought("In-channel work", secondsBeforeNow(10));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [
|
||||
thought("In-channel work", secondsBeforeNow(10)),
|
||||
inChannel,
|
||||
thought("Other-channel work", secondsBeforeNow(1), OTHER_CHANNEL),
|
||||
],
|
||||
});
|
||||
assert.equal(label, "In-channel work");
|
||||
assert.deepEqual(headline, { id: inChannel.id, label: "In-channel work" });
|
||||
});
|
||||
|
||||
test("deriveActivityPillLabel lets spine work headline over fresher metadata reads", () => {
|
||||
const label = deriveActivityPillLabel({
|
||||
const realWork = thought("Real work", secondsBeforeNow(10));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [
|
||||
thought("Real work", secondsBeforeNow(10)),
|
||||
metadata("Prompt context", secondsBeforeNow(1)),
|
||||
],
|
||||
transcript: [realWork, metadata("Prompt context", secondsBeforeNow(1))],
|
||||
});
|
||||
assert.equal(label, "Real work");
|
||||
assert.deepEqual(headline, { id: realWork.id, label: "Real work" });
|
||||
});
|
||||
|
||||
test("deriveActivityPillLabel falls back to metadata when no spine items exist", () => {
|
||||
const label = deriveActivityPillLabel({
|
||||
const context = metadata("Prompt context", secondsBeforeNow(5));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [metadata("Prompt context", secondsBeforeNow(5))],
|
||||
transcript: [context],
|
||||
});
|
||||
assert.equal(label, "Prompt context");
|
||||
assert.deepEqual(headline, { id: context.id, label: "Prompt context" });
|
||||
});
|
||||
|
||||
test("deriveActivityPillLabel returns null for an empty transcript", () => {
|
||||
const label = deriveActivityPillLabel({
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [],
|
||||
});
|
||||
assert.equal(label, null);
|
||||
assert.equal(headline, null);
|
||||
});
|
||||
|
||||
test("deriveActivityPillLabel never headlines usage/commands meta frames", () => {
|
||||
const realWork = thought("Real work", secondsBeforeNow(10));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [
|
||||
realWork,
|
||||
lifecycleMeta("usage_update", secondsBeforeNow(2)),
|
||||
lifecycleMeta("available_commands_update", secondsBeforeNow(1)),
|
||||
],
|
||||
});
|
||||
// Meta frames are skipped entirely — the older real action still headlines.
|
||||
assert.deepEqual(headline, { id: realWork.id, label: "Real work" });
|
||||
});
|
||||
|
||||
test("deriveActivityPillLabel returns null when only meta frames are fresh", () => {
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [lifecycleMeta("usage_update", secondsBeforeNow(1))],
|
||||
});
|
||||
assert.equal(headline, null);
|
||||
});
|
||||
|
||||
test("deriveActivityPillLabel keeps a stable id while a message streams", () => {
|
||||
const first = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [assistantMessage("msg-1", "Pass 1: reading", NOW_ISO)],
|
||||
});
|
||||
const extended = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [
|
||||
assistantMessage("msg-1", "Pass 1: reading the composer wiring", NOW_ISO),
|
||||
],
|
||||
});
|
||||
// Same item id: the pill updates text in place instead of re-animating.
|
||||
assert.equal(first.id, "msg-1");
|
||||
assert.equal(extended.id, "msg-1");
|
||||
assert.equal(extended.label, "Pass 1: reading the composer wiring");
|
||||
});
|
||||
|
||||
test("deriveAgentActivityOrder puts the most recently active agent first", () => {
|
||||
const transcripts = new Map([
|
||||
["alpha", [thought("Older work", secondsBeforeNow(20))]],
|
||||
["beta", [thought("Newer work", secondsBeforeNow(2))]],
|
||||
]);
|
||||
const order = deriveAgentActivityOrder({
|
||||
channelId: CHANNEL,
|
||||
getTranscript: (pubkey) => transcripts.get(pubkey) ?? [],
|
||||
pubkeys: ["alpha", "beta"],
|
||||
});
|
||||
assert.deepEqual(order, ["beta", "alpha"]);
|
||||
});
|
||||
|
||||
test("deriveAgentActivityOrder keeps roster order for agents with no activity", () => {
|
||||
const transcripts = new Map([
|
||||
["gamma", [thought("Only worker", secondsBeforeNow(5))]],
|
||||
]);
|
||||
const order = deriveAgentActivityOrder({
|
||||
channelId: CHANNEL,
|
||||
getTranscript: (pubkey) => transcripts.get(pubkey) ?? [],
|
||||
pubkeys: ["alpha", "beta", "gamma"],
|
||||
});
|
||||
assert.deepEqual(order, ["gamma", "alpha", "beta"]);
|
||||
});
|
||||
|
||||
test("deriveAgentActivityOrder ignores other-channel activity", () => {
|
||||
const transcripts = new Map([
|
||||
["alpha", [thought("In-channel", secondsBeforeNow(30))]],
|
||||
["beta", [thought("Elsewhere", secondsBeforeNow(1), OTHER_CHANNEL)]],
|
||||
]);
|
||||
const order = deriveAgentActivityOrder({
|
||||
channelId: CHANNEL,
|
||||
getTranscript: (pubkey) => transcripts.get(pubkey) ?? [],
|
||||
pubkeys: ["alpha", "beta"],
|
||||
});
|
||||
assert.deepEqual(order, ["alpha", "beta"]);
|
||||
});
|
||||
|
||||
test("deriveAgentActivityOrder is stable for identical timestamps", () => {
|
||||
const sameTime = secondsBeforeNow(3);
|
||||
const transcripts = new Map([
|
||||
["alpha", [thought("Tied A", sameTime)]],
|
||||
["beta", [thought("Tied B", sameTime)]],
|
||||
]);
|
||||
const order = deriveAgentActivityOrder({
|
||||
channelId: CHANNEL,
|
||||
getTranscript: (pubkey) => transcripts.get(pubkey) ?? [],
|
||||
pubkeys: ["alpha", "beta"],
|
||||
});
|
||||
assert.deepEqual(order, ["alpha", "beta"]);
|
||||
});
|
||||
|
||||
test("deriveLastLiveAt prefers the newest channel-scoped transcript item", () => {
|
||||
|
||||
@@ -11,6 +11,26 @@ import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes";
|
||||
*/
|
||||
export const ACTIVITY_PILL_STALE_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Lifecycle meta-frames that must never headline the pill. They pass the
|
||||
* spine filter (meaningful lifecycle items) but would surface as bare
|
||||
* "Usage" / "Commands" labels that read like phantom activity.
|
||||
*/
|
||||
const PILL_HEADLINE_EXCLUDED_SOURCES = new Set([
|
||||
"usage_update",
|
||||
"available_commands_update",
|
||||
]);
|
||||
|
||||
export type ActivityPillHeadline = {
|
||||
/**
|
||||
* Transcript item id backing the headline. The pill keys its ticker swap
|
||||
* on this id, so a NEW action animates while streamed chunks that grow the
|
||||
* same item (message first line, thought text) update text in place.
|
||||
*/
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Latest fresh action headline for a working agent's composer pill.
|
||||
*
|
||||
@@ -30,7 +50,7 @@ export function deriveActivityPillLabel({
|
||||
now: number;
|
||||
staleAfterMs?: number;
|
||||
transcript: readonly TranscriptItem[];
|
||||
}): string | null {
|
||||
}): ActivityPillHeadline | null {
|
||||
const scoped = channelId
|
||||
? transcript.filter((item) => item.channelId === channelId)
|
||||
: transcript;
|
||||
@@ -41,6 +61,13 @@ export function deriveActivityPillLabel({
|
||||
if (!item || !passFilter(item)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
item.type === "lifecycle" &&
|
||||
item.acpSource !== undefined &&
|
||||
PILL_HEADLINE_EXCLUDED_SOURCES.has(item.acpSource)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const headline = getActivityHeadline(item);
|
||||
if (!headline) {
|
||||
continue;
|
||||
@@ -49,12 +76,54 @@ export function deriveActivityPillLabel({
|
||||
if (!Number.isNaN(millis) && now - millis > staleAfterMs) {
|
||||
return null;
|
||||
}
|
||||
return headline;
|
||||
return { id: item.id, label: headline };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Working-agent pill order for the composer strip: agents with the newest
|
||||
* channel-scoped transcript activity first (left-most). Agents with no
|
||||
* recorded activity keep their incoming (roster) order at the end — the
|
||||
* sort is stable, so ties never reshuffle.
|
||||
*
|
||||
* `getTranscript` is injected so the helper stays pure and unit-testable;
|
||||
* callers pass the observer store's cached reader.
|
||||
*/
|
||||
export function deriveAgentActivityOrder({
|
||||
channelId,
|
||||
getTranscript,
|
||||
pubkeys,
|
||||
}: {
|
||||
channelId: string | null;
|
||||
getTranscript: (pubkey: string) => readonly TranscriptItem[];
|
||||
pubkeys: readonly string[];
|
||||
}): string[] {
|
||||
const lastActivityAt = new Map<string, number>();
|
||||
|
||||
for (const pubkey of pubkeys) {
|
||||
const transcript = getTranscript(pubkey);
|
||||
for (let index = transcript.length - 1; index >= 0; index -= 1) {
|
||||
const item = transcript[index];
|
||||
if (!item || (channelId && item.channelId !== channelId)) {
|
||||
continue;
|
||||
}
|
||||
const millis = Date.parse(item.timestamp);
|
||||
if (!Number.isNaN(millis)) {
|
||||
lastActivityAt.set(pubkey, millis);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...pubkeys].sort(
|
||||
(a, b) =>
|
||||
(lastActivityAt.get(b) ?? Number.NEGATIVE_INFINITY) -
|
||||
(lastActivityAt.get(a) ?? Number.NEGATIVE_INFINITY),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest activity timestamp (ms) for the composer preview's "Last live" pill.
|
||||
*
|
||||
|
||||
@@ -238,6 +238,15 @@
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* Truncated hosts (Tailwind `truncate`): ellipsize the overlay copy the same
|
||||
way as the base text so the highlight sweeps the ellipsis, instead of
|
||||
revealing the clipped characters at the right edge. `white-space: nowrap`
|
||||
is inherited from the host; overflow rules are not, so restate them. */
|
||||
.buzz-shimmer.truncate::before {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@keyframes buzz-shimmer {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
@@ -2060,19 +2060,15 @@ test("shows and clears activity indicators for active channel agents", async ({
|
||||
});
|
||||
}, TEST_IDENTITIES.alice.pubkey);
|
||||
|
||||
await expect(page.getByTestId("bot-activity-composer-trigger")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("bot-activity-composer-trigger"),
|
||||
).not.toContainText("View activity");
|
||||
// Hovering the pill opens the preview popover with the activity item.
|
||||
await page.getByTestId("bot-activity-composer-trigger").hover();
|
||||
const aliceActivityItem = page.getByTestId(
|
||||
`bot-activity-composer-item-${TEST_IDENTITIES.alice.pubkey}`,
|
||||
);
|
||||
await expect(aliceActivityItem).toBeVisible();
|
||||
await expect(aliceActivityItem).toContainText("View activity");
|
||||
// Clicking the pill itself opens the agent's runtime in the aux panel.
|
||||
await page.getByTestId("bot-activity-composer-trigger").click();
|
||||
const activityPill = page.getByTestId("bot-activity-composer-trigger");
|
||||
await expect(activityPill).toBeVisible();
|
||||
await expect(activityPill).not.toContainText("View activity");
|
||||
// E2E seeds every preview feature on, so the composerLiveActivity path is
|
||||
// what renders here: hovering the pill opens the live-activity preview.
|
||||
await activityPill.hover();
|
||||
await expect(page.getByTestId("composer-live-activity-feed")).toBeVisible();
|
||||
// Clicking the pill promotes the agent's runtime into the aux panel.
|
||||
await activityPill.click();
|
||||
await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-session-thread-panel")).toContainText(
|
||||
"alice",
|
||||
|
||||
Reference in New Issue
Block a user