mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish active agent activity UI
Made-with: Cursor
This commit is contained in:
@@ -42,13 +42,13 @@ export function ToolItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose w-full px-1">
|
||||
<div className="not-prose w-full px-0">
|
||||
<details
|
||||
className="group w-full"
|
||||
onToggle={handleToggle}
|
||||
open={isExpanded}
|
||||
>
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px">
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-0.5 text-left">
|
||||
{ToolIcon ? (
|
||||
<ToolIcon
|
||||
className={cn(
|
||||
|
||||
@@ -28,11 +28,11 @@ export function AgentSessionTranscriptList({
|
||||
<div
|
||||
aria-label="Live ACP transcript"
|
||||
aria-live="polite"
|
||||
className="mx-auto w-full max-w-3xl py-1"
|
||||
className="w-full space-y-3 py-1"
|
||||
role="log"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div className="mt-4 first:mt-0" key={item.id}>
|
||||
<div key={item.id}>
|
||||
<TranscriptItemView agentName={agentName} item={item} />
|
||||
</div>
|
||||
))}
|
||||
@@ -71,32 +71,26 @@ function MessageItem({
|
||||
}) {
|
||||
const isAssistant = item.role === "assistant";
|
||||
const text = item.text.trim();
|
||||
const label = isAssistant ? agentName : item.title;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex px-1 py-1 animate-in fade-in duration-200 motion-reduce:animate-none",
|
||||
isAssistant ? "flex-row" : "ml-auto flex-row-reverse",
|
||||
)}
|
||||
className="flex px-0 py-0.5 animate-in fade-in duration-200 motion-reduce:animate-none"
|
||||
data-role={isAssistant ? "assistant-message" : "user-message"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative min-w-0 flex flex-col gap-1",
|
||||
isAssistant ? "w-full items-start" : "max-w-[85%] items-end",
|
||||
)}
|
||||
>
|
||||
{isAssistant ? (
|
||||
<div className="mb-0.5 flex items-center gap-1 text-xs">
|
||||
<div className="group relative flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{isAssistant ? (
|
||||
<span className="flex h-5 w-5 items-center justify-center">
|
||||
<Bot className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="font-normal text-foreground">{agentName}</span>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
<span className="font-medium text-foreground">{label}</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-w-0 text-sm leading-relaxed",
|
||||
!isAssistant && "rounded-2xl bg-muted p-3 text-foreground",
|
||||
"w-full min-w-0 text-left text-sm leading-relaxed",
|
||||
!isAssistant &&
|
||||
"rounded-xl border border-border/60 bg-muted/35 px-3 py-2 text-foreground",
|
||||
)}
|
||||
>
|
||||
{isAssistant ? (
|
||||
@@ -116,8 +110,8 @@ function ThoughtItem({
|
||||
item: Extract<TranscriptItem, { type: "thought" }>;
|
||||
}) {
|
||||
return (
|
||||
<details className="group not-prose w-full px-1">
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px text-muted-foreground">
|
||||
<details className="group not-prose w-full px-0">
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-0.5 text-left text-muted-foreground">
|
||||
<Brain className="h-4 w-4" />
|
||||
<span className="truncate text-sm font-medium">{item.title}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 transition-transform group-open:rotate-180" />
|
||||
@@ -135,8 +129,8 @@ function MetadataItem({
|
||||
item: Extract<TranscriptItem, { type: "metadata" }>;
|
||||
}) {
|
||||
return (
|
||||
<details className="group not-prose w-full px-1">
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px text-muted-foreground">
|
||||
<details className="group not-prose w-full px-0">
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-0.5 text-left text-muted-foreground">
|
||||
<TerminalSquare className="h-4 w-4" />
|
||||
<span className="truncate text-sm font-medium">{item.title}</span>
|
||||
<span className="shrink-0 text-xs">
|
||||
@@ -173,7 +167,7 @@ function LifecycleItem({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-2 text-center text-xs",
|
||||
"px-0 py-1 text-left text-xs leading-5",
|
||||
isError ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,157 +1,171 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Bot, Loader2 } from "lucide-react";
|
||||
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
const AGENT_LIST_HEIGHT_ESTIMATE_PX = 220;
|
||||
|
||||
type BotActivityBarProps = {
|
||||
agents: ManagedAgent[];
|
||||
onOpenAgentSession: (pubkey: string) => void;
|
||||
openAgentSessionPubkey: string | null;
|
||||
profiles?: UserProfileLookup;
|
||||
typingBotPubkeys: string[];
|
||||
};
|
||||
|
||||
const COMPACT_THRESHOLD = 4;
|
||||
const OVERFLOW_THRESHOLD = 6;
|
||||
const MAX_VISIBLE_WITH_OVERFLOW = 5;
|
||||
|
||||
/**
|
||||
* Compact right-aligned row of clickable bot pills.
|
||||
* Only renders pills for bots that are currently typing (actively working).
|
||||
* Single collected active-agent pill. The dropdown exposes the individual
|
||||
* active agents while keeping the composer area visually quiet.
|
||||
*/
|
||||
export function BotActivityBar({
|
||||
agents,
|
||||
onOpenAgentSession,
|
||||
openAgentSessionPubkey,
|
||||
profiles,
|
||||
typingBotPubkeys,
|
||||
}: BotActivityBarProps) {
|
||||
if (typingBotPubkeys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [contentSide, setContentSide] = React.useState<"top" | "bottom">(
|
||||
"bottom",
|
||||
);
|
||||
const closeTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const typingSet = React.useMemo(
|
||||
() => new Set(typingBotPubkeys.map((pubkey) => pubkey.toLowerCase())),
|
||||
[typingBotPubkeys],
|
||||
);
|
||||
const typingAgents = React.useMemo(
|
||||
() => agents.filter((agent) => typingSet.has(agent.pubkey.toLowerCase())),
|
||||
[agents, typingSet],
|
||||
);
|
||||
const typingAgentKey = typingAgents
|
||||
.map((agent) => agent.pubkey.toLowerCase())
|
||||
.join("|");
|
||||
|
||||
const typingSet = new Set(
|
||||
typingBotPubkeys.map((pubkey) => pubkey.toLowerCase()),
|
||||
const clearCloseTimeout = React.useCallback(() => {
|
||||
if (closeTimeoutRef.current) {
|
||||
window.clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openAgentList = React.useCallback(() => {
|
||||
clearCloseTimeout();
|
||||
setIsOpen(true);
|
||||
}, [clearCloseTimeout]);
|
||||
|
||||
const openAgentListFromTrigger = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const triggerRect = event.currentTarget.getBoundingClientRect();
|
||||
const availableBelow = window.innerHeight - triggerRect.bottom;
|
||||
setContentSide(
|
||||
availableBelow < AGENT_LIST_HEIGHT_ESTIMATE_PX ? "top" : "bottom",
|
||||
);
|
||||
openAgentList();
|
||||
},
|
||||
[openAgentList],
|
||||
);
|
||||
|
||||
const typingAgents = agents.filter((agent) =>
|
||||
typingSet.has(agent.pubkey.toLowerCase()),
|
||||
);
|
||||
const scheduleCloseAgentList = React.useCallback(() => {
|
||||
clearCloseTimeout();
|
||||
closeTimeoutRef.current = window.setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
closeTimeoutRef.current = null;
|
||||
}, 250);
|
||||
}, [clearCloseTimeout]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return clearCloseTimeout;
|
||||
}, [clearCloseTimeout]);
|
||||
|
||||
if (typingAgents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { hiddenAgents, visibleAgents } = splitVisibleAgents(
|
||||
typingAgents,
|
||||
openAgentSessionPubkey,
|
||||
);
|
||||
const isCompact = typingAgents.length >= COMPACT_THRESHOLD;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 shrink items-center justify-end gap-1 overflow-hidden"
|
||||
data-testid="bot-activity-bar"
|
||||
>
|
||||
{visibleAgents.map((agent) => {
|
||||
const isSelected =
|
||||
openAgentSessionPubkey?.toLowerCase() === agent.pubkey.toLowerCase();
|
||||
return (
|
||||
<Tooltip key={agent.pubkey}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex min-w-0 shrink items-center gap-1 rounded-full border py-1 text-xs font-medium transition-colors",
|
||||
isCompact ? "max-w-[6.5rem] px-2" : "max-w-[9rem] px-2.5",
|
||||
isSelected
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border/60 bg-background text-muted-foreground hover:border-primary/30 hover:bg-primary/5 hover:text-foreground",
|
||||
)}
|
||||
data-testid={`bot-chip-${agent.pubkey}`}
|
||||
onClick={() => onOpenAgentSession(agent.pubkey)}
|
||||
type="button"
|
||||
>
|
||||
<Bot className="h-3 w-3 shrink-0" />
|
||||
<span className="min-w-0 truncate">{agent.name}</span>
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin opacity-60" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{agent.name} is working — click to view activity
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{hiddenAgents.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Show ${hiddenAgents.length} more working agents`}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border/60 bg-background px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary"
|
||||
data-testid="bot-chip-overflow"
|
||||
type="button"
|
||||
>
|
||||
+{hiddenAgents.length}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="px-2 py-1 text-xs text-muted-foreground">
|
||||
More agents working
|
||||
</DropdownMenuLabel>
|
||||
{hiddenAgents.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
data-testid={`bot-chip-overflow-item-${agent.pubkey}`}
|
||||
key={agent.pubkey}
|
||||
onClick={() => onOpenAgentSession(agent.pubkey)}
|
||||
>
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/70" />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function splitVisibleAgents(
|
||||
typingAgents: ManagedAgent[],
|
||||
openAgentSessionPubkey: string | null,
|
||||
): { visibleAgents: ManagedAgent[]; hiddenAgents: ManagedAgent[] } {
|
||||
if (typingAgents.length < OVERFLOW_THRESHOLD) {
|
||||
return { visibleAgents: typingAgents, hiddenAgents: [] };
|
||||
}
|
||||
|
||||
const selectedAgent = openAgentSessionPubkey
|
||||
? typingAgents.find(
|
||||
(agent) =>
|
||||
agent.pubkey.toLowerCase() === openAgentSessionPubkey.toLowerCase(),
|
||||
)
|
||||
: null;
|
||||
const label =
|
||||
typingAgents.length === 1
|
||||
? "1 active agent"
|
||||
: `${typingAgents.length} active agents`;
|
||||
|
||||
const visibleAgents = typingAgents.slice(0, MAX_VISIBLE_WITH_OVERFLOW);
|
||||
|
||||
if (
|
||||
selectedAgent &&
|
||||
!visibleAgents.some((agent) => agent.pubkey === selectedAgent.pubkey)
|
||||
) {
|
||||
visibleAgents[visibleAgents.length - 1] = selectedAgent;
|
||||
}
|
||||
|
||||
const visibleSet = new Set(visibleAgents.map((agent) => agent.pubkey));
|
||||
const hiddenAgents = typingAgents.filter(
|
||||
(agent) => !visibleSet.has(agent.pubkey),
|
||||
return (
|
||||
<div className="min-w-0" data-testid="bot-activity-bar">
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-label={`Show ${typingAgents.length} active agents`}
|
||||
className={cn(
|
||||
"inline-flex max-w-[18rem] items-center gap-1 rounded-full border bg-background px-2 py-1 text-xs font-medium transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary",
|
||||
"animate-in fade-in-0 zoom-in-95 duration-200 motion-reduce:animate-none",
|
||||
selectedAgent
|
||||
? "border-primary/40 text-primary"
|
||||
: "border-border/60 text-muted-foreground",
|
||||
)}
|
||||
data-agent-key={typingAgentKey}
|
||||
data-testid="bot-chip-overflow"
|
||||
key={typingAgentKey}
|
||||
onPointerEnter={openAgentListFromTrigger}
|
||||
onPointerLeave={scheduleCloseAgentList}
|
||||
type="button"
|
||||
>
|
||||
<Bot className="h-3 w-3 shrink-0" />
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin opacity-60" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
collisionPadding={12}
|
||||
className="w-56 p-1"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onPointerEnter={openAgentList}
|
||||
onPointerLeave={scheduleCloseAgentList}
|
||||
side={contentSide}
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground">
|
||||
Active agents
|
||||
</div>
|
||||
{typingAgents.map((agent) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground",
|
||||
"animate-in fade-in-0 duration-150 motion-reduce:animate-none",
|
||||
selectedAgent?.pubkey === agent.pubkey && "bg-primary/10",
|
||||
)}
|
||||
data-testid={`bot-chip-overflow-item-${agent.pubkey}`}
|
||||
key={agent.pubkey}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onOpenAgentSession(agent.pubkey);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={
|
||||
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null
|
||||
}
|
||||
className="rounded-full"
|
||||
displayName={agent.name}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/70" />
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
|
||||
return { visibleAgents, hiddenAgents };
|
||||
}
|
||||
|
||||
@@ -256,6 +256,17 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
: "No messages yet"
|
||||
: "No channel selected"
|
||||
}
|
||||
conversationFooter={
|
||||
botTypingPubkeys.length > 0 ? (
|
||||
<BotActivityBar
|
||||
agents={agentSessionAgents}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
profiles={profiles}
|
||||
typingBotPubkeys={botTypingPubkeys}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
isLoading={isTimelineLoading}
|
||||
messages={messages}
|
||||
onDelete={onDelete}
|
||||
@@ -277,6 +288,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onCancelEdit={onCancelEdit}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSendMessage}
|
||||
profiles={profiles}
|
||||
placeholder={
|
||||
activeChannel?.archivedAt
|
||||
? "Archived channels are read-only."
|
||||
@@ -296,14 +308,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
profiles={profiles}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
<div className="absolute right-0 top-0 flex h-8 items-center pr-8 sm:pr-10">
|
||||
<BotActivityBar
|
||||
agents={agentSessionAgents}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
typingBotPubkeys={botTypingPubkeys}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -128,14 +128,10 @@ export function ChannelScreen({
|
||||
() => collectMessageAuthorPubkeys(resolvedMessages),
|
||||
[resolvedMessages],
|
||||
);
|
||||
const latestMessageEvent = React.useMemo(
|
||||
() => resolvedMessages[resolvedMessages.length - 1] ?? null,
|
||||
[resolvedMessages],
|
||||
);
|
||||
const typingEntries = useChannelTyping(
|
||||
activeChannel,
|
||||
currentPubkey,
|
||||
latestMessageEvent,
|
||||
resolvedMessages,
|
||||
);
|
||||
const mainTypingPubkeys = React.useMemo(
|
||||
() =>
|
||||
@@ -175,11 +171,15 @@ export function ChannelScreen({
|
||||
humanTypingPubkeys: mainTypingPubkeys.filter(
|
||||
(pk) => !localAgentSet.has(pk.toLowerCase()),
|
||||
),
|
||||
botTypingPubkeys: mainTypingPubkeys.filter((pk) =>
|
||||
localAgentSet.has(pk.toLowerCase()),
|
||||
),
|
||||
botTypingPubkeys: [
|
||||
...new Set(
|
||||
typingEntries
|
||||
.map((entry) => entry.pubkey)
|
||||
.filter((pk) => localAgentSet.has(pk.toLowerCase())),
|
||||
),
|
||||
],
|
||||
};
|
||||
}, [mainTypingPubkeys, managedAgentsQuery.data]);
|
||||
}, [mainTypingPubkeys, managedAgentsQuery.data, typingEntries]);
|
||||
const messageProfiles = React.useMemo(() => {
|
||||
const base =
|
||||
mergeCurrentProfileIntoLookup(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
|
||||
@@ -258,17 +257,6 @@ export const MessageRow = React.memo(
|
||||
const messageBodyNode = (
|
||||
<>
|
||||
{renderBody()}
|
||||
<MessageReactions
|
||||
messageId={message.id}
|
||||
reactions={reactions}
|
||||
canToggle={canToggleReactions}
|
||||
pending={reactionPending}
|
||||
onSelect={(emoji) => {
|
||||
void handleReactionSelect(emoji).catch(() => {
|
||||
return;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-1.5 text-xs text-destructive">
|
||||
{reactionErrorMessage}
|
||||
|
||||
@@ -19,6 +19,7 @@ type MessageTimelineProps = {
|
||||
isLoading?: boolean;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
conversationFooter?: React.ReactNode;
|
||||
activeReplyTargetId?: string | null;
|
||||
currentPubkey?: string;
|
||||
fetchOlder?: () => Promise<void>;
|
||||
@@ -51,6 +52,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
isLoading = false,
|
||||
emptyTitle = "No messages yet",
|
||||
emptyDescription = "Send the first message to start the thread.",
|
||||
conversationFooter,
|
||||
activeReplyTargetId = null,
|
||||
currentPubkey,
|
||||
fetchOlder,
|
||||
@@ -199,6 +201,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
/>
|
||||
) : 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>
|
||||
|
||||
@@ -67,7 +67,7 @@ function getTypingStateKey(pubkey: string, threadHeadId: string | null) {
|
||||
export function useChannelTyping(
|
||||
channel: Channel | null,
|
||||
currentPubkey?: string,
|
||||
latestMessageEvent?: RelayEvent | null,
|
||||
completionEvents: RelayEvent[] = [],
|
||||
) {
|
||||
const channelId = channel?.id ?? null;
|
||||
const channelType = channel?.channelType ?? null;
|
||||
@@ -131,38 +131,55 @@ export function useChannelTyping(
|
||||
}, [channelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!channelId ||
|
||||
!latestMessageEvent ||
|
||||
!isTypingCompletionEvent(latestMessageEvent)
|
||||
) {
|
||||
if (!channelId || completionEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getChannelIdFromTags(latestMessageEvent.tags) !== channelId) {
|
||||
const completionKeys = new Set<string>();
|
||||
for (const event of completionEvents) {
|
||||
if (
|
||||
!isTypingCompletionEvent(event) ||
|
||||
getChannelIdFromTags(event.tags) !== channelId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const authorPubkey = event.pubkey.toLowerCase();
|
||||
const threadHeadId = getTypingScopeId(event);
|
||||
const typingKey = getTypingStateKey(authorPubkey, threadHeadId);
|
||||
latestMessageCreatedAtByPubkeyRef.current[typingKey] = Math.max(
|
||||
latestMessageCreatedAtByPubkeyRef.current[typingKey] ?? 0,
|
||||
event.created_at,
|
||||
);
|
||||
completionKeys.add(typingKey);
|
||||
}
|
||||
|
||||
if (completionKeys.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authorPubkey = latestMessageEvent.pubkey.toLowerCase();
|
||||
const threadHeadId = getTypingScopeId(latestMessageEvent);
|
||||
const typingKey = getTypingStateKey(authorPubkey, threadHeadId);
|
||||
latestMessageCreatedAtByPubkeyRef.current[typingKey] = Math.max(
|
||||
latestMessageCreatedAtByPubkeyRef.current[typingKey] ?? 0,
|
||||
latestMessageEvent.created_at,
|
||||
);
|
||||
typingSuppressUntilByPubkeyRef.current[typingKey] =
|
||||
Date.now() + TYPING_POST_MESSAGE_SUPPRESS_MS;
|
||||
setTypingByPubkey((current) => {
|
||||
const next = pruneTypingState(current);
|
||||
if (!(typingKey in next)) {
|
||||
let updated: TypingState | null = null;
|
||||
|
||||
for (const typingKey of completionKeys) {
|
||||
if (!(typingKey in next)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
updated ??= { ...next };
|
||||
delete updated[typingKey];
|
||||
typingSuppressUntilByPubkeyRef.current[typingKey] =
|
||||
Date.now() + TYPING_POST_MESSAGE_SUPPRESS_MS;
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const updated = { ...next };
|
||||
delete updated[typingKey];
|
||||
return updated;
|
||||
});
|
||||
}, [channelId, latestMessageEvent]);
|
||||
}, [channelId, completionEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!channelId || channelType === "forum") {
|
||||
|
||||
Reference in New Issue
Block a user