diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index e7e272d0a7..b0f8e314e7 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -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],
diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx
index 070526a54f..6d49c0435e 100644
--- a/desktop/src/features/channels/ui/BotActivityBar.tsx
+++ b/desktop/src/features/channels/ui/BotActivityBar.tsx
@@ -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 (
onOpenAgentSession(agent.pubkey)}
type="button"
>
-
+
{agent.name}
@@ -112,7 +122,11 @@ export function BotActivityBar({
key={agent.pubkey}
onClick={() => onOpenAgentSession(agent.pubkey)}
>
-
+
{agent.name}
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index bacec7079e..dc3231ed28 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -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
,
+ 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;
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();
+ 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 = {};
+ for (const [messageId, pubkeys] of botPubkeysByMessageId) {
+ footers[messageId] = (
+
+
+
+ );
+ }
+
+ 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}
/>
@@ -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}
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 4b0f3d6e04..b85a826ce4 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -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({
+
{reactions.map((reaction) => (
Promise;
hasOlderMessages?: boolean;
isFetchingOlder?: boolean;
+ messageFooters?: Record;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map;
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 ? (
- <>
-
- >
+
) : null}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index 17430c44ea..c322d3ae70 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -17,6 +17,7 @@ type TimelineMessageListProps = {
activeReplyTargetId?: string | null;
currentPubkey?: string;
highlightedMessageId?: string | null;
+ messageFooters?: Record;
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(
- ,
+
+
+ {footer}
+
,
);
} else if (summary && onReply) {
+ const footer = messageFooters?.[message.id] ?? null;
currentDayGroup?.elements.push(
+ {footer}
,
);
} else {
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
const isSearchActive = message.id === searchActiveMessageId;
+ const footer = messageFooters?.[message.id] ?? null;
currentDayGroup?.elements.push(
- ,
+
+
+ {footer}
+
,
);
}
}