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:
Taylor Ho
2026-07-26 22:20:42 -07:00
committed by Carl
parent 5aaef5cdf9
commit 8cfbcce370
5 changed files with 406 additions and 66 deletions

View File

@@ -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>
);
}

View File

@@ -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", () => {

View File

@@ -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.
*

View File

@@ -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% {

View File

@@ -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",