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