mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user