diff --git a/desktop/src/features/agents/agentWorkingSignal.test.mjs b/desktop/src/features/agents/agentWorkingSignal.test.mjs index 9d9ae31bd..9a5e04947 100644 --- a/desktop/src/features/agents/agentWorkingSignal.test.mjs +++ b/desktop/src/features/agents/agentWorkingSignal.test.mjs @@ -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"); diff --git a/desktop/src/features/agents/agentWorkingSignal.ts b/desktop/src/features/agents/agentWorkingSignal.ts index d75ea7ccc..c09c30785 100644 --- a/desktop/src/features/agents/agentWorkingSignal.ts +++ b/desktop/src/features/agents/agentWorkingSignal.ts @@ -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, diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index ab8f4b8c2..38c73fb51 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -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