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:
Taylor Ho
2026-08-05 16:41:59 -07:00
committed by Carl
parent 2fe4b68091
commit 33ede155c6
5 changed files with 311 additions and 183 deletions
@@ -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),
);
}
+8 -1
View File
@@ -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,
+131 -51
View File
@@ -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 ({