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:44:45 -07:00
committed by Carl
parent bd92fcc049
commit cb8118e9ce
4 changed files with 193 additions and 37 deletions
@@ -32,10 +32,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[];
};
@@ -236,6 +239,7 @@ function BotActivityAgentPill({
onOpenAgentSession,
pinWidth,
profiles,
typingBotPubkeys,
}: {
agent: BotActivityAgent;
avatarUrl: string | null;
@@ -252,6 +256,7 @@ function BotActivityAgentPill({
*/
pinWidth: boolean;
profiles?: UserProfileLookup;
typingBotPubkeys?: ReadonlySet<string>;
}) {
const pillKey = agent.pubkey.toLowerCase();
const open = hover.activePubkey === pillKey;
@@ -272,7 +277,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);
@@ -578,6 +584,7 @@ export function BotActivityComposerAction({
channelId = null,
onOpenAgentSession,
profiles,
typingBotPubkeys = [],
typingIndicator,
workingBotPubkeys,
}: BotActivityBarProps) {
@@ -607,6 +614,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
@@ -758,6 +769,7 @@ export function BotActivityComposerAction({
onOpenAgentSession={onOpenAgentSession}
pinWidth={guardsOpenCard}
profiles={profiles}
typingBotPubkeys={typingBotSet}
/>
)}
</AnimatedPillSlot>
@@ -40,6 +40,7 @@ import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPre
import { useCardMintJobs } from "@/features/agents/cardMintStore";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
import { ChannelComposerActivityRow } from "@/features/channels/ui/ChannelComposerActivityRow";
import { useThreadComposerActivity } from "@/features/channels/ui/useThreadComposerActivity";
import { ComposerActivityAccessory } from "@/features/messages/ui/ComposerActivityAccessory";
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
import {
@@ -286,6 +287,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onEdit(target);
return true;
}, [findLastOwnEditable, messages, onEdit]);
const handleEditLastOwnThreadMessage = React.useCallback((): boolean => {
if (!onEdit) return false;
const scope: TimelineMessage[] = [];
@@ -296,7 +298,9 @@ export const ChannelPane = React.memo(function ChannelPane({
onEdit(target);
return true;
}, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]);
const timeoutState = useTimeoutState();
// A moderation DM (1:1 with the relay identity) is read-only for the member;
// only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` →
// ordinary DM, composer enabled.
@@ -306,6 +310,7 @@ export const ChannelPane = React.memo(function ChannelPane({
currentPubkey,
relaySelfQuery.data,
);
const isComposerDisabled =
!activeChannel?.isMember ||
activeChannel.archivedAt !== null ||
@@ -315,6 +320,7 @@ export const ChannelPane = React.memo(function ChannelPane({
isSending;
const knownAgentPubkeys = React.useMemo(() => {
const pubkeys = new Set<string>();
for (const pubkey of agentPubkeys ?? []) {
pubkeys.add(pubkey.toLowerCase());
}
@@ -324,12 +330,14 @@ export const ChannelPane = React.memo(function ChannelPane({
for (const agent of activityAgents) {
pubkeys.add(agent.pubkey.toLowerCase());
}
return pubkeys;
}, [activityAgents, agentPubkeys, agentSessionAgents]);
const completeWelcomeComposerBanner = React.useCallback(() => {
if (!activeChannelId || !isActiveWelcomeChannel) {
return;
}
clearWelcomeComposerDismissTimer();
completedWelcomeBannerChannelIdsRef.current.add(activeChannelId);
setWelcomeComposerBannerState("complete");
@@ -361,8 +369,10 @@ export const ChannelPane = React.memo(function ChannelPane({
isActiveWelcomeChannel &&
(containsWelcomePersonaMention(content) ||
mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys));
messageTimelineRef.current?.scrollToBottomOnNextUpdate();
await onSendMessage(content, mentionPubkeys, mediaTags, channelId);
if (
channelId &&
channelId !== activeChannelId &&
@@ -371,6 +381,7 @@ export const ChannelPane = React.memo(function ChannelPane({
) {
await goChannel(channelId, { replace: true });
}
if (shouldCompleteWelcomeBanner) {
completeWelcomeComposerBanner();
}
@@ -389,32 +400,24 @@ export const ChannelPane = React.memo(function ChannelPane({
!isComposerDisabled &&
!isMainDeferredEditPending &&
!isSinglePanelView;
// Working set for the composer bar (observer turns + bot-typing fallback,
// folded by agentWorkingSignal); gates the dock's reserved bottom rail.
const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys(
activeChannel?.id ?? null,
);
// Background card mints surface in the same rail ("Minting card…" chip),
// so they must also reserve the activity row.
const hasCardMintActivity = useCardMintJobs().length > 0;
const hasComposerBottomActivity =
composerWorkingBotPubkeys.length > 0 ||
typingPubkeys.length > 0 ||
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({
@@ -424,6 +427,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}),
[activeChannel, currentPubkey, profiles],
);
const handleWelcomeAddAgent = React.useCallback(() => {
onAddAgent?.({
beforeSend: () =>
@@ -465,6 +469,7 @@ export const ChannelPane = React.memo(function ChannelPane({
for (const message of threadAllMessages) {
messagesById.set(message.id, message);
}
return buildVideoReviewContextsByMessageId({
channelId: activeChannel?.id ?? null,
channelName: activeChannel?.name,
@@ -485,6 +490,7 @@ export const ChannelPane = React.memo(function ChannelPane({
threadAllMessages,
threadHeadMessage,
]);
const isOverlay = useIsThreadPanelOverlay();
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
const threadViewMode = useThreadViewMode();
@@ -577,6 +583,7 @@ export const ChannelPane = React.memo(function ChannelPane({
data-testid="channel-shared-header-backdrop"
/>
) : null}
{!isSinglePanelView ? (
<section
aria-label="Channel messages and composer"
@@ -781,9 +788,6 @@ export const ChannelPane = React.memo(function ChannelPane({
}
showTopBorder={false}
/>
{/* The accessory is anchored in the dock's reserved bottom
rail, so fading it cannot change the observed overlay
height or move the conversation. */}
<ComposerActivityAccessory visible={hasComposerBottomActivity}>
<ChannelComposerActivityRow
agents={activityAgents}
@@ -802,6 +806,7 @@ export const ChannelPane = React.memo(function ChannelPane({
) : null}
</section>
) : null}
{/*
* `AnimatePresence` keeps the focus thread drawer mounted through its exit
* animation — without it the drawer's own existence condition
@@ -878,33 +883,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
}
@@ -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,
};
}
+74
View File
@@ -2111,6 +2111,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,
}) => {