mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(composer): relabel working pill to "is typing" in place while agent types
An observer-backed agent that started typing its reply mid-turn had the typing signal swallowed entirely: computeAgentWorkingState drops a channel's typing entry under observer precedence, so the pill kept showing the stale last tool action and typing could only ever surface as a separate item in the trailing typing group. Now the existing pill's ticker swaps to "<name> is typing…" in the same slot and swaps back to the last action headline when typing clears. - desktop/src/features/agents/agentWorkingSignal.ts: add getAgentChannelTypingSince(pubkey, channelId) — a raw typing-registry read that deliberately bypasses the observer-precedence fold, returning the first-seen typing timestamp or null - desktop/src/features/channels/ui/BotActivityBar.tsx: BotActivityAgentPill subscribes via useSyncExternalStore; while typing, the ticker keys on a dedicated TYPING_LABEL_ID and the label becomes "<name> is typing…" — same slot, avatar, and hover feed, no new strip item. Partition unchanged: typing-only agents still divert to the combined typing indicator group - desktop/src/features/agents/agentWorkingSignal.test.mjs: three new tests pinning the getter (null cases, first-seen anchor lifecycle, and not-folded-under-observer-precedence) Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { beforeEach, describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
getAgentChannelTypingSince,
|
||||
getAgentWorkingState,
|
||||
getWorkingAgentPubkeysForChannel,
|
||||
getWorkingChannels,
|
||||
@@ -116,6 +117,32 @@ describe("getAgentWorkingState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentChannelTypingSince", () => {
|
||||
it("returns null when the agent is not typing in the channel", () => {
|
||||
assert.equal(getAgentChannelTypingSince(AGENT, "chan-1"), null);
|
||||
reportChannelBotTyping("chan-1", [AGENT_2]);
|
||||
assert.equal(getAgentChannelTypingSince(AGENT, "chan-1"), null);
|
||||
assert.equal(getAgentChannelTypingSince(AGENT_2, "chan-2"), null);
|
||||
assert.equal(getAgentChannelTypingSince(null, "chan-1"), null);
|
||||
assert.equal(getAgentChannelTypingSince(AGENT, null), null);
|
||||
});
|
||||
|
||||
it("returns the first-seen anchor while typing", () => {
|
||||
reportChannelBotTyping("chan-1", [AGENT]);
|
||||
const since = getAgentChannelTypingSince(AGENT, "chan-1");
|
||||
assert.ok(since !== null && since <= Date.now());
|
||||
reportChannelBotTyping("chan-1", []);
|
||||
assert.equal(getAgentChannelTypingSince(AGENT, "chan-1"), null);
|
||||
});
|
||||
|
||||
it("is NOT folded under observer precedence (unlike getAgentWorkingState)", () => {
|
||||
startTurn(AGENT, "chan-1");
|
||||
reportChannelBotTyping("chan-1", [AGENT]);
|
||||
assert.equal(getAgentWorkingState(AGENT, "chan-1").source, "observer");
|
||||
assert.notEqual(getAgentChannelTypingSince(AGENT, "chan-1"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorkingChannels", () => {
|
||||
it("merges typing-only agents into an observer channel summary", () => {
|
||||
startTurn(AGENT, "chan-1");
|
||||
|
||||
@@ -130,6 +130,28 @@ export function reportChannelBotTyping(
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* First-seen timestamp (ms) of the agent's channel-scoped typing entry, or
|
||||
* 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.
|
||||
*/
|
||||
export function getAgentChannelTypingSince(
|
||||
agentPubkey: string | null | undefined,
|
||||
channelId: string | null | undefined,
|
||||
): number | null {
|
||||
if (!agentPubkey || !channelId) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
typingByChannel.get(channelId)?.get(normalizePubkey(agentPubkey)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function computeAgentWorkingState(
|
||||
agentPubkey: string,
|
||||
channelId: string | null,
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
|
||||
import {
|
||||
getAgentChannelTypingSince,
|
||||
getAgentWorkingState,
|
||||
subscribeAgentWorkingSignal,
|
||||
} from "@/features/agents/agentWorkingSignal";
|
||||
@@ -38,6 +39,8 @@ const HOVER_OPEN_DELAY_MS = 150;
|
||||
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.
|
||||
@@ -206,7 +209,9 @@ function useStripHoverPopover(): StripHoverPopover {
|
||||
*
|
||||
* Only observer-backed agents reach this pill: typing-fallback-only agents
|
||||
* are diverted into the combined typing indicator group by
|
||||
* ChannelComposerActivityRow before the strip renders.
|
||||
* ChannelComposerActivityRow before the strip renders. An observer-backed
|
||||
* agent that ALSO starts typing keeps its pill — the label relabels in
|
||||
* place to "is typing…" for the duration (see the typing override below).
|
||||
*/
|
||||
function BotActivityAgentPill({
|
||||
agent,
|
||||
@@ -242,10 +247,26 @@ function BotActivityAgentPill({
|
||||
() => deriveActivityPillLabel({ channelId, transcript }),
|
||||
[channelId, transcript],
|
||||
);
|
||||
const activeId = headline?.id ?? GENERIC_LABEL_ID;
|
||||
// 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 typingSince = React.useSyncExternalStore(
|
||||
subscribeAgentWorkingSignal,
|
||||
() => getAgentChannelTypingSince(agent.pubkey, channelId),
|
||||
);
|
||||
const isTyping = 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.
|
||||
const activeLabel = headline?.label ?? `${agent.name} is working…`;
|
||||
// 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…`);
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user