diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts new file mode 100644 index 000000000..087c458f1 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts @@ -0,0 +1,420 @@ +import type { + AgentConversation, + AgentConversationMarker, +} from "@/features/agents/agentConversations"; +import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages"; +import type { + TimelineMessage, + TimelineReaction, +} from "@/features/messages/types"; +import type { ManagedAgent, RelayAgent, RelayEvent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +const AGENT_STATUS_REACTION_EMOJIS = new Set(["👀", "💬"]); +const AGENT_PARTICIPANT_PREVIEW_LIMIT = 3; + +export type AgentConversationParticipant = { + avatarUrl: string | null; + canMessage: boolean; + displayName: string; + pubkey: string; +}; + +type KnownAgentParticipant = { + canMessage: boolean; + displayName: string; + pubkey: string; +}; + +export function uniqueMessages(messages: TimelineMessage[]) { + const byId = new Map(); + for (const message of messages) { + byId.set(message.id, message); + } + return [...byId.values()].sort((a, b) => a.createdAt - b.createdAt); +} + +export function flattenConversationMessages(messages: TimelineMessage[]) { + return messages.map((message) => ({ + ...message, + depth: 0, + parentId: null, + rootId: null, + })); +} + +export function buildAgentConversationTypingScopeIds( + conversation: AgentConversation, + messages: readonly TimelineMessage[], +) { + const ids = new Set([ + conversation.threadRootId, + conversation.agentReply.id, + ]); + + for (const message of messages) { + ids.add(message.id); + } + + return ids; +} + +function getAgentParticipantPreview( + participants: readonly AgentConversationParticipant[], +) { + const visibleParticipants = participants.slice( + 0, + AGENT_PARTICIPANT_PREVIEW_LIMIT, + ); + + return { + hiddenCount: Math.max( + 0, + participants.length - AGENT_PARTICIPANT_PREVIEW_LIMIT, + ), + visibleParticipants, + }; +} + +export function formatAgentParticipantNames( + participants: readonly AgentConversationParticipant[], +) { + const { hiddenCount, visibleParticipants } = + getAgentParticipantPreview(participants); + const names = visibleParticipants.map( + (participant) => participant.displayName, + ); + + return hiddenCount > 0 + ? [...names, `+${hiddenCount} more`].join(", ") + : names.join(", "); +} + +export function isConversationMessage( + message: TimelineMessage, + conversation: AgentConversation, + markers: readonly AgentConversationMarker[] = [], + messages: readonly TimelineMessage[] = [], +) { + if ( + message.id === conversation.threadRootId || + message.id === conversation.parentMessage?.id || + message.id === conversation.agentReply.id + ) { + return true; + } + + const messageThreadRootId = message.rootId ?? message.parentId ?? null; + if (messageThreadRootId !== conversation.threadRootId) { + return false; + } + + const markerAnchorIds = new Set( + markers + .filter( + (marker) => + marker.channelId === conversation.channelId && + marker.threadRootId === conversation.threadRootId && + marker.agentReplyId !== conversation.agentReply.id, + ) + .map((marker) => marker.agentReplyId), + ); + const orderedThreadMessages = + messages.length > 0 + ? messages.filter( + (candidate) => + candidate.id === conversation.threadRootId || + candidate.rootId === conversation.threadRootId || + candidate.parentId === conversation.threadRootId, + ) + : []; + const messageIndexById = new Map( + orderedThreadMessages.map((candidate, index) => [candidate.id, index]), + ); + const anchorIndex = messageIndexById.get(conversation.agentReply.id); + const messageIndex = messageIndexById.get(message.id); + + if (anchorIndex !== undefined && messageIndex !== undefined) { + if (messageIndex < anchorIndex) { + return false; + } + + let nextAnchorIndex = Number.POSITIVE_INFINITY; + for (const marker of markers) { + if ( + marker.channelId !== conversation.channelId || + marker.threadRootId !== conversation.threadRootId || + marker.agentReplyId === conversation.agentReply.id + ) { + continue; + } + + const markerAnchorIndex = messageIndexById.get(marker.agentReplyId); + if ( + markerAnchorIndex !== undefined && + markerAnchorIndex > anchorIndex && + markerAnchorIndex < nextAnchorIndex + ) { + nextAnchorIndex = markerAnchorIndex; + } + } + + if (messageIndex < nextAnchorIndex) { + return true; + } + + const selectedTaskMessageIds = new Set(); + for (const candidate of orderedThreadMessages) { + const candidateIndex = messageIndexById.get(candidate.id); + if ( + candidateIndex !== undefined && + candidateIndex >= anchorIndex && + candidateIndex < nextAnchorIndex + ) { + selectedTaskMessageIds.add(candidate.id); + } + } + selectedTaskMessageIds.delete(conversation.threadRootId); + + const messageById = new Map( + orderedThreadMessages.map((candidate) => [candidate.id, candidate]), + ); + let parentId = message.parentId; + const visited = new Set([message.id]); + while (parentId && !visited.has(parentId)) { + if (selectedTaskMessageIds.has(parentId)) { + return true; + } + if ( + parentId === conversation.threadRootId || + markerAnchorIds.has(parentId) + ) { + return false; + } + + visited.add(parentId); + parentId = messageById.get(parentId)?.parentId ?? null; + } + + return false; + } + + const currentMarker = + markers.find( + (marker) => + marker.channelId === conversation.channelId && + marker.threadRootId === conversation.threadRootId && + marker.agentReplyId === conversation.agentReply.id, + ) ?? null; + const selectedStartedAt = + currentMarker?.startedAt ?? conversation.agentReply.createdAt; + if (message.createdAt < selectedStartedAt) { + return false; + } + + const nextMarkerStartedAt = markers + .filter( + (marker) => + marker.channelId === conversation.channelId && + marker.threadRootId === conversation.threadRootId && + marker.agentReplyId !== conversation.agentReply.id && + marker.startedAt > selectedStartedAt, + ) + .sort((left, right) => left.startedAt - right.startedAt)[0]?.startedAt; + + return ( + nextMarkerStartedAt === undefined || message.createdAt < nextMarkerStartedAt + ); +} + +export function formatAgentMentionList(names: readonly string[]) { + const mentions = names.map((name) => `@${name}`); + + if (mentions.length === 0) { + return "this agent"; + } + + if (mentions.length === 1) { + return mentions[0]; + } + + if (mentions.length === 2) { + return `${mentions[0]} and ${mentions[1]}`; + } + + return `${mentions.slice(0, -1).join(", ")}, and ${ + mentions[mentions.length - 1] + }`; +} + +export function getLatestRelayMessageEvent(events: RelayEvent[]) { + return events.reduce((latest, event) => { + if (!latest || event.created_at > latest.created_at) { + return event; + } + + return latest; + }, null); +} + +function stripAgentStatusReactionUsers( + reaction: TimelineReaction, + agentPubkeys: ReadonlySet, +): TimelineReaction | null { + if (!AGENT_STATUS_REACTION_EMOJIS.has(reaction.emoji)) { + return reaction; + } + + const remainingUsers = reaction.users.filter( + (user) => !agentPubkeys.has(normalizePubkey(user.pubkey)), + ); + const removedCount = reaction.users.length - remainingUsers.length; + if (removedCount <= 0) { + return reaction; + } + + const nextCount = Math.max(0, reaction.count - removedCount); + if (nextCount === 0) { + return null; + } + + return { + ...reaction, + count: nextCount, + users: remainingUsers, + }; +} + +export function stripAgentStatusReactions( + message: TimelineMessage, + agentPubkeys: ReadonlySet, +) { + if (!message.reactions?.length || agentPubkeys.size === 0) { + return message; + } + + let didChange = false; + const reactions = message.reactions + .map((reaction) => { + const nextReaction = stripAgentStatusReactionUsers( + reaction, + agentPubkeys, + ); + if (nextReaction !== reaction) { + didChange = true; + } + return nextReaction; + }) + .filter((reaction): reaction is TimelineReaction => reaction !== null); + + if (!didChange) { + return message; + } + + return { + ...message, + reactions: reactions.length > 0 ? reactions : undefined, + }; +} + +function isRelayAgentMessageable(agent: RelayAgent) { + return agent.respondTo === "anyone"; +} + +export function normalizeRecapTextForComparison( + value: string | null | undefined, +) { + return (value ?? "").replace(/\s+/g, " ").trim().toLocaleLowerCase(); +} + +export function buildKnownAgentParticipants({ + conversation, + managedAgents, + relayAgents, +}: { + conversation: AgentConversation; + managedAgents: ManagedAgent[] | undefined; + relayAgents: RelayAgent[] | undefined; +}) { + const participants = new Map(); + const add = (participant: KnownAgentParticipant) => { + const normalized = normalizePubkey(participant.pubkey); + if (!normalized) { + return; + } + + const current = participants.get(normalized); + participants.set(normalized, { + canMessage: current?.canMessage || participant.canMessage, + displayName: + current?.displayName && current.displayName !== current.pubkey + ? current.displayName + : participant.displayName, + pubkey: current?.pubkey ?? participant.pubkey, + }); + }; + + for (const agent of managedAgents ?? []) { + add({ + canMessage: true, + displayName: agent.name, + pubkey: agent.pubkey, + }); + } + + for (const agent of relayAgents ?? []) { + add({ + canMessage: isRelayAgentMessageable(agent), + displayName: agent.name, + pubkey: agent.pubkey, + }); + } + + if (!participants.has(normalizePubkey(conversation.agentPubkey))) { + add({ + canMessage: true, + displayName: conversation.agentName, + pubkey: conversation.agentPubkey, + }); + } + + return participants; +} + +export function getKnownAgentPubkeysInMessages( + messages: readonly TimelineMessage[], + knownAgents: ReadonlyMap, +) { + const pubkeys: string[] = []; + const add = (pubkey: string | null | undefined) => { + if (!pubkey) { + return; + } + + const normalized = normalizePubkey(pubkey); + if ( + normalized && + knownAgents.has(normalized) && + !pubkeys.some((current) => normalizePubkey(current) === normalized) + ) { + pubkeys.push(knownAgents.get(normalized)?.pubkey ?? pubkey); + } + }; + + for (const message of messages) { + add(message.pubkey); + } + for (const pubkey of collectMessageMentionPubkeys([...messages])) { + add(pubkey); + } + + return pubkeys; +} + +export function collectTimelineMessageAuthorPubkeys( + messages: readonly TimelineMessage[], +) { + return messages + .map((message) => message.pubkey) + .filter((pubkey): pubkey is string => Boolean(pubkey)); +} diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.tsx b/desktop/src/features/agents/ui/AgentConversationScreen.tsx new file mode 100644 index 000000000..5f31411a9 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx @@ -0,0 +1,841 @@ +import * as React from "react"; +import { ArrowLeft, Bot, createLucideIcon } from "lucide-react"; +import { toast } from "sonner"; + +import { + buildAgentConversationMentionPubkeys, + buildAgentConversationMarkers, + buildAgentConversationRecap, + deriveAgentConversationTitle, + getAutoRoutedAgentConversationPubkeys, + type AgentConversation, + publishAgentConversationMarker, +} from "@/features/agents/agentConversations"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { useAppShell } from "@/app/AppShellContext"; +import { ChatHeader } from "@/features/chat/ui/ChatHeader"; +import { + useChannelMessagesQuery, + useChannelSubscription, + useSendMessageMutation, +} from "@/features/messages/hooks"; +import { + collectMessageAuthorPubkeys, + collectMessageMentionPubkeys, + formatTimelineMessages, +} from "@/features/messages/lib/formatTimelineMessages"; +import { + buildKnownAgentParticipants, + buildAgentConversationTypingScopeIds, + collectTimelineMessageAuthorPubkeys, + flattenConversationMessages, + formatAgentMentionList, + formatAgentParticipantNames, + getKnownAgentPubkeysInMessages, + getLatestRelayMessageEvent, + isConversationMessage, + normalizeRecapTextForComparison, + stripAgentStatusReactions, + uniqueMessages, + type AgentConversationParticipant, +} from "./AgentConversationScreen.helpers"; +import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; +import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; +import { useChannelTyping } from "@/features/messages/useChannelTyping"; +import { + MessageAuthorText, + MessageHeaderRow, +} from "@/features/messages/ui/MessageHeader"; +import { MessageComposer } from "@/features/messages/ui/MessageComposer"; +import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { Channel, Identity, Profile } from "@/shared/api/types"; +import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout"; +import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Shimmer } from "@/shared/ui/Shimmer"; +import { Button } from "@/shared/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const Summary = createLucideIcon("Summary", [ + ["path", { d: "M15 4H7", key: "summary-heading" }], + ["path", { d: "m18 16 3 3-3 3", key: "summary-arrow" }], + ["path", { d: "M3 4v13a2 2 0 0 0 2 2h16", key: "summary-page" }], + ["path", { d: "M7 14h7", key: "summary-line-short" }], + ["path", { d: "M7 9h12", key: "summary-line-long" }], +]); + +type AgentConversationScreenProps = { + channel: Channel | null; + conversation: AgentConversation; + currentIdentity?: Identity; + currentProfile?: Profile; + onBackToThread?: (conversation: AgentConversation) => void; +}; + +function AgentThinkingRow({ + agentName, + avatarUrl, +}: { + agentName: string; + avatarUrl: string | null; +}) { + return ( +
+ +
+ + {agentName} + +

