mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
Anchor agent activity to messages
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user