From 35249c91a96ffef639cb3d607ea66eeb1afe7af1 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 23 Aug 2026 14:09:30 -0700 Subject: [PATCH] fix(composer): keep live activity ahead of typing Keep observer-backed action headlines authoritative while an agent turn is active, using raw typing only before observer startup and across the completion-to-message gap. Cover both channel and thread composer lifecycles. Co-authored-by: Carl Signed-off-by: Taylor Ho --- .../src/features/agents/agentWorkingSignal.ts | 7 +- .../features/channels/ui/BotActivityBar.tsx | 46 +++++----- .../ui/ChannelComposerActivityRow.tsx | 3 +- .../channels/ui/composerLiveActivity.test.mjs | 50 +++++++++++ .../channels/ui/composerLiveActivity.ts | 48 +++++++++++ desktop/tests/e2e/channels.spec.ts | 86 +++++++++++++++++-- 6 files changed, 202 insertions(+), 38 deletions(-) diff --git a/desktop/src/features/agents/agentWorkingSignal.ts b/desktop/src/features/agents/agentWorkingSignal.ts index c09c307853..6a4e4378c3 100644 --- a/desktop/src/features/agents/agentWorkingSignal.ts +++ b/desktop/src/features/agents/agentWorkingSignal.ts @@ -135,10 +135,9 @@ export function reportChannelBotTyping( * null when the agent is not typing there. * * Raw registry read: unlike `getAgentWorkingState`, this does NOT fold - * typing under observer precedence — an observer-backed surface (the - * composer activity pill) uses it to relabel IN PLACE to "is typing…" while - * a turn is in flight, instead of the agent spawning a second, separate - * typing-group item. + * typing under observer precedence. The composer activity pill uses it as a + * fallback before observer telemetry arrives and after an observer turn ends, + * without spawning a second, separate typing-group item. */ export function getAgentChannelTypingSince( agentPubkey: string | null | undefined, diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 75cd204ff2..fbe629449b 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -26,6 +26,7 @@ import { } from "./composerStripAvatarLayout"; import { deriveActivityPillLabel, + deriveActivityPillPresentation, deriveAgentWorkingOrder, } from "./composerLiveActivity"; @@ -39,8 +40,8 @@ type BotActivityBarProps = { /** * Agent pubkeys known to be typing in this strip's scope. Thread-only * typing is intentionally absent from the channel-wide working registry, - * so callers pass it here to relabel an already pill-worthy agent without - * leaking thread activity into channel-level surfaces. + * so callers pass it here to retain the typing fallback without leaking + * thread activity into channel-level surfaces. */ typingBotPubkeys?: string[]; /** Combined human + typing-fallback agent indicator. */ @@ -49,10 +50,6 @@ type BotActivityBarProps = { }; const HOVER_CLOSE_DELAY_MS = 180; -/** Ticker key for the generic label shown before the first action lands. */ -const GENERIC_LABEL_ID = "generic-working"; -/** Ticker key for the typing override label (see BotActivityAgentPill). */ -const TYPING_LABEL_ID = "typing-override"; /** * Perceptual duration shared by the pill reorder spring and the opacity dip * keyframes so the fade lands together with the layout switch. @@ -230,9 +227,9 @@ function useStripHoverPopover(): StripHoverPopover { * partitionComposerWorkingAgents): typing agents with nothing to hover or * open are diverted into the combined typing indicator group by * ChannelComposerActivityRow before the strip renders. An agent that is - * typing — mid-turn or across the turn-end gap — keeps its pill, with the - * label relabeled in place to "is typing…" for the duration (see the - * typing override below). + * typing across the turn-end gap keeps its pill and relabels in place to + * "is typing…"; during an active observer turn, the current action remains + * authoritative despite concurrent typing refreshes. */ function BotActivityAgentPill({ agent, @@ -271,27 +268,28 @@ function BotActivityAgentPill({ () => deriveActivityPillLabel({ channelId, transcript }), [channelId, transcript], ); - // Typing overrides the action headline IN PLACE: when this observer-backed - // agent's typing signal is also on for the channel (it starts composing its - // reply mid-turn), the pill's ticker swaps to "is typing…" — same slot, - // avatar, and hover feed — instead of the stale last action persisting or a - // second typing-group item appearing. Raw registry read, because - // getAgentWorkingState folds typing away under observer precedence. + const workingState = React.useSyncExternalStore( + subscribeAgentWorkingSignal, + () => getAgentWorkingState(agent.pubkey, channelId), + ); + // Raw typing remains available even when getAgentWorkingState folds it under + // observer precedence. It supplies the fallback before observer telemetry + // arrives and after the observer turn completes; it must not hide live + // observer activity while that turn is active. const typingSince = React.useSyncExternalStore( subscribeAgentWorkingSignal, () => getAgentChannelTypingSince(agent.pubkey, channelId), ); const isTyping = typingBotPubkeys?.has(pillKey) === true || typingSince !== null; - const activeId = isTyping - ? TYPING_LABEL_ID - : (headline?.id ?? GENERIC_LABEL_ID); - // No action headline yet (see deriveActivityPillLabel) — show the - // agent-named generic working label until the first action lands. When - // typing ends, the ticker swaps back to the last action headline. - const activeLabel = isTyping - ? `${agent.name} is typing…` - : (headline?.label ?? `${agent.name} is working…`); + const presentation = deriveActivityPillPresentation({ + agentName: agent.name, + headline, + isTyping, + workingSource: workingState.source, + }); + const activeId = presentation.id; + const activeLabel = presentation.label; // The rendered label lags the derived one while the pill is moving: the // push-up ticker plays after the slot settles (or immediately when idle). // Keyed by transcript item id, not label text — a NEW action swaps, while diff --git a/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx b/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx index 0b7caa00cd..2e7135292c 100644 --- a/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx +++ b/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx @@ -32,7 +32,8 @@ import type { Channel } from "@/shared/api/types"; * into ONE combined typing indicator group ("X and Y are typing…") with an * overlapping avatar set. An agent whose turn just ended but who is still * typing therefore keeps its pill (relabeled "is typing…") instead of - * demoting to the group. + * demoting to the group. While an observer turn is active, its latest action + * remains the pill label even if the harness continues refreshing typing. * * The row has a FIXED height (not min-h): it must not grow when the inline * bot-activity button (h-7) mounts, or the bottom-anchored composer above it diff --git a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs index 53fa6c375e..7b3dc55589 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs +++ b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { deriveActivityPillLabel, + deriveActivityPillPresentation, deriveAgentWorkingOrder, deriveLastLiveAt, partitionComposerWorkingAgents, @@ -197,6 +198,55 @@ test("deriveActivityPillLabel keeps a stable id while a message streams", () => assert.equal(extended.label, "Pass 1: reading the composer wiring"); }); +test("deriveActivityPillPresentation keeps observer activity authoritative over typing", () => { + const headline = { id: "action-1", label: "Inspect composer state" }; + const presentation = deriveActivityPillPresentation({ + agentName: "Alice", + headline, + isTyping: true, + workingSource: "observer", + }); + + assert.deepEqual(presentation, headline); +}); + +test("deriveActivityPillPresentation uses working copy before the first observer action", () => { + const presentation = deriveActivityPillPresentation({ + agentName: "Alice", + headline: null, + isTyping: true, + workingSource: "observer", + }); + + assert.deepEqual(presentation, { + id: "generic-working", + label: "Alice is working…", + }); +}); + +test("deriveActivityPillPresentation uses typing before observer startup and after completion", () => { + const headline = { id: "action-1", label: "Inspect composer state" }; + + assert.deepEqual( + deriveActivityPillPresentation({ + agentName: "Alice", + headline: null, + isTyping: true, + workingSource: "none", + }), + { id: "typing-override", label: "Alice is typing…" }, + ); + assert.deepEqual( + deriveActivityPillPresentation({ + agentName: "Alice", + headline, + isTyping: true, + workingSource: "typing", + }), + { id: "typing-override", label: "Alice is typing…" }, + ); +}); + /** Lifecycle noise item ("Turn started") — meaningful:false, never headlines. */ const turnStartedLifecycle = (timestamp, channelId = CHANNEL) => ({ id: `lifecycle-turn-${timestamp}`, diff --git a/desktop/src/features/channels/ui/composerLiveActivity.ts b/desktop/src/features/channels/ui/composerLiveActivity.ts index dab29f41e4..910abef420 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.ts +++ b/desktop/src/features/channels/ui/composerLiveActivity.ts @@ -25,6 +25,54 @@ export type ActivityPillHeadline = { label: string; }; +export type ActivityPillPresentation = { + id: string; + label: string; +}; + +/** + * Choose the label shown by an agent's composer pill. + * + * Observer activity is authoritative while a turn is active, even though the + * ACP harness continues refreshing its public typing signal throughout that + * turn. Raw typing remains a fallback before observer telemetry arrives and + * after the observer turn completes. + */ +export function deriveActivityPillPresentation({ + agentName, + headline, + isTyping, + workingSource, +}: { + agentName: string; + headline: ActivityPillHeadline | null; + isTyping: boolean; + workingSource: "observer" | "typing" | "none"; +}): ActivityPillPresentation { + if (workingSource === "observer") { + return ( + headline ?? { + id: "generic-working", + label: `${agentName} is working…`, + } + ); + } + + if (isTyping) { + return { + id: "typing-override", + label: `${agentName} is typing…`, + }; + } + + return ( + headline ?? { + id: "generic-working", + label: `${agentName} is working…`, + } + ); +} + /** * Latest action headline for a working agent's composer pill. * diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index f9d11e5e95..66f88776d6 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2093,12 +2093,22 @@ test("shows and clears activity indicators for active channel agents", async ({ atMs: Date.now(), seq: Date.now(), text: "Alice: indexing the channel history", + turnId: "indicator-turn", }); const activityPill = page.getByTestId("bot-activity-composer-trigger"); await expect(activityPill).toBeVisible(); - await expect(activityPill).not.toContainText("View activity"); + await expect(activityPill).toContainText("indexing the channel history"); await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0); + // The harness refreshes typing throughout the turn. Observer activity must + // remain authoritative instead of regressing the pill to "is typing…". + await page.evaluate((pubkey) => { + window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({ + channelName: "agents", + pubkey, + }); + }, TEST_IDENTITIES.alice.pubkey); + await expect(activityPill).toContainText("indexing the channel history"); // Hovering the pill opens the live-activity preview. await activityPill.hover(); await expect(page.getByTestId("composer-live-activity-feed")).toBeVisible(); @@ -2122,8 +2132,8 @@ test("shows and clears activity indicators for active channel agents", async ({ await page.keyboard.press("Escape"); await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0); - // Completing the turn clears the pill; the agent's finished message lands - // in the timeline as usual. + // Completion makes raw typing the fallback again until the final message + // clears it. await page.evaluate( ({ channelId, pubkey }) => { window.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ @@ -2132,13 +2142,20 @@ test("shows and clears activity indicators for active channel agents", async ({ turnId: "indicator-turn", kind: "turn_completed", }); + }, + { channelId: AGENTS_CHANNEL_ID, pubkey: TEST_IDENTITIES.alice.pubkey }, + ); + await expect(activityPill).toContainText("alice is typing"); + + await page.evaluate( + ({ pubkey }) => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "agents", content: "Done.", pubkey, }); }, - { channelId: AGENTS_CHANNEL_ID, pubkey: TEST_IDENTITIES.alice.pubkey }, + { pubkey: TEST_IDENTITIES.alice.pubkey }, ); await expect(page.getByTestId("message-timeline")).toContainText("Done."); @@ -2171,7 +2188,8 @@ test("thread agent typing promotes only when activity exists", async ({ await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && - typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function", + typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function" && + typeof window.__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function", ); const threadHeadId = await page.evaluate(() => { @@ -2216,9 +2234,18 @@ test("thread agent typing promotes only when activity exists", async ({ ).toHaveCount(0); // Once channel-scoped observer activity exists, the same thread typer earns - // a pill and leaves the combined typing group. The explicit thread typing - // set keeps the promoted pill truthfully labeled "is typing…" even though - // thread typing is intentionally absent from the channel-wide registry. + // a pill and leaves the combined typing group. Active observer work remains + // authoritative over the concurrent thread typing signal. + await page.evaluate( + ({ channelId, pubkey }) => { + window.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey: pubkey, + channelId, + turnId: "thread-indicator-turn", + }); + }, + { channelId: AGENTS_CHANNEL_ID, pubkey: TEST_IDENTITIES.alice.pubkey }, + ); await seedPillActivityMessage(page, { agentPubkey: TEST_IDENTITIES.alice.pubkey, atMs: Date.now(), @@ -2228,10 +2255,51 @@ test("thread agent typing promotes only when activity exists", async ({ }); const pill = threadPanel.getByTestId("bot-activity-composer-trigger"); await expect(pill).toBeVisible(); - await expect(pill).toContainText("alice is typing"); + await expect(pill).toContainText("inspecting the thread context"); await expect(threadPanel.getByTestId("message-typing-indicator")).toHaveCount( 0, ); + // A later typing refresh still cannot mask the live action. + await page.evaluate( + ({ pubkey, threadHeadId }) => { + window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({ + channelName: "agents", + pubkey, + threadHeadId, + }); + }, + { pubkey: TEST_IDENTITIES.alice.pubkey, threadHeadId }, + ); + await expect(pill).toContainText("inspecting the thread context"); + + // Once the observer turn completes, thread typing becomes the fallback and + // the eventual reply clears the pill in the same scope. + await page.evaluate( + ({ channelId, pubkey }) => { + window.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey: pubkey, + channelId, + turnId: "thread-indicator-turn", + kind: "turn_completed", + }); + }, + { channelId: AGENTS_CHANNEL_ID, pubkey: TEST_IDENTITIES.alice.pubkey }, + ); + await expect(pill).toContainText("alice is typing"); + await page.evaluate( + ({ pubkey, threadHeadId }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "agents", + content: "Thread done.", + parentEventId: threadHeadId, + pubkey, + }); + }, + { pubkey: TEST_IDENTITIES.alice.pubkey, threadHeadId }, + ); + await expect( + threadPanel.getByTestId("bot-activity-composer-trigger"), + ).toHaveCount(0); }); test("composer does not shift when the activity row mounts and clears", async ({