From f88e5f5ce055c68b348278998017e50b0379f343 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 30 Jun 2026 13:43:55 +0100 Subject: [PATCH] Wire agent conversations into navigation --- desktop/src/app/AppShell.tsx | 108 ++++++-- .../features/channels/ui/ChannelScreen.tsx | 61 ++++- .../channels/useChannelPaneHandlers.ts | 25 +- .../search/ui/SearchPromptPlaceholder.tsx | 159 +---------- .../src/features/sidebar/ui/AppSidebar.tsx | 90 +++++- .../sidebar/ui/CustomChannelSection.tsx | 259 +++++++++++------- .../ui/SidebarAgentConversationChildren.tsx | 112 ++++++++ .../features/sidebar/ui/SidebarSection.tsx | 156 +++++++---- desktop/tests/e2e/messaging.spec.ts | 14 +- 9 files changed, 623 insertions(+), 361 deletions(-) create mode 100644 desktop/src/features/sidebar/ui/SidebarAgentConversationChildren.tsx diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 5c7c8959e..0dcff4095 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -18,6 +18,8 @@ import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotific import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems"; import { useTauriWindowDrag } from "@/app/useTauriWindowDrag"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; +import { AgentConversationScreen } from "@/features/agents/ui/AgentConversationScreen"; +import { useAgentConversationShellState } from "@/features/agents/useAgentConversationShellState"; import { channelsQueryKey, useChannelsQuery, @@ -133,14 +135,13 @@ export function AppShell() { const startupReady = useDeferredStartup(); const identityQuery = useIdentityQuery(); - const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes( - identityQuery.data?.pubkey, - ); - const { starredChannelIds, starChannel, unstarChannel } = useChannelStars( - identityQuery.data?.pubkey, - ); - usePersonaSync(identityQuery.data?.pubkey); useAgentsDataRefresh(); + const currentPubkey = identityQuery.data?.pubkey; + const { mutedChannelIds, muteChannel, unmuteChannel } = + useChannelMutes(currentPubkey); + const { starredChannelIds, starChannel, unstarChannel } = + useChannelStars(currentPubkey); + usePersonaSync(currentPubkey); const profileQuery = useProfileQuery(); const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; useRelayAutoHeal(); @@ -196,6 +197,26 @@ export function AppShell() { ? (channels.find((channel) => channel.id === targetChannelId) ?? null) : null; }, [channels, managedChannelId, selectedChannelId]); + const { + agentConversations, + backToAgentConversationThread: handleBackToAgentConversationThread, + clearSelectedAgentConversation, + hideAgentConversation: handleHideAgentConversation, + openAgentConversation: handleOpenAgentConversation, + selectAgentConversation: handleSelectAgentConversation, + selectedAgentConversation, + selectedAgentConversationChannel, + selectedAgentConversationId, + updateAgentConversationTitle: handleUpdateAgentConversationTitle, + visibleAgentConversations, + } = useAgentConversationShellState({ + channels, + currentPubkey, + goAgents, + goChannel, + selectedView, + workspaceScope: workspacesHook.activeWorkspace?.relayUrl ?? null, + }); const { handleChannelNotification, @@ -421,9 +442,17 @@ export function AppShell() { const handleOpenSearchResult = React.useCallback( (hit: SearchHit) => { + clearSelectedAgentConversation(); void openSearchHit(hit); }, - [openSearchHit], + [clearSelectedAgentConversation, openSearchHit], + ); + const handleSelectChannel = React.useCallback( + (channelId: string) => { + clearSelectedAgentConversation(); + void goChannel(channelId); + }, + [clearSelectedAgentConversation, goChannel], ); // Prevent webview file:/// navigation on file drop outside the composer. @@ -569,12 +598,12 @@ export function AppShell() { {}, - updateAgentConversationTitle: () => {}, + openAgentConversation: handleOpenAgentConversation, + updateAgentConversationTitle: handleUpdateAgentConversationTitle, openCreateChannel: handleOpenCreateChannel, openChannelManagement: (channelId?: string) => { setManagedChannelId( @@ -666,6 +695,7 @@ export function AppShell() {
setIsAddWorkspaceOpen(true)} onUpdateWorkspace={workspacesHook.updateWorkspace} @@ -716,6 +747,7 @@ export function AppShell() { createdChannel.id, name, ); + clearSelectedAgentConversation(); await goChannel(createdChannel.id); void applyAgents(templateId, createdChannel.id); }} @@ -740,6 +772,7 @@ export function AppShell() { createdForum.id, name, ); + clearSelectedAgentConversation(); await goChannel(createdForum.id); void applyAgents(templateId, createdForum.id); }} @@ -753,20 +786,37 @@ export function AppShell() { await openDmMutation.mutateAsync({ pubkeys, }); + clearSelectedAgentConversation(); await goChannel(directMessage.id); }} - onSelectAgents={() => void goAgents()} - onSelectChannel={(channelId) => - void goChannel(channelId) + onSelectAgentConversation={ + handleSelectAgentConversation } + onSelectAgents={() => { + clearSelectedAgentConversation(); + void goAgents(); + }} + onSelectChannel={handleSelectChannel} onOpenSearchResult={handleOpenSearchResult} searchChannels={channels} searchFocusRequest={searchFocusRequest} - onSelectHome={() => void goHome()} - onSelectProjects={() => void goProjects()} - onSelectPulse={() => void goPulse()} + onSelectHome={() => { + clearSelectedAgentConversation(); + void goHome(); + }} + onSelectProjects={() => { + clearSelectedAgentConversation(); + void goProjects(); + }} + onSelectPulse={() => { + clearSelectedAgentConversation(); + void goPulse(); + }} onSelectSettings={handleOpenSettings} - onSelectWorkflows={() => void goWorkflows()} + onSelectWorkflows={() => { + clearSelectedAgentConversation(); + void goWorkflows(); + }} onSetPresenceStatus={(status) => presenceSession.setStatus(status) } @@ -788,6 +838,9 @@ export function AppShell() { : undefined } selectedChannelId={selectedChannelId} + selectedAgentConversationId={ + selectedAgentConversationId + } selectedView={selectedView} unreadChannelIds={unreadChannelIds} unreadChannelCounts={unreadChannelCounts} @@ -808,7 +861,19 @@ export function AppShell() { - + {selectedAgentConversation ? ( + + ) : ( + + )}
@@ -831,11 +896,10 @@ export function AppShell() { onDeleteActiveChannel={() => { setIsChannelManagementOpen(false); setManagedChannelId(null); + clearSelectedAgentConversation(); void goHome({ replace: true }); }} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} + onSelectChannel={handleSelectChannel} /> diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index bfa0b9cc3..67c4929e4 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -4,6 +4,10 @@ import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers"; +import { + buildAgentConversationMarkers, + getHiddenAgentConversationMessageIds, +} from "@/features/agents/agentConversations"; import { useChannelMembersQuery, useJoinChannelMutation, @@ -18,6 +22,10 @@ import { ChannelPane, ForumView, } from "@/features/channels/ui/ChannelScreenLazyViews"; +import { + getDmAutoRouteAgentPubkeys, + getThreadAutoRouteAgentPubkeys, +} from "@/features/channels/ui/ChannelPane.helpers"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; import { useManagedAgentsQuery, @@ -437,6 +445,23 @@ export function ChannelScreen({ : [...currentEvents, event], ); }, []); + const agentConversationMarkers = React.useMemo( + () => buildAgentConversationMarkers(resolvedMessages), + [resolvedMessages], + ); + const unreadTimelineMessages = React.useMemo(() => { + const hiddenMessageIds = getHiddenAgentConversationMessageIds( + timelineMessages, + agentConversationMarkers, + ); + if (hiddenMessageIds.size === 0) { + return timelineMessages; + } + + return timelineMessages.filter( + (message) => !hiddenMessageIds.has(message.id), + ); + }, [agentConversationMarkers, timelineMessages]); const channelFind = useChannelFind({ channelId: activeChannelId, messages: timelineMessages, @@ -459,7 +484,7 @@ export function ChannelScreen({ unreadCount, } = useChannelUnreadState({ activeChannelId, - timelineMessages, + timelineMessages: unreadTimelineMessages, currentPubkey, openThreadHeadId, threadReplyTargetId, @@ -476,6 +501,37 @@ export function ChannelScreen({ timelineMessages.find((message) => message.id === editTargetId) ?? null, [editTargetId, timelineMessages], ); + const routingAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set(agentPubkeys); + for (const [pubkey, profile] of Object.entries(messageProfiles)) { + if (profile?.isAgent) { + pubkeys.add(normalizePubkey(pubkey)); + } + } + return pubkeys; + }, [agentPubkeys, messageProfiles]); + const messageAutoRouteAgentPubkeys = React.useMemo( + () => + getDmAutoRouteAgentPubkeys({ + channel: activeChannel, + currentPubkey, + knownAgentPubkeys: routingAgentPubkeys, + }), + [activeChannel, currentPubkey, routingAgentPubkeys], + ); + const threadAutoRouteAgentPubkeys = React.useMemo(() => { + if (!openThreadHeadMessage) { + return []; + } + + return getThreadAutoRouteAgentPubkeys({ + knownAgentPubkeys: routingAgentPubkeys, + messages: [ + openThreadHeadMessage, + ...threadMessages.map((entry) => entry.message), + ], + }); + }, [openThreadHeadMessage, routingAgentPubkeys, threadMessages]); const { handleCancelEdit, handleCancelThreadReply, @@ -493,6 +549,7 @@ export function ChannelScreen({ deleteMessageMutation, editMessageMutation, editTargetId, + messageAutoRouteAgentPubkeys, expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -505,6 +562,7 @@ export function ChannelScreen({ setOpenThreadHeadId, setThreadReplyTargetId, setThreadScrollTargetId, + threadAutoRouteAgentPubkeys, threadReplyTargetId, toggleReactionMutation, }); @@ -841,6 +899,7 @@ export function ChannelScreen({ ; editMessageMutation: ReturnType; editTargetId: string | null; + messageAutoRouteAgentPubkeys: readonly string[]; expandedThreadReplyIds: ReadonlySet; getFirstReplyIdForMessage: (messageId: string) => string | null; getReplyDescendantIdsForMessage: (messageId: string) => string[]; @@ -53,6 +57,7 @@ export function useChannelPaneHandlers({ setOpenThreadHeadId: (value: string | null) => void; setThreadReplyTargetId: React.Dispatch>; setThreadScrollTargetId: React.Dispatch>; + threadAutoRouteAgentPubkeys: readonly string[]; threadReplyTargetId: string | null; toggleReactionMutation: ReturnType; }) { @@ -69,6 +74,16 @@ export function useChannelPaneHandlers({ const expandedThreadReplyIdsRef = React.useRef(expandedThreadReplyIds); expandedThreadReplyIdsRef.current = expandedThreadReplyIds; + const messageAutoRouteAgentPubkeysRef = React.useRef( + messageAutoRouteAgentPubkeys, + ); + messageAutoRouteAgentPubkeysRef.current = messageAutoRouteAgentPubkeys; + + const threadAutoRouteAgentPubkeysRef = React.useRef( + threadAutoRouteAgentPubkeys, + ); + threadAutoRouteAgentPubkeysRef.current = threadAutoRouteAgentPubkeys; + const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync); sendMutateRef.current = sendMessageMutation.mutateAsync; @@ -227,7 +242,10 @@ export function useChannelPaneHandlers({ ) => { await sendMutateRef.current({ content, - mentionPubkeys, + mentionPubkeys: mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: messageAutoRouteAgentPubkeysRef.current, + mentionPubkeys, + }), mediaTags, }); }, @@ -261,7 +279,10 @@ export function useChannelPaneHandlers({ const sentMessage = await sendMutateRef.current({ content, - mentionPubkeys, + mentionPubkeys: mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: threadAutoRouteAgentPubkeysRef.current, + mentionPubkeys, + }), parentEventId, mediaTags, }); diff --git a/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx b/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx index 801fe7e33..a42f2a4f2 100644 --- a/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx +++ b/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx @@ -1,6 +1,8 @@ -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useReducedMotion } from "motion/react"; import * as React from "react"; +import { AnimatedTextSwap } from "@/shared/ui/AnimatedTextSwap"; + const SEARCH_PROMPT_WORDS = [ "everything", "a channel", @@ -9,91 +11,11 @@ const SEARCH_PROMPT_WORDS = [ "an agent", ] as const; const SEARCH_PROMPT_ROTATION_MS = 3200; -const SEARCH_PROMPT_EASE = [0.22, 1, 0.36, 1] as const; -const SEARCH_PROMPT_EXIT_EASE = [0.64, 0, 0.78, 0] as const; -const SEARCH_PROMPT_ENTER_DURATION_SECONDS = 0.54; -const SEARCH_PROMPT_EXIT_DURATION_SECONDS = 0.32; -const SEARCH_PROMPT_ENTER_STAGGER_SECONDS = 0.014; -const SEARCH_PROMPT_EXIT_STAGGER_SECONDS = 0.008; -const SEARCH_PROMPT_Y_OFFSET = "0.5rem"; -const SEARCH_PROMPT_NEGATIVE_Y_OFFSET = "-0.5rem"; -const SEARCH_PROMPT_BLUR = "0.25rem"; - -const searchPromptPhraseVariants = { - animate: { - transition: { - staggerChildren: SEARCH_PROMPT_ENTER_STAGGER_SECONDS, - }, - }, - exit: { - transition: { - staggerChildren: SEARCH_PROMPT_EXIT_STAGGER_SECONDS, - }, - }, - initial: {}, -}; - -const searchPromptCharacterVariants = { - animate: { - filter: "blur(0)", - opacity: 1, - transition: { - duration: SEARCH_PROMPT_ENTER_DURATION_SECONDS, - ease: SEARCH_PROMPT_EASE, - }, - y: 0, - }, - exit: { - filter: `blur(${SEARCH_PROMPT_BLUR})`, - opacity: 0, - transition: { - duration: SEARCH_PROMPT_EXIT_DURATION_SECONDS, - ease: SEARCH_PROMPT_EXIT_EASE, - }, - y: SEARCH_PROMPT_NEGATIVE_Y_OFFSET, - }, - initial: { - filter: `blur(${SEARCH_PROMPT_BLUR})`, - opacity: 0, - y: SEARCH_PROMPT_Y_OFFSET, - }, -}; - -function getPromptCharacters(value: string) { - const characterCounts = new Map(); - - return [...value].map((character) => { - const occurrence = characterCounts.get(character) ?? 0; - characterCounts.set(character, occurrence + 1); - - return { - character, - key: `${character}-${occurrence}`, - }; - }); -} - -function getPromptEnterTotalSeconds(characterCount: number) { - return ( - SEARCH_PROMPT_ENTER_DURATION_SECONDS + - Math.max(0, characterCount - 1) * SEARCH_PROMPT_ENTER_STAGGER_SECONDS - ); -} export function SearchPromptPlaceholder() { const shouldReduceMotion = useReducedMotion(); const [wordIndex, setWordIndex] = React.useState(0); const activeWord = SEARCH_PROMPT_WORDS[wordIndex]; - const activeCharacters = React.useMemo( - () => getPromptCharacters(activeWord), - [activeWord], - ); - const widthAnimationDurationSeconds = getPromptEnterTotalSeconds( - activeCharacters.length, - ); - const measureRef = React.useRef(null); - const pendingWordWidthRef = React.useRef(null); - const [wordWidth, setWordWidth] = React.useState(null); React.useEffect(() => { if (shouldReduceMotion) { @@ -110,31 +32,6 @@ export function SearchPromptPlaceholder() { return () => window.clearInterval(intervalId); }, [shouldReduceMotion]); - React.useLayoutEffect(() => { - if (shouldReduceMotion || activeWord.length === 0) { - return; - } - - const width = measureRef.current?.getBoundingClientRect().width; - if (typeof width === "number" && Number.isFinite(width)) { - if (wordWidth === null) { - setWordWidth(width); - } else { - pendingWordWidthRef.current = width; - } - } - }, [activeWord, shouldReduceMotion, wordWidth]); - - const handleWordExitComplete = React.useCallback(() => { - const nextWidth = pendingWordWidthRef.current; - if (nextWidth === null) { - return; - } - - pendingWordWidthRef.current = null; - setWordWidth(nextWidth); - }, []); - if (shouldReduceMotion) { return ( Search for  - - everything - - - - - + ); } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 46293a3d4..cd820c8ff 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -6,6 +6,7 @@ import { FeatureGate } from "@/shared/features"; import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd"; import type { Workspace } from "@/features/workspaces/types"; +import type { AgentConversation } from "@/features/agents/agentConversations"; import { AddWorkspaceDialog } from "@/features/workspaces/ui/AddWorkspaceDialog"; import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup"; import { @@ -75,6 +76,7 @@ type CreateChannelKind = "stream" | "forum"; type AppSidebarProps = { activeWorkspace: Workspace | null; + agentConversations?: AgentConversation[]; channels: Channel[]; currentPubkey?: string; fallbackDisplayName?: string; @@ -87,6 +89,7 @@ type AppSidebarProps = { profile?: Profile; selfPresenceStatus: PresenceStatus; errorMessage?: string; + selectedAgentConversationId?: string | null; selectedChannelId: string | null; selectedView: | "home" @@ -115,6 +118,7 @@ type AppSidebarProps = { templateId?: string; }) => Promise; onOpenAddWorkspace: () => void; + onHideAgentConversation?: (conversationId: string) => void; onHideDm: (channelId: string) => void; onMarkChannelUnread: (channelId: string) => void; onMarkChannelRead: ( @@ -130,6 +134,7 @@ type AppSidebarProps = { ) => void; onRemoveWorkspace: (id: string) => void; onCreateAgent: () => void; + onSelectAgentConversation?: (conversationId: string) => void; onSelectAgents: () => void; onSelectProjects: () => void; onSelectPulse: () => void; @@ -165,6 +170,7 @@ type AppSidebarProps = { export function AppSidebar({ activeWorkspace, + agentConversations = [], channels, currentPubkey, fallbackDisplayName, @@ -177,6 +183,7 @@ export function AppSidebar({ profile, selfPresenceStatus, errorMessage, + selectedAgentConversationId, selectedChannelId, selectedView, unreadChannelCounts, @@ -187,6 +194,7 @@ export function AppSidebar({ onCreateChannel, onCreateForum, onOpenAddWorkspace, + onHideAgentConversation, onHideDm, onMarkChannelUnread, onMarkChannelRead, @@ -196,6 +204,7 @@ export function AppSidebar({ onUpdateWorkspace, onRemoveWorkspace, onCreateAgent, + onSelectAgentConversation, onSelectAgents, onSelectProjects, onSelectPulse, @@ -461,6 +470,21 @@ export function AppSidebar({ () => sortDmChannelsByLabel(directMessages, dmChannelLabels), [directMessages, dmChannelLabels], ); + const agentConversationsByChannelId = React.useMemo(() => { + const byChannelId = new Map(); + + for (const conversation of agentConversations) { + const channelConversations = + byChannelId.get(conversation.channelId) ?? []; + channelConversations.push(conversation); + byChannelId.set(conversation.channelId, channelConversations); + } + + return byChannelId; + }, [agentConversations]); + const isAgentConversationActive = selectedView === "agents"; + const displayUnreadChannelIds = unreadChannelIds; + const displayUnreadChannelCounts = unreadChannelCounts; const sidebarLoadingShape = useSidebarLoadingShape({ activeWorkspaceId: activeWorkspace?.id, currentPubkey, @@ -478,7 +502,10 @@ export function AppSidebar({ scrollToNextBelow, unreadAboveCount, unreadBelowCount, - } = useUnreadOverflow({ scrollRef, unreadChannelIds }); + } = useUnreadOverflow({ + scrollRef, + unreadChannelIds: displayUnreadChannelIds, + }); const isCreatingAny = createDialogKind === "stream" @@ -569,10 +596,14 @@ export function AppSidebar({ <> {starredChannels.length > 0 ? ( - unreadChannelIds.has(c.id), + displayUnreadChannelIds.has(c.id), )} + isAgentConversationActive={isAgentConversationActive} isCollapsed={collapsedGroups.starred} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} @@ -583,14 +614,17 @@ export function AppSidebar({ onMarkChannelRead(channel.id, channel.lastMessageAt); } }} + onHideAgentConversation={onHideAgentConversation} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} + onSelectAgentConversation={onSelectAgentConversation} onSelectChannel={onSelectChannel} onToggleCollapsed={() => toggleCollapsedGroup("starred")} selectedChannelId={selectedChannelId} + selectedAgentConversationId={selectedAgentConversationId} title="Starred" - unreadChannelCounts={unreadChannelCounts} - unreadChannelIds={unreadChannelIds} + unreadChannelCounts={displayUnreadChannelCounts} + unreadChannelIds={displayUnreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} onUnmuteChannel={onUnmuteChannel} @@ -610,20 +644,25 @@ export function AppSidebar({ > {channelSections.map((section, idx) => ( - unreadChannelIds.has(c.id), + displayUnreadChannelIds.has(c.id), ) ?? false } + isAgentConversationActive={isAgentConversationActive} isCollapsed={collapsedSections[section.id] ?? false} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} selectedChannelId={selectedChannelId} - unreadChannelCounts={unreadChannelCounts} - unreadChannelIds={unreadChannelIds} + selectedAgentConversationId={selectedAgentConversationId} + unreadChannelCounts={displayUnreadChannelCounts} + unreadChannelIds={displayUnreadChannelIds} sections={channelSections} assignments={channelAssignments} isFirst={idx === 0} @@ -631,7 +670,9 @@ export function AppSidebar({ onToggleCollapsed={() => toggleCollapsedSection(section.id) } + onHideAgentConversation={onHideAgentConversation} onSelectChannel={onSelectChannel} + onSelectAgentConversation={onSelectAgentConversation} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} onMarkSectionRead={() => { @@ -658,10 +699,14 @@ export function AppSidebar({ /> ))} 0} + hasUnread={displayUnreadChannelIds.size > 0} + isAgentConversationActive={isAgentConversationActive} isCollapsed={collapsedGroups.channels} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} @@ -670,14 +715,17 @@ export function AppSidebar({ onBrowseClick={onBrowseChannels} onCreateClick={() => openCreateDialog("stream")} onMarkAllRead={onMarkAllChannelsRead} + onHideAgentConversation={onHideAgentConversation} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} + onSelectAgentConversation={onSelectAgentConversation} onSelectChannel={onSelectChannel} onToggleCollapsed={() => toggleCollapsedGroup("channels")} selectedChannelId={selectedChannelId} + selectedAgentConversationId={selectedAgentConversationId} title="Channels" - unreadChannelCounts={unreadChannelCounts} - unreadChannelIds={unreadChannelIds} + unreadChannelCounts={displayUnreadChannelCounts} + unreadChannelIds={displayUnreadChannelIds} sections={channelSections} assignments={channelAssignments} onAssignChannel={assignChannel} @@ -694,8 +742,12 @@ export function AppSidebar({ 0} + hasUnread={displayUnreadChannelIds.size > 0} + isAgentConversationActive={isAgentConversationActive} isCollapsed={collapsedGroups.forums} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} @@ -703,14 +755,17 @@ export function AppSidebar({ listTestId="forum-list" onCreateClick={() => openCreateDialog("forum")} onMarkAllRead={onMarkAllChannelsRead} + onHideAgentConversation={onHideAgentConversation} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} + onSelectAgentConversation={onSelectAgentConversation} onSelectChannel={onSelectChannel} onToggleCollapsed={() => toggleCollapsedGroup("forums")} selectedChannelId={selectedChannelId} + selectedAgentConversationId={selectedAgentConversationId} title="Forums" - unreadChannelCounts={unreadChannelCounts} - unreadChannelIds={unreadChannelIds} + unreadChannelCounts={displayUnreadChannelCounts} + unreadChannelIds={displayUnreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} onUnmuteChannel={onUnmuteChannel} @@ -734,25 +789,30 @@ export function AppSidebar({ } + agentConversationsByChannelId={agentConversationsByChannelId} dmParticipantsByChannelId={dmParticipantsByChannelId} isCollapsed={collapsedGroups.directMessages} + isAgentConversationActive={isAgentConversationActive} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={sortedDirectMessages} channelLabels={dmChannelLabels} + onHideAgentConversation={onHideAgentConversation} onHideDm={onHideDm} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} + onSelectAgentConversation={onSelectAgentConversation} onSelectChannel={onSelectChannel} onToggleCollapsed={() => toggleCollapsedGroup("directMessages") } presenceByChannelId={dmPresenceByChannelId} + selectedAgentConversationId={selectedAgentConversationId} selectedChannelId={selectedChannelId} testId="dm-list" title="Direct messages" - unreadChannelCounts={unreadChannelCounts} - unreadChannelIds={unreadChannelIds} + unreadChannelCounts={displayUnreadChannelCounts} + unreadChannelIds={displayUnreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} onUnmuteChannel={onUnmuteChannel} diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index fe15cfce3..b355a5e4f 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -18,6 +18,7 @@ import { StarOff, Trash2, } from "lucide-react"; +import { Fragment } from "react"; import { toast } from "sonner"; @@ -38,6 +39,7 @@ import { SidebarMenu, SidebarMenuItem, } from "@/shared/ui/sidebar"; +import type { AgentConversation } from "@/features/agents/agentConversations"; import { ChannelMenuButton } from "@/features/sidebar/ui/SidebarSection"; import { DraggableChannelRow, @@ -45,6 +47,7 @@ import { DroppableUngroupedBody, SortableSectionShell, } from "@/features/sidebar/ui/SidebarDnd"; +import { SidebarAgentConversationChildren } from "@/features/sidebar/ui/SidebarAgentConversationChildren"; import { SECTION_ACTION_VISIBILITY_CLASS, SECTION_ICON_BUTTON_CLASS, @@ -322,6 +325,7 @@ function SectionHeaderActions({ } export function ChannelGroupSection({ + agentConversationsByChannelId, browseAriaLabel, createAriaLabel, draggable, @@ -330,6 +334,7 @@ export function ChannelGroupSection({ isCollapsed, isActiveChannel, activeWorkingByChannelId, + isAgentConversationActive, items, listTestId, onBrowseClick, @@ -337,9 +342,12 @@ export function ChannelGroupSection({ onMarkAllRead, onMarkChannelRead, onMarkChannelUnread, + onHideAgentConversation, + onSelectAgentConversation, onSelectChannel, onToggleCollapsed, selectedChannelId, + selectedAgentConversationId, title, unreadChannelCounts, unreadChannelIds, @@ -356,6 +364,10 @@ export function ChannelGroupSection({ onUnstarChannel, onLeaveChannel, }: { + agentConversationsByChannelId?: ReadonlyMap< + string, + readonly AgentConversation[] + >; browseAriaLabel?: string; createAriaLabel: string; draggable?: boolean; @@ -363,6 +375,7 @@ export function ChannelGroupSection({ isCollapsed: boolean; isActiveChannel: boolean; activeWorkingByChannelId?: ReadonlyMap; + isAgentConversationActive?: boolean; items: Channel[]; listTestId: string; onBrowseClick?: () => void; @@ -372,9 +385,12 @@ export function ChannelGroupSection({ lastMessageAt: string | null | undefined, ) => void; onMarkChannelUnread: (channelId: string) => void; + onHideAgentConversation?: (conversationId: string) => void; + onSelectAgentConversation?: (conversationId: string) => void; onSelectChannel: (channelId: string) => void; onToggleCollapsed: () => void; selectedChannelId: string | null; + selectedAgentConversationId?: string | null; title: string; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; @@ -399,59 +415,75 @@ export function ChannelGroupSection({ items.length > 0 ? ( {items.map((channel) => ( - - - - {draggable ? ( - - - - ) : ( - - )} - - - - - - + + + + +
+ {draggable ? ( + + + + ) : ( + + )} +
+
+
+ + + +
+ +
))}
) : null; @@ -503,13 +535,16 @@ export function ChannelGroupSection({ } export function CustomChannelSection({ + agentConversationsByChannelId, section, channels, hasUnread, isCollapsed, isActiveChannel, activeWorkingByChannelId, + isAgentConversationActive, selectedChannelId, + selectedAgentConversationId, unreadChannelCounts, unreadChannelIds, sections, @@ -521,6 +556,8 @@ export function CustomChannelSection({ onMarkChannelRead, onMarkChannelUnread, onMarkSectionRead, + onHideAgentConversation, + onSelectAgentConversation, onAssignChannel, onUnassignChannel, onCreateSectionForChannel, @@ -536,13 +573,19 @@ export function CustomChannelSection({ onUnstarChannel, onLeaveChannel, }: { + agentConversationsByChannelId?: ReadonlyMap< + string, + readonly AgentConversation[] + >; section: ChannelSection; channels: Channel[]; hasUnread: boolean; isCollapsed: boolean; isActiveChannel: boolean; activeWorkingByChannelId?: ReadonlyMap; + isAgentConversationActive?: boolean; selectedChannelId: string | null; + selectedAgentConversationId?: string | null; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; sections: ChannelSection[]; @@ -557,6 +600,8 @@ export function CustomChannelSection({ ) => void; onMarkChannelUnread: (channelId: string) => void; onMarkSectionRead: () => void; + onHideAgentConversation?: (conversationId: string) => void; + onSelectAgentConversation?: (conversationId: string) => void; onAssignChannel: (channelId: string, sectionId: string) => void; onUnassignChannel: (channelId: string) => void; onCreateSectionForChannel: (channelId: string) => void; @@ -685,52 +730,68 @@ export function CustomChannelSection({ {channels.length > 0 ? ( {channels.map((channel) => ( - - - - - - - - - - - - + + + + +
+ + + +
+
+
+ + + +
+ +
))}
) : null} diff --git a/desktop/src/features/sidebar/ui/SidebarAgentConversationChildren.tsx b/desktop/src/features/sidebar/ui/SidebarAgentConversationChildren.tsx new file mode 100644 index 000000000..fbbbe8ec7 --- /dev/null +++ b/desktop/src/features/sidebar/ui/SidebarAgentConversationChildren.tsx @@ -0,0 +1,112 @@ +import type { AgentConversation } from "@/features/agents/agentConversations"; +import { cn } from "@/shared/lib/cn"; +import { X } from "lucide-react"; +import * as React from "react"; +import { SidebarMenuButton, SidebarMenuItem } from "@/shared/ui/sidebar"; + +const COLLAPSED_CONVERSATION_LIMIT = 4; + +type SidebarAgentConversationChildrenProps = { + channelId: string; + conversations?: readonly AgentConversation[]; + isConversationViewActive: boolean; + onHideConversation?: (conversationId: string) => void; + onSelectConversation?: (conversationId: string) => void; + selectedConversationId?: string | null; +}; + +export function SidebarAgentConversationChildren({ + channelId, + conversations, + isConversationViewActive, + onHideConversation, + onSelectConversation, + selectedConversationId, +}: SidebarAgentConversationChildrenProps) { + const [isExpanded, setIsExpanded] = React.useState(false); + + if (!conversations || conversations.length === 0) { + return null; + } + + const hasOverflow = conversations.length > COLLAPSED_CONVERSATION_LIMIT; + const visibleConversations = isExpanded + ? conversations + : conversations.slice(0, COLLAPSED_CONVERSATION_LIMIT); + const toggleLabel = isExpanded ? "Show less" : "Show more"; + + return ( + <> + {visibleConversations.map((conversation) => { + const isActive = + isConversationViewActive && + selectedConversationId === conversation.id; + + return ( + +
+ { + event.stopPropagation(); + onSelectConversation?.(conversation.id); + }} + tooltip={conversation.title} + type="button" + > + + {conversation.title} + + + {onHideConversation ? ( + + ) : null} +
+
+ ); + })} + {hasOverflow ? ( + + { + event.stopPropagation(); + setIsExpanded((current) => !current); + }} + tooltip={toggleLabel} + type="button" + > + {toggleLabel} + + + ) : null} + + ); +} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index d435553b4..7fa5d0474 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -1,3 +1,4 @@ +import { Fragment } from "react"; import type * as React from "react"; import { BellOff, @@ -20,11 +21,13 @@ import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurn import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge"; +import type { AgentConversation } from "@/features/agents/agentConversations"; import { DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY, ProfileAvatarWithStatus, scaleProfileAvatarStatusGeometry, } from "@/features/profile/ui/ProfileAvatarWithStatus"; +import { SidebarAgentConversationChildren } from "@/features/sidebar/ui/SidebarAgentConversationChildren"; import type { Channel, PresenceStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; @@ -317,21 +320,26 @@ export function ChannelMenuButton({ export function SidebarSection({ action, activeWorkingByChannelId, + agentConversationsByChannelId, dmParticipantsByChannelId, emptyState, items, channelLabels, isCollapsed, isActiveChannel, + isAgentConversationActive, presenceByChannelId, + selectedAgentConversationId, selectedChannelId, title, testId, unreadChannelCounts, unreadChannelIds, + onHideAgentConversation, onHideDm, onMarkChannelRead, onMarkChannelUnread, + onSelectAgentConversation, onSelectChannel, onToggleCollapsed, mutedChannelIds, @@ -340,13 +348,19 @@ export function SidebarSection({ }: { action?: React.ReactNode; activeWorkingByChannelId?: ReadonlyMap; + agentConversationsByChannelId?: ReadonlyMap< + string, + readonly AgentConversation[] + >; dmParticipantsByChannelId?: Record; emptyState?: React.ReactNode; items: Channel[]; channelLabels?: Record; isCollapsed?: boolean; isActiveChannel: boolean; + isAgentConversationActive?: boolean; presenceByChannelId?: Record; + selectedAgentConversationId?: string | null; selectedChannelId: string | null; title: string; testId: string; @@ -357,7 +371,9 @@ export function SidebarSection({ channelId: string, lastMessageAt: string | null | undefined, ) => void; + onHideAgentConversation?: (conversationId: string) => void; onMarkChannelUnread?: (channelId: string) => void; + onSelectAgentConversation?: (conversationId: string) => void; onSelectChannel: (channelId: string) => void; onToggleCollapsed?: () => void; mutedChannelIds?: ReadonlySet; @@ -409,74 +425,94 @@ export function SidebarSection({ key={onMarkChannelUnread ? undefined : channel.id} className="group/menu-item" > - - {channel.channelType === "dm" && - unreadChannelIds.has(channel.id) && - !(isActiveChannel && selectedChannelId === channel.id) ? ( - + - ) : null} - {channel.channelType === "dm" && onHideDm ? ( - - ) : null} + {channel.channelType === "dm" && + unreadChannelIds.has(channel.id) && + !(isActiveChannel && selectedChannelId === channel.id) ? ( + + ) : null} + {channel.channelType === "dm" && onHideDm ? ( + + ) : null} + ); // The shared menu always renders copy actions, so every row // gets a context menu regardless of read/mute availability. return ( - - {menuItem} - - - - + + + + {menuItem} + + + + + + + ); })} diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 13c3aa6bf..4e01d04dc 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -676,14 +676,10 @@ test("opens a single-level thread panel with inline expansion", async ({ .first(); await expect(nestedReplyFromBobRow).toBeVisible(); - const firstReplySummaryRow = threadReplies.locator( - `[data-testid="message-thread-summary"][data-thread-head-id="${firstReplyId}"]`, - ); - await expect(firstReplySummaryRow).toHaveCount(0); const firstReplyBranchRail = threadReplies.locator( `[data-testid="thread-collapse-rail"][data-thread-head-id="${firstReplyId}"]`, ); - await expect(firstReplyBranchRail).toHaveCount(1); + await expect(firstReplyBranchRail).toHaveCount(0); await expect(rootSummaryRow).toContainText("18 replies"); await expect( @@ -702,18 +698,14 @@ test("opens a single-level thread panel with inline expansion", async ({ .toBe("1,2"); await expectThreadReplyUnobscured(nestedReplyRow); - - await firstReplyBranchRail.click(); - await expect(firstReplySummaryRow).toHaveCount(1); - await expect(firstReplySummaryRow).toContainText("2 replies"); await expect( threadReplies.getByTestId("message-row").filter({ hasText: nestedReply }), - ).toHaveCount(0); + ).toHaveCount(1); await expect( threadReplies .getByTestId("message-row") .filter({ hasText: nestedReplyFromBob }), - ).toHaveCount(0); + ).toHaveCount(1); }); test("thread panel width uses session storage and reset handle", async ({