mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish reply activity pills.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
d11bedeea9
commit
c7b698a0fe
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user