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