Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .source
5 changes: 5 additions & 0 deletions apps/dashboard/src/api/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { get } from './api.client';
export type ConversationFilters = {
dateRange?: string;
subscriberId?: string;
agentId?: string;
provider?: string[];
conversationId?: string;
status?: string;
Expand Down Expand Up @@ -89,6 +90,10 @@ export function getConversationsList({
searchParams.append('subscriberId', filters.subscriberId);
}

if (filters?.agentId) {
searchParams.append('agentId', filters.agentId);
}

if (filters?.dateRange) {
const ms = getDateRangeInMs(filters.dateRange);
if (ms > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function AgentConnectedOverview({ agent }: AgentConnectedOverviewProps) {
<div className="flex min-w-0 flex-1 flex-col gap-4">
<AgentBehaviorSection />
<ConnectedProvidersSection agent={agent} />
<RecentConversationsSection />
<RecentConversationsSection agent={agent} />
</div>
);
}
270 changes: 205 additions & 65 deletions apps/dashboard/src/components/agents/recent-conversations-section.tsx
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 },
});
Comment on lines +59 to +62
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect AgentResponse shape and how _agentId is populated on conversations.
fd -t f 'agents.ts' apps/dashboard/src/api | xargs -I{} sh -c 'echo "=== {} ==="; cat {}'
echo "---- agent entity / schema ----"
fd -t f 'agent.entity.ts|agent.schema.ts' libs/dal/src | xargs -I{} sh -c 'echo "=== {} ==="; cat {}'
echo "---- conversation creation / _agentId assignment ----"
rg -nP --type=ts -C3 '_agentId\s*[:=]'

Repository: novuhq/novu

Length of output: 30067


🏁 Script executed:

fd -t f 'conversations.ts' apps/dashboard/src -type f

Repository: novuhq/novu

Length of output: 225


🏁 Script executed:

rg -n 'useFetchConversations' apps/dashboard/src --type=ts --type=tsx -A 5

Repository: novuhq/novu

Length of output: 82


🏁 Script executed:

rg -n 'const.*fetchConversations\|export.*useFetchConversations' apps/dashboard/src --type=ts --type=tsx -B 2 -A 10

Repository: novuhq/novu

Length of output: 82


🏁 Script executed:

fd -type f 'conversations' apps/dashboard/src

Repository: novuhq/novu

Length of output: 225


🏁 Script executed:

rg 'useFetchConversations' apps/dashboard/src -g '*.ts' -g '*.tsx' -A 5

Repository: novuhq/novu

Length of output: 3160


🏁 Script executed:

rg 'fetchConversations|useFetchConversations' apps/dashboard/src -g '*.ts' -g '*.tsx' -B 2 -A 10

Repository: novuhq/novu

Length of output: 9446


🏁 Script executed:

cat apps/dashboard/src/api/conversations.ts

Repository: novuhq/novu

Length of output: 4696


Use agent._id instead of agent.identifier for the agentId filter.

The conversation schema stores agent references as _agentId: Schema.Types.ObjectId, but you're filtering with agent.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:

