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:
Taylor Ho
2026-08-05 16:42:01 -07:00
committed by Carl
parent 2dc5d4826d
commit 1b3b236555
3 changed files with 74 additions and 4 deletions
@@ -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