mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(composer): stable turn-start pill order and persistent action labels
Calm the working-agent pill strip while multiple agents stream: position now encodes identity (stable for a whole turn), liveness stays on the per-pill label tickers, and positional motion is reserved for membership changes (an agent starting or finishing) — the transitions a builder actually needs to notice. Grounded in the work-visibility and completion-awareness JTBDs: transcript-recency ordering shuffled pills on every agent event (cry-wolf motion) and buried quiet/stuck agents at the scrolled-off end of the strip. - composerLiveActivity.ts: replace deriveAgentActivityOrder (transcript recency, newest first) with deriveAgentWorkingOrder — sorts by channel-scoped turn anchorAt ascending (earliest worker left-most, new agents append right), quantized to whole seconds so retroactive clock-offset refinements cannot reorder the strip, stable roster-order tiebreak, no-anchor agents keep roster order at the end - composerLiveActivity.ts: remove the 6s label decay (ACTIVITY_PILL_STALE_MS and the now/staleAfterMs params) — the last real action now persists through quiet stretches; the generic "<name> is working…" label only shows before the first action lands, since the last action is more informative than a placeholder while a turn is in progress - BotActivityBar.tsx: order snapshot subscribes to agentWorkingSignal (turn anchors) instead of the observer store's transcript scan; drop the per-pill useNow(1000) staleness tick so pills no longer re-render every second; hover-freeze machinery (order/membership/width hold) unchanged and still guards membership changes under the cursor - composerLiveActivity.test.mjs: six anchor-ordering tests (earliest first, append-on-later-start, channel scoping, unscoped earliest-across-channels, roster fallback, sub-second-shift stability); decay tests replaced with a headline-persists-regardless-of-age test - channels.spec.ts: retarget the two hover-freeze e2e tests from recency flips to membership changes — a third agent (lyra) starting a turn mid-hover appends only after the hold releases, and a turn completing mid-bar-hover exits only after release - e2eBridge.ts: __BUZZ_E2E_SEED_ACTIVE_TURNS__ accepts an explicit atMs so tests seed well-separated turn-start anchors deterministically - channels.spec.ts: seedPillActivityMessage now takes the matching turnId — the old hardcoded id resurrected a phantom sibling turn that would have kept a completed agent "working" forever - debugAgentHarness.ts / debug README: update decay-era comments to the hold-last-headline behavior Verified: 3652 unit tests pass; 12 pill/activity/typing e2e tests pass against a fresh build:e2e. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -2,21 +2,20 @@ import * as React from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
|
||||
import {
|
||||
getAgentTranscript,
|
||||
subscribeAgentObserverStore,
|
||||
} from "@/features/agents/observerRelayStore";
|
||||
getAgentWorkingState,
|
||||
subscribeAgentWorkingSignal,
|
||||
} from "@/features/agents/agentWorkingSignal";
|
||||
import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useNow } from "@/shared/lib/useNow";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Shimmer } from "@/shared/ui/Shimmer";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { ComposerLiveActivityFeed } from "./ComposerLiveActivityFeed";
|
||||
import {
|
||||
deriveActivityPillLabel,
|
||||
deriveAgentActivityOrder,
|
||||
deriveAgentWorkingOrder,
|
||||
} from "./composerLiveActivity";
|
||||
|
||||
export type BotActivityAgent = Pick<ManagedAgent, "pubkey" | "name" | "status">;
|
||||
@@ -31,12 +30,7 @@ type BotActivityBarProps = {
|
||||
|
||||
const HOVER_OPEN_DELAY_MS = 150;
|
||||
const HOVER_CLOSE_DELAY_MS = 180;
|
||||
/**
|
||||
* 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. */
|
||||
/** Ticker key for the generic label shown before the first action lands. */
|
||||
const GENERIC_LABEL_ID = "generic-working";
|
||||
/**
|
||||
* Perceptual duration shared by the pill reorder spring and the opacity dip
|
||||
@@ -44,11 +38,6 @@ const GENERIC_LABEL_ID = "generic-working";
|
||||
*/
|
||||
const PILL_REORDER_DURATION_S = 0.9;
|
||||
|
||||
// Stable subscribe reference for useSyncExternalStore (same pattern as
|
||||
// useObserverEvents).
|
||||
const subscribeToObserverStore = (onStoreChange: () => void) =>
|
||||
subscribeAgentObserverStore(onStoreChange);
|
||||
|
||||
/**
|
||||
* Strip-level hover popover state: ONE active pill and ONE timer for the
|
||||
* whole composer strip.
|
||||
@@ -196,9 +185,11 @@ function useStripHoverPopover(): StripHoverPopover {
|
||||
|
||||
/**
|
||||
* One working agent's status pill: avatar + the agent's latest action summary
|
||||
* (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.
|
||||
* (a generic working label until the first action lands; after that the last
|
||||
* action persists — during a quiet stretch, what the agent last did is more
|
||||
* informative than a generic placeholder). 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.
|
||||
@@ -233,14 +224,13 @@ function BotActivityAgentPill({
|
||||
const open = hover.activePubkey === pillKey;
|
||||
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],
|
||||
() => deriveActivityPillLabel({ channelId, transcript }),
|
||||
[channelId, transcript],
|
||||
);
|
||||
const activeId = headline?.id ?? GENERIC_LABEL_ID;
|
||||
// No fresh action headline (see deriveActivityPillLabel) — decay to the
|
||||
// agent-named generic working label.
|
||||
// No action headline yet (see deriveActivityPillLabel) — show the
|
||||
// agent-named generic working label until the first action lands.
|
||||
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).
|
||||
@@ -461,11 +451,14 @@ function AnimatedPillSlot({
|
||||
/**
|
||||
* 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. Each pill truncates its label at its own max width. A lone
|
||||
* ordered by turn start (earliest worker left-most) — a stable slot for the
|
||||
* whole turn, so liveness is carried by each pill's label ticker while
|
||||
* positional motion is reserved for membership changes: a newly working
|
||||
* agent's pill enters on the right, a finished agent's pill exits and its
|
||||
* neighbors animate into the gap. No movement happens 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. Each pill truncates its label at its own max width. A lone
|
||||
* pill shrinks with a narrow container (its label ellipsizes) so it always
|
||||
* fits; when SEVERAL pills outgrow a narrow container (thread panel
|
||||
* toolbars especially) the strip scrolls horizontally — scrollbar hidden,
|
||||
@@ -503,20 +496,23 @@ export function BotActivityComposerAction({
|
||||
return agents.filter((agent) => workingSet.has(agent.pubkey.toLowerCase()));
|
||||
}, [agents, workingBotPubkeys]);
|
||||
|
||||
// Most-recent-first pill order. The snapshot is a joined string so
|
||||
// Turn-start pill order (earliest worker left-most, new agents append on
|
||||
// the right). Anchored to when each agent STARTED working — stable for the
|
||||
// whole turn — so pills never shuffle on transcript activity; only
|
||||
// membership changes move slots. The snapshot is a joined string so
|
||||
// useSyncExternalStore only re-renders this strip when the ORDER actually
|
||||
// changes, not on every observer store write.
|
||||
// changes, not on every working-signal write.
|
||||
const getOrderSnapshot = React.useCallback(
|
||||
() =>
|
||||
deriveAgentActivityOrder({
|
||||
deriveAgentWorkingOrder({
|
||||
channelId,
|
||||
getTranscript: (pubkey) => getAgentTranscript(pubkey, true),
|
||||
getWorkingState: (pubkey) => getAgentWorkingState(pubkey, channelId),
|
||||
pubkeys: workingAgents.map((agent) => agent.pubkey.toLowerCase()),
|
||||
}).join(","),
|
||||
[channelId, workingAgents],
|
||||
);
|
||||
const orderKey = React.useSyncExternalStore(
|
||||
subscribeToObserverStore,
|
||||
subscribeAgentWorkingSignal,
|
||||
getOrderSnapshot,
|
||||
);
|
||||
const liveOrderedAgents = React.useMemo(() => {
|
||||
@@ -530,9 +526,10 @@ export function BotActivityComposerAction({
|
||||
|
||||
// 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
|
||||
// Membership changes (the only source of slot movement under turn-start
|
||||
// ordering) queue up behind the hold and apply when it releases; an agent
|
||||
// that finishes mid-hold keeps its pill until then. Without this, a pill
|
||||
// exiting 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).
|
||||
//
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
deriveActivityPillLabel,
|
||||
deriveAgentActivityOrder,
|
||||
deriveAgentWorkingOrder,
|
||||
deriveLastLiveAt,
|
||||
} from "./composerLiveActivity.ts";
|
||||
|
||||
@@ -62,31 +62,21 @@ const assistantMessage = (id, text, timestamp, channelId = CHANNEL) => ({
|
||||
const secondsBeforeNow = (seconds) =>
|
||||
new Date(NOW - seconds * 1000).toISOString();
|
||||
|
||||
test("deriveActivityPillLabel returns the newest fresh headline, no rotation", () => {
|
||||
test("deriveActivityPillLabel returns the newest headline, no rotation", () => {
|
||||
const editing = thought("Editing ChannelPane", secondsBeforeNow(2));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [thought("Reading files", secondsBeforeNow(4)), editing],
|
||||
});
|
||||
assert.deepEqual(headline, { id: editing.id, label: "Editing ChannelPane" });
|
||||
});
|
||||
|
||||
test("deriveActivityPillLabel decays to null once the newest headline is stale", () => {
|
||||
test("deriveActivityPillLabel keeps the last action headline regardless of age", () => {
|
||||
// A quiet stretch (long tool call, thinking gap) must NOT decay the label
|
||||
// to the generic placeholder — the last real action stays informative.
|
||||
const editing = thought("Editing ChannelPane", secondsBeforeNow(300));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [thought("Editing ChannelPane", secondsBeforeNow(30))],
|
||||
});
|
||||
assert.equal(headline, null);
|
||||
});
|
||||
|
||||
test("deriveActivityPillLabel honors a custom staleness window", () => {
|
||||
const editing = thought("Editing ChannelPane", secondsBeforeNow(30));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
staleAfterMs: 60_000,
|
||||
transcript: [editing],
|
||||
});
|
||||
assert.deepEqual(headline, { id: editing.id, label: "Editing ChannelPane" });
|
||||
@@ -96,7 +86,6 @@ test("deriveActivityPillLabel ignores other-channel items", () => {
|
||||
const inChannel = thought("In-channel work", secondsBeforeNow(3));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [
|
||||
inChannel,
|
||||
thought("Other-channel work", secondsBeforeNow(1), OTHER_CHANNEL),
|
||||
@@ -109,7 +98,6 @@ test("deriveActivityPillLabel lets spine work headline over fresher metadata rea
|
||||
const realWork = thought("Real work", secondsBeforeNow(4));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [realWork, metadata("Prompt context", secondsBeforeNow(1))],
|
||||
});
|
||||
assert.deepEqual(headline, { id: realWork.id, label: "Real work" });
|
||||
@@ -119,7 +107,6 @@ test("deriveActivityPillLabel falls back to metadata when no spine items exist",
|
||||
const context = metadata("Prompt context", secondsBeforeNow(5));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [context],
|
||||
});
|
||||
assert.deepEqual(headline, { id: context.id, label: "Prompt context" });
|
||||
@@ -128,7 +115,6 @@ test("deriveActivityPillLabel falls back to metadata when no spine items exist",
|
||||
test("deriveActivityPillLabel returns null for an empty transcript", () => {
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [],
|
||||
});
|
||||
assert.equal(headline, null);
|
||||
@@ -138,7 +124,6 @@ test("deriveActivityPillLabel never headlines usage/commands meta frames", () =>
|
||||
const realWork = thought("Real work", secondsBeforeNow(4));
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [
|
||||
realWork,
|
||||
lifecycleMeta("usage_update", secondsBeforeNow(2)),
|
||||
@@ -149,10 +134,9 @@ test("deriveActivityPillLabel never headlines usage/commands meta frames", () =>
|
||||
assert.deepEqual(headline, { id: realWork.id, label: "Real work" });
|
||||
});
|
||||
|
||||
test("deriveActivityPillLabel returns null when only meta frames are fresh", () => {
|
||||
test("deriveActivityPillLabel returns null when only meta frames exist", () => {
|
||||
const headline = deriveActivityPillLabel({
|
||||
channelId: CHANNEL,
|
||||
now: NOW,
|
||||
transcript: [lifecycleMeta("usage_update", secondsBeforeNow(1))],
|
||||
});
|
||||
assert.equal(headline, null);
|
||||
@@ -161,12 +145,10 @@ test("deriveActivityPillLabel returns null when only meta frames are fresh", ()
|
||||
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),
|
||||
],
|
||||
@@ -177,56 +159,114 @@ test("deriveActivityPillLabel keeps a stable id while a message streams", () =>
|
||||
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))]],
|
||||
/** Fake working-state reader over pubkey → [{channelId, anchorAt}] entries. */
|
||||
const workingStates = (entries) => (pubkey) => ({
|
||||
channels: entries.get(pubkey) ?? [],
|
||||
});
|
||||
|
||||
test("deriveAgentWorkingOrder puts the earliest-started agent first", () => {
|
||||
const states = new Map([
|
||||
["alpha", [{ channelId: CHANNEL, anchorAt: NOW - 20_000 }]],
|
||||
["beta", [{ channelId: CHANNEL, anchorAt: NOW - 90_000 }]],
|
||||
]);
|
||||
const order = deriveAgentActivityOrder({
|
||||
const order = deriveAgentWorkingOrder({
|
||||
channelId: CHANNEL,
|
||||
getTranscript: (pubkey) => transcripts.get(pubkey) ?? [],
|
||||
getWorkingState: workingStates(states),
|
||||
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))]],
|
||||
test("deriveAgentWorkingOrder appends a later starter after existing workers", () => {
|
||||
const states = new Map([
|
||||
["alpha", [{ channelId: CHANNEL, anchorAt: NOW - 60_000 }]],
|
||||
["beta", [{ channelId: CHANNEL, anchorAt: NOW - 30_000 }]],
|
||||
["gamma", [{ channelId: CHANNEL, anchorAt: NOW - 2_000 }]],
|
||||
]);
|
||||
const order = deriveAgentActivityOrder({
|
||||
const order = deriveAgentWorkingOrder({
|
||||
channelId: CHANNEL,
|
||||
getTranscript: (pubkey) => transcripts.get(pubkey) ?? [],
|
||||
getWorkingState: workingStates(states),
|
||||
// Roster order deliberately differs from start order.
|
||||
pubkeys: ["gamma", "beta", "alpha"],
|
||||
});
|
||||
assert.deepEqual(order, ["alpha", "beta", "gamma"]);
|
||||
});
|
||||
|
||||
test("deriveAgentWorkingOrder ignores other-channel anchors", () => {
|
||||
const states = new Map([
|
||||
["alpha", [{ channelId: CHANNEL, anchorAt: NOW - 5_000 }]],
|
||||
// Beta started much earlier — but in another channel, so it has no
|
||||
// anchor for this scope and sorts to the end.
|
||||
["beta", [{ channelId: OTHER_CHANNEL, anchorAt: NOW - 300_000 }]],
|
||||
]);
|
||||
const order = deriveAgentWorkingOrder({
|
||||
channelId: CHANNEL,
|
||||
getWorkingState: workingStates(states),
|
||||
pubkeys: ["beta", "alpha"],
|
||||
});
|
||||
assert.deepEqual(order, ["alpha", "beta"]);
|
||||
});
|
||||
|
||||
test("deriveAgentWorkingOrder uses the earliest anchor across channels when unscoped", () => {
|
||||
const states = new Map([
|
||||
[
|
||||
"alpha",
|
||||
[
|
||||
{ channelId: CHANNEL, anchorAt: NOW - 10_000 },
|
||||
{ channelId: OTHER_CHANNEL, anchorAt: NOW - 120_000 },
|
||||
],
|
||||
],
|
||||
["beta", [{ channelId: CHANNEL, anchorAt: NOW - 60_000 }]],
|
||||
]);
|
||||
const order = deriveAgentWorkingOrder({
|
||||
channelId: null,
|
||||
getWorkingState: workingStates(states),
|
||||
pubkeys: ["beta", "alpha"],
|
||||
});
|
||||
assert.deepEqual(order, ["alpha", "beta"]);
|
||||
});
|
||||
|
||||
test("deriveAgentWorkingOrder keeps roster order for agents with no anchor", () => {
|
||||
const states = new Map([
|
||||
["gamma", [{ channelId: CHANNEL, anchorAt: NOW - 5_000 }]],
|
||||
]);
|
||||
const order = deriveAgentWorkingOrder({
|
||||
channelId: CHANNEL,
|
||||
getWorkingState: workingStates(states),
|
||||
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({
|
||||
test("deriveAgentWorkingOrder quantizes anchors to seconds so sub-second shifts never reorder", () => {
|
||||
// Same wall-clock second; beta's anchor is a few hundred ms earlier (the
|
||||
// shape of a retroactive clock-offset refinement). Order must follow the
|
||||
// roster, and must not flip when the sub-second part changes.
|
||||
const base = Math.floor((NOW - 10_000) / 1000) * 1000;
|
||||
const order = deriveAgentWorkingOrder({
|
||||
channelId: CHANNEL,
|
||||
getTranscript: (pubkey) => transcripts.get(pubkey) ?? [],
|
||||
getWorkingState: workingStates(
|
||||
new Map([
|
||||
["alpha", [{ channelId: CHANNEL, anchorAt: base + 700 }]],
|
||||
["beta", [{ channelId: CHANNEL, anchorAt: base + 100 }]],
|
||||
]),
|
||||
),
|
||||
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({
|
||||
const afterRefinement = deriveAgentWorkingOrder({
|
||||
channelId: CHANNEL,
|
||||
getTranscript: (pubkey) => transcripts.get(pubkey) ?? [],
|
||||
getWorkingState: workingStates(
|
||||
new Map([
|
||||
// Alpha's offset estimate tightened: anchor slid 400ms earlier.
|
||||
["alpha", [{ channelId: CHANNEL, anchorAt: base + 300 }]],
|
||||
["beta", [{ channelId: CHANNEL, anchorAt: base + 100 }]],
|
||||
]),
|
||||
),
|
||||
pubkeys: ["alpha", "beta"],
|
||||
});
|
||||
assert.deepEqual(order, ["alpha", "beta"]);
|
||||
assert.deepEqual(afterRefinement, order);
|
||||
});
|
||||
|
||||
test("deriveLastLiveAt prefers the newest channel-scoped transcript item", () => {
|
||||
|
||||
@@ -5,14 +5,6 @@ import {
|
||||
} from "@/features/agents/ui/agentSessionTranscriptPresentation";
|
||||
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. 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 = 6_000;
|
||||
|
||||
/**
|
||||
* Lifecycle meta-frames that must never headline the pill. They pass the
|
||||
* spine filter (meaningful lifecycle items) but would surface as bare
|
||||
@@ -34,23 +26,21 @@ export type ActivityPillHeadline = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Latest fresh action headline for a working agent's composer pill.
|
||||
* Latest action headline for a working agent's composer pill.
|
||||
*
|
||||
* 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 "<name> is working…" label.
|
||||
* Deliberately no rotation through recent actions: one headline, then decay.
|
||||
* mirroring the session transcript's noise gate), newest wins. The headline
|
||||
* PERSISTS regardless of age — while a turn is in progress, the last real
|
||||
* action is more informative than a generic placeholder, so there is
|
||||
* deliberately no staleness decay (and no rotation through recent actions).
|
||||
* Returns null only when the transcript has nothing headline-able yet; the
|
||||
* pill then falls back to its generic "<name> is working…" label.
|
||||
*/
|
||||
export function deriveActivityPillLabel({
|
||||
channelId,
|
||||
now,
|
||||
staleAfterMs = ACTIVITY_PILL_STALE_MS,
|
||||
transcript,
|
||||
}: {
|
||||
channelId: string | null;
|
||||
now: number;
|
||||
staleAfterMs?: number;
|
||||
transcript: readonly TranscriptItem[];
|
||||
}): ActivityPillHeadline | null {
|
||||
const scoped = channelId
|
||||
@@ -74,55 +64,69 @@ export function deriveActivityPillLabel({
|
||||
if (!headline) {
|
||||
continue;
|
||||
}
|
||||
const millis = Date.parse(item.timestamp);
|
||||
if (!Number.isNaN(millis) && now - millis > staleAfterMs) {
|
||||
return null;
|
||||
}
|
||||
return { id: item.id, label: headline };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Minimal working-state shape the ordering needs (see agentWorkingSignal). */
|
||||
export type AgentWorkingChannelAnchor = {
|
||||
channelId: string;
|
||||
/** Desktop-clock anchor for when the agent started working there. */
|
||||
anchorAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Working-agent pill order for the composer strip: agents ordered by when
|
||||
* they STARTED working in this channel (turn anchor), earliest first —
|
||||
* new agents append on the right. Deliberately not transcript recency:
|
||||
* a start anchor is stable for the whole turn, so pills hold their slot
|
||||
* while agents stream instead of shuffling on every event, and positional
|
||||
* motion is reserved for membership changes (an agent starting/finishing).
|
||||
*
|
||||
* `getTranscript` is injected so the helper stays pure and unit-testable;
|
||||
* callers pass the observer store's cached reader.
|
||||
* Anchors are quantized to whole seconds — clock-offset refinement can
|
||||
* retroactively shift an anchor by the sub-second network delay it just
|
||||
* measured out, and such a shift must not reorder the strip. Ties (same
|
||||
* second, or no anchor for the requested scope) keep the incoming roster
|
||||
* order — the sort is stable, so they never reshuffle. Agents with no
|
||||
* anchor sort to the end.
|
||||
*
|
||||
* `getWorkingState` is injected so the helper stays pure and unit-testable;
|
||||
* callers pass the working signal's cached reader.
|
||||
*/
|
||||
export function deriveAgentActivityOrder({
|
||||
export function deriveAgentWorkingOrder({
|
||||
channelId,
|
||||
getTranscript,
|
||||
getWorkingState,
|
||||
pubkeys,
|
||||
}: {
|
||||
channelId: string | null;
|
||||
getTranscript: (pubkey: string) => readonly TranscriptItem[];
|
||||
getWorkingState: (pubkey: string) => {
|
||||
channels: readonly AgentWorkingChannelAnchor[];
|
||||
};
|
||||
pubkeys: readonly string[];
|
||||
}): string[] {
|
||||
const lastActivityAt = new Map<string, number>();
|
||||
const startSecond = 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)) {
|
||||
let earliest: number | null = null;
|
||||
for (const channel of getWorkingState(pubkey).channels) {
|
||||
if (channelId !== null && channel.channelId !== channelId) {
|
||||
continue;
|
||||
}
|
||||
const millis = Date.parse(item.timestamp);
|
||||
if (!Number.isNaN(millis)) {
|
||||
lastActivityAt.set(pubkey, millis);
|
||||
break;
|
||||
if (earliest === null || channel.anchorAt < earliest) {
|
||||
earliest = channel.anchorAt;
|
||||
}
|
||||
}
|
||||
if (earliest !== null) {
|
||||
startSecond.set(pubkey, Math.floor(earliest / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
return [...pubkeys].sort(
|
||||
(a, b) =>
|
||||
(lastActivityAt.get(b) ?? Number.NEGATIVE_INFINITY) -
|
||||
(lastActivityAt.get(a) ?? Number.NEGATIVE_INFINITY),
|
||||
(startSecond.get(a) ?? Number.POSITIVE_INFINITY) -
|
||||
(startSecond.get(b) ?? Number.POSITIVE_INFINITY),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1248,6 +1248,12 @@ declare global {
|
||||
channelId: string;
|
||||
turnId: string;
|
||||
kind?: "turn_started" | "turn_completed";
|
||||
/**
|
||||
* Explicit event timestamp (epoch ms). The composer strip orders pills
|
||||
* by turn START anchor, so tests seed well-separated starts to make
|
||||
* the order deterministic. Defaults to now.
|
||||
*/
|
||||
atMs?: number;
|
||||
}) => void;
|
||||
__BUZZ_E2E_SEED_OBSERVER_EVENTS__?: (input: {
|
||||
agentPubkey: string;
|
||||
@@ -10265,11 +10271,12 @@ export function maybeInstallE2eTauriMocks() {
|
||||
channelId,
|
||||
turnId,
|
||||
kind = "turn_started",
|
||||
atMs,
|
||||
}) => {
|
||||
seedTurnSeq += 1;
|
||||
const event = {
|
||||
seq: seedTurnSeq,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: new Date(atMs ?? Date.now()).toISOString(),
|
||||
kind,
|
||||
agentIndex: 0,
|
||||
channelId,
|
||||
|
||||
@@ -2181,14 +2181,18 @@ test("composer does not shift when the activity row mounts and clears", async ({
|
||||
});
|
||||
|
||||
// ── 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).
|
||||
// Working pills hold a stable turn-start order (earliest worker left-most);
|
||||
// slots only change when MEMBERSHIP does — an agent starting work appends a
|
||||
// pill on the right, a finishing agent's pill exits. While the cursor is
|
||||
// anywhere over the activity bar OR a hover card is showing, even those
|
||||
// changes must not move the strip under the cursor: pill order, membership,
|
||||
// and widths are frozen, and the queued membership change 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_LYRA = "ee".repeat(32);
|
||||
const PILL_AGENT_SEEDS = [
|
||||
{
|
||||
pubkey: PILL_AGENT_NOVA,
|
||||
@@ -2202,19 +2206,36 @@ const PILL_AGENT_SEEDS = [
|
||||
status: "running" as const,
|
||||
channelNames: ["agents"],
|
||||
},
|
||||
// Not seeded as working by the shared helper — the membership-freeze test
|
||||
// starts lyra's turn mid-hover to prove a queued pill append.
|
||||
{
|
||||
pubkey: PILL_AGENT_LYRA,
|
||||
name: "lyra",
|
||||
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.
|
||||
* Seed one channel-scoped assistant-message observer event. The message
|
||||
* drives the pill's LABEL (the newest headline-able transcript item wins) —
|
||||
* pill ORDER is anchored to turn start and does not move on messages.
|
||||
* `turnId` must match the agent's seeded active turn so the event refreshes
|
||||
* that turn's liveness instead of resurrecting a phantom sibling turn
|
||||
* (which would keep the agent "working" after its real turn completes).
|
||||
*/
|
||||
async function seedPillActivityMessage(
|
||||
page: import("@playwright/test").Page,
|
||||
input: { agentPubkey: string; atMs: number; seq: number; text: string },
|
||||
input: {
|
||||
agentPubkey: string;
|
||||
atMs: number;
|
||||
seq: number;
|
||||
text: string;
|
||||
turnId: string;
|
||||
},
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ agentPubkey, atMs, channelId, seq, text }) => {
|
||||
({ agentPubkey, atMs, channelId, seq, text, turnId }) => {
|
||||
const sessionId = `seed-session-${agentPubkey.slice(0, 6)}`;
|
||||
window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({
|
||||
agentPubkey,
|
||||
@@ -2226,7 +2247,7 @@ async function seedPillActivityMessage(
|
||||
agentIndex: 0,
|
||||
channelId,
|
||||
sessionId,
|
||||
turnId: `seed-turn-${agentPubkey.slice(0, 6)}`,
|
||||
turnId,
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
@@ -2248,9 +2269,12 @@ async function seedPillActivityMessage(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Open #agents with two seeded working agents. Pill order follows turn
|
||||
* START anchors (earliest worker left-most): nova's turn starts 60s before
|
||||
* base and astra's 30s before, so the strip renders [nova, astra] — well
|
||||
* clear of the ordering's whole-second anchor quantization, and unaffected
|
||||
* by later message activity. Returns the shared trigger locator and the
|
||||
* timestamp base used for the seeds.
|
||||
*/
|
||||
async function openAgentsChannelWithTwoWorkingPills(
|
||||
page: import("@playwright/test").Page,
|
||||
@@ -2266,33 +2290,43 @@ async function openAgentsChannelWithTwoWorkingPills(
|
||||
|
||||
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}`,
|
||||
});
|
||||
({ channelId, turns }) => {
|
||||
for (const turn of turns) {
|
||||
window.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ channelId, ...turn });
|
||||
}
|
||||
},
|
||||
{
|
||||
agents: [PILL_AGENT_ASTRA, PILL_AGENT_NOVA],
|
||||
channelId: AGENTS_CHANNEL_ID,
|
||||
turns: [
|
||||
{
|
||||
agentPubkey: PILL_AGENT_NOVA,
|
||||
turnId: "pill-turn-nova",
|
||||
atMs: baseMs - 60_000,
|
||||
},
|
||||
{
|
||||
agentPubkey: PILL_AGENT_ASTRA,
|
||||
turnId: "pill-turn-astra",
|
||||
atMs: baseMs - 30_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
// Future-stamped messages outrank the just-seeded turn events regardless
|
||||
// of turn-seed timing: nova (+2s) newest, astra (+1s) second.
|
||||
// Seeded messages give each pill a distinctive action headline (labels
|
||||
// hold the newest headline-able item) without touching the turn-start
|
||||
// order.
|
||||
await seedPillActivityMessage(page, {
|
||||
agentPubkey: PILL_AGENT_ASTRA,
|
||||
atMs: baseMs + 1_000,
|
||||
seq: baseMs + 10_001,
|
||||
text: "Astra: reviewing the composer wiring",
|
||||
turnId: "pill-turn-astra",
|
||||
});
|
||||
await seedPillActivityMessage(page, {
|
||||
agentPubkey: PILL_AGENT_NOVA,
|
||||
atMs: baseMs + 2_000,
|
||||
seq: baseMs + 10_002,
|
||||
text: "Nova: tracing the observer store",
|
||||
turnId: "pill-turn-nova",
|
||||
});
|
||||
|
||||
const triggers = page.getByTestId("bot-activity-composer-trigger");
|
||||
@@ -2304,7 +2338,7 @@ async function openAgentsChannelWithTwoWorkingPills(
|
||||
return { baseMs, triggers };
|
||||
}
|
||||
|
||||
test("hover card freezes pill order; queued reorder applies on close", async ({
|
||||
test("hover card freezes pill membership; queued pill append applies on close", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, { managedAgents: PILL_AGENT_SEEDS });
|
||||
@@ -2325,44 +2359,69 @@ test("hover card freezes pill order; queued reorder applies on close", async ({
|
||||
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. Waiting on the pill's label
|
||||
// to swap to the new message text proves the store update reached this
|
||||
// strip's render; a broken freeze would have flipped the DOM order in that
|
||||
// same commit, so no blind animation-length sleep is needed.
|
||||
// Lyra starting a turn would append her pill on the right — but a hover
|
||||
// card is showing, so the strip's membership must not change. Waiting on
|
||||
// the hovered pill's label to swap to the new message text proves the
|
||||
// store updates reached this strip's render; a broken freeze would have
|
||||
// mounted lyra's pill in that same commit, so no blind animation-length
|
||||
// sleep is needed.
|
||||
await page.evaluate(
|
||||
({ agentPubkey, atMs, channelId }) => {
|
||||
window.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({
|
||||
agentPubkey,
|
||||
channelId,
|
||||
turnId: "pill-turn-lyra",
|
||||
atMs,
|
||||
});
|
||||
},
|
||||
{
|
||||
agentPubkey: PILL_AGENT_LYRA,
|
||||
atMs: baseMs + 4_000,
|
||||
channelId: AGENTS_CHANNEL_ID,
|
||||
},
|
||||
);
|
||||
await seedPillActivityMessage(page, {
|
||||
agentPubkey: PILL_AGENT_ASTRA,
|
||||
atMs: baseMs + 5_000,
|
||||
seq: baseMs + 10_003,
|
||||
text: "Astra: verifying the turn-resurrection path",
|
||||
turnId: "pill-turn-astra",
|
||||
});
|
||||
await expect(astraPill).toContainText(
|
||||
"Astra: verifying the turn-resurrection path",
|
||||
);
|
||||
await expect(feed).toBeVisible();
|
||||
await expect(triggers).toHaveCount(2);
|
||||
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.");
|
||||
throw new Error("Hovered pill is not visible after the membership change.");
|
||||
}
|
||||
// Frozen order + pinned width: the hovered pill's slot and size held even
|
||||
// though its label swapped to the new (longer) message text.
|
||||
// Frozen membership + 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.
|
||||
// Release the hover: the card closes and the queued append applies —
|
||||
// lyra's pill enters at the END (turn-start order: newest starter last),
|
||||
// leaving the existing pills' slots untouched.
|
||||
await page.getByTestId("chat-title").hover();
|
||||
await expect(feed).toBeHidden();
|
||||
await expect(triggers).toHaveCount(3);
|
||||
await expect(triggers.first()).toHaveAttribute(
|
||||
"aria-label",
|
||||
"astra is working. View activity.",
|
||||
"nova is working. View activity.",
|
||||
);
|
||||
await expect(triggers.nth(2)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"lyra is working. View activity.",
|
||||
);
|
||||
});
|
||||
|
||||
test("hovering the bar itself freezes pill order without opening a card", async ({
|
||||
test("hovering the bar itself freezes pill membership without opening a card", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, { managedAgents: PILL_AGENT_SEEDS });
|
||||
@@ -2382,34 +2441,55 @@ test("hovering the bar itself freezes pill order without opening a card", async
|
||||
novaBox.y + novaBox.height / 2,
|
||||
);
|
||||
// The bar hold pins every pill's width via an inline style — waiting for
|
||||
// it proves the freeze is engaged before the reorder attempt below.
|
||||
// it proves the freeze is engaged before the membership change below.
|
||||
await expect(triggers.first()).toHaveAttribute("style", /width/);
|
||||
await expect(page.getByTestId("composer-live-activity-feed")).toHaveCount(0);
|
||||
|
||||
// Newest activity for astra would move her pill to the front — but the
|
||||
// cursor is over the bar, so the order stays frozen. The label swap
|
||||
// confirms the store update reached the strip without a blind sleep.
|
||||
// Astra's turn completes — her pill would exit, sliding the strip under
|
||||
// the parked cursor. The cursor is over the bar, so membership stays
|
||||
// frozen and the pill holds. Nova's label swap confirms the store updates
|
||||
// reached the strip without a blind sleep.
|
||||
await page.evaluate(
|
||||
({ agentPubkey, atMs, channelId }) => {
|
||||
window.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({
|
||||
agentPubkey,
|
||||
channelId,
|
||||
turnId: "pill-turn-astra",
|
||||
kind: "turn_completed",
|
||||
atMs,
|
||||
});
|
||||
},
|
||||
{
|
||||
agentPubkey: PILL_AGENT_ASTRA,
|
||||
atMs: baseMs + 4_000,
|
||||
channelId: AGENTS_CHANNEL_ID,
|
||||
},
|
||||
);
|
||||
await seedPillActivityMessage(page, {
|
||||
agentPubkey: PILL_AGENT_ASTRA,
|
||||
agentPubkey: PILL_AGENT_NOVA,
|
||||
atMs: baseMs + 5_000,
|
||||
seq: baseMs + 10_003,
|
||||
text: "Astra: verifying the turn-resurrection path",
|
||||
text: "Nova: confirming the frozen strip",
|
||||
turnId: "pill-turn-nova",
|
||||
});
|
||||
await expect(triggers.nth(1)).toContainText(
|
||||
"Astra: verifying the turn-resurrection path",
|
||||
await expect(triggers.first()).toContainText(
|
||||
"Nova: confirming the frozen strip",
|
||||
);
|
||||
await expect(page.getByTestId("composer-live-activity-feed")).toHaveCount(0);
|
||||
await expect(triggers).toHaveCount(2);
|
||||
await expect(triggers.nth(1)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"astra is working. View activity.",
|
||||
);
|
||||
|
||||
// Leaving the bar releases the hold: the queued exit applies and astra's
|
||||
// pill leaves the strip.
|
||||
await page.getByTestId("chat-title").hover();
|
||||
await expect(triggers).toHaveCount(1);
|
||||
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 ({
|
||||
|
||||
Reference in New Issue
Block a user