diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 7b2ae4ef0..34c3e15ec 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -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; }) { 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} /> )} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index c7a3f84e9..86dec79aa 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -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(); + 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 ? (
- {/* The accessory is anchored in the dock's reserved bottom - rail, so fading it cannot change the observed overlay - height or move the conversation. */} ) : 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 ? ( 0 ? ( + combinedThreadTypingPubkeys.length > 0 ? ( ) : null } - workingBotPubkeys={threadComposerBotTypingPubkeys} + workingBotPubkeys={threadPillBotPubkeys} /> ) : null } diff --git a/desktop/src/features/channels/ui/useThreadComposerActivity.ts b/desktop/src/features/channels/ui/useThreadComposerActivity.ts new file mode 100644 index 000000000..2d15c58aa --- /dev/null +++ b/desktop/src/features/channels/ui/useThreadComposerActivity.ts @@ -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, + }; +} diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 124fe4d9b..81e549416 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -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, }) => {