Polish active agent activity UI

Made-with: Cursor
This commit is contained in:
Thomas Petersen
2026-04-29 20:58:26 -04:00
parent 95c07e797c
commit 7fa2a43d3b
8 changed files with 223 additions and 195 deletions
@@ -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") {