Stabilize active agent pill updates

Made-with: Cursor
This commit is contained in:
Thomas Petersen
2026-04-29 21:24:44 -04:00
parent 7fa2a43d3b
commit d1c44aa3bd
4 changed files with 106 additions and 27 deletions

View File

@@ -114,7 +114,6 @@ export function BotActivityBar({
)}
data-agent-key={typingAgentKey}
data-testid="bot-chip-overflow"
key={typingAgentKey}
onPointerEnter={openAgentListFromTrigger}
onPointerLeave={scheduleCloseAgentList}
type="button"

View File

@@ -198,18 +198,10 @@ export const MessageTimeline = React.memo(function MessageTimeline({
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
trailingContent={conversationFooter}
/>
) : null}
{conversationFooter ? (
<div
className="flex min-w-0 pb-1"
data-testid="message-timeline-footer"
>
{conversationFooter}
</div>
) : null}
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
</div>

View File

@@ -35,6 +35,7 @@ type TimelineMessageListProps = {
searchMatchingMessageIds?: Set<string>;
/** 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(
<MessageThreadSummaryRow
key={`thread-summary-${message.id}`}
message={message}
onOpenThread={onReply}
summary={summary}
/>,
);
const isLastEntry = i === entries.length - 1;
if (trailingContent && isLastEntry) {
renderedTrailingContent = true;
elements.push(
<div
className="flex min-w-0 items-center gap-3"
data-testid="message-thread-summary-with-footer"
key={`thread-summary-with-footer-${message.id}`}
>
<div className="min-w-0 shrink">
<MessageThreadSummaryRow
message={message}
onOpenThread={onReply}
summary={summary}
/>
</div>
<div className="shrink-0">{trailingContent}</div>
</div>,
);
} else {
elements.push(
<MessageThreadSummaryRow
key={`thread-summary-${message.id}`}
message={message}
onOpenThread={onReply}
summary={summary}
/>,
);
}
}
}
}
if (trailingContent && !renderedTrailingContent) {
elements.push(
<div
className="flex min-w-0 justify-start pb-1"
data-testid="message-timeline-footer"
key="message-timeline-footer"
>
{trailingContent}
</div>,
);
}
return elements;
});

View File

@@ -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<string, TypingEntry>;
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<TypingState>({});
const normalizedCurrentPubkey = currentPubkey?.toLowerCase();
const hasSeededCompletionEventsRef = useRef(false);
const processedCompletionEventIdsRef = useRef<Set<string>>(new Set());
const typingSuppressUntilByAgentRef = useRef<Record<string, number>>({});
const typingSuppressUntilByPubkeyRef = useRef<Record<string, number>>({});
const latestMessageCreatedAtByPubkeyRef = useRef<Record<string, number>>({});
@@ -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) {