Polish reply activity pills.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-05-01 11:34:11 -04:00
co-authored by Cursor
parent d11bedeea9
commit c7b698a0fe
9 changed files with 93 additions and 234 deletions
@@ -99,6 +99,10 @@ export function BotActivityBar({
typingAgents.length === 1
? typingAgents[0]?.name
: `${typingAgents[0]?.name ?? "Agent"} +${typingAgents.length - 1}`;
const activeAgentCountLabel =
typingAgents.length === 1
? "1 active agent"
: `${typingAgents.length} active agents`;
const visibleInlineAgents = typingAgents.slice(0, MAX_INLINE_AGENT_AVATARS);
return (
@@ -153,7 +157,7 @@ export function BotActivityBar({
sideOffset={8}
>
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground">
Active agents
{activeAgentCountLabel}
</div>
{typingAgents.map((agent) => (
<button
@@ -3,6 +3,8 @@ import test from "node:test";
import { buildMainTimelineEntries } from "./threadPanel.ts";
const KIND_STREAM_MESSAGE_DIFF = 40008;
function message(overrides) {
return {
id: "message",
@@ -56,3 +58,42 @@ test("buildMainTimelineEntries includes broadcast replies", () => {
["root", "broadcast-reply"],
);
});
test("buildMainTimelineEntries excludes diff artifacts from reply summaries", () => {
const root = message({ id: "root", createdAt: 1 });
const firstReply = message({
id: "first-reply",
createdAt: 2,
parentId: "root",
rootId: "root",
tags: [["e", "root", "", "reply"]],
});
const diffArtifact = message({
id: "diff-artifact",
createdAt: 3,
kind: KIND_STREAM_MESSAGE_DIFF,
parentId: "root",
rootId: "root",
tags: [["e", "root", "", "reply"]],
});
const secondReply = message({
id: "second-reply",
createdAt: 4,
parentId: "root",
rootId: "root",
tags: [["e", "root", "", "reply"]],
});
const entries = buildMainTimelineEntries([
root,
firstReply,
diffArtifact,
secondReply,
]);
assert.equal(entries[0]?.summary?.replyCount, 2);
assert.deepEqual(
entries[0]?.summary?.participants.map((participant) => participant.id),
["author"],
);
});
@@ -1,4 +1,5 @@
import type { TimelineMessage } from "@/features/messages/types";
import { KIND_STREAM_MESSAGE_DIFF } from "../../../shared/constants/kinds.ts";
type ThreadPanelData = {
threadHead: TimelineMessage | null;
@@ -38,6 +39,10 @@ function isBroadcastReply(message: TimelineMessage): boolean {
);
}
function isCountableThreadReply(message: TimelineMessage): boolean {
return message.kind !== KIND_STREAM_MESSAGE_DIFF;
}
function normalizeHeadMessage(message: TimelineMessage): TimelineMessage {
return {
...message,
@@ -97,6 +102,10 @@ function buildDescendantStatsByMessageId(
for (let index = orderedMessages.length - 1; index >= 0; index -= 1) {
const message = orderedMessages[index].message;
if (!isCountableThreadReply(message)) {
continue;
}
const participantKey = message.pubkey ?? message.id;
const participant: TimelineThreadSummaryParticipant = {
id: participantKey,
@@ -1,194 +0,0 @@
import * as React from "react";
import type { TimelineReaction } from "@/features/messages/types";
import { cn } from "@/shared/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const MAX_VISIBLE_REACTORS = 10;
function ReactionPopoverContent({ reaction }: { reaction: TimelineReaction }) {
const visible = reaction.users.slice(0, MAX_VISIBLE_REACTORS);
const overflow = reaction.users.length - MAX_VISIBLE_REACTORS;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 pb-1 border-b border-border/50">
<span className="text-2xl">{reaction.emoji}</span>
<span className="text-xs text-muted-foreground">
{reaction.count} {reaction.count === 1 ? "reaction" : "reactions"}
</span>
</div>
<div className="flex flex-col gap-1.5">
{visible.map((user) => (
<div key={user.pubkey} className="flex items-center gap-2 min-w-0">
<UserAvatar
avatarUrl={user.avatarUrl}
displayName={user.displayName}
size="xs"
/>
<span className="text-sm truncate">{user.displayName}</span>
</div>
))}
</div>
{overflow > 0 && (
<span className="text-xs text-muted-foreground">+{overflow} more</span>
)}
{reaction.reactedByCurrentUser && (
<span className="text-xs text-muted-foreground border-t border-border/50 pt-1.5">
Click to remove your reaction
</span>
)}
</div>
);
}
export function MessageReactions({
messageId,
reactions,
canToggle,
pending,
onSelect,
}: {
messageId: string;
reactions: TimelineReaction[];
canToggle: boolean;
pending: boolean;
onSelect: (emoji: string) => void;
}) {
if (reactions.length === 0) {
return null;
}
return (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pt-1">
{reactions.map((reaction) => (
<ReactionPill
key={`${messageId}-${reaction.emoji}`}
canToggle={canToggle}
pending={pending}
reaction={reaction}
onSelect={onSelect}
/>
))}
</div>
);
}
function ReactionPill({
reaction,
canToggle,
pending,
onSelect,
}: {
reaction: TimelineReaction;
canToggle: boolean;
pending: boolean;
onSelect: (emoji: string) => void;
}) {
const [open, setOpen] = React.useState(false);
const openTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = React.useCallback(() => {
if (openTimeout.current) {
clearTimeout(openTimeout.current);
openTimeout.current = null;
}
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
closeTimeout.current = null;
}
}, []);
const handleMouseEnter = React.useCallback(() => {
if (reaction.users.length === 0) return;
clearTimers();
openTimeout.current = setTimeout(() => setOpen(true), 200);
}, [reaction.users.length, clearTimers]);
const scheduleClose = React.useCallback(() => {
clearTimers();
closeTimeout.current = setTimeout(() => setOpen(false), 150);
}, [clearTimers]);
const handleFocus = React.useCallback(() => {
if (reaction.users.length === 0) return;
clearTimers();
setOpen(true);
}, [reaction.users.length, clearTimers]);
React.useEffect(() => {
return clearTimers;
}, [clearTimers]);
const pillClasses = cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
reaction.reactedByCurrentUser
? "border-primary/40 bg-primary/10 text-primary"
: "border-border/70 bg-muted/70 text-foreground/90",
canToggle
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
: "cursor-default",
);
const handleClick = () => {
if (!canToggle) return;
onSelect(reaction.emoji);
};
if (reaction.users.length === 0) {
return (
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
className={pillClasses}
disabled={!canToggle || pending}
onClick={handleClick}
type="button"
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">{reaction.count}</span>
</button>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{/* biome-ignore lint/a11y/noStaticElementInteractions: span delegates hover/focus to disabled button */}
<span
className="inline-flex"
onMouseEnter={handleMouseEnter}
onMouseLeave={scheduleClose}
onFocus={handleFocus}
onBlur={scheduleClose}
>
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
className={pillClasses}
disabled={!canToggle || pending}
onClick={handleClick}
type="button"
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">{reaction.count}</span>
</button>
</span>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
sideOffset={6}
className="w-auto min-w-48 max-w-64 p-3"
onMouseEnter={handleMouseEnter}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<ReactionPopoverContent reaction={reaction} />
</PopoverContent>
</Popover>
);
}
@@ -1,7 +1,6 @@
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
@@ -223,15 +222,6 @@ export const MessageRow = React.memo(
const messageBodyNode = (
<>
{renderBody()}
<MessageReactions
messageId={message.id}
reactions={reactions}
canToggle={canToggleReactions}
pending={reactionPending}
onSelect={(emoji) => {
void handleReactionSelect(emoji);
}}
/>
{reactionErrorMessage ? (
<p className="mt-1.5 text-xs text-destructive">
{reactionErrorMessage}
@@ -29,25 +29,28 @@ function ParticipantAvatar({
}
export function MessageThreadSummaryRow({
alignWithText = true,
depth = 0,
message,
onOpenThread,
summary,
}: {
alignWithText?: boolean;
depth?: number;
message: TimelineMessage;
onOpenThread: (message: TimelineMessage) => void;
summary: TimelineThreadSummary;
}) {
const visibleDepth = Math.min(Math.max(depth, 0), 6);
const marginLeftPx = visibleDepth * 28;
const messageTextOffsetPx = 60;
const marginLeftPx = visibleDepth * 28 + messageTextOffsetPx;
const depthGuideOffsets = Array.from(
{ length: visibleDepth },
(_, index) => 14 + index * 28,
);
return (
<div className="relative">
<div className="relative pb-2">
{depthGuideOffsets.length > 0 ? (
<div
aria-hidden
@@ -68,14 +71,14 @@ export function MessageThreadSummaryRow({
) : null}
<button
className="flex w-fit max-w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
className="inline-flex w-fit max-w-full items-center gap-1.5 rounded-full border border-border/60 bg-background px-2 py-1 text-left text-xs font-medium text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground"
data-thread-head-id={message.id}
data-testid="message-thread-summary"
onClick={() => onOpenThread(message)}
style={{ marginLeft: `${marginLeftPx}px` }}
style={alignWithText ? { marginLeft: `${marginLeftPx}px` } : undefined}
type="button"
>
<div className="flex shrink-0 items-center">
<span className="flex shrink-0 items-center">
{summary.participants.map((participant, index) => (
<ParticipantAvatar
index={index}
@@ -83,15 +86,10 @@ export function MessageThreadSummaryRow({
participant={participant}
/>
))}
</div>
<div className="min-w-0">
<div className="font-medium">
<span>
{summary.replyCount}{" "}
{summary.replyCount === 1 ? "reply" : "replies"}
</span>
</div>
</div>
</span>
<span>
{summary.replyCount} {summary.replyCount === 1 ? "reply" : "replies"}
</span>
</button>
</div>
);
@@ -4,7 +4,6 @@ import data from "@emoji-mart/data";
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { resolveUserLabel } from "@/features/profile/lib/identity";
@@ -98,7 +97,6 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
}) {
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
const {
reactions,
canToggle: canToggleReactions,
pending: reactionPending,
errorMessage: reactionErrorMessage,
@@ -214,15 +212,6 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
<MessageTimestamp createdAt={message.createdAt} time={message.time} />
</div>
</div>
<MessageReactions
messageId={message.id}
reactions={reactions}
canToggle={canToggleReactions}
pending={reactionPending}
onSelect={(emoji) => {
void handleReactionSelect(emoji);
}}
/>
{reactionErrorMessage ? (
<p className="mt-1.5 text-xs text-destructive">
{reactionErrorMessage}
@@ -61,6 +61,11 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
[messages],
);
function getTextColumnOffsetPx(depth = 0) {
const visibleDepth = Math.min(Math.max(depth, 0), 6);
return visibleDepth * 28 + 60;
}
for (let i = 0; i < entries.length; i++) {
const { message, summary } = entries[i];
const prev = i > 0 ? entries[i - 1]?.message : null;
@@ -119,12 +124,16 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
renderedTrailingContent = true;
elements.push(
<div
className="flex min-w-0 items-center gap-1.5"
className="flex min-w-0 items-start gap-1.5"
data-testid="message-thread-summary-with-footer"
key={`thread-summary-with-footer-${message.id}`}
style={{
marginLeft: `${getTextColumnOffsetPx(message.depth)}px`,
}}
>
<div className="min-w-0 shrink">
<MessageThreadSummaryRow
alignWithText={false}
message={message}
onOpenThread={onReply}
summary={summary}
@@ -143,6 +152,20 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
/>,
);
}
} else if (trailingContent && i === entries.length - 1) {
renderedTrailingContent = true;
elements.push(
<div
className="flex min-w-0 justify-start pb-1"
data-testid="message-timeline-footer"
key={`message-timeline-footer-${message.id}`}
style={{
marginLeft: `${getTextColumnOffsetPx(message.depth)}px`,
}}
>
{trailingContent}
</div>,
);
}
}
}
@@ -8,7 +8,7 @@ import { relayClient } from "@/shared/api/relayClient";
import type { Channel, RelayEvent } from "@/shared/api/types";
import {
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_V2,
KIND_TYPING_INDICATOR,
} from "@/shared/constants/kinds";
import { resolveEventAuthorPubkey } from "@/shared/lib/authors";
@@ -52,8 +52,7 @@ function isTypingCompletionEvent(event: RelayEvent | null | undefined) {
}
return (
event.kind === KIND_STREAM_MESSAGE ||
event.kind === KIND_STREAM_MESSAGE_DIFF
event.kind === KIND_STREAM_MESSAGE || event.kind === KIND_STREAM_MESSAGE_V2
);
}