Move agent activity into composer

Place working-agent selection beside the send button so activity stays close to message composition, and tighten chat paragraph spacing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-05-06 13:10:14 -04:00
parent 8c0ba24fb9
commit 96035eb4a1
4 changed files with 153 additions and 228 deletions

View File

@@ -1,171 +1,166 @@
import * as React from "react";
import { 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";
export type BotActivityAgent = Pick<ManagedAgent, "pubkey" | "name">;
type BotActivityBarProps = {
agents: ManagedAgent[];
agents: BotActivityAgent[];
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;
const HOVER_OPEN_DELAY_MS = 150;
const HOVER_CLOSE_DELAY_MS = 180;
/**
* Compact right-aligned row of clickable bot pills.
* Only renders pills for bots that are currently typing (actively working).
*/
export function BotActivityBar({
export function BotActivityComposerAction({
agents,
onOpenAgentSession,
openAgentSessionPubkey,
profiles,
typingBotPubkeys,
}: BotActivityBarProps) {
if (typingBotPubkeys.length === 0) {
return null;
}
const typingSet = new Set(
typingBotPubkeys.map((pubkey) => pubkey.toLowerCase()),
const [open, setOpen] = React.useState(false);
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const typingAgents = agents.filter((agent) =>
typingSet.has(agent.pubkey.toLowerCase()),
);
const typingAgents = React.useMemo(() => {
const typingSet = new Set(
typingBotPubkeys.map((pubkey) => pubkey.toLowerCase()),
);
return agents.filter((agent) => typingSet.has(agent.pubkey.toLowerCase()));
}, [agents, typingBotPubkeys]);
const clearHoverTimer = React.useCallback(() => {
if (hoverTimerRef.current !== null) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
}, []);
const openWithDelay = React.useCallback(() => {
clearHoverTimer();
hoverTimerRef.current = setTimeout(() => {
setOpen(true);
}, HOVER_OPEN_DELAY_MS);
}, [clearHoverTimer]);
const closeWithDelay = React.useCallback(() => {
clearHoverTimer();
hoverTimerRef.current = setTimeout(() => {
setOpen(false);
}, HOVER_CLOSE_DELAY_MS);
}, [clearHoverTimer]);
const keepOpen = React.useCallback(() => {
clearHoverTimer();
}, [clearHoverTimer]);
React.useEffect(() => {
return () => clearHoverTimer();
}, [clearHoverTimer]);
if (typingAgents.length === 0) {
return null;
}
const { hiddenAgents, visibleAgents } = splitVisibleAgents(
typingAgents,
openAgentSessionPubkey,
);
const isCompact = typingAgents.length >= COMPACT_THRESHOLD;
const agentAvatarUrl = (agent: ManagedAgent) =>
const agentAvatarUrl = (agent: BotActivityAgent) =>
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null;
const selectedPubkey = openAgentSessionPubkey?.toLowerCase() ?? null;
const triggerLabel =
typingAgents.length === 1
? `${typingAgents[0]?.name ?? "Agent"} is working`
: `${typingAgents.length} agents working`;
return (
<div
className="flex min-w-0 shrink items-center justify-start 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>
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<button
aria-label={`${triggerLabel}. View activity.`}
className="inline-flex h-9 min-w-9 items-center justify-center gap-1.5 rounded-full border border-border/60 bg-background px-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary"
data-testid="bot-activity-composer-trigger"
onBlur={closeWithDelay}
onClick={() => {
clearHoverTimer();
setOpen((current) => !current);
}}
onFocus={() => setOpen(true)}
onMouseEnter={openWithDelay}
onMouseLeave={closeWithDelay}
type="button"
>
<span className="flex -space-x-1">
{typingAgents.slice(0, 2).map((agent) => (
<UserAvatar
avatarUrl={agentAvatarUrl(agent)}
className="!h-5 !w-5 rounded-full border border-background text-[8px]"
displayName={agent.name}
key={agent.pubkey}
/>
))}
</span>
{typingAgents.length > 2 ? (
<span className="text-[11px] leading-none">
+{typingAgents.length - 2}
</span>
) : null}
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin opacity-70" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-64 p-2"
onMouseEnter={keepOpen}
onMouseLeave={closeWithDelay}
onOpenAutoFocus={(event) => event.preventDefault()}
side="top"
sideOffset={8}
>
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
Agents working
</div>
<div className="mt-1 flex flex-col gap-1">
{typingAgents.map((agent) => {
const isSelected = selectedPubkey === agent.pubkey.toLowerCase();
return (
<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",
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
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",
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`bot-chip-${agent.pubkey}`}
onClick={() => onOpenAgentSession(agent.pubkey)}
data-testid={`bot-activity-composer-item-${agent.pubkey}`}
key={agent.pubkey}
onClick={() => {
clearHoverTimer();
setOpen(false);
onOpenAgentSession(agent.pubkey);
}}
type="button"
>
<UserAvatar
avatarUrl={agentAvatarUrl(agent)}
className="!h-4 !w-4 shrink-0 rounded-full text-[7px]"
displayName={agent.name}
/>
<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)}
>
<UserAvatar
avatarUrl={agentAvatarUrl(agent)}
className="!h-5 !w-5 shrink-0 rounded-full text-[8px]"
className="!h-6 !w-6 shrink-0 rounded-full text-[9px]"
displayName={agent.name}
/>
<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>
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/70" />
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}
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 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 { visibleAgents, hiddenAgents };
}

View File

@@ -9,7 +9,10 @@ import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { BotActivityBar } from "@/features/channels/ui/BotActivityBar";
import {
BotActivityComposerAction,
type BotActivityAgent,
} from "@/features/channels/ui/BotActivityBar";
import { Button } from "@/shared/ui/button";
import type { useChannelFind } from "@/features/search/useChannelFind";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
@@ -51,47 +54,9 @@ function getInitialThreadPanelWidth(): number {
}
}
function messageMentionsPubkey(message: TimelineMessage, pubkey: string) {
const normalizedPubkey = pubkey.toLowerCase();
if (message.pubkey?.toLowerCase() === normalizedPubkey) {
return false;
}
return (
message.tags?.some(
(tag) => tag[0] === "p" && tag[1]?.toLowerCase() === normalizedPubkey,
) ?? false
);
}
function findLatestMentionedMessageId(
messages: TimelineMessage[],
pubkey: string,
) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message && messageMentionsPubkey(message, pubkey)) {
return message.id;
}
}
return null;
}
function addPubkey(
map: Map<string, string[]>,
messageId: string,
pubkey: string,
) {
const current = map.get(messageId) ?? [];
if (!current.some((value) => value.toLowerCase() === pubkey.toLowerCase())) {
current.push(pubkey);
}
map.set(messageId, current);
}
type ChannelPaneProps = {
activeChannel: Channel | null;
activityAgents: BotActivityAgent[];
agentSessionAgents: ManagedAgent[];
botTypingEntries: TypingIndicatorEntry[];
channelFind: ReturnType<typeof useChannelFind>;
@@ -157,6 +122,7 @@ type ChannelPaneProps = {
export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
activityAgents,
agentSessionAgents,
botTypingEntries,
channelFind,
@@ -270,53 +236,19 @@ export const ChannelPane = React.memo(function ChannelPane({
activeChannel.channelType === "forum" ||
isSending;
const hasTypingActivity = typingPubkeys.length > 0;
const { messageActivityFooters, unanchoredBotTypingPubkeys } =
React.useMemo(() => {
const botPubkeysByMessageId = new Map<string, string[]>();
const unanchoredPubkeys: string[] = [];
for (const entry of botTypingEntries) {
const messageId =
entry.threadHeadId ??
findLatestMentionedMessageId(messages, entry.pubkey);
if (messageId) {
addPubkey(botPubkeysByMessageId, messageId, entry.pubkey);
} else if (
!unanchoredPubkeys.some(
(pubkey) => pubkey.toLowerCase() === entry.pubkey.toLowerCase(),
)
) {
unanchoredPubkeys.push(entry.pubkey);
}
const composerBotTypingPubkeys = React.useMemo(() => {
const pubkeys: string[] = [];
for (const entry of botTypingEntries) {
if (
!pubkeys.some(
(pubkey) => pubkey.toLowerCase() === entry.pubkey.toLowerCase(),
)
) {
pubkeys.push(entry.pubkey);
}
const footers: Record<string, React.ReactNode> = {};
for (const [messageId, pubkeys] of botPubkeysByMessageId) {
footers[messageId] = (
<div className="flex justify-start pl-[3.125rem]">
<BotActivityBar
agents={agentSessionAgents}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={pubkeys}
/>
</div>
);
}
return {
messageActivityFooters: footers,
unanchoredBotTypingPubkeys: unanchoredPubkeys,
};
}, [
agentSessionAgents,
botTypingEntries,
messages,
onOpenAgentSession,
openAgentSessionPubkey,
profiles,
]);
const hasBotActivity = unanchoredBotTypingPubkeys.length > 0;
}
return pubkeys;
}, [botTypingEntries]);
const selectedAgent = React.useMemo(
() =>
@@ -349,7 +281,6 @@ export const ChannelPane = React.memo(function ChannelPane({
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
messageFooters={messageActivityFooters}
personaLookup={personaLookup}
profiles={profiles}
emptyDescription={
@@ -386,19 +317,6 @@ export const ChannelPane = React.memo(function ChannelPane({
/>
</div>
) : null}
{hasBotActivity ? (
<div className="relative z-10 bg-background px-4 pb-2 sm:px-6">
<div className="mx-auto flex w-full max-w-4xl justify-start pl-[3.125rem]">
<BotActivityBar
agents={agentSessionAgents}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={unanchoredBotTypingPubkeys}
/>
</div>
</div>
) : null}
{isNonMemberView ? (
<div
data-testid="join-banner"
@@ -437,6 +355,15 @@ export const ChannelPane = React.memo(function ChannelPane({
onEditSave={onEditSave}
onSend={onSendMessage}
profiles={profiles}
toolbarExtraActions={
<BotActivityComposerAction
agents={activityAgents}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={composerBotTypingPubkeys}
/>
}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."

View File

@@ -58,6 +58,7 @@ type MessageComposerProps = {
id: string;
} | null;
showTopBorder?: boolean;
toolbarExtraActions?: React.ReactNode;
typingParentEventId?: string | null;
typingRootEventId?: string | null;
};
@@ -77,6 +78,7 @@ export function MessageComposer({
profiles,
replyTarget = null,
showTopBorder = false,
toolbarExtraActions,
typingParentEventId = null,
typingRootEventId = null,
}: MessageComposerProps) {
@@ -663,6 +665,7 @@ export function MessageComposer({
<MessageComposerToolbar
composerDisabled={disabled}
editor={richText.editor}
extraActions={toolbarExtraActions}
formattingDisabled={disabled}
isEmojiPickerOpen={isEmojiPickerOpen}
isFormattingOpen={isFormattingOpen}

View File

@@ -425,7 +425,7 @@ function MarkdownInner({
<div
className={cn(
tight
? "max-w-none break-words text-sm leading-5 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-1"
? "max-w-none break-words text-sm leading-5 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-0.5"
: compact
? "max-w-none break-words text-[15px] leading-6 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-1.5"
: "max-w-none break-words text-sm leading-7 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-3",