mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
Reference in New Issue
Block a user