feat(composer): capability-based pill partition keeps pills through the turn-end gap

The composer strip's pill-vs-typing-group partition keyed on signal
source: any typing-fallback agent went to the merged typing group. An
agent whose observer turn just completed but who was still typing
therefore flipped partitions — its pill exited and a new typing-group
item entered at the trailing slot — and re-typing after a finished turn
mounted a group item instead of relighting the pill.

Partition on capability instead: an agent renders as a pill when there
is a session worth hovering and opening — an active observer turn, or a
headline-able transcript for the channel left behind by a prior turn.
Only an agent's first-ever activity in a channel can now start in the
typing group; every later working↔typing transition is an in-place pill
relabel (the "is typing…" override from cf6014de9).

- desktop/src/features/channels/ui/composerLiveActivity.ts: add pure
  partitionComposerWorkingAgents helper (injected getWorkingSource /
  getTranscript readers, mirrors deriveAgentWorkingOrder's style);
  pill-worthiness = source !== "typing" OR deriveActivityPillLabel
  non-null, so lifecycle-noise-only transcripts ("Turn started" seeds)
  still fold into the group
- desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx:
  partition snapshot now subscribes to BOTH the working signal and the
  observer store (a transcript landing must be able to promote a typing
  agent); rename observerWorkingPubkeys → pillBotPubkeys to match the
  capability semantics
- desktop/src/features/channels/ui/composerLiveActivity.test.mjs: six
  new tests — observer always pills, transcript-less typing groups,
  turn-end gap keeps the pill, lifecycle-noise-only transcripts group,
  channel scoping, mixed-roster order preservation
- desktop/src/features/channels/ui/BotActivityBar.tsx +
  desktop/src/features/agents/debug/README.md: docs updated to the
  capability partition

E2E: shows-and-clears, composer-does-not-shift, typing-group-in-strip,
typing-avatars, and the pill hover-freeze suite all pass (8/8 smoke).

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 bb1271fcaf
commit 2f132519c6
4 changed files with 232 additions and 25 deletions
@@ -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)}
@@ -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 ? (
<BotActivityComposerAction
agents={agents}
channelId={channelId}
@@ -138,7 +162,7 @@ export function ChannelComposerActivityRow({
/>
) : null
}
workingBotPubkeys={observerWorkingPubkeys}
workingBotPubkeys={pillBotPubkeys}
/>
) : null}
</div>
@@ -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) ?? [],
@@ -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;