Anchor agent activity to messages

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-05-05 12:18:14 -04:00
parent 1e6b579161
commit 8def39a436
7 changed files with 196 additions and 74 deletions

View File

@@ -35,7 +35,8 @@ const overrides = new Map([
["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/channels/ui/ChannelScreen.tsx", 530], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context
["src/features/channels/ui/ChannelPane.tsx", 520], // composer/timeline/sidebar orchestration + anchored agent activity footers
["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape)
["src/features/settings/ui/SettingsView.tsx", 600],

View File

@@ -1,5 +1,6 @@
import { Bot, Loader2 } from "lucide-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 {
@@ -10,11 +11,13 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type BotActivityBarProps = {
agents: ManagedAgent[];
onOpenAgentSession: (pubkey: string) => void;
openAgentSessionPubkey: string | null;
profiles?: UserProfileLookup;
typingBotPubkeys: string[];
};
@@ -30,6 +33,7 @@ export function BotActivityBar({
agents,
onOpenAgentSession,
openAgentSessionPubkey,
profiles,
typingBotPubkeys,
}: BotActivityBarProps) {
if (typingBotPubkeys.length === 0) {
@@ -53,6 +57,8 @@ export function BotActivityBar({
openAgentSessionPubkey,
);
const isCompact = typingAgents.length >= COMPACT_THRESHOLD;
const agentAvatarUrl = (agent: ManagedAgent) =>
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null;
return (
<div
@@ -77,7 +83,11 @@ export function BotActivityBar({
onClick={() => onOpenAgentSession(agent.pubkey)}
type="button"
>
<Bot className="h-3 w-3 shrink-0" />
<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>
@@ -112,7 +122,11 @@ export function BotActivityBar({
key={agent.pubkey}
onClick={() => onOpenAgentSession(agent.pubkey)}
>
<Bot className="h-4 w-4 text-muted-foreground" />
<UserAvatar
avatarUrl={agentAvatarUrl(agent)}
className="!h-5 !w-5 shrink-0 rounded-full text-[8px]"
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>

View File

@@ -5,6 +5,7 @@ import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel";
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
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";
@@ -50,10 +51,49 @@ 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;
agentSessionAgents: ManagedAgent[];
botTypingPubkeys: string[];
botTypingEntries: TypingIndicatorEntry[];
channelFind: ReturnType<typeof useChannelFind>;
currentPubkey?: string;
editTarget?: {
@@ -118,7 +158,7 @@ type ChannelPaneProps = {
export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
agentSessionAgents,
botTypingPubkeys,
botTypingEntries,
channelFind,
currentPubkey,
editTarget = null,
@@ -230,7 +270,53 @@ export const ChannelPane = React.memo(function ChannelPane({
activeChannel.channelType === "forum" ||
isSending;
const hasTypingActivity = typingPubkeys.length > 0;
const hasBotActivity = botTypingPubkeys.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 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;
const selectedAgent = React.useMemo(
() =>
@@ -263,6 +349,7 @@ export const ChannelPane = React.memo(function ChannelPane({
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
messageFooters={messageActivityFooters}
personaLookup={personaLookup}
profiles={profiles}
emptyDescription={
@@ -306,7 +393,8 @@ export const ChannelPane = React.memo(function ChannelPane({
agents={agentSessionAgents}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
typingBotPubkeys={botTypingPubkeys}
profiles={profiles}
typingBotPubkeys={unanchoredBotTypingPubkeys}
/>
</div>
</div>
@@ -401,9 +489,9 @@ export const ChannelPane = React.memo(function ChannelPane({
agent={selectedAgent}
canResetWidth={canResetThreadPanelWidth}
channel={activeChannel}
isWorking={botTypingPubkeys.some(
(pubkey) =>
pubkey.toLowerCase() === selectedAgent.pubkey.toLowerCase(),
isWorking={botTypingEntries.some(
(entry) =>
entry.pubkey.toLowerCase() === selectedAgent.pubkey.toLowerCase(),
)}
onClose={onCloseAgentSession}
onResetWidth={handleThreadPanelWidthReset}

View File

@@ -34,7 +34,10 @@ import {
import { buildThreadPanelData } from "@/features/messages/lib/threadPanel";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors";
import { useChannelTyping } from "@/features/messages/useChannelTyping";
import {
type TypingIndicatorEntry,
useChannelTyping,
} from "@/features/messages/useChannelTyping";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import type {
@@ -142,13 +145,6 @@ export function ChannelScreen({
currentPubkey,
latestMessageEvent,
);
const mainTypingPubkeys = React.useMemo(
() =>
typingEntries
.filter((entry) => entry.threadHeadId === null)
.map((entry) => entry.pubkey),
[typingEntries],
);
const threadTypingPubkeys = React.useMemo(
() =>
typingEntries
@@ -170,21 +166,28 @@ export function ChannelScreen({
});
const managedAgentsQuery = useManagedAgentsQuery();
useManagedAgentObserverBridge(managedAgentsQuery.data ?? []);
const { humanTypingPubkeys, botTypingPubkeys } = React.useMemo(() => {
const { botTypingEntries, humanTypingPubkeys } = React.useMemo<{
botTypingEntries: TypingIndicatorEntry[];
humanTypingPubkeys: string[];
}>(() => {
const localAgentSet = new Set(
(managedAgentsQuery.data ?? [])
.filter((agent) => agent.backend.type === "local")
.map((agent) => agent.pubkey.toLowerCase()),
);
const channelTypingEntries = typingEntries.filter(
(entry) => entry.threadHeadId === null,
);
const agentTypingEntries = typingEntries.filter((entry) =>
localAgentSet.has(entry.pubkey.toLowerCase()),
);
return {
humanTypingPubkeys: mainTypingPubkeys.filter(
(pk) => !localAgentSet.has(pk.toLowerCase()),
),
botTypingPubkeys: mainTypingPubkeys.filter((pk) =>
localAgentSet.has(pk.toLowerCase()),
),
botTypingEntries: agentTypingEntries,
humanTypingPubkeys: channelTypingEntries
.filter((entry) => !localAgentSet.has(entry.pubkey.toLowerCase()))
.map((entry) => entry.pubkey),
};
}, [mainTypingPubkeys, managedAgentsQuery.data]);
}, [managedAgentsQuery.data, typingEntries]);
const messageProfiles = React.useMemo(() => {
const base =
mergeCurrentProfileIntoLookup(
@@ -468,7 +471,7 @@ export function ChannelScreen({
<ChannelPane
activeChannel={activeChannel}
agentSessionAgents={channelAgentSessionAgents}
botTypingPubkeys={botTypingPubkeys}
botTypingEntries={botTypingEntries}
channelFind={channelFind}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}

View File

@@ -63,7 +63,12 @@ export function MessageReactions({
}
return (
<div className={cn("mt-1.5 flex flex-wrap items-center gap-1.5 pt-1", className)}>
<div
className={cn(
"mt-1.5 flex flex-wrap items-center gap-1.5 pt-1",
className,
)}
>
{reactions.map((reaction) => (
<ReactionPill
key={`${messageId}-${reaction.emoji}`}

View File

@@ -23,6 +23,7 @@ type MessageTimelineProps = {
fetchOlder?: () => Promise<void>;
hasOlderMessages?: boolean;
isFetchingOlder?: boolean;
messageFooters?: Record<string, React.ReactNode>;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
@@ -55,6 +56,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
fetchOlder,
hasOlderMessages = true,
isFetchingOlder = false,
messageFooters,
personaLookup,
profiles,
onDelete,
@@ -172,23 +174,22 @@ export const MessageTimeline = React.memo(function MessageTimeline({
) : null}
{!isLoading && messages.length > 0 ? (
<>
<TimelineMessageList
activeReplyTargetId={activeReplyTargetId}
currentPubkey={currentPubkey}
highlightedMessageId={highlightedMessageId}
messages={messages}
onDelete={onDelete}
onEdit={onEdit}
onReply={onReply}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
/>
</>
<TimelineMessageList
activeReplyTargetId={activeReplyTargetId}
currentPubkey={currentPubkey}
highlightedMessageId={highlightedMessageId}
messageFooters={messageFooters}
messages={messages}
onDelete={onDelete}
onEdit={onEdit}
onReply={onReply}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
/>
) : null}
<div aria-hidden className="h-px" ref={bottomAnchorRef} />

View File

@@ -17,6 +17,7 @@ type TimelineMessageListProps = {
activeReplyTargetId?: string | null;
currentPubkey?: string;
highlightedMessageId?: string | null;
messageFooters?: Record<string, React.ReactNode>;
messages: TimelineMessage[];
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
@@ -41,6 +42,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
activeReplyTargetId = null,
currentPubkey,
highlightedMessageId = null,
messageFooters,
messages,
onDelete,
onEdit,
@@ -77,17 +79,21 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
}
if (message.kind === KIND_SYSTEM_MESSAGE) {
const footer = messageFooters?.[message.id] ?? null;
currentDayGroup?.elements.push(
<SystemMessageRow
key={message.id}
message={message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
/>,
<div key={message.id} className="flex flex-col gap-1">
<SystemMessageRow
message={message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
/>
{footer}
</div>,
);
} else if (summary && onReply) {
const footer = messageFooters?.[message.id] ?? null;
currentDayGroup?.elements.push(
<div key={message.id} className="flex flex-col gap-0">
<MessageRow
@@ -114,33 +120,37 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onOpenThread={onReply}
summary={summary}
/>
{footer}
</div>,
);
} else {
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
const isSearchActive = message.id === searchActiveMessageId;
const footer = messageFooters?.[message.id] ?? null;
currentDayGroup?.elements.push(
<MessageRow
key={message.id}
activeReplyTargetId={activeReplyTargetId}
highlighted={message.id === highlightedMessageId || isSearchActive}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
/>,
<div key={message.id} className="flex flex-col gap-1">
<MessageRow
activeReplyTargetId={activeReplyTargetId}
highlighted={message.id === highlightedMessageId || isSearchActive}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
/>
{footer}
</div>,
);
}
}