filters: { agentId: agent._id },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/components/agents/recent-conversations-section.tsx` around
lines 59 - 62, The useFetchConversations call is passing the wrong agent
identifier; update the filters argument in the useFetchConversations invocation
(the call that uses RECENT_CONVERSATIONS_DISPLAY_LIMIT) to use agent._id instead
of agent.identifier so the agentId filter matches the ObjectId stored in the
conversation schema.

Copy link
Copy Markdown
Contributor Author

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for backend conversion of agentId/identifier to _id in conversation-related code
rg -n 'identifier|agentId' apps/api/src --type=ts -g '*conversation*' -A 5 -B 2

echo "---- Check use-cases or services that handle agentId ----"
rg -rn 'agentId' apps/api/src --type=ts -A 5 -B 2

Length of output: 95710


🏁 Script executed:

#!/bin/bash
# Find the list-conversations usecase to see how agentId is processed
rg -rn 'agentId' apps/api/src --type=ts -g '*conversation*' -A 5 -B 2

echo "---- list conversations usecase ----"
fd -t f 'list-conversations' apps/api/src | xargs -I{} sh -c 'echo "=== {} ==="; cat {}'

echo "---- conversations controller ----"
fd -t f 'conversations.controller.ts' apps/api/src | xargs -I{} sh -c 'echo "=== {} ==="; cat {}'

Length of output: 1897


🏁 Script executed:

#!/bin/bash
# Broader search for conversations listing and agentId handling
rg -rn 'agentId' apps/api/src --type=ts -A 5 -B 2

echo "==== conversations controller candidates ===="
fd -t f 'conversation' apps/api/src --extension ts | grep -v 'agent-conversation'

echo "==== list conversations ===="
rg -rn 'listConversations\|list_conversations\|getConversationsList\|ConversationFilters' apps/api/src --type=ts -A 5 -B 2

Length of output: 92861


🏁 Script executed:

#!/bin/bash
# Find the conversations listing API endpoint in apps/api
fd -t f --extension ts 'conversation' apps/api/src | grep -v 'agent-conversation\|e2e\|spec'

echo "==== search for agentId in non-agents context ===="
rg -rn 'agentId' apps/api/src --type=ts --iglob '!*agent*' -A 5 -B 3

echo "==== dal listConversations call sites ===="
rg -rn 'listConversations' --type=ts -A 5 -B 3

Length of output: 789


🏁 Script executed:

#!/bin/bash
cat libs/dal/src/repositories/conversation/conversation.repository.ts

echo "==== Find conversations API controller/usecase ===="
fd -t f --extension ts . apps/api/src | xargs grep -l 'agentId\|agent_id\|_agentId' 2>/dev/null | grep -v 'agent-conversation\|bridge-executor\|chat-sdk\|agent-inbound\|agent-config'

echo "==== conversations usecase files ===="
fd -t f --extension ts . apps/api/src/app | xargs grep -l 'ConversationFilters\|conversations.*usecase\|usecase.*conversation' 2>/dev/null

Length of output: 8864


🏁 Script executed:

#!/bin/bash
# Find conversations API endpoint/controller outside agents module
rg -rn 'getConversationsList\|conversations.*list\|list.*conversations' apps/api/src --type=ts -B 2 -A 5

echo "==== search for conversation routes ===="
rg -rn "'/conversations'\|\"conversations\"" apps/api/src --type=ts -B 2 -A 3

echo "==== Check what URL dashboard calls for conversations ===="
cat apps/dashboard/src/api/conversations.ts

Length of output: 4741


🏁 Script executed:

#!/bin/bash
# Find the /conversations endpoint handler
rg -rn "conversations" apps/api/src --type=ts -g '*controller*' -B 2 -A 5

echo "==== search for conversation module ===="
fd -t d 'conversation' apps/api/src

echo "==== check all controllers for conversations route ===="
fd -t f 'controller' apps/api/src --extension ts | xargs grep -l 'conversation' 2>/dev/null

Length of output: 194


🏁 Script executed:

#!/bin/bash
# Search across all apps for the conversations GET endpoint
rg -rn "Get.*conversations\|conversations.*Get\|@Get.*conver\|route.*conversation\|conversation.*route" --type=ts -B 2 -A 5

echo "==== find conversations endpoint in all apps ===="
rg -rn "'/conversations'\|\"\/conversations\"\|'/v1/conversations'\|v1.*conversations" --type=ts -B 2 -A 5

Length of output: 210


🏁 Script executed:

#!/bin/bash
# Search for conversations endpoint across all apps
rg -rn "conversations" --type=ts -g '*controller*' -B 2 -A 5

echo "==== find route /conversations ===="
rg -rn "conversations" --type=ts -g '*router*' -B 2 -A 5

echo "==== search all TS files for conversations endpoint ===="
rg -rn "@Get\('conversations'\)" --type=ts -B 2 -A 5

echo "==== look for usecase that handles list conversations with agentId conversion ===="
rg -rn "agentId" --type=ts -g '!**/agents/**' -B 3 -A 5

Length of output: 75597


@scopsy Thanks 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:

  • Conversation schema uses _n: ObjectId
  • ConversationRepository.listConversations takes n and sets query._n = n
  • Dashboard API client appends n when provided in filters and ConversationDto exposes _n

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:

  const { conversations, isLoading, isError } = useFetchConversations({
    limit: RECENT_CONVERSATIONS_DISPLAY_LIMIT,
-   filters: { agentId: agent.identifier },
+   filters: { n: agent._id },
  });

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
Learnt from: CR
Repo: novuhq/novu PR: 0
File: .cursor/rules/pullrequest.mdc:0-0
Timestamp: 2026-03-24T13:18:35.551Z
Learning: Pull request title format: `type(scope): Description fixes NOV-<ticket-id>` (omit `fixes NOV-XXX` when no Linear ticket is in context). Example: `feat(dashboard): add workflow trigger button fixes NOV-123`


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>
);
}
Comment thread
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}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ 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 { ConversationsUpgradeCta } from '@/components/conversations/conversations-upgrade-cta';
import { ResizablePanel, ResizablePanelGroup } from '@/components/primitives/resizable';
import { UpdatedAgo } from '@/components/updated-ago';
import { IS_ENTERPRISE } from '@/config';
import { useEnvironment } from '@/context/environment/hooks';
import { useConversationUrlState } from '@/hooks/use-conversation-url-state';
import { cn } from '@/utils/ui';
Expand All @@ -22,6 +24,25 @@ type ConversationsContentProps = {
export function ConversationsContent({
className,
contentHeight = 'h-[calc(100vh-140px)]',
}: ConversationsContentProps) {
if (!IS_ENTERPRISE) {
return (
<div className={cn('p-2.5', className)}>
<div className={cn('flex', contentHeight)}>
<div className="border-stroke-soft flex flex-1 items-center justify-center rounded-lg border bg-white">
<ConversationsUpgradeCta source="activity-feed-conversations" />
</div>
</div>
</div>
);
}

return <EnterpriseConversationsContent className={className} contentHeight={contentHeight} />;
}

function EnterpriseConversationsContent({
className,
contentHeight = 'h-[calc(100vh-140px)]',
}: ConversationsContentProps) {
const { conversationItemId, filters, filterValues, handleConversationSelect, handleFiltersChange } =
useConversationUrlState();
Expand Down
Loading
Loading