+
+
+ {isAssistant ? (
- {agentName}
-
- ) : null}
+ ) : null}
+
{label}
+
{isAssistant ? (
@@ -116,8 +110,8 @@ function ThoughtItem({
item: Extract
;
}) {
return (
-
-
+
+
{item.title}
@@ -135,8 +129,8 @@ function MetadataItem({
item: Extract;
}) {
return (
-
-
+
+
{item.title}
@@ -173,7 +167,7 @@ function LifecycleItem({
return (
diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx
index c0179d0c7..f04cac66c 100644
--- a/desktop/src/features/channels/ui/BotActivityBar.tsx
+++ b/desktop/src/features/channels/ui/BotActivityBar.tsx
@@ -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
| 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) => {
+ 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 (
-
- {visibleAgents.map((agent) => {
- const isSelected =
- openAgentSessionPubkey?.toLowerCase() === agent.pubkey.toLowerCase();
- return (
-
-
-
-
-
- {agent.name} is working — click to view activity
-
-
- );
- })}
-
- {hiddenAgents.length > 0 ? (
-
-
-
-
-
-
- More agents working
-
- {hiddenAgents.map((agent) => (
- onOpenAgentSession(agent.pubkey)}
- >
-
- {agent.name}
-
-
- ))}
-
-
- ) : null}
-
- );
-}
-
-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 (
+
+
+
+
+
+ event.preventDefault()}
+ onPointerEnter={openAgentList}
+ onPointerLeave={scheduleCloseAgentList}
+ side={contentSide}
+ sideOffset={8}
+ >
+
+ Active agents
+
+ {typingAgents.map((agent) => (
+
+ ))}
+
+
+
);
-
- return { visibleAgents, hiddenAgents };
}
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index ef60c74cf..927656007 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -256,6 +256,17 @@ export const ChannelPane = React.memo(function ChannelPane({
: "No messages yet"
: "No channel selected"
}
+ conversationFooter={
+ botTypingPubkeys.length > 0 ? (
+
+ ) : 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}
/>
-
-
-
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 0fd3de8dc..5110662ac 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -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(
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx
index c5fe185e1..97e6d528c 100644
--- a/desktop/src/features/messages/ui/MessageRow.tsx
+++ b/desktop/src/features/messages/ui/MessageRow.tsx
@@ -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()}
-
{
- void handleReactionSelect(emoji).catch(() => {
- return;
- });
- }}
- />
{reactionErrorMessage ? (
{reactionErrorMessage}
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 0d1e4774d..c5fa46976 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -19,6 +19,7 @@ type MessageTimelineProps = {
isLoading?: boolean;
emptyTitle?: string;
emptyDescription?: string;
+ conversationFooter?: React.ReactNode;
activeReplyTargetId?: string | null;
currentPubkey?: string;
fetchOlder?: () => Promise;
@@ -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 ? (
+
+ {conversationFooter}
+
+ ) : null}
+