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 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-23 14:09:30 -07:00
committed by Carl
parent 67de35b61c
commit 35249c91a9
6 changed files with 202 additions and 38 deletions

View File

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

View File

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

View File

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

View File

@@ -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}`,

View File

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

View File

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