mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
Stabilize active agent pill updates
Made-with: Cursor
This commit is contained in:
@@ -114,7 +114,6 @@ export function BotActivityBar({
|
||||
)}
|
||||
data-agent-key={typingAgentKey}
|
||||
data-testid="bot-chip-overflow"
|
||||
key={typingAgentKey}
|
||||
onPointerEnter={openAgentListFromTrigger}
|
||||
onPointerLeave={scheduleCloseAgentList}
|
||||
type="button"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user