fix(composer): partition thread agent typing by activity

Keep first-time thread-only agent typers in the combined typing group until channel-scoped observer activity provides a real session to preview. Preserve the truthful typing label after promotion and cover the full group-to-pill transition.

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:26:43 -07:00
committed by Carl
parent 465f1fb02a
commit 67de35b61c
4 changed files with 178 additions and 31 deletions

View File

@@ -37,10 +37,13 @@ type BotActivityBarProps = {
onOpenAgentSession: (pubkey: string, channelId?: string | null) => void;
profiles?: UserProfileLookup;
/**
* Combined typing indicator (humans + typing-fallback agents), rendered as
* the strip's trailing item — a sibling of the working pills, so it shares
* the scroller, edge fades, and layout/enter/exit animations.
* Agent pubkeys known to be typing in this strip's scope. Thread-only
* typing is intentionally absent from the channel-wide working registry,
* so callers pass it here to relabel an already pill-worthy agent without
* leaking thread activity into channel-level surfaces.
*/
typingBotPubkeys?: string[];
/** Combined human + typing-fallback agent indicator. */
typingIndicator?: React.ReactNode;
workingBotPubkeys: string[];
};
@@ -240,6 +243,7 @@ function BotActivityAgentPill({
onOpenAgentSession,
pinWidth,
profiles,
typingBotPubkeys,
}: {
agent: BotActivityAgent;
avatarUrl: string | null;
@@ -256,6 +260,7 @@ function BotActivityAgentPill({
*/
pinWidth: boolean;
profiles?: UserProfileLookup;
typingBotPubkeys?: ReadonlySet<string>;
}) {
const pillKey = agent.pubkey.toLowerCase();
const open = hover.activePubkey === pillKey;
@@ -276,7 +281,8 @@ function BotActivityAgentPill({
subscribeAgentWorkingSignal,
() => getAgentChannelTypingSince(agent.pubkey, channelId),
);
const isTyping = typingSince !== null;
const isTyping =
typingBotPubkeys?.has(pillKey) === true || typingSince !== null;
const activeId = isTyping
? TYPING_LABEL_ID
: (headline?.id ?? GENERIC_LABEL_ID);
@@ -582,6 +588,7 @@ export function BotActivityComposerAction({
channelId = null,
onOpenAgentSession,
profiles,
typingBotPubkeys = [],
typingIndicator,
workingBotPubkeys,
}: BotActivityBarProps) {
@@ -611,6 +618,10 @@ export function BotActivityComposerAction({
return agents.filter((agent) => workingSet.has(agent.pubkey.toLowerCase()));
}, [agents, workingBotPubkeys]);
const typingBotSet = React.useMemo(
() => new Set(typingBotPubkeys.map((pubkey) => pubkey.toLowerCase())),
[typingBotPubkeys],
);
// Turn-start pill order (earliest worker left-most, new agents append on
// the right). Anchored to when each agent STARTED working — stable for the
@@ -762,6 +773,7 @@ export function BotActivityComposerAction({
onOpenAgentSession={onOpenAgentSession}
pinWidth={guardsOpenCard}
profiles={profiles}
typingBotPubkeys={typingBotSet}
/>
)}
</AnimatedPillSlot>

View File

@@ -39,6 +39,7 @@ import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSig
import { useCardMintJobs } from "@/features/agents/cardMintStore";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory";
import { useThreadComposerActivity } from "@/features/channels/ui/useThreadComposerActivity";
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
import {
containsWelcomePersonaMention,
@@ -364,20 +365,16 @@ export const ChannelPane = React.memo(function ChannelPane({
const hasCardMintActivity = useCardMintJobs().length > 0;
const hasComposerBottomActivity =
hasComposerBotActivity || hasTypingActivity || hasCardMintActivity;
const threadComposerBotTypingPubkeys = React.useMemo(() => {
if (!openThreadHeadId) return [];
return botTypingEntries
.filter((entry) => entry.threadHeadId === openThreadHeadId)
.map((entry) => entry.pubkey)
.filter(
(pubkey, index, all) =>
all.findIndex(
(candidate) => candidate.toLowerCase() === pubkey.toLowerCase(),
) === index,
);
}, [botTypingEntries, openThreadHeadId]);
const hasThreadComposerBotActivity =
threadComposerBotTypingPubkeys.length > 0;
const {
combinedTypingPubkeys: combinedThreadTypingPubkeys,
hasActivity: hasThreadComposerActivity,
pillBotPubkeys: threadPillBotPubkeys,
} = useThreadComposerActivity({
botTypingEntries,
channelId: activeChannel?.id ?? null,
threadHeadId: openThreadHeadId,
typingPubkeys: threadTypingPubkeys,
});
const directMessageIntro = React.useMemo(
() =>
buildDirectMessageIntro({
@@ -836,34 +833,27 @@ export const ChannelPane = React.memo(function ChannelPane({
threadHeadMessage.id,
)}
threadReplyUnreadCounts={threadReplyUnreadCounts}
activityAccessoryVisible={
hasThreadComposerBotActivity ||
threadTypingPubkeys.length > 0
}
activityAccessoryVisible={hasThreadComposerActivity}
activityAccessoryContent={
hasThreadComposerBotActivity ||
threadTypingPubkeys.length > 0 ? (
hasThreadComposerActivity ? (
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
profiles={profiles}
typingBotPubkeys={threadPillBotPubkeys}
typingIndicator={
threadTypingPubkeys.length > 0 ? (
combinedThreadTypingPubkeys.length > 0 ? (
<TypingIndicatorRow
channel={activeChannel}
// The strip's slot owns spacing and the
// typing-only inset; zero the base paddings and
// let the row shrink so the lone-item slot can
// ellipsize the label.
className="min-w-0 shrink px-0 py-0 sm:px-0"
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={threadTypingPubkeys}
typingPubkeys={combinedThreadTypingPubkeys}
/>
) : null
}
workingBotPubkeys={threadComposerBotTypingPubkeys}
workingBotPubkeys={threadPillBotPubkeys}
/>
) : null
}

View File

@@ -0,0 +1,71 @@
import * as React from "react";
import {
getAgentTranscript,
subscribeAgentObserverStore,
} from "@/features/agents/observerRelayStore";
import { partitionComposerWorkingAgents } from "@/features/channels/ui/composerLiveActivity";
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
/** Partitions thread-scoped bot typers without leaking them into channel state. */
export function useThreadComposerActivity({
botTypingEntries,
channelId,
threadHeadId,
typingPubkeys,
}: {
botTypingEntries: readonly TypingIndicatorEntry[];
channelId: string | null;
threadHeadId: string | null;
typingPubkeys: readonly string[];
}): {
combinedTypingPubkeys: string[];
hasActivity: boolean;
pillBotPubkeys: string[];
} {
const botPubkeys = React.useMemo(() => {
if (!threadHeadId) return [];
return botTypingEntries
.filter((entry) => entry.threadHeadId === threadHeadId)
.map((entry) => entry.pubkey)
.filter(
(pubkey, index, all) =>
all.findIndex(
(candidate) => candidate.toLowerCase() === pubkey.toLowerCase(),
) === index,
);
}, [botTypingEntries, threadHeadId]);
const subscribe = React.useCallback(
(onChange: () => void) => subscribeAgentObserverStore(onChange),
[],
);
const getSnapshot = React.useCallback(() => {
const partition = partitionComposerWorkingAgents({
channelId,
getTranscript: getAgentTranscript,
// Thread typing stays out of the channel-wide working registry. A
// channel-scoped transcript alone decides whether a real preview exists.
getWorkingSource: () => "typing",
pubkeys: botPubkeys,
});
return `${partition.pillPubkeys.join(",")}\n${partition.typingGroupPubkeys.join(",")}`;
}, [botPubkeys, channelId]);
const partitionKey = React.useSyncExternalStore(subscribe, getSnapshot);
const [pillKey = "", typingKey = ""] = partitionKey.split("\n");
const pillBotPubkeys = React.useMemo(
() => (pillKey === "" ? [] : pillKey.split(",")),
[pillKey],
);
const typingBotPubkeys = React.useMemo(
() => (typingKey === "" ? [] : typingKey.split(",")),
[typingKey],
);
const combinedTypingPubkeys = React.useMemo(
() => [...typingPubkeys, ...typingBotPubkeys],
[typingBotPubkeys, typingPubkeys],
);
return {
combinedTypingPubkeys,
hasActivity: pillBotPubkeys.length > 0 || combinedTypingPubkeys.length > 0,
pillBotPubkeys,
};
}

View File

@@ -2160,6 +2160,80 @@ test("shows and clears activity indicators for active channel agents", async ({
await expect(page.getByTestId("bot-activity-composer-trigger")).toBeVisible();
});
test("thread agent typing promotes only when activity exists", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
await waitForMockLiveSubscription(page, "agents", KIND_TYPING_INDICATOR);
await page.waitForFunction(
() =>
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function",
);
const threadHeadId = await page.evaluate(() => {
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "agents",
content: "Thread activity partition root",
});
if (!root) throw new Error("Failed to seed thread root");
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "agents",
content: "Thread activity partition reply",
parentEventId: root.id,
});
return root.id;
});
const summary = page.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${threadHeadId}"]`,
);
await expect(summary).toBeVisible();
await summary.click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await page.evaluate(
({ pubkey, threadHeadId }) => {
window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
channelName: "agents",
pubkey,
threadHeadId,
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey, threadHeadId },
);
// Thread-only typing has no observer-backed session to preview, so it joins
// the thread typing group and must not manufacture an interactive pill.
await expect(
threadPanel.getByTestId("message-typing-indicator-label"),
).toContainText("alice");
await expect(
threadPanel.getByTestId("bot-activity-composer-trigger"),
).toHaveCount(0);
// Once channel-scoped observer activity exists, the same thread typer earns
// a pill and leaves the combined typing group. The explicit thread typing
// set keeps the promoted pill truthfully labeled "is typing…" even though
// thread typing is intentionally absent from the channel-wide registry.
await seedPillActivityMessage(page, {
agentPubkey: TEST_IDENTITIES.alice.pubkey,
atMs: Date.now(),
seq: Date.now(),
text: "Alice: inspecting the thread context",
turnId: "thread-indicator-turn",
});
const pill = threadPanel.getByTestId("bot-activity-composer-trigger");
await expect(pill).toBeVisible();
await expect(pill).toContainText("alice is typing");
await expect(threadPanel.getByTestId("message-typing-indicator")).toHaveCount(
0,
);
});
test("composer does not shift when the activity row mounts and clears", async ({
page,
}) => {