+ Thinking... +

+
+
+ ); +} + +export function AgentConversationScreen({ + channel, + conversation, + currentIdentity, + currentProfile, + onBackToThread, +}: AgentConversationScreenProps) { + const screenRef = React.useRef(null); + const timelineScrollRef = React.useRef(null); + const composerWrapperRef = React.useRef(null); + const media = useMediaUpload(); + const messagesQuery = useChannelMessagesQuery(channel); + const managedAgentsQuery = useManagedAgentsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); + useChannelSubscription(channel); + const sendMessageMutation = useSendMessageMutation(channel, currentIdentity); + + const relayMessages = messagesQuery.data ?? []; + const agentConversationMarkers = React.useMemo( + () => buildAgentConversationMarkers(relayMessages), + [relayMessages], + ); + const currentConversationMarker = React.useMemo( + () => + agentConversationMarkers.find( + (marker) => + marker.channelId === conversation.channelId && + marker.agentReplyId === conversation.agentReply.id, + ) ?? null, + [ + agentConversationMarkers, + conversation.agentReply.id, + conversation.channelId, + ], + ); + const { + getMessageReadAt, + isThreadMuted, + markMessageRead, + updateAgentConversationTitle, + } = useAppShell(); + const latestMessageEvent = React.useMemo( + () => getLatestRelayMessageEvent(relayMessages), + [relayMessages], + ); + const typingEntries = useChannelTyping( + channel, + currentIdentity?.pubkey, + latestMessageEvent, + ); + const knownAgentParticipants = React.useMemo( + () => + buildKnownAgentParticipants({ + conversation, + managedAgents: managedAgentsQuery.data, + relayAgents: relayAgentsQuery.data, + }), + [conversation, managedAgentsQuery.data, relayAgentsQuery.data], + ); + const profilePubkeys = React.useMemo( + () => + [ + ...new Set([ + ...collectMessageAuthorPubkeys(relayMessages), + ...collectMessageMentionPubkeys(relayMessages), + ...collectTimelineMessageAuthorPubkeys(conversation.contextMessages), + ...collectMessageMentionPubkeys([...conversation.contextMessages]), + ...typingEntries.map((entry) => entry.pubkey), + conversation.agentPubkey, + currentIdentity?.pubkey ?? "", + ]), + ].filter(Boolean), + [ + conversation.agentPubkey, + conversation.contextMessages, + currentIdentity?.pubkey, + relayMessages, + typingEntries, + ], + ); + const profilesQuery = useUsersBatchQuery(profilePubkeys, { + enabled: profilePubkeys.length > 0, + }); + const profiles = React.useMemo( + () => + mergeCurrentProfileIntoLookup( + profilesQuery.data?.profiles, + currentProfile, + ) ?? {}, + [currentProfile, profilesQuery.data?.profiles], + ); + + const knownAgentPubkeys = React.useMemo( + () => new Set(knownAgentParticipants.keys()), + [knownAgentParticipants], + ); + const conversationSourceMessages = React.useMemo(() => { + if (!channel || relayMessages.length === 0) { + return uniqueMessages( + conversation.contextMessages.length > 0 + ? conversation.contextMessages + : ([ + conversation.threadRootMessage, + conversation.parentMessage, + conversation.agentReply, + ].filter(Boolean) as TimelineMessage[]), + ).map((message) => stripAgentStatusReactions(message, knownAgentPubkeys)); + } + + const formatted = formatTimelineMessages( + relayMessages, + channel, + currentIdentity?.pubkey, + currentProfile?.avatarUrl ?? null, + profiles, + ); + const scoped = formatted.filter((message) => + isConversationMessage( + message, + conversation, + agentConversationMarkers, + formatted, + ), + ); + const sourceMessages = + scoped.length > 0 + ? scoped + : uniqueMessages( + conversation.contextMessages.length > 0 + ? conversation.contextMessages + : ([ + conversation.threadRootMessage, + conversation.parentMessage, + conversation.agentReply, + ].filter(Boolean) as TimelineMessage[]), + ); + + return sourceMessages.map((message) => + stripAgentStatusReactions(message, knownAgentPubkeys), + ); + }, [ + channel, + agentConversationMarkers, + conversation, + currentIdentity?.pubkey, + currentProfile?.avatarUrl, + knownAgentPubkeys, + profiles, + relayMessages, + ]); + const timelineMessages = React.useMemo( + () => flattenConversationMessages(conversationSourceMessages), + [conversationSourceMessages], + ); + + const conversationAgentPubkeys = React.useMemo(() => { + const pubkeys = getKnownAgentPubkeysInMessages( + conversationSourceMessages, + knownAgentParticipants, + ); + if ( + !pubkeys.some( + (pubkey) => + normalizePubkey(pubkey) === normalizePubkey(conversation.agentPubkey), + ) + ) { + pubkeys.unshift(conversation.agentPubkey); + } + + return pubkeys; + }, [ + conversation.agentPubkey, + conversationSourceMessages, + knownAgentParticipants, + ]); + const agentPubkeys = React.useMemo( + () => + new Set( + conversationAgentPubkeys.map((pubkey) => normalizePubkey(pubkey)), + ), + [conversationAgentPubkeys], + ); + const typingScopeIds = React.useMemo( + () => + buildAgentConversationTypingScopeIds( + conversation, + conversationSourceMessages, + ), + [conversation, conversationSourceMessages], + ); + const typingAgentPubkeys = React.useMemo(() => { + const latestMessage = timelineMessages[timelineMessages.length - 1] ?? null; + const latestMessagePubkey = latestMessage?.pubkey + ? normalizePubkey(latestMessage.pubkey) + : null; + const pubkeys: string[] = []; + for (const entry of typingEntries) { + const normalized = normalizePubkey(entry.pubkey); + if ( + entry.threadHeadId == null || + !typingScopeIds.has(entry.threadHeadId) || + !agentPubkeys.has(normalized) || + latestMessagePubkey === normalized || + pubkeys.some((pubkey) => normalizePubkey(pubkey) === normalized) + ) { + continue; + } + + pubkeys.push( + knownAgentParticipants.get(normalized)?.pubkey ?? entry.pubkey, + ); + } + + return pubkeys; + }, [ + agentPubkeys, + knownAgentParticipants, + timelineMessages, + typingScopeIds, + typingEntries, + ]); + const agentParticipants = React.useMemo( + () => + conversationAgentPubkeys.map((pubkey) => { + const normalized = normalizePubkey(pubkey); + const knownAgent = knownAgentParticipants.get(normalized); + const profile = profiles[normalized]; + + return { + avatarUrl: profile?.avatarUrl ?? null, + canMessage: knownAgent?.canMessage ?? true, + displayName: + profile?.displayName?.trim() || + knownAgent?.displayName || + (normalized === normalizePubkey(conversation.agentPubkey) + ? conversation.agentName + : pubkey), + pubkey: knownAgent?.pubkey ?? pubkey, + }; + }), + [ + conversation.agentName, + conversation.agentPubkey, + conversationAgentPubkeys, + knownAgentParticipants, + profiles, + ], + ); + const typingAgentParticipants = React.useMemo( + () => + typingAgentPubkeys + .map((pubkey) => { + const normalized = normalizePubkey(pubkey); + return agentParticipants.find( + (participant) => normalizePubkey(participant.pubkey) === normalized, + ); + }) + .filter( + (participant): participant is AgentConversationParticipant => + participant != null, + ), + [agentParticipants, typingAgentPubkeys], + ); + const participantSubtitle = React.useMemo( + () => formatAgentParticipantNames(agentParticipants), + [agentParticipants], + ); + const lastTitlePublishKeyRef = React.useRef(null); + React.useEffect(() => { + const threadRootMessage = + conversationSourceMessages.find( + (message) => message.id === conversation.threadRootId, + ) ?? + conversation.threadRootMessage ?? + null; + const parentMessage = + conversation.agentReply.parentId != null + ? (conversationSourceMessages.find( + (message) => message.id === conversation.agentReply.parentId, + ) ?? + conversation.parentMessage ?? + null) + : (conversation.parentMessage ?? null); + const derivedTitle = deriveAgentConversationTitle({ + agentPubkey: conversation.agentPubkey, + agentReply: conversation.agentReply, + contextMessages: conversationSourceMessages, + parentMessage, + threadRootId: conversation.threadRootId, + threadRootMessage, + }); + + if (derivedTitle.status !== "resolved") { + return; + } + if ( + conversation.titleStatus === derivedTitle.status && + conversation.title === derivedTitle.title + ) { + return; + } + + const latestContextMessage = + conversationSourceMessages[conversationSourceMessages.length - 1] ?? null; + const publishKey = `${conversation.id}:${derivedTitle.status}:${derivedTitle.title}:${latestContextMessage?.id ?? "none"}`; + if (lastTitlePublishKeyRef.current === publishKey) { + return; + } + lastTitlePublishKeyRef.current = publishKey; + + updateAgentConversationTitle( + conversation.id, + derivedTitle.title, + derivedTitle.status, + ); + void publishAgentConversationMarker( + { + agentName: conversation.agentName, + agentPubkey: conversation.agentPubkey, + agentReply: conversation.agentReply, + channel: { + id: conversation.channelId, + name: conversation.channelName, + }, + contextMessages: conversationSourceMessages, + parentMessage, + threadRootMessage, + }, + { + startedAt: currentConversationMarker?.startedAt ?? null, + summary: currentConversationMarker?.summary ?? null, + summaryAuthorName: currentConversationMarker?.summaryAuthorName ?? null, + summaryAuthorPubkey: + currentConversationMarker?.summaryAuthorPubkey ?? null, + summaryCreatedAt: currentConversationMarker?.summaryCreatedAt ?? null, + }, + ).catch((error) => { + console.warn("[agentConversations] title marker publish failed:", error); + }); + }, [ + conversation, + conversationSourceMessages, + currentConversationMarker?.startedAt, + currentConversationMarker?.summary, + currentConversationMarker?.summaryAuthorName, + currentConversationMarker?.summaryAuthorPubkey, + currentConversationMarker?.summaryCreatedAt, + updateAgentConversationTitle, + ]); + React.useEffect(() => { + if (isThreadMuted(conversation.threadRootId)) { + return; + } + + for (const message of timelineMessages) { + const readAt = getMessageReadAt(message.id); + if (readAt === null || readAt < message.createdAt) { + markMessageRead(message.id, message.createdAt); + } + } + }, [ + conversation.threadRootId, + getMessageReadAt, + isThreadMuted, + markMessageRead, + timelineMessages, + ]); + const replyParentEventId = React.useMemo(() => { + const latestTaskMessage = [...timelineMessages] + .reverse() + .find((message) => message.id !== conversation.threadRootId); + + return ( + latestTaskMessage?.id ?? + conversation.agentReply.id ?? + conversation.threadRootId + ); + }, [conversation.agentReply.id, conversation.threadRootId, timelineMessages]); + const routeableAgentPubkeys = React.useMemo( + () => + agentParticipants + .filter((participant) => participant.canMessage) + .map((participant) => participant.pubkey), + [agentParticipants], + ); + const autoRoutedAgentPubkeys = React.useMemo( + () => getAutoRoutedAgentConversationPubkeys(agentParticipants), + [agentParticipants], + ); + const canMessageAnyAgent = routeableAgentPubkeys.length > 0; + const restrictedAgentNames = React.useMemo( + () => + agentParticipants + .filter((participant) => !participant.canMessage) + .map((participant) => participant.displayName), + [agentParticipants], + ); + const restrictedAgentLabel = React.useMemo( + () => formatAgentMentionList(restrictedAgentNames), + [restrictedAgentNames], + ); + const composerPlaceholder = React.useMemo(() => { + if (!canMessageAnyAgent) { + return "Reply to conversation"; + } + if (agentParticipants.length === 1) { + return `Message ${agentParticipants[0]?.displayName ?? "agent"}`; + } + + return "Message conversation"; + }, [agentParticipants, canMessageAnyAgent]); + const emptyDescription = + agentParticipants.length === 1 + ? "Send a message below to keep working with this agent on the topic." + : "Send a message below to keep working with these agents on the topic."; + const [isPublishingThreadSummary, setIsPublishingThreadSummary] = + React.useState(false); + const lastPublishedThreadRecapRef = React.useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: reset the cached recap when switching conversations. + React.useEffect(() => { + lastPublishedThreadRecapRef.current = null; + }, [conversation.id]); + const generatedThreadRecap = React.useMemo( + () => + buildAgentConversationRecap({ + agentPubkeys, + conversationTitle: conversation.title, + messages: timelineMessages, + }), + [agentPubkeys, conversation.title, timelineMessages], + ); + const primaryRecapAgent = agentParticipants[0] ?? null; + const latestPublishedRecap = + currentConversationMarker?.summary ?? + lastPublishedThreadRecapRef.current ?? + null; + const headerChromeRef = useMeasuredCssVariable({ + targetRef: screenRef, + ...channelContentTopPaddingMeasurement, + resetKey: conversation.id, + }); + useComposerHeightPadding( + timelineScrollRef, + composerWrapperRef, + conversation.id, + 16, + ); + + const handleSend = React.useCallback( + async ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => { + await sendMessageMutation.mutateAsync({ + clientTags: [ + ["client", "agent-conversation", conversation.agentReply.id], + ], + content, + mediaTags, + mentionPubkeys: buildAgentConversationMentionPubkeys({ + autoRouteAgentPubkeys: autoRoutedAgentPubkeys, + mentionPubkeys, + }), + parentEventId: replyParentEventId, + }); + }, + [ + autoRoutedAgentPubkeys, + conversation.agentReply.id, + replyParentEventId, + sendMessageMutation, + ], + ); + + const isComposerDisabled = + !channel?.isMember || + channel.archivedAt !== null || + sendMessageMutation.isPending; + const canSendThreadSummary = + Boolean(channel?.isMember) && + channel?.archivedAt === null && + !isPublishingThreadSummary && + generatedThreadRecap !== null; + const markerThreadRootMessage = React.useMemo( + () => + conversationSourceMessages.find( + (message) => message.id === conversation.threadRootId, + ) ?? + conversation.threadRootMessage ?? + null, + [ + conversation.threadRootId, + conversation.threadRootMessage, + conversationSourceMessages, + ], + ); + const markerParentMessage = React.useMemo(() => { + if (conversation.agentReply.parentId == null) { + return conversation.parentMessage ?? null; + } + + return ( + conversationSourceMessages.find( + (message) => message.id === conversation.agentReply.parentId, + ) ?? + conversation.parentMessage ?? + null + ); + }, [ + conversation.agentReply.parentId, + conversation.parentMessage, + conversationSourceMessages, + ]); + const handleSendSummaryToThread = React.useCallback(async () => { + if (!canSendThreadSummary || !generatedThreadRecap) { + return; + } + + const nextRecap = generatedThreadRecap.trim(); + if ( + normalizeRecapTextForComparison(nextRecap) === + normalizeRecapTextForComparison(latestPublishedRecap) + ) { + toast.info("Recap is already up to date"); + return; + } + + setIsPublishingThreadSummary(true); + try { + await publishAgentConversationMarker( + { + agentName: conversation.agentName, + agentPubkey: conversation.agentPubkey, + agentReply: conversation.agentReply, + channel: { + id: conversation.channelId, + name: conversation.channelName, + }, + contextMessages: conversationSourceMessages, + parentMessage: markerParentMessage, + threadRootMessage: markerThreadRootMessage, + }, + { + startedAt: currentConversationMarker?.startedAt ?? null, + summary: nextRecap, + summaryAuthorName: + primaryRecapAgent?.displayName ?? conversation.agentName, + summaryAuthorPubkey: + primaryRecapAgent?.pubkey ?? conversation.agentPubkey, + summaryCreatedAt: Math.floor(Date.now() / 1_000), + }, + ); + lastPublishedThreadRecapRef.current = nextRecap; + toast.success( + latestPublishedRecap + ? "Updated recap in thread" + : "Added recap to thread", + ); + } catch (error) { + console.error("[agentConversations] failed to publish recap:", error); + toast.error("Failed to add recap to thread"); + } finally { + setIsPublishingThreadSummary(false); + } + }, [ + canSendThreadSummary, + conversation.agentName, + conversation.agentPubkey, + conversation.agentReply, + conversation.channelId, + conversation.channelName, + conversationSourceMessages, + currentConversationMarker?.startedAt, + generatedThreadRecap, + latestPublishedRecap, + markerThreadRootMessage, + markerParentMessage, + primaryRecapAgent?.displayName, + primaryRecapAgent?.pubkey, + ]); + const headerActions = ( + + + + + + Add a conversation recap to the original thread + + + ); + const headerLeadingContent = onBackToThread ? ( + + + + + Back to source thread + + ) : ( + false + ); + + return ( +
+ + + , + }} + channelName={channel?.name ?? conversation.channelName} + channelType={channel?.channelType ?? "stream"} + contentTopPadding="chrome" + currentPubkey={currentIdentity?.pubkey} + emptyDescription={emptyDescription} + emptyTitle="No conversation messages yet" + hasComposerOverlay + isLoading={messagesQuery.isLoading && timelineMessages.length === 0} + layoutShiftKey={conversation.id} + messageListPlacement="top" + messages={timelineMessages} + profiles={profiles} + scrollContainerRef={timelineScrollRef} + showInitialDayDivider={false} + trailingContent={ + typingAgentParticipants.length > 0 + ? typingAgentParticipants.map((participant) => ( + + )) + : null + } + /> + +
+
+ +

