From d1c44aa3bd13c54a5643278828f4d4bae98aabac Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 29 Apr 2026 21:24:44 -0400 Subject: [PATCH] Stabilize active agent pill updates Made-with: Cursor --- .../features/channels/ui/BotActivityBar.tsx | 1 - .../features/messages/ui/MessageTimeline.tsx | 10 +-- .../messages/ui/TimelineMessageList.tsx | 53 +++++++++++--- .../src/features/messages/useChannelTyping.ts | 69 ++++++++++++++++--- 4 files changed, 106 insertions(+), 27 deletions(-) diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index f04cac66c3..ad21aaff63 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -114,7 +114,6 @@ export function BotActivityBar({ )} data-agent-key={typingAgentKey} data-testid="bot-chip-overflow" - key={typingAgentKey} onPointerEnter={openAgentListFromTrigger} onPointerLeave={scheduleCloseAgentList} type="button" diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index c5fa46976a..586ad91a26 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -198,18 +198,10 @@ export const MessageTimeline = React.memo(function MessageTimeline({ searchActiveMessageId={searchActiveMessageId} searchMatchingMessageIds={searchMatchingMessageIds} searchQuery={searchQuery} + trailingContent={conversationFooter} /> ) : null} - {conversationFooter ? ( -
- {conversationFooter} -
- ) : null} -
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 647f231bbc..6a9d48395e 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -35,6 +35,7 @@ type TimelineMessageListProps = { searchMatchingMessageIds?: Set; /** The current find-in-channel query string. */ searchQuery?: string; + trailingContent?: React.ReactNode; }; export const TimelineMessageList = React.memo(function TimelineMessageList({ @@ -51,8 +52,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchActiveMessageId = null, searchMatchingMessageIds, searchQuery, + trailingContent, }: TimelineMessageListProps) { const elements: React.ReactNode[] = []; + let renderedTrailingContent = false; const entries = React.useMemo( () => buildMainTimelineEntries(messages), [messages], @@ -111,17 +114,51 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ); if (summary && onReply) { - elements.push( - , - ); + const isLastEntry = i === entries.length - 1; + + if (trailingContent && isLastEntry) { + renderedTrailingContent = true; + elements.push( +
+
+ +
+
{trailingContent}
+
, + ); + } else { + elements.push( + , + ); + } } } } + if (trailingContent && !renderedTrailingContent) { + elements.push( +
+ {trailingContent} +
, + ); + } + return elements; }); diff --git a/desktop/src/features/messages/useChannelTyping.ts b/desktop/src/features/messages/useChannelTyping.ts index 75d50aaa55..69975b9491 100644 --- a/desktop/src/features/messages/useChannelTyping.ts +++ b/desktop/src/features/messages/useChannelTyping.ts @@ -11,6 +11,7 @@ import { KIND_STREAM_MESSAGE_DIFF, KIND_TYPING_INDICATOR, } from "@/shared/constants/kinds"; +import { resolveEventAuthorPubkey } from "@/shared/lib/authors"; export type TypingIndicatorEntry = { pubkey: string; @@ -27,7 +28,7 @@ type TypingState = Record; const TYPING_INDICATOR_TTL_MS = 8_000; const TYPING_PRUNE_INTERVAL_MS = 1_000; -const TYPING_POST_MESSAGE_SUPPRESS_MS = 2_000; +const TYPING_POST_MESSAGE_SUPPRESS_MS = 5_000; function pruneTypingState(state: TypingState, now = Date.now()) { let changed = false; @@ -64,6 +65,15 @@ function getTypingStateKey(pubkey: string, threadHeadId: string | null) { return `${pubkey}:${threadHeadId ?? "channel"}`; } +function getTypingPubkey(event: RelayEvent) { + return resolveEventAuthorPubkey({ + pubkey: event.pubkey, + tags: event.tags, + preferActorTag: true, + requireChannelTagForPTags: true, + }).toLowerCase(); +} + export function useChannelTyping( channel: Channel | null, currentPubkey?: string, @@ -73,6 +83,9 @@ export function useChannelTyping( const channelType = channel?.channelType ?? null; const [typingByPubkey, setTypingByPubkey] = useState({}); const normalizedCurrentPubkey = currentPubkey?.toLowerCase(); + const hasSeededCompletionEventsRef = useRef(false); + const processedCompletionEventIdsRef = useRef>(new Set()); + const typingSuppressUntilByAgentRef = useRef>({}); const typingSuppressUntilByPubkeyRef = useRef>({}); const latestMessageCreatedAtByPubkeyRef = useRef>({}); @@ -85,13 +98,22 @@ export function useChannelTyping( return; } - const typingPubkey = event.pubkey.toLowerCase(); + const typingPubkey = getTypingPubkey(event); const threadHeadId = getTypingScopeId(event); const typingKey = getTypingStateKey(typingPubkey, threadHeadId); if (normalizedCurrentPubkey && typingPubkey === normalizedCurrentPubkey) { return; } + const agentSuppressUntil = + typingSuppressUntilByAgentRef.current[typingPubkey] ?? 0; + if (agentSuppressUntil > Date.now()) { + return; + } + if (agentSuppressUntil > 0) { + delete typingSuppressUntilByAgentRef.current[typingPubkey]; + } + const suppressUntil = typingSuppressUntilByPubkeyRef.current[typingKey] ?? 0; if (suppressUntil > Date.now()) { @@ -126,6 +148,9 @@ export function useChannelTyping( // biome-ignore lint/correctness/useExhaustiveDependencies: channel changes should clear local typing state useEffect(() => { setTypingByPubkey({}); + hasSeededCompletionEventsRef.current = false; + processedCompletionEventIdsRef.current = new Set(); + typingSuppressUntilByAgentRef.current = {}; typingSuppressUntilByPubkeyRef.current = {}; latestMessageCreatedAtByPubkeyRef.current = {}; }, [channelId]); @@ -144,16 +169,31 @@ export function useChannelTyping( continue; } - const authorPubkey = event.pubkey.toLowerCase(); + const authorPubkey = getTypingPubkey(event); const threadHeadId = getTypingScopeId(event); const typingKey = getTypingStateKey(authorPubkey, threadHeadId); latestMessageCreatedAtByPubkeyRef.current[typingKey] = Math.max( latestMessageCreatedAtByPubkeyRef.current[typingKey] ?? 0, event.created_at, ); + + if (!hasSeededCompletionEventsRef.current) { + processedCompletionEventIdsRef.current.add(event.id); + continue; + } + + if (processedCompletionEventIdsRef.current.has(event.id)) { + continue; + } + + processedCompletionEventIdsRef.current.add(event.id); + typingSuppressUntilByAgentRef.current[authorPubkey] = + Date.now() + TYPING_POST_MESSAGE_SUPPRESS_MS; completionKeys.add(typingKey); } + hasSeededCompletionEventsRef.current = true; + if (completionKeys.size === 0) { return; } @@ -163,14 +203,25 @@ export function useChannelTyping( let updated: TypingState | null = null; for (const typingKey of completionKeys) { - if (!(typingKey in next)) { - continue; + const pubkey = typingKey.slice(0, typingKey.indexOf(":")); + const keysToClear = Object.keys(next).filter((currentKey) => + currentKey.startsWith(`${pubkey}:`), + ); + + if (keysToClear.length === 0) { + keysToClear.push(typingKey); } - updated ??= { ...next }; - delete updated[typingKey]; - typingSuppressUntilByPubkeyRef.current[typingKey] = - Date.now() + TYPING_POST_MESSAGE_SUPPRESS_MS; + for (const keyToClear of keysToClear) { + if (!(keyToClear in next)) { + continue; + } + + updated ??= { ...next }; + delete updated[keyToClear]; + typingSuppressUntilByPubkeyRef.current[keyToClear] = + Date.now() + TYPING_POST_MESSAGE_SUPPRESS_MS; + } } if (!updated) {