diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 296a80d73..b8822bb4e 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -45,7 +45,7 @@ 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. */ -const PILL_REORDER_DURATION_S = 0.9; +const PILL_REORDER_DURATION_S = 0.45; /** * Enter/exit fade+scale for a pill slot. Deliberately much quicker than the * reorder spring — the slow reorder pacing must not bleed into membership @@ -213,11 +213,14 @@ function useStripHoverPopover(): StripHoverPopover { * itself — flat, no inset box, no tab strip — while clicking the pill opens * the agent's full runtime in the auxiliary panel. * - * 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. 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). + * Only pill-worthy agents reach this pill (an active observer turn, or a + * headline-able transcript left by a prior turn — see + * 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). */ function BotActivityAgentPill({ agent, @@ -472,7 +475,16 @@ function AnimatedPillSlot({ // instead of compressing every pill into an unreadable sliver. A lone // pill instead shrinks to fit (min-w-0) so it never scrolls. className={cn("flex", shrinkToFit ? "min-w-0" : "shrink-0")} - exit={{ opacity: 0, scale: 0.9 }} + // Inline exit transition: a slot removed mid-travel would otherwise + // inherit the slow isMoving opacity tween and fade out sluggishly. + exit={{ + opacity: 0, + scale: 0.9, + transition: { + duration: PILL_ENTER_EXIT_DURATION_S, + ease: "easeOut", + }, + }} initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.9 }} layout={!shouldReduceMotion && !freezeLayout} onLayoutAnimationComplete={() => setIsMoving(false)} diff --git a/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx b/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx index bc08e388e..a62afea38 100644 --- a/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx +++ b/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx @@ -5,10 +5,15 @@ import { subscribeAgentWorkingSignal, useChannelWorkingAgentPubkeys, } from "@/features/agents/agentWorkingSignal"; +import { + getAgentTranscript, + subscribeAgentObserverStore, +} from "@/features/agents/observerRelayStore"; import { BotActivityComposerAction, type BotActivityAgent, } from "@/features/channels/ui/BotActivityBar"; +import { partitionComposerWorkingAgents } from "@/features/channels/ui/composerLiveActivity"; import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel } from "@/shared/api/types"; @@ -19,11 +24,15 @@ import type { Channel } from "@/shared/api/types"; * ONE strip (BotActivityComposerAction) so both share the scroller, edge * fades, and layout/enter/exit animations. * - * The working set splits by signal source: observer-backed agents get the - * interactive activity pills, while typing-fallback-only agents (no observer - * turn — nothing to hover or open) merge with the human typers into ONE - * combined typing indicator group ("X and Y are typing…") with an - * overlapping avatar set. + * The working set splits by pill-worthiness (capability, not signal + * source — see partitionComposerWorkingAgents): agents with a session worth + * hovering/opening (an active observer turn, or a headline-able transcript + * left by a prior turn) get the interactive activity pills, while + * typing-fallback agents with nothing to show merge with the human typers + * 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. * * 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 @@ -53,28 +62,44 @@ export function ChannelComposerActivityRow({ // whose typing signal never arrives — and vice versa. const workingBotPubkeys = useChannelWorkingAgentPubkeys(channelId); - // Typing-fallback-only pubkeys (channel-scoped working source "typing"). + // Typing-group pubkeys: typing-fallback agents with NO headline-able + // transcript for this channel (see partitionComposerWorkingAgents). + // The partition reads two stores — the working signal (source) and the + // observer store (transcripts) — so the snapshot subscribes to both: + // a transcript landing must be able to promote a typing agent to a pill. // Snapshot is a joined string so useSyncExternalStore only re-renders when - // the partition actually changes, not on every signal write. + // the partition actually changes, not on every store write. + const subscribeToPartitionSources = React.useCallback( + (onChange: () => void) => { + const unsubscribeWorking = subscribeAgentWorkingSignal(onChange); + const unsubscribeObserver = subscribeAgentObserverStore(onChange); + return () => { + unsubscribeWorking(); + unsubscribeObserver(); + }; + }, + [], + ); const getAgentTypingSnapshot = React.useCallback( () => - workingBotPubkeys - .filter( - (pubkey) => - getAgentWorkingState(pubkey, channelId).source === "typing", - ) - .join(","), + partitionComposerWorkingAgents({ + channelId, + getTranscript: (pubkey) => getAgentTranscript(pubkey), + getWorkingSource: (pubkey) => + getAgentWorkingState(pubkey, channelId).source, + pubkeys: workingBotPubkeys, + }).typingGroupPubkeys.join(","), [channelId, workingBotPubkeys], ); const agentTypingKey = React.useSyncExternalStore( - subscribeAgentWorkingSignal, + subscribeToPartitionSources, getAgentTypingSnapshot, ); const agentTypingPubkeys = React.useMemo( () => (agentTypingKey === "" ? [] : agentTypingKey.split(",")), [agentTypingKey], ); - const observerWorkingPubkeys = React.useMemo(() => { + const pillBotPubkeys = React.useMemo(() => { const typingSet = new Set(agentTypingPubkeys); return workingBotPubkeys.filter((pubkey) => !typingSet.has(pubkey)); }, [agentTypingPubkeys, workingBotPubkeys]); @@ -117,8 +142,7 @@ export function ChannelComposerActivityRow({ edge fades, and layout/enter/exit animations. When the row gets tight the strip scrolls horizontally (edge fades signal clipped items) rather than compressing. */} - {observerWorkingPubkeys.length > 0 || - combinedTypingPubkeys.length > 0 ? ( + {pillBotPubkeys.length > 0 || combinedTypingPubkeys.length > 0 ? ( ) : null } - workingBotPubkeys={observerWorkingPubkeys} + workingBotPubkeys={pillBotPubkeys} /> ) : null} diff --git a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs index b8962465e..53fa6c375 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs +++ b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs @@ -5,6 +5,7 @@ import { deriveActivityPillLabel, deriveAgentWorkingOrder, deriveLastLiveAt, + partitionComposerWorkingAgents, } from "./composerLiveActivity.ts"; const CHANNEL = "channel-1"; @@ -196,6 +197,122 @@ test("deriveActivityPillLabel keeps a stable id while a message streams", () => assert.equal(extended.label, "Pass 1: reading the composer wiring"); }); +/** Lifecycle noise item ("Turn started") — meaningful:false, never headlines. */ +const turnStartedLifecycle = (timestamp, channelId = CHANNEL) => ({ + id: `lifecycle-turn-${timestamp}`, + type: "lifecycle", + renderClass: "status", + title: "Turn started", + text: "", + timestamp, + channelId, +}); + +/** Fake partition readers over pubkey → source / transcript maps. */ +const partitionReaders = (sources, transcripts) => ({ + getTranscript: (pubkey) => transcripts.get(pubkey) ?? [], + getWorkingSource: (pubkey) => sources.get(pubkey) ?? "none", +}); + +test("partitionComposerWorkingAgents pills observer-backed agents regardless of transcript", () => { + const partition = partitionComposerWorkingAgents({ + channelId: CHANNEL, + ...partitionReaders(new Map([["alpha", "observer"]]), new Map()), + pubkeys: ["alpha"], + }); + assert.deepEqual(partition, { + pillPubkeys: ["alpha"], + typingGroupPubkeys: [], + }); +}); + +test("partitionComposerWorkingAgents groups typing agents with nothing to show", () => { + // First-ever activity in the channel: no transcript at all. + const partition = partitionComposerWorkingAgents({ + channelId: CHANNEL, + ...partitionReaders(new Map([["alpha", "typing"]]), new Map()), + pubkeys: ["alpha"], + }); + assert.deepEqual(partition, { + pillPubkeys: [], + typingGroupPubkeys: ["alpha"], + }); +}); + +test("partitionComposerWorkingAgents keeps a typing agent's pill across the turn-end gap", () => { + // Turn completed but the agent is still typing: the prior turn's real + // action keeps the agent pill-worthy — the pill relabels instead of + // demoting to the typing group. + const partition = partitionComposerWorkingAgents({ + channelId: CHANNEL, + ...partitionReaders( + new Map([["alpha", "typing"]]), + new Map([ + ["alpha", [thought("Editing ChannelPane", secondsBeforeNow(9))]], + ]), + ), + pubkeys: ["alpha"], + }); + assert.deepEqual(partition, { + pillPubkeys: ["alpha"], + typingGroupPubkeys: [], + }); +}); + +test("partitionComposerWorkingAgents ignores lifecycle-noise-only transcripts", () => { + // A transcript holding only "Turn started" noise (seeded turns, no content + // frames) is NOT pill-worthy — nothing would render in the hover feed. + const partition = partitionComposerWorkingAgents({ + channelId: CHANNEL, + ...partitionReaders( + new Map([["alpha", "typing"]]), + new Map([["alpha", [turnStartedLifecycle(secondsBeforeNow(5))]]]), + ), + pubkeys: ["alpha"], + }); + assert.deepEqual(partition, { + pillPubkeys: [], + typingGroupPubkeys: ["alpha"], + }); +}); + +test("partitionComposerWorkingAgents scopes pill-worthiness to the channel", () => { + // Real work in ANOTHER channel must not promote this channel's typing. + const partition = partitionComposerWorkingAgents({ + channelId: CHANNEL, + ...partitionReaders( + new Map([["alpha", "typing"]]), + new Map([ + ["alpha", [thought("Other work", secondsBeforeNow(3), OTHER_CHANNEL)]], + ]), + ), + pubkeys: ["alpha"], + }); + assert.deepEqual(partition, { + pillPubkeys: [], + typingGroupPubkeys: ["alpha"], + }); +}); + +test("partitionComposerWorkingAgents splits a mixed roster preserving order", () => { + const partition = partitionComposerWorkingAgents({ + channelId: CHANNEL, + ...partitionReaders( + new Map([ + ["alpha", "observer"], + ["beta", "typing"], + ["gamma", "typing"], + ]), + new Map([["gamma", [thought("Prior work", secondsBeforeNow(30))]]]), + ), + pubkeys: ["alpha", "beta", "gamma"], + }); + assert.deepEqual(partition, { + pillPubkeys: ["alpha", "gamma"], + typingGroupPubkeys: ["beta"], + }); +}); + /** Fake working-state reader over pubkey → [{channelId, anchorAt}] entries. */ const workingStates = (entries) => (pubkey) => ({ channels: entries.get(pubkey) ?? [], diff --git a/desktop/src/features/channels/ui/composerLiveActivity.ts b/desktop/src/features/channels/ui/composerLiveActivity.ts index 2248e700d..dab29f41e 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.ts +++ b/desktop/src/features/channels/ui/composerLiveActivity.ts @@ -74,6 +74,60 @@ export function deriveActivityPillLabel({ return null; } +/** + * Partition a channel's working agents into pill-rendered agents and the + * combined typing-indicator group. + * + * Pill-worthiness is capability-based, not source-based: an agent renders as + * a pill when there is an agent session worth hovering and opening — an + * active observer turn, or a transcript with a headline-able item for this + * channel left behind by a prior turn. Typing agents with nothing to show + * fold into the combined typing group with the human typers. + * + * This is what keeps a pill stable across the turn-end gap: an agent whose + * observer turn just completed but who is still typing keeps its pill (the + * label swaps to "is typing…" in place) instead of demoting to the group, + * and re-typing after a finished turn relights the pill rather than + * mounting a group item. Only an agent's FIRST-ever activity in the channel + * can start in the typing group. + * + * `getWorkingSource` / `getTranscript` are injected so the helper stays pure + * and unit-testable; callers pass the working signal's and observer store's + * cached readers. + */ +export function partitionComposerWorkingAgents({ + channelId, + getTranscript, + getWorkingSource, + pubkeys, +}: { + channelId: string | null; + getTranscript: (pubkey: string) => readonly TranscriptItem[]; + getWorkingSource: (pubkey: string) => "observer" | "typing" | "none"; + pubkeys: readonly string[]; +}): { pillPubkeys: string[]; typingGroupPubkeys: string[] } { + const pillPubkeys: string[] = []; + const typingGroupPubkeys: string[] = []; + + for (const pubkey of pubkeys) { + if (getWorkingSource(pubkey) !== "typing") { + pillPubkeys.push(pubkey); + continue; + } + const headline = deriveActivityPillLabel({ + channelId, + transcript: getTranscript(pubkey), + }); + if (headline !== null) { + pillPubkeys.push(pubkey); + } else { + typingGroupPubkeys.push(pubkey); + } + } + + return { pillPubkeys, typingGroupPubkeys }; +} + /** Minimal working-state shape the ordering needs (see agentWorkingSignal). */ export type AgentWorkingChannelAnchor = { channelId: string;