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) {