Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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>
);
}

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