diff --git a/.source b/.source index 69825514698..791db25903f 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 6982551469882fd2b63c4c1599d4b457f1182f80 +Subproject commit 791db25903f8d8e5ca17b476b678bd770fdfb7c0 diff --git a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts index d084c3fa4d3..9291688bb0e 100644 --- a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts +++ b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts @@ -1,6 +1,14 @@ -import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + forwardRef, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { PinoLogger, shortId } from '@novu/application-generic'; import { + AgentRepository, ConversationActivityRepository, ConversationActivityTypeEnum, ConversationChannel, @@ -10,11 +18,11 @@ import { SubscriberRepository, } from '@novu/dal'; import { AgentEventEnum } from '../../dtos/agent-event.enum'; +import type { ReplyContentDto } from '../../dtos/agent-reply-payload.dto'; import { AgentConfigResolver, ResolvedAgentConfig } from '../../services/agent-config-resolver.service'; import { AgentConversationService } from '../../services/agent-conversation.service'; import { BridgeExecutorService } from '../../services/bridge-executor.service'; import { ChatSdkService } from '../../services/chat-sdk.service'; -import type { ReplyContentDto } from '../../dtos/agent-reply-payload.dto'; import { HandleAgentReplyCommand } from './handle-agent-reply.command'; @Injectable() @@ -23,6 +31,7 @@ export class HandleAgentReply { private readonly conversationRepository: ConversationRepository, private readonly activityRepository: ConversationActivityRepository, private readonly subscriberRepository: SubscriberRepository, + private readonly agentRepository: AgentRepository, @Inject(forwardRef(() => ChatSdkService)) private readonly chatSdkService: ChatSdkService, private readonly bridgeExecutor: BridgeExecutorService, @@ -54,7 +63,16 @@ export class HandleAgentReply { const channel = this.getPrimaryChannel(conversation); if (command.update) { - await this.deliverMessage(command, conversation, channel, command.update, ConversationActivityTypeEnum.UPDATE); + const agentName = await this.resolveValidatedAgentNameForDelivery(command, conversation); + + await this.deliverMessage( + command, + conversation, + channel, + command.update, + ConversationActivityTypeEnum.UPDATE, + agentName + ); return { status: 'update_sent' }; } @@ -65,7 +83,16 @@ export class HandleAgentReply { : null; if (command.reply) { - await this.deliverMessage(command, conversation, channel, command.reply, ConversationActivityTypeEnum.MESSAGE); + const agentName = await this.resolveValidatedAgentNameForDelivery(command, conversation); + + await this.deliverMessage( + command, + conversation, + channel, + command.reply, + ConversationActivityTypeEnum.MESSAGE, + agentName + ); this.removeAckReaction(config!, conversation, channel).catch((err) => { this.logger.warn(err, `[agent:${command.agentIdentifier}] Failed to remove ack reaction`); @@ -83,6 +110,30 @@ export class HandleAgentReply { return { status: 'ok' }; } + private async resolveValidatedAgentNameForDelivery( + command: HandleAgentReplyCommand, + conversation: ConversationEntity + ): Promise { + const agent = await this.agentRepository.findOne( + { + _environmentId: command.environmentId, + _organizationId: command.organizationId, + identifier: command.agentIdentifier, + }, + { _id: 1, name: 1, identifier: 1 } + ); + + if (!agent) { + throw new NotFoundException('Agent not found'); + } + + if (String(agent._id) !== conversation._agentId) { + throw new ForbiddenException('Agent identifier does not match this conversation'); + } + + return agent.name; + } + private getPrimaryChannel(conversation: ConversationEntity): ConversationChannel { const channel = conversation.channels[0]; if (!channel?.serializedThread) { @@ -97,7 +148,8 @@ export class HandleAgentReply { conversation: ConversationEntity, channel: ConversationChannel, content: ReplyContentDto, - type: ConversationActivityTypeEnum + type: ConversationActivityTypeEnum, + agentName?: string ): Promise { const textFallback = this.extractTextFallback(content); @@ -116,8 +168,9 @@ export class HandleAgentReply { integrationId: channel._integrationId, platformThreadId: channel.platformThreadId, agentId: command.agentIdentifier, + senderName: agentName, content: textFallback, - richContent: (content.card || content.files?.length) ? (content as Record) : undefined, + richContent: content.card || content.files?.length ? (content as Record) : undefined, type, environmentId: command.environmentId, organizationId: command.organizationId, @@ -150,7 +203,8 @@ export class HandleAgentReply { signals: HandleAgentReplyCommand['signals'] ): Promise { const metadataSignals = (signals ?? []).filter( - (s): s is Extract[number], { type: 'metadata' }> => s.type === 'metadata' + (s): s is Extract[number], { type: 'metadata' }> => + s.type === 'metadata' ); if (metadataSignals.length) { diff --git a/apps/dashboard/src/api/conversations.ts b/apps/dashboard/src/api/conversations.ts new file mode 100644 index 00000000000..83388423168 --- /dev/null +++ b/apps/dashboard/src/api/conversations.ts @@ -0,0 +1,164 @@ +import { getDateRangeInMs, type IEnvironment } from '@novu/shared'; +import { get } from './api.client'; + +export type ConversationFilters = { + dateRange?: string; + subscriberId?: string; + provider?: string[]; + conversationId?: string; + status?: string; +}; + +export type ParticipantSubscriberData = { + firstName?: string; + lastName?: string; + avatar?: string; + subscriberId: string; +}; + +export type ParticipantAgentData = { + name: string; + identifier: string; +}; + +export type ConversationParticipantDto = { + type: string; + id: string; + subscriber?: ParticipantSubscriberData | null; + agent?: ParticipantAgentData | null; +}; + +export type ConversationChannelDto = { + platform: string; + _integrationId: string; + platformThreadId: string; +}; + +export type ConversationDto = { + _id: string; + identifier: string; + _agentId: string; + participants?: ConversationParticipantDto[]; + channels?: ConversationChannelDto[]; + status: string; + title: string; + metadata: Record; + _environmentId: string; + _organizationId: string; + createdAt: string; + lastActivityAt: string; +}; + +export type ConversationsListResponse = { + data: ConversationDto[]; + page: number; + totalCount: number; + pageSize: number; + hasMore: boolean; +}; + +export function getConversationsList({ + environment, + page, + limit, + filters, + signal, +}: { + environment: IEnvironment; + page: number; + limit: number; + filters?: ConversationFilters; + signal?: AbortSignal; +}): Promise { + const searchParams = new URLSearchParams(); + searchParams.append('page', page.toString()); + searchParams.append('limit', limit.toString()); + + if (filters?.status) { + searchParams.append('status', filters.status); + } + + if (filters?.subscriberId) { + searchParams.append('subscriberId', filters.subscriberId); + } + + if (filters?.dateRange) { + const after = new Date(Date.now() - getDateRangeInMs(filters.dateRange)); + searchParams.append('after', after.toISOString()); + } + + if (filters?.provider?.length) { + for (const p of filters.provider) { + searchParams.append('provider', p); + } + } + + if (filters?.conversationId) { + searchParams.append('conversationId', filters.conversationId); + } + + return get(`/conversations?${searchParams.toString()}`, { + environment, + signal, + }); +} + +export type ConversationActivityDto = { + _id: string; + identifier: string; + _conversationId: string; + type: 'message' | 'update' | 'signal'; + content: string; + platform: string; + _integrationId: string; + platformThreadId: string; + senderType: 'subscriber' | 'platform_user' | 'agent' | 'system'; + senderId: string; + senderName?: string; + platformMessageId?: string; + signalData?: { type: string; payload?: Record }; + _environmentId: string; + _organizationId: string; + createdAt: string; +}; + +export type ConversationActivitiesResponse = { + data: ConversationActivityDto[]; + page: number; + totalCount: number; + pageSize: number; + hasMore: boolean; +}; + +/** `conversationIdentifier` is the public `identifier` field — the API resolves by identifier, not Mongo `_id`. */ +export function getConversation( + conversationIdentifier: string, + environment: IEnvironment +): Promise { + return get(`/conversations/${encodeURIComponent(conversationIdentifier)}`, { + environment, + }); +} + +export function getConversationActivities({ + conversationIdentifier, + environment, + page = 0, + limit = 50, + signal, +}: { + conversationIdentifier: string; + environment: IEnvironment; + page?: number; + limit?: number; + signal?: AbortSignal; +}): Promise { + const searchParams = new URLSearchParams(); + searchParams.append('page', page.toString()); + searchParams.append('limit', limit.toString()); + + return get( + `/conversations/${encodeURIComponent(conversationIdentifier)}/activities?${searchParams.toString()}`, + { environment, signal } + ); +} diff --git a/apps/dashboard/src/components/conversations/constants.ts b/apps/dashboard/src/components/conversations/constants.ts new file mode 100644 index 00000000000..35682372b2f --- /dev/null +++ b/apps/dashboard/src/components/conversations/constants.ts @@ -0,0 +1,14 @@ +import { CONVERSATIONAL_PROVIDERS } from '@novu/shared'; +import { ConversationFiltersData } from '@/types/conversation'; + +export const PROVIDER_OPTIONS = CONVERSATIONAL_PROVIDERS.filter((p) => !p.comingSoon).map((p) => ({ + label: p.displayName, + value: p.providerId, +})); + +export const defaultConversationFilters: ConversationFiltersData = { + dateRange: '24h', + subscriberId: '', + provider: [], + conversationId: '', +} as const; diff --git a/apps/dashboard/src/components/conversations/conversation-detail.tsx b/apps/dashboard/src/components/conversations/conversation-detail.tsx new file mode 100644 index 00000000000..20d1c56fd68 --- /dev/null +++ b/apps/dashboard/src/components/conversations/conversation-detail.tsx @@ -0,0 +1,108 @@ +import { RiArrowDownSLine, RiArrowUpSLine, RiCloseFill } from 'react-icons/ri'; +import { Separator } from '@/components/primitives/separator'; +import { Skeleton } from '@/components/primitives/skeleton'; +import { + useFetchConversation, + useFetchConversationActivities, +} from '@/hooks/use-fetch-conversation-activities'; +import { ConversationOverview } from './conversation-overview'; +import { ConversationTimeline } from './conversation-timeline'; + +type ConversationDetailProps = { + conversationId: string; + onClose?: () => void; + onNavigate?: (direction: 'prev' | 'next') => void; +}; + +export function ConversationDetail({ conversationId, onClose, onNavigate }: ConversationDetailProps) { + const { conversation, isLoading: isConversationLoading } = useFetchConversation(conversationId); + const { activities, totalCount, isLoading: isActivitiesLoading } = + useFetchConversationActivities(conversationId); + + return ( +
+
+ Conversation +
+ {onNavigate && ( + <> + + + + )} + {onNavigate && onClose &&
} + {onClose && ( + + )} +
+
+ +
+ {isConversationLoading ? ( + + ) : conversation ? ( +
+ +
+ ) : null} + + + + +
+
+ ); +} + +function OverviewSkeleton() { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/conversations/conversation-overview.tsx b/apps/dashboard/src/components/conversations/conversation-overview.tsx new file mode 100644 index 00000000000..ca792c38a6a --- /dev/null +++ b/apps/dashboard/src/components/conversations/conversation-overview.tsx @@ -0,0 +1,122 @@ +import { RiRobot2Line } from 'react-icons/ri'; +import { ConversationDto } from '@/api/conversations'; +import { ConversationStatusBadge } from './conversation-status-badge'; + +type ConversationOverviewProps = { + conversation: ConversationDto; +}; + +function formatTimestamp(dateStr: string): string { + const d = new Date(dateStr); + const month = d.toLocaleDateString('en-US', { month: 'short' }); + const day = d.getDate(); + const year = d.getFullYear(); + const time = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); + + return `${month} ${day} ${year} ${time}`; +} + +function MetaRow({ label, children, isLast }: { label: string; children: React.ReactNode; isLast?: boolean }) { + return ( +
+
+ {label} +
{children}
+
+
+ ); +} + +export function ConversationOverview({ conversation }: ConversationOverviewProps) { + const participants = conversation.participants ?? []; + const channels = conversation.channels ?? []; + const subscriber = participants.find((p) => p.type === 'subscriber'); + const agent = participants.find((p) => p.type === 'agent'); + const agentName = agent?.agent?.name ?? agent?.id ?? conversation._agentId ?? 'agent'; + const platforms = [...new Set(channels.map((c) => c.platform))]; + + const sourceRequestId = (conversation.metadata?.sourceRequestId as string) ?? undefined; + + return ( +
+
+
+
+
+
+ + Conversation initiated + +
+
+ + {formatTimestamp(conversation.createdAt)} + +
+ +
+ {sourceRequestId && ( + + {sourceRequestId} ↗ + + )} + + {conversation.identifier} + + + + + {agentName} ↗ + + + +
+ {platforms.map((platform) => ( +
+ {platform} +
+ ))} + {platforms.length === 0 && } +
+
+ + + +
+ +
+
+
+ + {subscriber && (() => { + const sub = subscriber.subscriber; + const displayName = [sub?.firstName, sub?.lastName].filter(Boolean).join(' ') || subscriber.id; + const subscriberId = sub?.subscriberId ?? subscriber.id; + + return ( +
+
+ {sub?.avatar ? ( + + ) : ( +
+ )} +
+
+ {displayName} + {subscriberId} +
+ {subscriberId} +
+
+
+ ); + })()} +
+
+ ); +} diff --git a/apps/dashboard/src/components/conversations/conversation-query-keys.ts b/apps/dashboard/src/components/conversations/conversation-query-keys.ts new file mode 100644 index 00000000000..ef2c7e5e87c --- /dev/null +++ b/apps/dashboard/src/components/conversations/conversation-query-keys.ts @@ -0,0 +1,4 @@ +export const conversationQueryKeys = Object.freeze({ + fetchConversations: 'fetchConversations', + fetchConversation: 'fetchConversation', +}); diff --git a/apps/dashboard/src/components/conversations/conversation-status-badge.tsx b/apps/dashboard/src/components/conversations/conversation-status-badge.tsx new file mode 100644 index 00000000000..95ac2d1ae42 --- /dev/null +++ b/apps/dashboard/src/components/conversations/conversation-status-badge.tsx @@ -0,0 +1,48 @@ +import { cn } from '@/utils/ui'; + +type StatusStyle = { label: string; bgClass: string; textClass: string }; + +const STATUS_CONFIG: Record = { + resolved: { + label: 'RESOLVED', + bgClass: 'bg-success-lighter', + textClass: 'text-success-base', + }, + active: { + label: 'OPEN', + bgClass: 'bg-warning-lighter', + textClass: 'text-warning-base', + }, + failed: { + label: 'FAILED', + bgClass: 'bg-error-lighter', + textClass: 'text-destructive-base', + }, + unknown: { + label: 'UNKNOWN', + bgClass: 'bg-neutral-100', + textClass: 'text-text-soft', + }, +}; + +type ConversationStatusBadgeProps = { + status: string; + className?: string; +}; + +export function ConversationStatusBadge({ status, className }: ConversationStatusBadgeProps) { + const config: StatusStyle = STATUS_CONFIG[status] ?? STATUS_CONFIG.unknown; + + return ( + + {config.label} + + ); +} diff --git a/apps/dashboard/src/components/conversations/conversation-table-row.tsx b/apps/dashboard/src/components/conversations/conversation-table-row.tsx new file mode 100644 index 00000000000..3cf9ea90d15 --- /dev/null +++ b/apps/dashboard/src/components/conversations/conversation-table-row.tsx @@ -0,0 +1,110 @@ +import type { KeyboardEvent } from 'react'; +import { RiCheckboxCircleFill, RiRobot2Line } from 'react-icons/ri'; +import { ConversationDto } from '@/api/conversations'; +import { TableCell, TableRow } from '@/components/primitives/table'; +import { cn } from '@/utils/ui'; +import { ConversationStatusBadge } from './conversation-status-badge'; + +type ConversationTableRowProps = { + conversation: ConversationDto; + isSelected?: boolean; + onClick?: (conversationId: string) => void; +}; + +function getSubscriberLabel(conversation: ConversationDto): string | undefined { + const p = (conversation.participants ?? []).find((p) => p.type === 'subscriber'); + if (!p) return undefined; + + const sub = p.subscriber; + if (sub?.firstName || sub?.lastName) { + return [sub.firstName, sub.lastName].filter(Boolean).join(' '); + } + + return sub?.subscriberId ?? p.id; +} + +function getAgentName(conversation: ConversationDto): string { + const agent = (conversation.participants ?? []).find((p) => p.type === 'agent'); + + return agent?.agent?.name ?? agent?.id ?? conversation._agentId ?? 'agent'; +} + +function formatTimestamp(dateStr: string): string { + const d = new Date(dateStr); + + const month = d.toLocaleDateString('en-US', { month: 'short' }); + const day = d.getDate(); + const year = d.getFullYear(); + const time = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); + + return `${month} ${day} ${year} ${time}`; +} + +export function ConversationTableRow({ conversation, isSelected, onClick }: ConversationTableRowProps) { + const handleClick = () => { + onClick?.(conversation.identifier); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + if (event.key === ' ') { + event.preventDefault(); + } + + handleClick(); + } + }; + + const subscriber = getSubscriberLabel(conversation); + const agentName = getAgentName(conversation); + const isResolved = conversation.status === 'resolved'; + + return ( + + +
+
+
+ + + {conversation.title || 'Untitled conversation'} + +
+ + {formatTimestamp(conversation.lastActivityAt || conversation.createdAt)} + +
+ +
+
+ + {agentName} +
+
+ {subscriber && ( + <> +
+
+ + {subscriber} + +
+ + + )} + +
+
+
+ + + ); +} diff --git a/apps/dashboard/src/components/conversations/conversation-timeline.tsx b/apps/dashboard/src/components/conversations/conversation-timeline.tsx new file mode 100644 index 00000000000..95c61b105f5 --- /dev/null +++ b/apps/dashboard/src/components/conversations/conversation-timeline.tsx @@ -0,0 +1,271 @@ +import { Fragment, useId, useState } from 'react'; +import { + RiCheckboxCircleFill, + RiExpandUpDownLine, + RiReplyLine, + RiRobot2Line, + RiRouteFill, + RiShareForwardLine, +} from 'react-icons/ri'; +import { ConversationActivityDto } from '@/api/conversations'; +import { Skeleton } from '@/components/primitives/skeleton'; +import { cn } from '@/utils/ui'; +import { ConversationStatusBadge } from './conversation-status-badge'; + +type ConversationTimelineProps = { + activities: ConversationActivityDto[]; + isLoading: boolean; + totalCount: number; +}; + +function formatActivityTimestamp(dateStr: string): string { + const d = new Date(dateStr); + const day = String(d.getDate()).padStart(2, '0'); + const month = d.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); + const year = String(d.getFullYear()).slice(2); + const time = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' }); + + return `${day} ${month} ${year}, ${time}`; +} + +function TimelineDivider() { + return ( +
+
+
+ ); +} + +function SenderHeader({ activity }: { activity: ConversationActivityDto }) { + const isAgent = activity.senderType === 'agent'; + const name = activity.senderName ?? activity.senderId; + + return ( +
+
+ +
+
+ {isAgent ? ( + + ) : ( +
+ )} + {name} +
+ {isAgent && ( +
+ +
+ )} +
+ ); +} + +function MessageTimestamp({ activity }: { activity: ConversationActivityDto }) { + const isAgent = activity.senderType === 'agent'; + + return ( +
+
+
+ {isAgent ? ( + + ) : ( + + )} + + {formatActivityTimestamp(activity.createdAt)} + {activity.platform ? ' via' : ''} + +
+ {activity.platform && ( +
+ {activity.platform} + + {activity.platform} + +
+ )} +
+ +
+ ); +} + +function MessageContent({ content }: { content: string }) { + const [expanded, setExpanded] = useState(false); + const contentId = useId(); + const isLong = content.length > 80; + const displayContent = expanded ? content : content.slice(0, 80); + + return ( +
+

+ {displayContent} + {isLong && !expanded && '...'} +

+ {isLong && ( + + )} +
+ ); +} + +function MessageCard({ activity }: { activity: ConversationActivityDto }) { + const isAgent = activity.senderType === 'agent'; + + return ( +
+
+
+ + +
+ {activity.content && } +
+
+ ); +} + +function InlineLogRow({ activity }: { activity: ConversationActivityDto }) { + const isAgentAction = activity.senderType === 'agent' || activity.senderType === 'system'; + const signalType = activity.signalData?.type; + + const icon = signalType === 'trigger' ? ( + + ) : ( + + ); + + return ( +
+ {icon} + {activity.content} + + + {formatActivityTimestamp(activity.createdAt)} + +
+ ); +} + +function ResolvedFooter({ totalCount }: { totalCount: number }) { + return ( +
+
+
+
+ + +
+ + + + {totalCount} +
+
+
+
+ ); +} + +export function ConversationTimeline({ activities, isLoading, totalCount }: ConversationTimelineProps) { + if (isLoading) { + return ( +
+ + + {Array.from({ length: 3 }).map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+ ); + } + + if (activities.length === 0) { + return ( +
+

No activities yet

+
+ ); + } + + const hasResolvedSignal = activities.some( + (a) => a.type === 'signal' && a.signalData?.type === 'resolve' + ); + + return ( +
+
+

Conversation timeline

+

+ Everything that happened in this conversation, in order +

+
+ +
+ {activities.map((activity, index) => ( + + {index > 0 && } + {activity.type === 'message' ? ( + + ) : ( + + )} + + ))} + + {hasResolvedSignal && ( + <> + + + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/conversations/conversations-content.tsx b/apps/dashboard/src/components/conversations/conversations-content.tsx new file mode 100644 index 00000000000..7b52a84a279 --- /dev/null +++ b/apps/dashboard/src/components/conversations/conversations-content.tsx @@ -0,0 +1,138 @@ +/** biome-ignore-all lint/correctness/useUniqueElementIds: expected */ +import { useQueryClient } from '@tanstack/react-query'; +import { AnimatePresence, motion } from 'motion/react'; +import { useCallback, useMemo, useState } from 'react'; +import { conversationQueryKeys } from '@/components/conversations/conversation-query-keys'; +import { ConversationFilters } from '@/components/conversations/conversations-filters'; +import { ConversationsTable } from '@/components/conversations/conversations-table'; +import { ResizablePanel, ResizablePanelGroup } from '@/components/primitives/resizable'; +import { UpdatedAgo } from '@/components/updated-ago'; +import { useEnvironment } from '@/context/environment/hooks'; +import { useConversationUrlState } from '@/hooks/use-conversation-url-state'; +import { cn } from '@/utils/ui'; +import { EmptyTopicsIllustration } from '../topics/empty-topics-illustration'; +import { defaultConversationFilters } from './constants'; +import { ConversationDetail } from './conversation-detail'; + +type ConversationsContentProps = { + className?: string; + contentHeight?: string; +}; + +export function ConversationsContent({ + className, + contentHeight = 'h-[calc(100vh-140px)]', +}: ConversationsContentProps) { + const { conversationItemId, filters, filterValues, handleConversationSelect, handleFiltersChange } = + useConversationUrlState(); + const [showDetailPanel, setShowDetailPanel] = useState(false); + const onListStateChange = useCallback((hasConversations: boolean) => setShowDetailPanel(hasConversations), []); + + const queryClient = useQueryClient(); + const { currentEnvironment } = useEnvironment(); + + const [lastUpdated, setLastUpdated] = useState(new Date()); + + const mergedFilterValues = useMemo( + () => ({ + ...defaultConversationFilters, + ...filterValues, + }), + [filterValues] + ); + + const hasActiveFilters = Object.entries(filters).some(([key, value]) => { + if (key === 'dateRange') return false; + if (Array.isArray(value)) return value.length > 0; + + return !!value; + }); + + const handleClearFilters = () => { + handleFiltersChange({ ...defaultConversationFilters }); + }; + + const hasChanges = useMemo(() => { + return ( + mergedFilterValues.dateRange !== defaultConversationFilters.dateRange || + mergedFilterValues.subscriberId !== '' || + mergedFilterValues.provider.length > 0 || + mergedFilterValues.conversationId !== '' + ); + }, [mergedFilterValues]); + + const handleRefresh = async () => { + await queryClient.invalidateQueries({ queryKey: [conversationQueryKeys.fetchConversations, currentEnvironment?._id] }); + setLastUpdated(new Date()); + }; + + return ( +
+
+ + +
+
+ + + + + + {showDetailPanel && ( + + + + {conversationItemId ? ( + handleConversationSelect('')} + /> + ) : ( +
+ +

+ Nothing to show, +
+ Select a conversation on the left to view details here +

+
+ )} +
+
+
+ )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/conversations/conversations-empty-state.tsx b/apps/dashboard/src/components/conversations/conversations-empty-state.tsx new file mode 100644 index 00000000000..5b62cb881b0 --- /dev/null +++ b/apps/dashboard/src/components/conversations/conversations-empty-state.tsx @@ -0,0 +1,69 @@ +import { AnimatePresence, motion } from 'motion/react'; +import { RiChat3Line, RiCloseCircleLine } from 'react-icons/ri'; +import { Button } from '../primitives/button'; + +type ConversationsEmptyStateProps = { + emptySearchResults?: boolean; + onClearFilters?: () => void; +}; + +export function ConversationsEmptyState({ emptySearchResults, onClearFilters }: ConversationsEmptyStateProps) { + return ( + + + + + + + + +

+ {emptySearchResults ? 'No conversations match that filter' : 'No conversations yet'} +

+

+ {emptySearchResults + ? 'Try adjusting your filters to see more results.' + : 'Conversations will appear here once your agents start interacting with subscribers.'} +

+
+ + {emptySearchResults && onClearFilters && ( + + + + )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/conversations/conversations-filters.tsx b/apps/dashboard/src/components/conversations/conversations-filters.tsx new file mode 100644 index 00000000000..81ed371a63f --- /dev/null +++ b/apps/dashboard/src/components/conversations/conversations-filters.tsx @@ -0,0 +1,145 @@ +import { useOrganization } from '@clerk/clerk-react'; +import { CalendarIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { useDebouncedForm } from '@/hooks/use-debounced-form'; +import { useFetchSubscription } from '@/hooks/use-fetch-subscription'; +import { ConversationFiltersData } from '@/types/conversation'; +import { buildActivityDateFilters } from '@/utils/activityFilters'; +import { cn } from '@/utils/ui'; +import { IS_SELF_HOSTED } from '../../config'; +import { PROVIDER_OPTIONS } from './constants'; +import { Button } from '../primitives/button'; +import { FacetedFormFilter } from '../primitives/form/faceted-filter/facated-form-filter'; +import { Form, FormField, FormItem, FormRoot } from '../primitives/form/form'; + +type ConversationFiltersProps = { + filters: ConversationFiltersData; + showReset?: boolean; + onFiltersChange: (filters: ConversationFiltersData) => void; + onReset?: () => void; + className?: string; +}; + +export function ConversationFilters({ + onFiltersChange, + filters, + onReset, + showReset = false, + className, +}: ConversationFiltersProps) { + const { organization } = useOrganization(); + const { subscription } = useFetchSubscription(); + + const form = useForm({ + values: filters, + defaultValues: filters, + }); + const { watch, setValue } = form; + + useDebouncedForm(watch, onFiltersChange, 400); + + const dateFilterOptions = useMemo(() => { + const missingSubscription = !subscription && !IS_SELF_HOSTED; + + if (!organization || missingSubscription) { + return []; + } + + return buildActivityDateFilters({ + organization, + apiServiceLevel: subscription?.apiServiceLevel, + }); + }, [organization, subscription]); + + const handleReset = () => { + if (onReset) { + onReset(); + } + }; + + return ( +
+ + ( + + setValue('dateRange', values[0])} + icon={CalendarIcon} + /> + + )} + /> + + ( + + setValue('provider', values)} + /> + + )} + /> + + ( + + setValue('conversationId', value)} + placeholder="Search by Conversation ID" + /> + + )} + /> + + ( + + setValue('subscriberId', value)} + placeholder="Search by Subscriber ID" + /> + + )} + /> + + {showReset && ( + + )} + +
+ ); +} diff --git a/apps/dashboard/src/components/conversations/conversations-table.tsx b/apps/dashboard/src/components/conversations/conversations-table.tsx new file mode 100644 index 00000000000..3ebc32cebb5 --- /dev/null +++ b/apps/dashboard/src/components/conversations/conversations-table.tsx @@ -0,0 +1,184 @@ +import { AnimatePresence, motion } from 'motion/react'; +import { useEffect } from 'react'; +import { createSearchParams, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import { ConversationFilters } from '@/api/conversations'; +import { Skeleton } from '@/components/primitives/skeleton'; +import { showErrorToast } from '@/components/primitives/sonner-helpers'; +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from '@/components/primitives/table'; +import { TablePaginationFooter } from '@/components/primitives/table-pagination-footer'; +import { useFetchConversations } from '@/hooks/use-fetch-conversations'; +import { usePersistedPageSize } from '@/hooks/use-persisted-page-size'; +import { parsePageParam } from '@/utils/parse-page-param'; +import { ConversationTableRow } from './conversation-table-row'; +import { ConversationsEmptyState } from './conversations-empty-state'; + +const CONVERSATIONS_TABLE_ID = 'conversations-table'; + +export type ConversationsTableProps = { + selectedConversationId: string | null; + onConversationSelect: (conversationId: string) => void; + filters?: ConversationFilters; + hasActiveFilters: boolean; + onClearFilters: () => void; + onListStateChange?: (hasConversations: boolean) => void; +}; + +export function ConversationsTable({ + selectedConversationId, + onConversationSelect, + filters, + hasActiveFilters, + onClearFilters, + onListStateChange, +}: ConversationsTableProps) { + const [searchParams] = useSearchParams(); + const location = useLocation(); + const navigate = useNavigate(); + const { pageSize, setPageSize } = usePersistedPageSize({ + tableId: CONVERSATIONS_TABLE_ID, + defaultPageSize: 10, + }); + + const page = parsePageParam(searchParams.get('page')); + + const { conversations, hasMore, totalCount, isLoading, error } = useFetchConversations( + { + filters, + page, + limit: pageSize, + }, + { + refetchOnWindowFocus: false, + } + ); + + useEffect(() => { + if (error) { + showErrorToast( + error instanceof Error ? error.message : 'There was an error loading the conversations.', + 'Failed to fetch conversations' + ); + } + }, [error]); + + useEffect(() => { + onListStateChange?.(!isLoading && conversations.length > 0); + }, [isLoading, conversations.length, onListStateChange]); + + function handlePageChange(newPage: number) { + const newParams = createSearchParams({ + ...Object.fromEntries(searchParams), + page: newPage.toString(), + }); + navigate(`${location.pathname}?${newParams}`); + } + + function handlePageSizeChange(newPageSize: number) { + setPageSize(newPageSize); + handlePageChange(0); + } + + return ( + + {!isLoading && conversations.length === 0 ? ( + + + + ) : ( + + } + containerClassname="bg-transparent w-full flex flex-col overflow-y-auto overflow-x-hidden max-h-full rounded-lg border border-neutral-200 bg-white" + > + + + + Activity + + + + + {conversations.map((conversation) => ( + + ))} + + + + + handlePageChange(Math.max(0, page - 1))} + onNextPage={() => handlePageChange(page + 1)} + onPageSizeChange={handlePageSizeChange} + hasPreviousPage={page > 0} + hasNextPage={hasMore} + className="bg-transparent shadow-none" + totalCount={totalCount} + pageSizeOptions={[10, 20, 50]} + /> + + + +
+
+ )} +
+ ); +} + +function SkeletonRow() { + return ( + + +
+
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+
+
+
+ ); +} diff --git a/apps/dashboard/src/hooks/use-conversation-url-state.ts b/apps/dashboard/src/hooks/use-conversation-url-state.ts new file mode 100644 index 00000000000..e1e352fdc3c --- /dev/null +++ b/apps/dashboard/src/hooks/use-conversation-url-state.ts @@ -0,0 +1,101 @@ +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { ConversationFilters } from '@/api/conversations'; +import { DEFAULT_DATE_RANGE } from '@/components/activity/constants'; +import { ConversationFiltersData, ConversationUrlState } from '@/types/conversation'; + +function parseFilters(searchParams: URLSearchParams): ConversationFilters { + const result: ConversationFilters = {}; + + const subscriberId = searchParams.get('subscriberId'); + if (subscriberId) { + result.subscriberId = subscriberId; + } + + const provider = searchParams.get('provider')?.split(',').filter(Boolean); + if (provider?.length) { + result.provider = provider; + } + + const conversationId = searchParams.get('conversationId'); + if (conversationId) { + result.conversationId = conversationId; + } + + const dateRange = searchParams.get('dateRange'); + result.dateRange = dateRange || DEFAULT_DATE_RANGE; + + return result; +} + +function parseFilterValues(searchParams: URLSearchParams): ConversationFiltersData { + return { + dateRange: searchParams.get('dateRange') || DEFAULT_DATE_RANGE, + subscriberId: searchParams.get('subscriberId') || '', + provider: searchParams.get('provider')?.split(',').filter(Boolean) || [], + conversationId: searchParams.get('conversationId') || '', + }; +} + +export function useConversationUrlState(): ConversationUrlState & { + handleConversationSelect: (conversationItemId: string) => void; + handleFiltersChange: (data: ConversationFiltersData) => void; +} { + const [searchParams, setSearchParams] = useSearchParams(); + const conversationItemId = searchParams.get('conversationItemId'); + + const handleConversationSelect = useCallback( + (newConversationItemId: string) => { + const newParams = new URLSearchParams(searchParams); + + if (newConversationItemId === conversationItemId) { + newParams.delete('conversationItemId'); + } else { + newParams.set('conversationItemId', newConversationItemId); + } + + setSearchParams(newParams, { replace: true }); + }, + [conversationItemId, searchParams, setSearchParams] + ); + + const handleFiltersChange = useCallback( + (data: ConversationFiltersData) => { + const newParams = new URLSearchParams(); + + if (conversationItemId) { + newParams.set('conversationItemId', conversationItemId); + } + + if (data.subscriberId) { + newParams.set('subscriberId', data.subscriberId); + } + + if (data.provider?.length) { + newParams.set('provider', data.provider.join(',')); + } + + if (data.conversationId) { + newParams.set('conversationId', data.conversationId); + } + + if (data.dateRange && data.dateRange !== DEFAULT_DATE_RANGE) { + newParams.set('dateRange', data.dateRange); + } + + setSearchParams(newParams, { replace: true }); + }, + [conversationItemId, setSearchParams] + ); + + const filters = useMemo(() => parseFilters(searchParams), [searchParams]); + const filterValues = useMemo(() => parseFilterValues(searchParams), [searchParams]); + + return { + conversationItemId, + filters, + filterValues, + handleConversationSelect, + handleFiltersChange, + }; +} diff --git a/apps/dashboard/src/hooks/use-fetch-conversation-activities.ts b/apps/dashboard/src/hooks/use-fetch-conversation-activities.ts new file mode 100644 index 00000000000..8c0cfd27b6b --- /dev/null +++ b/apps/dashboard/src/hooks/use-fetch-conversation-activities.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query'; +import { + ConversationActivitiesResponse, + ConversationDto, + getConversation, + getConversationActivities, +} from '@/api/conversations'; +import { conversationQueryKeys } from '@/components/conversations/conversation-query-keys'; +import { useEnvironment } from '../context/environment/hooks'; + +export function useFetchConversation(conversationId: string | null) { + const { currentEnvironment } = useEnvironment(); + + const { data, ...rest } = useQuery({ + queryKey: [conversationQueryKeys.fetchConversation, currentEnvironment?._id, conversationId], + queryFn: async () => { + if (!conversationId || !currentEnvironment) { + throw new Error('Missing conversation identifier or environment'); + } + + return getConversation(conversationId, currentEnvironment); + }, + enabled: !!conversationId && !!currentEnvironment, + refetchOnWindowFocus: false, + }); + + return { conversation: data, ...rest }; +} + +export function useFetchConversationActivities(conversationId: string | null) { + const { currentEnvironment } = useEnvironment(); + + const { data, ...rest } = useQuery({ + queryKey: [conversationQueryKeys.fetchConversation, 'activities', currentEnvironment?._id, conversationId], + queryFn: async ({ signal }) => { + if (!conversationId || !currentEnvironment) { + throw new Error('Missing conversation identifier or environment'); + } + + return getConversationActivities({ + conversationIdentifier: conversationId, + environment: currentEnvironment, + signal, + }); + }, + enabled: !!conversationId && !!currentEnvironment, + refetchOnWindowFocus: false, + }); + + return { + activities: data?.data ?? [], + totalCount: data?.totalCount ?? 0, + ...rest, + }; +} diff --git a/apps/dashboard/src/hooks/use-fetch-conversations.ts b/apps/dashboard/src/hooks/use-fetch-conversations.ts new file mode 100644 index 00000000000..93821c2a598 --- /dev/null +++ b/apps/dashboard/src/hooks/use-fetch-conversations.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; +import { ConversationFilters, ConversationsListResponse, getConversationsList } from '@/api/conversations'; +import { conversationQueryKeys } from '@/components/conversations/conversation-query-keys'; +import { useEnvironment } from '../context/environment/hooks'; + +type UseFetchConversationsOptions = { + filters?: ConversationFilters; + page?: number; + limit?: number; +}; + +export function useFetchConversations( + { filters, page = 0, limit = 10 }: UseFetchConversationsOptions = {}, + { + enabled = true, + refetchOnWindowFocus = false, + }: { + enabled?: boolean; + refetchOnWindowFocus?: boolean; + } = {} +) { + const { currentEnvironment } = useEnvironment(); + + const { data, ...rest } = useQuery({ + queryKey: [conversationQueryKeys.fetchConversations, currentEnvironment?._id, page, limit, filters], + queryFn: async ({ signal }) => { + // biome-ignore lint/style/noNonNullAssertion: guarded by `enabled` below + return getConversationsList({ environment: currentEnvironment!, page, limit, filters, signal }); + }, + refetchOnWindowFocus, + enabled: enabled && !!currentEnvironment, + }); + + return { + conversations: data?.data || [], + hasMore: data?.hasMore || false, + totalCount: data?.totalCount || 0, + ...rest, + page, + }; +} diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 82bced69625..4fc831f8c38 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -429,6 +429,14 @@ const router = createBrowserRouter([ ), }, + { + path: ROUTES.ACTIVITY_CONVERSATIONS, + element: ( + + + + ), + }, { path: ROUTES.ANALYTICS, element: ( diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index 54a8772074e..5b5096c139b 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -2,6 +2,7 @@ import { FeatureFlagsKeysEnum } from '@novu/shared'; import { useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { ActivityFeedContent } from '@/components/activity/activity-feed-content'; +import { ConversationsContent } from '@/components/conversations/conversations-content'; import { DashboardLayout } from '@/components/dashboard-layout'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; import { useEnvironment } from '@/context/environment/hooks'; @@ -14,13 +15,21 @@ import { PageMeta } from '../components/page-meta'; export function ActivityFeed() { const isHttpLogsPageEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_HTTP_LOGS_PAGE_ENABLED, false); + const isConversationalAgentsEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_CONVERSATIONAL_AGENTS_ENABLED, false); const { currentEnvironment } = useEnvironment(); const location = useLocation(); const navigate = useNavigate(); const track = useTelemetry(); - // Determine current tab based on URL const getCurrentTab = () => { + if (location.pathname.includes('/activity/conversations')) { + if (!isConversationalAgentsEnabled) { + return 'workflow-runs'; + } + + return 'conversations'; + } + if (location.pathname.includes('/activity/requests')) { return 'requests'; } @@ -29,7 +38,6 @@ export function ActivityFeed() { return 'workflow-runs'; } - // Default fallback for the original activity-feed route if (location.pathname.includes('/activity-feed')) { return 'workflow-runs'; } @@ -39,18 +47,18 @@ export function ActivityFeed() { const currentTab = getCurrentTab(); - // Handle tab changes by navigating to the appropriate URL const handleTabChange = (value: string) => { if (!currentEnvironment?.slug) return; if (value === 'requests') { navigate(buildRoute(ROUTES.ACTIVITY_REQUESTS, { environmentSlug: currentEnvironment.slug })); + } else if (value === 'conversations') { + navigate(buildRoute(ROUTES.ACTIVITY_CONVERSATIONS, { environmentSlug: currentEnvironment.slug })); } else if (value === 'workflow-runs') { navigate(buildRoute(ROUTES.ACTIVITY_WORKFLOW_RUNS, { environmentSlug: currentEnvironment.slug })); } }; - // Redirect legacy activity-feed URLs to the new runs URL when feature flag is enabled useEffect(() => { if (isHttpLogsPageEnabled && location.pathname.includes('/activity-feed') && currentEnvironment?.slug) { const newPath = buildRoute(ROUTES.ACTIVITY_WORKFLOW_RUNS, { environmentSlug: currentEnvironment.slug }); @@ -60,7 +68,17 @@ export function ActivityFeed() { } }, [isHttpLogsPageEnabled, location.pathname, location.search, currentEnvironment?.slug, navigate]); - // Track page visit for requests tab + useEffect(() => { + if ( + !isConversationalAgentsEnabled && + location.pathname.includes('/activity/conversations') && + currentEnvironment?.slug + ) { + const fallbackPath = buildRoute(ROUTES.ACTIVITY_WORKFLOW_RUNS, { environmentSlug: currentEnvironment.slug }); + navigate(`${fallbackPath}${location.search}`, { replace: true }); + } + }, [isConversationalAgentsEnabled, location.pathname, location.search, currentEnvironment?.slug, navigate]); + useEffect(() => { if (currentTab === 'requests') { track(TelemetryEvent.REQUEST_LOGS_PAGE_VISIT); @@ -82,6 +100,11 @@ export function ActivityFeed() { Workflow Runs + {isConversationalAgentsEnabled && ( + + Conversations + + )} {isHttpLogsPageEnabled && ( Requests @@ -91,6 +114,11 @@ export function ActivityFeed() { + {isConversationalAgentsEnabled && ( + + + + )} diff --git a/apps/dashboard/src/types/conversation.ts b/apps/dashboard/src/types/conversation.ts new file mode 100644 index 00000000000..96df6da7e43 --- /dev/null +++ b/apps/dashboard/src/types/conversation.ts @@ -0,0 +1,14 @@ +import { ConversationFilters } from '@/api/conversations'; + +export type ConversationFiltersData = { + dateRange: string; + subscriberId: string; + provider: string[]; + conversationId: string; +}; + +export type ConversationUrlState = { + conversationItemId: string | null; + filters: ConversationFilters; + filterValues: ConversationFiltersData; +}; diff --git a/apps/dashboard/src/utils/routes.ts b/apps/dashboard/src/utils/routes.ts index ecdd182476b..dc1b21ccfcc 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -42,6 +42,7 @@ export const ROUTES = { ACTIVITY_FEED: '/env/:environmentSlug/activity-feed', ACTIVITY_WORKFLOW_RUNS: '/env/:environmentSlug/activity/workflow-runs', ACTIVITY_REQUESTS: '/env/:environmentSlug/activity/requests', + ACTIVITY_CONVERSATIONS: '/env/:environmentSlug/activity/conversations', ANALYTICS: '/env/:environmentSlug/analytics', LOGS: '/env/:environmentSlug/requests', TEMPLATE_STORE: '/env/:environmentSlug/workflows/templates',