-
Notifications
You must be signed in to change notification settings - Fork 4.3k
feat(dashboard, api-service): Filter conversations by agent; add recent UI & CTA #10767
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
74a604d
Filter conversations by agent; add recent UI & CTA
scopsy 186f0ab
Merge branch 'next' into recent-conversations-flow
scopsy 8953eac
Update agent-connected-overview.tsx
scopsy 5c4aeae
Refactor recent conversations UI and add indexes
scopsy 70c73a5
Update .source
scopsy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Submodule .source
updated
from bb487b to e132a9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
270 changes: 205 additions & 65 deletions
270
apps/dashboard/src/components/agents/recent-conversations-section.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,89 +1,229 @@ | ||
| import { RiRobot2Line } from 'react-icons/ri'; | ||
| import { RiArrowRightLine, RiCheckboxCircleFill, RiRobot2Line } from 'react-icons/ri'; | ||
| import { Link } from 'react-router-dom'; | ||
| import type { AgentResponse } from '@/api/agents'; | ||
| import type { ConversationDto } from '@/api/conversations'; | ||
| import { ConversationStatusBadge } from '@/components/conversations/conversation-status-badge'; | ||
| import { ConversationsUpgradeCta } from '@/components/conversations/conversations-upgrade-cta'; | ||
| import { SubscriberFallbackAvatar } from '@/components/conversations/subscriber-fallback-avatar'; | ||
| import { Skeleton } from '@/components/primitives/skeleton'; | ||
| import { IS_ENTERPRISE } from '@/config'; | ||
| import { useEnvironment } from '@/context/environment/hooks'; | ||
| import { useFetchConversations } from '@/hooks/use-fetch-conversations'; | ||
| import { buildRoute, ROUTES } from '@/utils/routes'; | ||
| import { cn } from '@/utils/ui'; | ||
|
|
||
| function SkeletonBar({ className }: { className?: string }) { | ||
| return ( | ||
| <div | ||
| className={className} | ||
| style={{ | ||
| background: 'linear-gradient(90deg, #f1efef 24%, #f9f8f8 43%, rgba(249,248,248,0.75) 115%)', | ||
| }} | ||
| /> | ||
| ); | ||
| } | ||
| const RECENT_CONVERSATIONS_DISPLAY_LIMIT = 5; | ||
|
|
||
| type RecentConversationsSectionProps = { | ||
| agent: AgentResponse; | ||
| }; | ||
|
|
||
| export function RecentConversationsSection({ agent }: RecentConversationsSectionProps) { | ||
| const { currentEnvironment } = useEnvironment(); | ||
|
|
||
| const conversationsPath = currentEnvironment?.slug | ||
| ? buildRoute(ROUTES.ACTIVITY_CONVERSATIONS, { environmentSlug: currentEnvironment.slug }) | ||
| : undefined; | ||
|
|
||
| function SkeletonMessageCard({ className }: { className?: string }) { | ||
| return ( | ||
| <div className={`border-stroke-soft rounded border bg-white p-2 ${className ?? ''}`}> | ||
| <div className="flex flex-col gap-1"> | ||
| <div className="flex items-center gap-1"> | ||
| <div className="bg-bg-weak size-3 rounded-full" /> | ||
| <SkeletonBar className="h-2 w-11 rounded-sm" /> | ||
| </div> | ||
| <div className="flex flex-wrap gap-x-0.5 gap-y-[3px]"> | ||
| <SkeletonBar className="h-1.5 w-[77px] rounded-full" /> | ||
| <SkeletonBar className="h-1.5 min-w-0 flex-1 rounded-full" /> | ||
| <SkeletonBar className="h-1.5 min-w-[50px] flex-1 rounded-full" /> | ||
| <SkeletonBar className="h-1.5 w-[91px] rounded-full" /> | ||
| </div> | ||
| <div className="bg-bg-weak flex flex-col rounded-[10px] p-1"> | ||
| <div className="flex items-center justify-between px-2 py-1.5"> | ||
| <span className="text-text-soft font-code text-[11px] font-medium uppercase leading-4 tracking-wider"> | ||
| Recent conversations | ||
| </span> | ||
| {IS_ENTERPRISE && conversationsPath ? ( | ||
| <Link | ||
| to={conversationsPath} | ||
| className="text-text-sub hover:text-text-strong text-label-xs flex items-center gap-0.5 rounded-lg p-1.5 font-medium transition-colors" | ||
| > | ||
| View all | ||
| <RiArrowRightLine className="size-4" /> | ||
| </Link> | ||
| ) : null} | ||
| </div> | ||
|
|
||
| <div className="bg-bg-white flex h-[300px] flex-col overflow-hidden rounded-md shadow-[0px_0px_0px_1px_rgba(25,28,33,0.04),0px_1px_2px_0px_rgba(25,28,33,0.06),0px_0px_2px_0px_rgba(0,0,0,0.08)]"> | ||
| {IS_ENTERPRISE ? ( | ||
| <RecentConversationsContent agent={agent} /> | ||
| ) : ( | ||
| <ConversationsUpgradeCta source="agent-overview" variant="compact" /> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function NovuIcon() { | ||
| function RecentConversationsContent({ agent }: { agent: AgentResponse }) { | ||
| const { currentEnvironment } = useEnvironment(); | ||
|
|
||
| const { conversations, isLoading, isError } = useFetchConversations({ | ||
| limit: RECENT_CONVERSATIONS_DISPLAY_LIMIT, | ||
| filters: { agentId: agent.identifier }, | ||
| }); | ||
|
|
||
| if (isLoading) { | ||
| return <RecentConversationsSkeleton />; | ||
| } | ||
|
|
||
| if (isError) { | ||
| return ( | ||
| <div className="flex flex-1 items-center justify-center px-4 text-center"> | ||
| <p className="text-text-soft text-label-xs max-w-[320px] font-medium leading-4"> | ||
| We couldn't load recent conversations. Please try again in a moment. | ||
| </p> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (conversations.length === 0) { | ||
| return ( | ||
| <div className="flex flex-1 items-center justify-center px-4 text-center"> | ||
| <p className="text-text-soft text-label-xs max-w-[320px] font-medium leading-4"> | ||
| No conversations yet. Once this agent starts replying to messages, they'll show up here. | ||
| </p> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <svg className="size-3" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||
| <path | ||
| d="M8 0C3.58 0 0 3.58 0 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8Zm3.53 11.53L8 8.06l-3.53 3.47L3.06 10.12 6.53 6.65 3.06 3.18l1.41-1.41L8 5.24l3.53-3.47 1.41 1.41L9.47 6.65l3.47 3.47-1.41 1.41Z" | ||
| fill="currentColor" | ||
| className="text-text-soft" | ||
| /> | ||
| </svg> | ||
| <ul className="flex flex-1 flex-col divide-y divide-stroke-soft overflow-auto"> | ||
| {conversations.map((conversation) => ( | ||
| <li key={conversation._id}> | ||
| <RecentConversationItem conversation={conversation} environmentSlug={currentEnvironment?.slug} /> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| ); | ||
| } | ||
|
|
||
| function EmptyStateIllustration() { | ||
| return ( | ||
| <div className="flex flex-col items-center gap-12"> | ||
| <div className="flex flex-col items-center"> | ||
| <div className="border-stroke-weak rounded-lg border p-1"> | ||
| <SkeletonMessageCard className="w-[197px]" /> | ||
| type RecentConversationItemProps = { | ||
| conversation: ConversationDto; | ||
| environmentSlug: string | undefined; | ||
| }; | ||
|
|
||
| function RecentConversationItem({ conversation, environmentSlug }: RecentConversationItemProps) { | ||
| const subscriber = getSubscriberLabel(conversation); | ||
| const subscriberParticipant = (conversation.participants ?? []).find((p) => p.type === 'subscriber'); | ||
| const subscriberAvatar = subscriberParticipant?.subscriber?.avatar; | ||
| const isFailed = conversation.status === 'failed'; | ||
|
|
||
| const baseClassName = 'flex flex-col gap-1.5 px-3 py-2'; | ||
| const interactiveClassName = | ||
| 'group transition-colors hover:bg-neutral-50 focus-visible:bg-neutral-50 focus-visible:outline-none'; | ||
|
|
||
| const content = ( | ||
| <> | ||
| <div className="flex items-center gap-8"> | ||
| <div className="flex min-w-0 flex-1 items-center gap-1"> | ||
| <RiCheckboxCircleFill | ||
| className={cn('size-4 shrink-0', isFailed ? 'text-destructive-base' : 'text-success-base')} | ||
| /> | ||
| <span className="text-text-sub text-label-xs min-w-0 truncate font-medium"> | ||
| {conversation.title || 'Untitled conversation'} | ||
| </span> | ||
| </div> | ||
| <span className="text-text-soft font-code shrink-0 text-[11px] leading-normal"> | ||
| {formatTimestamp(conversation.lastActivityAt || conversation.createdAt)} | ||
| </span> | ||
| </div> | ||
|
|
||
| <div className="flex items-center gap-[50px]"> | ||
| <div className="border-stroke-weak w-[136px] rounded-lg border border-dashed p-0.5"> | ||
| <div className="border-stroke-soft bg-bg-white flex h-[47px] items-center justify-center gap-6 rounded-md border p-3"> | ||
| <RiRobot2Line className="text-text-soft size-4" /> | ||
| <NovuIcon /> | ||
| </div> | ||
| <div className="flex items-center justify-between gap-2"> | ||
| <div className="flex min-w-0 items-center gap-1"> | ||
| <RiRobot2Line className="text-text-soft size-4 shrink-0" /> | ||
| <span className="text-text-soft font-code truncate text-xs font-medium tracking-tight"> | ||
| {getAgentName(conversation)} | ||
| </span> | ||
| </div> | ||
|
|
||
| <div className="border-stroke-weak rounded-lg border p-1"> | ||
| <SkeletonMessageCard className="w-[197px]" /> | ||
| <div className="flex shrink-0 items-center gap-1"> | ||
| {subscriber && ( | ||
| <> | ||
| <div className="border-stroke-soft flex max-w-[150px] items-center gap-1 rounded border bg-[#fbfbfb] px-1 py-0.5"> | ||
| {subscriberAvatar ? ( | ||
| <img src={subscriberAvatar} alt="" className="size-4 shrink-0 rounded-full object-cover" /> | ||
| ) : ( | ||
| <SubscriberFallbackAvatar className="size-4" /> | ||
| )} | ||
| <span className="text-text-strong font-code min-w-0 truncate text-xs font-medium">{subscriber}</span> | ||
| </div> | ||
| <span className="text-text-soft font-code text-[11px] leading-normal">•</span> | ||
| </> | ||
| )} | ||
| <ConversationStatusBadge status={conversation.status} /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| export function RecentConversationsSection() { | ||
| if (!environmentSlug) { | ||
| return <div className={baseClassName}>{content}</div>; | ||
| } | ||
|
|
||
| const detailPath = `${buildRoute(ROUTES.ACTIVITY_CONVERSATIONS, { environmentSlug })}?conversationItemId=${encodeURIComponent(conversation.identifier)}`; | ||
|
|
||
| return ( | ||
| <div className="bg-bg-weak flex flex-col rounded-[10px] p-1"> | ||
| <div className="flex items-center justify-between px-2 py-1.5"> | ||
| <span className="text-text-soft font-code text-[11px] font-medium uppercase leading-4 tracking-wider"> | ||
| Recent conversations | ||
| </span> | ||
| </div> | ||
| <Link to={detailPath} className={cn(baseClassName, interactiveClassName)}> | ||
| {content} | ||
| </Link> | ||
| ); | ||
| } | ||
|
|
||
| <div className="bg-bg-white flex h-[300px] flex-col items-center justify-center overflow-hidden rounded-md shadow-[0px_0px_0px_1px_rgba(25,28,33,0.04),0px_1px_2px_0px_rgba(25,28,33,0.06),0px_0px_2px_0px_rgba(0,0,0,0.08)]"> | ||
| <div className="flex flex-1 flex-col items-center justify-center gap-6 p-4"> | ||
| <EmptyStateIllustration /> | ||
| <p className="text-text-soft text-label-xs max-w-[400px] text-center font-medium leading-4"> | ||
| No conversations, agent conversations will appear here once the agent starts responding to messages. | ||
| </p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| function RecentConversationsSkeleton() { | ||
| return ( | ||
| <ul className="flex flex-1 flex-col divide-y divide-stroke-soft"> | ||
| {Array.from({ length: RECENT_CONVERSATIONS_DISPLAY_LIMIT }, (_, index) => ( | ||
| <li key={`skeleton-${index}`} className="flex flex-col gap-1.5 px-3 py-2"> | ||
| <div className="flex items-center justify-between"> | ||
| <div className="flex items-center gap-1"> | ||
| <Skeleton className="size-4 rounded-full" /> | ||
| <Skeleton className="h-3.5 w-40" /> | ||
| </div> | ||
| <Skeleton className="h-3 w-24" /> | ||
| </div> | ||
| <div className="flex items-center justify-between"> | ||
| <Skeleton className="h-3.5 w-20" /> | ||
| <div className="flex items-center gap-1"> | ||
| <Skeleton className="h-4 w-24 rounded" /> | ||
| <Skeleton className="h-4 w-12 rounded-md" /> | ||
| </div> | ||
| </div> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| ); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| function getSubscriberLabel(conversation: ConversationDto): string | undefined { | ||
| const participant = (conversation.participants ?? []).find((p) => p.type === 'subscriber'); | ||
| if (!participant) return undefined; | ||
|
|
||
| const sub = participant.subscriber; | ||
| if (sub?.firstName || sub?.lastName) { | ||
| return [sub.firstName, sub.lastName].filter(Boolean).join(' '); | ||
| } | ||
|
|
||
| return sub?.subscriberId ?? participant.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 | undefined): string { | ||
| if (!dateStr?.trim()) { | ||
| return '—'; | ||
| } | ||
|
|
||
| const d = new Date(dateStr); | ||
|
|
||
| if (Number.isNaN(d.getTime())) { | ||
| return '—'; | ||
| } | ||
|
|
||
| const month = d.toLocaleDateString('en-US', { month: 'short' }); | ||
| const day = d.getDate(); | ||
| const time = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' }); | ||
|
|
||
| return `${month} ${day} ${time}`; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: novuhq/novu
Length of output: 30067
🏁 Script executed:
fd -t f 'conversations.ts' apps/dashboard/src -type fRepository: novuhq/novu
Length of output: 225
🏁 Script executed:
rg -n 'useFetchConversations' apps/dashboard/src --type=ts --type=tsx -A 5Repository: novuhq/novu
Length of output: 82
🏁 Script executed:
rg -n 'const.*fetchConversations\|export.*useFetchConversations' apps/dashboard/src --type=ts --type=tsx -B 2 -A 10Repository: novuhq/novu
Length of output: 82
🏁 Script executed:
fd -type f 'conversations' apps/dashboard/srcRepository: novuhq/novu
Length of output: 225
🏁 Script executed:
Repository: novuhq/novu
Length of output: 3160
🏁 Script executed:
Repository: novuhq/novu
Length of output: 9446
🏁 Script executed:
Repository: novuhq/novu
Length of output: 4696
Use
agent._idinstead ofagent.identifierfor theagentIdfilter.The conversation schema stores agent references as
_agentId: Schema.Types.ObjectId, but you're filtering withagent.identifier(a string slug). The API will send?agentId=<identifier-slug>to the backend, which tries to match it against an ObjectId field. This causes Mongoose to either throw a cast error or silently return zero results, leaving the Recent Conversations list empty.Change line 61 to:
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But the backend converts the identifier to _id
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Length of output: 95710
🏁 Script executed:
Length of output: 1897
🏁 Script executed:
Length of output: 92861
🏁 Script executed:
Length of output: 789
🏁 Script executed:
Length of output: 8864
🏁 Script executed:
Length of output: 4741
🏁 Script executed:
Length of output: 194
🏁 Script executed:
Length of output: 210
🏁 Script executed:
Length of output: 75597
@scopsyThanks for the clarification — you’re right that several agent endpoints take an identifier and resolve it to _id server‑side. For conversations, though, the listing path expects the agent’s ObjectId under query param n, and the DAL filters on _n (ObjectId).Concretely:
Given that, this component should pass the agent’s _id under n rather than the textual identifier under agentId. Suggested change in apps/dashboard/src/components/agents/recent-conversations-section.tsx:
This aligns the UI with the conversations API contract and will reliably scope results to the agent. If you prefer keeping a friendlier prop name (agentId) in the hook, we can also translate agentId → n inside useFetchConversations, but today the API client already speaks in terms of n, so wiring it directly here is the smallest change.
🧠 Learnings used