+ You can view and reply to this conversation. +

+

+ You can't message{" "} + + {restrictedAgentLabel} + + . +

+
+ ) + } + profiles={profiles} + showTopBorder={false} + typingParentEventId={conversation.threadRootId} + typingRootEventId={conversation.threadRootId} + /> +
+
+
+
+ ); +} diff --git a/desktop/src/features/agents/useAgentConversationShellState.ts b/desktop/src/features/agents/useAgentConversationShellState.ts new file mode 100644 index 000000000..fdfeca685 --- /dev/null +++ b/desktop/src/features/agents/useAgentConversationShellState.ts @@ -0,0 +1,261 @@ +import * as React from "react"; + +import type { Channel } from "@/shared/api/types"; +import { + buildAgentConversation, + type AgentConversation, + type AgentConversationTitleStatus, + type OpenAgentConversationInput, + publishAgentConversationMarker, + readHiddenAgentConversationIds, + readPersistedAgentConversations, + writeHiddenAgentConversationIds, + writePersistedAgentConversations, +} from "./agentConversations"; + +type GoAgents = () => Promise; +type GoChannel = ( + channelId: string, + options?: { + messageId?: string; + replace?: boolean; + taskReplyId?: string; + threadRootId?: string | null; + }, +) => Promise; + +type AgentConversationShellStateInput = { + channels: readonly Channel[]; + currentPubkey?: string; + enabled?: boolean; + goAgents: GoAgents; + goChannel: GoChannel; + selectedView: string; + workspaceScope?: string | null; +}; + +export function useAgentConversationShellState({ + channels, + currentPubkey, + enabled = true, + goAgents, + goChannel, + selectedView, + workspaceScope, +}: AgentConversationShellStateInput) { + const [agentConversations, setAgentConversations] = React.useState< + AgentConversation[] + >([]); + const [hiddenAgentConversationIds, setHiddenAgentConversationIds] = + React.useState>(() => new Set()); + const [agentConversationStorageKey, setAgentConversationStorageKey] = + React.useState(null); + const [selectedAgentConversationId, setSelectedAgentConversationId] = + React.useState(null); + const activeStorageKey = + currentPubkey && workspaceScope + ? `${workspaceScope}:${currentPubkey}` + : null; + + React.useEffect(() => { + if (!currentPubkey || !workspaceScope) { + setAgentConversations([]); + setHiddenAgentConversationIds(new Set()); + setAgentConversationStorageKey(null); + return; + } + + setAgentConversations( + readPersistedAgentConversations(currentPubkey, workspaceScope), + ); + setHiddenAgentConversationIds( + readHiddenAgentConversationIds(currentPubkey, workspaceScope), + ); + setAgentConversationStorageKey(activeStorageKey); + }, [activeStorageKey, currentPubkey, workspaceScope]); + + React.useEffect(() => { + if ( + !currentPubkey || + !workspaceScope || + agentConversationStorageKey !== activeStorageKey + ) { + return; + } + + writePersistedAgentConversations( + currentPubkey, + workspaceScope, + agentConversations, + ); + }, [ + activeStorageKey, + agentConversationStorageKey, + agentConversations, + currentPubkey, + workspaceScope, + ]); + + React.useEffect(() => { + if (!enabled) { + setSelectedAgentConversationId(null); + } + }, [enabled]); + + const selectedAgentConversation = + enabled && selectedView === "agents" && selectedAgentConversationId + ? (agentConversations.find( + (conversation) => conversation.id === selectedAgentConversationId, + ) ?? null) + : null; + + const visibleAgentConversations = React.useMemo( + () => + enabled + ? agentConversations.filter( + (conversation) => !hiddenAgentConversationIds.has(conversation.id), + ) + : [], + [agentConversations, enabled, hiddenAgentConversationIds], + ); + + const selectedAgentConversationChannel = selectedAgentConversation + ? (channels.find( + (channel) => channel.id === selectedAgentConversation.channelId, + ) ?? null) + : null; + + const clearSelectedAgentConversation = React.useCallback(() => { + setSelectedAgentConversationId(null); + }, []); + + const openAgentConversation = React.useCallback( + ( + input: OpenAgentConversationInput, + options?: { publishMarker?: boolean }, + ) => { + if (!enabled) { + return; + } + + const conversation = buildAgentConversation(input); + if (options?.publishMarker !== false) { + void publishAgentConversationMarker(input).catch((error) => { + console.warn("[agentConversations] marker publish failed:", error); + }); + } + if (currentPubkey && workspaceScope) { + setHiddenAgentConversationIds((current) => { + if (!current.has(conversation.id)) { + return current; + } + + const next = new Set(current); + next.delete(conversation.id); + writeHiddenAgentConversationIds(currentPubkey, workspaceScope, next); + return next; + }); + } + setAgentConversations((current) => { + const existingIndex = current.findIndex( + (item) => item.id === conversation.id, + ); + if (existingIndex < 0) { + return [conversation, ...current]; + } + + const next = [...current]; + next.splice(existingIndex, 1); + return [conversation, ...next]; + }); + setSelectedAgentConversationId(conversation.id); + void goAgents(); + }, + [currentPubkey, enabled, goAgents, workspaceScope], + ); + + const updateAgentConversationTitle = React.useCallback( + ( + conversationId: string, + title: string, + titleStatus: AgentConversationTitleStatus, + ) => { + setAgentConversations((current) => + current.map((conversation) => + conversation.id === conversationId + ? { ...conversation, title, titleStatus } + : conversation, + ), + ); + }, + [], + ); + + const hideAgentConversation = React.useCallback( + (conversationId: string) => { + const conversation = + agentConversations.find((item) => item.id === conversationId) ?? null; + if (!currentPubkey || !workspaceScope) { + return; + } + + setHiddenAgentConversationIds((current) => { + if (current.has(conversationId)) { + return current; + } + + const next = new Set(current); + next.add(conversationId); + writeHiddenAgentConversationIds(currentPubkey, workspaceScope, next); + return next; + }); + + if (selectedAgentConversationId === conversationId) { + setSelectedAgentConversationId(null); + if (conversation) { + void goChannel(conversation.channelId); + } + } + }, + [ + agentConversations, + currentPubkey, + goChannel, + selectedAgentConversationId, + workspaceScope, + ], + ); + + const selectAgentConversation = React.useCallback( + (conversationId: string) => { + setSelectedAgentConversationId(conversationId); + void goAgents(); + }, + [goAgents], + ); + + const backToAgentConversationThread = React.useCallback( + (conversation: AgentConversation) => { + setSelectedAgentConversationId(null); + void goChannel(conversation.channelId, { + messageId: conversation.agentReply.id, + threadRootId: conversation.threadRootId, + }); + }, + [goChannel], + ); + + return { + agentConversations: enabled ? agentConversations : [], + backToAgentConversationThread, + clearSelectedAgentConversation, + hideAgentConversation, + openAgentConversation, + selectAgentConversation, + selectedAgentConversation, + selectedAgentConversationChannel, + selectedAgentConversationId: enabled ? selectedAgentConversationId : null, + updateAgentConversationTitle, + visibleAgentConversations, + }; +} diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index b20a45188..67968dd18 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -16,24 +16,32 @@ import { toast } from "sonner"; import type { ChannelType, ChannelVisibility } from "@/shared/api/types"; import { UpdateIndicator } from "@/features/settings/UpdateIndicator"; import { cn } from "@/shared/lib/cn"; +import { AnimatedTextSwap } from "@/shared/ui/AnimatedTextSwap"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { Button } from "@/shared/ui/button"; +import { useOptionalSidebar } from "@/shared/ui/sidebar"; type ChatHeaderProps = { actions?: React.ReactNode; + animatedTitle?: boolean; + animatedTitleResetKey?: string; belowSystemChrome?: boolean; + compactTitleStack?: boolean; /** Ref to the outer chrome wrapper when `belowSystemChrome` is true. */ chromeWrapperRef?: React.Ref; title: string; description?: string; channelType?: ChannelType; visibility?: ChannelVisibility; - leadingContent?: React.ReactNode; + leadingContent?: React.ReactNode | false; + leadingContentContainerClassName?: string; + leadingContentLayout?: "inline" | "side"; mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects"; overlaysContent?: boolean; statusBadge?: React.ReactNode; /** Render the chrome wrapper without an individual backdrop when a parent supplies shared blur. */ transparentChrome?: boolean; + subtitle?: string | null; }; const HEADER_ICON_CLASS = "h-4 w-4 text-muted-foreground"; @@ -85,19 +93,29 @@ function ChannelIcon({ export function ChatHeader({ actions, + animatedTitle = false, + animatedTitleResetKey, belowSystemChrome = false, + compactTitleStack = false, chromeWrapperRef, title, description, channelType, visibility, leadingContent, + leadingContentContainerClassName, + leadingContentLayout = "inline", mode = "channel", overlaysContent = false, statusBadge, transparentChrome = false, + subtitle, }: ChatHeaderProps) { const trimmedDescription = description?.trim() ?? ""; + const trimmedSubtitle = subtitle?.trim() ?? ""; + const sidebar = useOptionalSidebar(); + const clearCollapsedTopChromeControls = + belowSystemChrome && sidebar?.state === "collapsed" && !sidebar.isMobile; async function handleCopyTitle() { const value = title.trim(); @@ -111,40 +129,69 @@ export function ChatHeader({ } } + const renderedLeadingContent = + leadingContent === false + ? null + : (leadingContent ?? ( + + )); + const header = (
-
+
+ {renderedLeadingContent && leadingContentLayout === "side" ? ( +
+ {renderedLeadingContent} +
+ ) : null}
-
- {leadingContent ?? ( - - )} -
+ {renderedLeadingContent && leadingContentLayout === "inline" ? ( +
+ {renderedLeadingContent} +
+ ) : null}

- {title} + {animatedTitle ? ( + + ) : ( + title + )}

) : null}
+ {trimmedSubtitle ? ( +

+ {trimmedSubtitle} +

+ ) : null}
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 746f7e1d4..d1ccdef88 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { EditorContent } from "@tiptap/react"; +import { Info } from "lucide-react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; @@ -59,6 +60,7 @@ type MessageComposerProps = { containerClassName?: string; disabled?: boolean; draftKey?: string; + composerNotice?: React.ReactNode; editTarget?: { author: string; body: string; @@ -111,6 +113,7 @@ function MessageComposerImpl({ containerClassName, disabled = false, draftKey, + composerNotice, editTarget = null, isSending = false, onCancelEdit, @@ -831,6 +834,15 @@ function MessageComposerImpl({ onCancelEdit={onCancelEdit} onCancelReply={onCancelReply} /> + {!editTarget && !replyTarget && composerNotice ? ( +
+ +
{composerNotice}
+
+ ) : null}
(); + + return [...value].map((character) => { + const occurrence = characterCounts.get(character) ?? 0; + characterCounts.set(character, occurrence + 1); + + return { + character, + key: `${character}-${occurrence}`, + }; + }); +} + +function getAnimatedTextEnterTotalSeconds(characterCount: number) { + return ( + ANIMATED_TEXT_SWAP_ENTER_DURATION_SECONDS + + Math.max(0, characterCount - 1) * ANIMATED_TEXT_SWAP_ENTER_STAGGER_SECONDS + ); +} + +type AnimatedTextSwapProps = { + ariaHidden?: boolean; + characterTestId?: string; + className?: string; + textClassName?: string; + value: string; +}; + +export function AnimatedTextSwap({ + ariaHidden = false, + characterTestId, + className, + textClassName, + value, +}: AnimatedTextSwapProps) { + const shouldReduceMotion = useReducedMotion(); + const activeCharacters = React.useMemo( + () => getAnimatedTextCharacters(value), + [value], + ); + const widthAnimationDurationSeconds = getAnimatedTextEnterTotalSeconds( + activeCharacters.length, + ); + const measureRef = React.useRef(null); + const pendingTextWidthRef = React.useRef(null); + const [textWidth, setTextWidth] = React.useState(null); + + React.useLayoutEffect(() => { + if (shouldReduceMotion || value.length === 0) { + return; + } + + const width = measureRef.current?.getBoundingClientRect().width; + if (typeof width === "number" && Number.isFinite(width)) { + if (textWidth === null) { + setTextWidth(width); + } else { + pendingTextWidthRef.current = width; + } + } + }, [shouldReduceMotion, textWidth, value]); + + const handleTextExitComplete = React.useCallback(() => { + const nextWidth = pendingTextWidthRef.current; + if (nextWidth === null) { + return; + } + + pendingTextWidthRef.current = null; + setTextWidth(nextWidth); + }, []); + + if (shouldReduceMotion) { + return ( + + {value} + + ); + } + + return ( + + {ariaHidden ? null : {value}} + + + + + + ); +}