From 33ede155c6b9a7d25f97043a6924a5108da56365 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 27 Jul 2026 14:43:14 -0700 Subject: [PATCH] feat(composer): stable turn-start pill order and persistent action labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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 Signed-off-by: Taylor Ho --- .../features/channels/ui/BotActivityBar.tsx | 71 ++++--- .../channels/ui/composerLiveActivity.test.mjs | 146 +++++++++----- .../channels/ui/composerLiveActivity.ts | 86 +++++---- desktop/src/testing/e2eBridge.ts | 9 +- desktop/tests/e2e/channels.spec.ts | 182 +++++++++++++----- 5 files changed, 311 insertions(+), 183 deletions(-) diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index eadcd0729..c13e918cc 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -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; @@ -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). // diff --git a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs index 4652346c7..c65cca267 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs +++ b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs @@ -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", () => { diff --git a/desktop/src/features/channels/ui/composerLiveActivity.ts b/desktop/src/features/channels/ui/composerLiveActivity.ts index 390ce9456..20157bde1 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.ts +++ b/desktop/src/features/channels/ui/composerLiveActivity.ts @@ -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 " 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 " 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 " 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(); + const startSecond = new Map(); 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), ); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 53dc3aca0..5b5d1415f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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, diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 4716cd49f..4d750885b 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -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 ({