From 6a75e2554dfab9c971c53370dddae5766f602b6b Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Thu, 3 Sep 2026 12:30:53 +0200 Subject: [PATCH 1/3] feat(dashboard): port web chat preview to assistant-ui fixes NV-8762 (#12550) --- apps/dashboard/package.json | 3 + .../agent-message-to-thread-message.ts | 220 ++++ .../assistant-ui/approval-options.ts | 25 + .../web-chat-panel/assistant-ui/card-view.ts | 103 ++ .../assistant-ui/elements/approval-card.tsx | 121 +++ .../assistant-ui/elements/connect-card.tsx | 76 ++ .../assistant-ui/elements/day-separator.tsx | 72 ++ .../assistant-ui/elements/error-state.tsx | 55 + .../assistant-ui/elements/file.tsx | 43 + .../assistant-ui/elements/markdown-text.tsx | 165 +++ .../elements/novu-approval-card.tsx | 73 ++ .../assistant-ui/elements/reasoning.aui.tsx | 41 + .../elements/thinking-indicator.tsx | 27 + .../assistant-ui/elements/thread.aui.tsx | 478 +++++++++ .../elements/tool-fallback.aui.tsx | 90 ++ .../assistant-ui/elements/tool-group.aui.tsx | 50 + .../elements/tooltip-icon-button.tsx | 35 + .../assistant-ui/novu-parts.tsx | 141 +++ .../web-chat-panel/assistant-ui/thread.tsx | 325 ++++++ .../assistant-ui/web-chat-actions.tsx | 37 + .../assistant-ui/web-chat-runtime.tsx | 78 ++ .../use-web-chat-conversation-list.ts | 107 +- .../web-chat-conversation-list.tsx | 85 ++ .../agents/web-chat-panel/web-chat-drawer.tsx | 2 +- .../agents/web-chat-panel/web-chat-panel.tsx | 321 ++---- .../agents/web-chat-panel/web-chat-parts.tsx | 841 --------------- .../web-chat-panel/web-chat-session-bar.tsx | 109 -- apps/dashboard/src/index.css | 21 + pnpm-lock.yaml | 969 +++++++++++++++++- 29 files changed, 3483 insertions(+), 1230 deletions(-) create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/agent-message-to-thread-message.ts create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/approval-options.ts create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/card-view.ts create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/approval-card.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/connect-card.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/day-separator.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/error-state.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/file.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/markdown-text.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/novu-approval-card.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/reasoning.aui.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thinking-indicator.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thread.aui.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/tool-fallback.aui.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/tool-group.aui.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/tooltip-icon-button.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/novu-parts.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/thread.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/web-chat-actions.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/web-chat-runtime.tsx create mode 100644 apps/dashboard/src/components/agents/web-chat-panel/web-chat-conversation-list.tsx delete mode 100644 apps/dashboard/src/components/agents/web-chat-panel/web-chat-parts.tsx delete mode 100644 apps/dashboard/src/components/agents/web-chat-panel/web-chat-session-bar.tsx diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 8695da33670..86a91e16d87 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -31,6 +31,8 @@ }, "dependencies": { "@ai-sdk/react": "^3.0.51", + "@assistant-ui/react": "^0.15.16", + "@assistant-ui/react-markdown": "^0.14.12", "@better-auth/sso": "^1.6.23", "@calcom/embed-react": "1.5.2", "@clerk/react": "^6.11.3", @@ -143,6 +145,7 @@ "react-router-dom": "^7.18.2", "react-timezone-select": "^3.2.8", "recharts": "2.15.4", + "remark-gfm": "^4.0.1", "sanitize-html": "^2.17.5", "shiki": "^3.21.0", "sonner": "^1.7.0", diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/agent-message-to-thread-message.ts b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/agent-message-to-thread-message.ts new file mode 100644 index 00000000000..df4dae7c1bd --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/agent-message-to-thread-message.ts @@ -0,0 +1,220 @@ +import type { ThreadMessageLike, ToolApprovalOption } from '@assistant-ui/react'; +import type { AgentCardElement, AgentMessage } from '@novu/react'; +import { APPROVAL_OPTIONS } from './approval-options'; + +const POWERED_BY = + /(?:\n+)?(?:_*Powered by\s*\[[^\]]+\]\([^)]+\)_*|_*\[Powered by Novu\]\([^)]+\)_*|Powered by\s*]+\|[^>]+>|Powered by\s*]*>[\s\S]*?<\/a>|Powered by Novu\u200B?)\s*$/i; + +function stripPoweredBy(text: string): string { + return text.replace(POWERED_BY, '').trimEnd(); +} + +function isPoweredByWatermark(content: string): boolean { + const trimmed = content + .trim() + .replace(/^_+|_+$/g, '') + .trim(); + + return /^powered by/i.test(trimmed) && /novu/i.test(trimmed); +} + +function brandedReplyMarkdown(card: AgentCardElement): string | null { + if (card.title?.trim()) return null; + if (card.subtitle?.trim()) return null; + if (card.imageUrl?.trim()) return null; + + const texts: string[] = []; + let sawWatermark = false; + + for (const child of card.children) { + if (child.type !== 'text') { + return null; + } + + const content = child.content; + if (!content) continue; + + if (isPoweredByWatermark(content)) { + sawWatermark = true; + continue; + } + + texts.push(content); + } + + if (!sawWatermark || texts.length === 0) { + return null; + } + + return texts.join('\n\n'); +} + +function approvalOptions(part: Extract): ToolApprovalOption[] { + const options: ToolApprovalOption[] = []; + + if (part.denyActionId) { + options.push(APPROVAL_OPTIONS.denied); + } + if (part.approveActionId) { + options.push(APPROVAL_OPTIONS.approved); + } + if (part.trustToolActionId) { + options.push(APPROVAL_OPTIONS['trust-tool']); + } + if (part.trustServerActionId && part.source?.type === 'mcp') { + options.push({ + ...APPROVAL_OPTIONS['trust-server'], + label: `Always allow ${part.source.serverName}`, + }); + } + + return options; +} + +function approvalGate(part: Extract) { + const options = approvalOptions(part); + + if (part.state === 'pending') { + return { id: part.approvalId, options }; + } + + return { + id: part.approvalId, + options, + approved: part.state === 'approved', + optionId: part.state === 'approved' ? APPROVAL_OPTIONS.approved.id : APPROVAL_OPTIONS.denied.id, + }; +} + +type ThreadContent = Exclude; + +export function agentMessageToThreadMessage(message: AgentMessage): ThreadMessageLike { + const content: ThreadContent[number][] = []; + const approvalByToolUseId = new Map( + message.parts + .filter((part): part is Extract => part.type === 'approval') + .map((part) => [part.toolUseId, part]) + ); + + const isStreaming = message.parts.some( + (part) => (part.type === 'text' || part.type === 'thinking') && part.state === 'streaming' + ); + const hasPendingApproval = message.parts.some((part) => part.type === 'approval' && part.state === 'pending'); + + for (const part of message.parts) { + switch (part.type) { + case 'text': { + const text = stripPoweredBy(part.text); + if (!text.trim()) break; + content.push({ + type: 'text', + text, + status: part.state === 'streaming' ? { type: 'running' } : { type: 'complete' }, + }); + break; + } + case 'thinking': { + if (!part.text.trim() && part.state !== 'streaming') break; + content.push({ + type: 'reasoning', + text: part.text || '\u200b', + status: part.state === 'streaming' ? { type: 'running' } : { type: 'complete' }, + }); + break; + } + case 'tool': { + if (approvalByToolUseId.has(part.toolUseId)) break; + content.push({ + type: 'tool-call', + toolCallId: part.toolUseId, + toolName: part.toolName, + argsText: JSON.stringify(part.input ?? {}), + result: part.output, + isError: part.state === 'output-error', + }); + break; + } + case 'approval': { + content.push({ + type: 'tool-call', + toolCallId: part.toolUseId, + toolName: part.toolName, + argsText: JSON.stringify(part.input ?? {}), + approval: approvalGate(part), + }); + break; + } + case 'card': { + const unwrapped = brandedReplyMarkdown(part.card); + if (unwrapped) { + const text = stripPoweredBy(unwrapped); + if (text.trim()) { + content.push({ type: 'text', text, status: { type: 'complete' } }); + } + break; + } + content.push({ + type: 'data', + name: 'novu-card', + data: part, + }); + break; + } + case 'mcp-connection': { + content.push({ type: 'data', name: 'novu-mcp', data: part }); + break; + } + case 'source': + break; + case 'file': { + content.push({ type: 'data', name: 'novu-file', data: part }); + break; + } + case 'data': { + content.push({ type: 'data', name: part.name, data: part.data }); + break; + } + default: + break; + } + } + + if (message.role === 'user') { + const threadMessageId = message.idempotencyKey ?? message.id; + + return { + id: threadMessageId, + role: 'user', + createdAt: new Date(message.createdAt), + content: content.length > 0 ? content : [{ type: 'text', text: '' }], + metadata: { + custom: { novuStatus: message.status, novuMessageId: message.id }, + }, + }; + } + + let status: ThreadMessageLike['status']; + if (isStreaming || message.status === 'sending') { + status = { type: 'running' }; + } else if (hasPendingApproval) { + status = { type: 'requires-action', reason: 'tool-calls' }; + } else { + status = { type: 'complete', reason: 'stop' }; + } + + return { + id: message.id, + role: 'assistant', + createdAt: new Date(message.createdAt), + content, + status, + metadata: { custom: { novuStatus: message.status } }, + }; +} + +export function textFromAppendContent(content: readonly { type: string; text?: string }[]): string { + return content + .filter((part) => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text ?? '') + .join(''); +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/approval-options.ts b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/approval-options.ts new file mode 100644 index 00000000000..edaa44f6360 --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/approval-options.ts @@ -0,0 +1,25 @@ +import type { ToolApprovalOption } from '@assistant-ui/react'; +import type { AgentToolApprovalDecision } from '@novu/react'; + +export const APPROVAL_OPTIONS = { + denied: { id: 'denied', kind: 'reject-once', label: 'Deny' }, + approved: { id: 'approved', kind: 'allow-once', label: 'Approve once' }, + 'trust-tool': { id: 'trust-tool', kind: 'allow-always', label: 'Always allow this tool' }, + 'trust-server': { id: 'trust-server', kind: 'allow-always', label: 'Always allow server' }, +} as const satisfies Record; + +export function isApprovalOptionId(id: string): id is AgentToolApprovalDecision { + return id in APPROVAL_OPTIONS; +} + +export function decisionFromApprovalOption(optionId: string | undefined, approved: boolean): AgentToolApprovalDecision { + if (optionId && isApprovalOptionId(optionId)) { + return optionId; + } + + if (optionId) { + throw new Error(`Unknown approval option id: ${optionId}`); + } + + return approved ? 'approved' : 'denied'; +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/card-view.ts b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/card-view.ts new file mode 100644 index 00000000000..645ff96bd9a --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/card-view.ts @@ -0,0 +1,103 @@ +import type { AgentCardChild, AgentCardElement } from '@novu/react'; +import { toSafeExternalUrl } from '@/utils/url'; + +export type CardButtonView = { id: string; label: string; value?: string; style?: string }; + +export type CardChildView = + | { type: 'text'; content: string } + | { type: 'divider' } + | { type: 'image'; url: string; alt: string } + | { type: 'link'; url: string; label: string } + | { type: 'actions'; buttons: CardButtonView[] }; + +export type CardView = { + title?: string; + subtitle?: string; + imageUrl?: string; + children: CardChildView[]; +}; + +function linkView(label: string, url: string): CardChildView | null { + const safeUrl = toSafeExternalUrl(url); + const trimmedLabel = label.trim(); + + return safeUrl && trimmedLabel ? { type: 'link', url: safeUrl, label: trimmedLabel } : null; +} + +function viewsFromAgentChild(child: AgentCardChild): CardChildView[] { + switch (child.type) { + case 'text': { + const content = child.content.trim(); + + return content ? [{ type: 'text', content }] : []; + } + case 'divider': + return [{ type: 'divider' }]; + case 'image': { + const url = toSafeExternalUrl(child.url); + + return url ? [{ type: 'image', url, alt: child.alt ?? '' }] : []; + } + case 'link': { + const view = linkView(child.label, child.url); + + return view ? [view] : []; + } + case 'button': + return [ + { + type: 'actions', + buttons: [{ id: child.id, label: child.label, value: child.value, style: child.style }], + }, + ]; + case 'actions': { + const views: CardChildView[] = []; + const buttons: CardButtonView[] = []; + + for (const actionChild of child.children) { + if (actionChild.type === 'button') { + buttons.push({ + id: actionChild.id, + label: actionChild.label, + value: actionChild.value, + style: actionChild.style, + }); + continue; + } + + if (actionChild.type === 'link-button') { + const view = linkView(actionChild.label, actionChild.url); + if (view) { + views.push(view); + } + } + } + + if (buttons.length > 0) { + views.push({ type: 'actions', buttons }); + } + + return views; + } + case 'section': + return child.children.flatMap((nested) => viewsFromAgentChild(nested)); + case 'fields': + return child.children + .map((field) => `${field.label}: ${field.value}`.trim()) + .filter(Boolean) + .map((content) => ({ type: 'text' as const, content })); + case 'table': + return []; + } +} + +export function cardViewFromElement(card: AgentCardElement): CardView { + const children = Array.isArray(card.children) ? card.children : []; + + return { + title: card.title?.trim() || undefined, + subtitle: card.subtitle?.trim() || undefined, + imageUrl: toSafeExternalUrl(card.imageUrl), + children: children.flatMap((child) => viewsFromAgentChild(child)), + }; +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/approval-card.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/approval-card.tsx new file mode 100644 index 00000000000..9a52ae28e21 --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/approval-card.tsx @@ -0,0 +1,121 @@ +import type { ComponentProps } from 'react'; +import { RiCheckLine, RiCloseLine, RiLoader4Line, RiTerminalBoxLine } from 'react-icons/ri'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/primitives/dropdown-menu'; +import { cn } from '@/utils/ui'; + +export type ApprovalState = 'request' | 'running' | 'done' | 'denied'; + +export type AlwaysAllowOption = { + label: string; + onSelect: () => void; +}; + +const ghostPill = + 'text-text-soft hover:bg-bg-weak hover:text-text-strong focus-visible:ring-stroke-soft h-8 shrink-0 rounded-full px-3.5 text-xs font-medium whitespace-nowrap transition-[background-color,color,scale] duration-150 outline-none focus-visible:ring-2 active:scale-[0.96]'; + +const primaryPill = + 'bg-primary-base text-static-white hover:bg-primary-darker focus-visible:ring-primary-base h-8 shrink-0 rounded-full px-3.5 text-xs font-medium whitespace-nowrap transition-[background-color,scale] duration-150 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 active:scale-[0.96]'; + +export function ApprovalCard({ + state, + command, + title, + subtitle, + onAllowOnce, + alwaysAllowOptions = [], + onDeny, + className, + ...props +}: Omit, 'children' | 'state' | 'command' | 'title' | 'subtitle' | 'onAllowOnce' | 'onDeny'> & { + state: ApprovalState; + command: string; + title: string; + subtitle: string; + onAllowOnce?: () => void; + alwaysAllowOptions?: AlwaysAllowOption[]; + onDeny?: () => void; +}) { + return ( +
+
+ + + +
+

{title}

+

{subtitle}

+
+
+ +
+        {command}
+      
+ +
+ {state === 'request' ? ( + <> + +
+ {alwaysAllowOptions.length === 1 ? ( + + ) : alwaysAllowOptions.length > 1 ? ( + + + + + + {alwaysAllowOptions.map((option) => ( + + {option.label} + + ))} + + + ) : null} + +
+ + ) : ( +
+ {state === 'running' ? ( + <> + + Approved, running + + ) : state === 'denied' ? ( + <> + + Denied + + ) : ( + <> + + Approved + + )} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/connect-card.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/connect-card.tsx new file mode 100644 index 00000000000..85c967c92ee --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/connect-card.tsx @@ -0,0 +1,76 @@ +import type { AgentMessage } from '@novu/react'; +import { RiCheckLine, RiCloseLine, RiExternalLinkLine } from 'react-icons/ri'; +import { McpIcon } from '@/components/agents/mcp-icon'; +import { Button } from '@/components/primitives/button'; +import { toSafeExternalUrl } from '@/utils/url'; + +type McpConnectionPart = Extract; + +export function ConnectCard({ part }: { part: McpConnectionPart }) { + const connectUrl = toSafeExternalUrl(part.authorizeUrl); + const autoApproveUrl = toSafeExternalUrl(part.authorizeUrlWithAutoApprove); + const pending = part.state === 'pending'; + const connected = part.state === 'connected'; + + if (!pending) { + return ( +
+ {connected ? ( + + ) : ( + + )} + + {connected ? 'Connected' : 'Failed to connect'}: {part.displayName} + +
+ ); + } + + return ( +
+
+ + + +
+

Connect {part.displayName}

+

Connection request

+
+
+ +

+ {part.message ?? `Connect your ${part.displayName} account so the agent can continue.`} +

+ +
+ {autoApproveUrl ? ( + + ) : null} + {connectUrl ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/day-separator.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/day-separator.tsx new file mode 100644 index 00000000000..855c5618786 --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/day-separator.tsx @@ -0,0 +1,72 @@ +import { useAuiState } from '@assistant-ui/react'; +import type { FC, ReactNode } from 'react'; +import { cn } from '@/utils/ui'; + +function asDate(value: Date | string | number | undefined): Date | null { + if (value == null) return null; + const date = value instanceof Date ? value : new Date(value); + + return Number.isNaN(date.getTime()) ? null : date; +} + +function dayKey(date: Date): string { + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; +} + +function dayLabel(date: Date): string { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const day = new Date(date); + day.setHours(0, 0, 0, 0); + const diffDays = Math.round((today.getTime() - day.getTime()) / 86_400_000); + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + + return date.toLocaleDateString(undefined, { + weekday: 'long', + month: 'short', + day: 'numeric', + }); +} + +function timeLabel(date: Date): string { + return date.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }); +} + +export const MessageChronology: FC<{ children: ReactNode }> = ({ children }) => { + const role = useAuiState((s) => s.message.role); + const createdAt = useAuiState((s) => s.message.createdAt); + const index = useAuiState((s) => s.message.index); + const prevCreatedAt = useAuiState((s) => s.thread.messages[index - 1]?.createdAt); + + const date = asDate(createdAt); + const prev = asDate(prevCreatedAt); + const showDay = date != null && (index === 0 || !prev || dayKey(date) !== dayKey(prev)); + + return ( +
+ {showDay && date ? ( +
+ + {dayLabel(date)} + +
+ ) : null} + {children} + {date ? ( + + ) : null} +
+ ); +}; diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/error-state.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/error-state.tsx new file mode 100644 index 00000000000..315a57bcc43 --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/error-state.tsx @@ -0,0 +1,55 @@ +import type { ComponentProps } from 'react'; +import { RiErrorWarningLine, RiRefreshLine } from 'react-icons/ri'; +import { Shimmer } from '@/components/ai-elements/shimmer'; +import { cn } from '@/utils/ui'; + +export type ErrorStateProps = Omit, 'children' | 'role'> & { + title: string; + detail: string; + retrying: boolean; + onRetry?: () => void; +}; + +export function ErrorState({ title, detail, retrying, onRetry, className, ...props }: ErrorStateProps) { + if (retrying) { + return ( +
+ + + Retrying + +
+ ); + } + + return ( +
+ +
+

{title}

+

{detail}

+
+ {onRetry ? ( + + ) : null} +
+ ); +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/file.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/file.tsx new file mode 100644 index 00000000000..7e69c72298e --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/file.tsx @@ -0,0 +1,43 @@ +import type { FileMessagePartComponent } from '@assistant-ui/react'; +import { memo } from 'react'; +import { RiDownloadLine, RiFileLine } from 'react-icons/ri'; + +function getFileDataKind(data: string, sourceType?: 'url' | 'id'): 'data-uri' | 'url' | 'base64' | 'id' { + if (sourceType === 'url' && /^data:/i.test(data)) return 'data-uri'; + if (sourceType) return sourceType; + if (/^data:/i.test(data)) return 'data-uri'; + if (/^https?:\/\//i.test(data)) return 'url'; + + return 'base64'; +} + +const FileImpl: FileMessagePartComponent = ({ filename, data, mimeType, sourceType }) => { + const kind = getFileDataKind(data, sourceType); + const canDownload = kind !== 'id' && (kind !== 'url' || /^(https?:\/\/|blob:)/i.test(data)); + const href = kind === 'base64' ? `data:${mimeType};base64,${data}` : data; + + return ( +
+ + + {filename || 'Unnamed file'} + + {canDownload ? ( + + + Download + + ) : null} +
+ ); +}; + +export const File = memo(FileImpl); diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/markdown-text.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/markdown-text.tsx new file mode 100644 index 00000000000..c2cf01a35da --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/markdown-text.tsx @@ -0,0 +1,165 @@ +import '@assistant-ui/react-markdown/styles/dot.css'; + +import type { TextMessagePartProps } from '@assistant-ui/react'; +import { + type CodeHeaderProps, + MarkdownTextPrimitive, + unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, + useIsMarkdownCodeBlock, +} from '@assistant-ui/react-markdown'; +import { type FC, memo, useMemo, useRef, useState } from 'react'; +import { RiCheckLine, RiFileCopyLine } from 'react-icons/ri'; +import remarkGfm from 'remark-gfm'; +import { cn } from '@/utils/ui'; +import { TooltipIconButton } from './tooltip-icon-button'; + +type MarkdownTextProps = Partial & { + components?: Parameters[0]; +}; + +const useShallowStable = | undefined>(value: T): T => { + const ref = useRef(value); + if (value !== ref.current) { + const prev = ref.current; + const stable = + value !== undefined && + prev !== undefined && + Object.keys(prev).length === Object.keys(value).length && + Object.keys(value).every((key) => prev[key] === value[key]); + if (!stable) ref.current = value; + } + + return ref.current; +}; + +const MarkdownTextImpl: FC = ({ components }) => { + const stableComponents = useShallowStable(components); + const markdownComponents = useMemo(() => { + if (!stableComponents) return defaultComponents; + + return { + ...defaultComponents, + ...memoizeMarkdownComponents(stableComponents), + }; + }, [stableComponents]); + + return ( + + ); +}; + +export const MarkdownText = memo(MarkdownTextImpl); + +function useCopyToClipboard() { + const [isCopied, setIsCopied] = useState(false); + + const copyToClipboard = async (value: string) => { + await navigator.clipboard.writeText(value); + setIsCopied(true); + window.setTimeout(() => setIsCopied(false), 1500); + }; + + return { isCopied, copyToClipboard }; +} + +const CodeHeader: FC = ({ language, code }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard(); + + return ( +
+ {language} + { + if (!code || isCopied) return; + void copyToClipboard(code); + }} + > + {isCopied ? : } + +
+ ); +}; + +const defaultComponents = memoizeMarkdownComponents({ + h1: ({ className, ...props }) => ( +

+ ), + h2: ({ className, ...props }) => ( +

+ ), + h3: ({ className, ...props }) => ( +

+ ), + h4: ({ className, ...props }) => ( +

+ ), + p: ({ className, ...props }) =>

, + a: ({ className, ...props }) => ( + + ), + blockquote: ({ className, ...props }) => ( +

+ ), + ul: ({ className, ...props }) => ( +
    li]:mt-1', className)} {...props} /> + ), + ol: ({ className, ...props }) => ( +
      li]:mt-1', className)} {...props} /> + ), + hr: ({ className, ...props }) =>
      , + table: ({ className, ...props }) => ( + + ), + th: ({ className, ...props }) => ( + td:first-child]:rounded-es-lg [&:last-child>td:last-child]:rounded-ee-lg', + className + )} + {...props} + /> + ), + li: ({ className, ...props }) =>
    1. , + strong: ({ className, ...props }) => , + pre: ({ className, ...props }) => ( +
      +  ),
      +  code: function Code({ className, ...props }) {
      +    const isCodeBlock = useIsMarkdownCodeBlock();
      +
      +    return (
      +      
      +    );
      +  },
      +  CodeHeader,
      +});
      diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/novu-approval-card.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/novu-approval-card.tsx
      new file mode 100644
      index 00000000000..9291f09e96e
      --- /dev/null
      +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/novu-approval-card.tsx
      @@ -0,0 +1,73 @@
      +import type { ToolCallMessagePartComponent } from '@assistant-ui/react';
      +import { type AlwaysAllowOption, ApprovalCard, type ApprovalState } from './approval-card';
      +
      +function commandPreview(args: unknown, argsText: string | undefined, toolName: string): string {
      +  if (argsText?.trim()) return argsText.trim();
      +  if (args && typeof args === 'object' && Object.keys(args as object).length > 0) {
      +    try {
      +      return JSON.stringify(args, null, 2);
      +    } catch {
      +      return toolName;
      +    }
      +  }
      +
      +  return toolName;
      +}
      +
      +function approvalState(approved: boolean | undefined, resolution: 'cancelled' | 'expired' | undefined): ApprovalState {
      +  if (resolution === 'cancelled' || resolution === 'expired' || approved === false) {
      +    return 'denied';
      +  }
      +  if (approved === undefined) return 'request';
      +
      +  return 'done';
      +}
      +
      +export const NovuApprovalCard: ToolCallMessagePartComponent = ({
      +  toolName,
      +  args,
      +  argsText,
      +  approval,
      +  respondToApproval,
      +}) => {
      +  const options = approval?.options ?? [];
      +
      +  const respond = (optionId: string, approved: boolean) => {
      +    respondToApproval?.({ optionId, approved });
      +  };
      +
      +  let onAllowOnce: (() => void) | undefined;
      +  let onDeny: (() => void) | undefined;
      +  const alwaysAllowOptions: AlwaysAllowOption[] = [];
      +
      +  for (const option of options) {
      +    switch (option.kind) {
      +      case 'allow-once':
      +        onAllowOnce = () => respond(option.id, true);
      +        break;
      +      case 'allow-always':
      +        alwaysAllowOptions.push({
      +          label: option.label ?? 'Always allow',
      +          onSelect: () => respond(option.id, true),
      +        });
      +        break;
      +      case 'reject-once':
      +        onDeny = () => respond(option.id, false);
      +        break;
      +      default:
      +        break;
      +    }
      +  }
      +
      +  return (
      +    
      +  );
      +};
      diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/reasoning.aui.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/reasoning.aui.tsx
      new file mode 100644
      index 00000000000..42d3491c078
      --- /dev/null
      +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/reasoning.aui.tsx
      @@ -0,0 +1,41 @@
      +import { type ReasoningMessagePartComponent } from '@assistant-ui/react';
      +import { memo, type PropsWithChildren } from 'react';
      +import { RiArrowDownSLine, RiBrainLine } from 'react-icons/ri';
      +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/primitives/collapsible';
      +import { MarkdownText } from './markdown-text';
      +
      +export function ReasoningRoot({ streaming, children }: PropsWithChildren<{ streaming?: boolean }>) {
      +  return (
      +    
      +      {children}
      +    
      +  );
      +}
      +
      +export function ReasoningTrigger({ active }: { active?: boolean }) {
      +  return (
      +    
      +      
      +      {active ? 'Reasoning' : 'Reasoning'}
      +      
      +    
      +  );
      +}
      +
      +export function ReasoningContent({ children, ...props }: PropsWithChildren<{ 'aria-busy'?: boolean }>) {
      +  return (
      +    
      +      
      {children}
      +
      + ); +} + +export function ReasoningText({ children }: PropsWithChildren) { + return
      {children}
      ; +} + +const ReasoningImpl: ReasoningMessagePartComponent = () => ; + +// biome-ignore lint/style/useComponentExportOnlyModules: assistant-ui part renderer +export const Reasoning = memo(ReasoningImpl) as unknown as ReasoningMessagePartComponent; +Reasoning.displayName = 'Reasoning'; diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thinking-indicator.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thinking-indicator.tsx new file mode 100644 index 00000000000..f648d6bec4c --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thinking-indicator.tsx @@ -0,0 +1,27 @@ +import type { ComponentProps } from 'react'; +import { Shimmer } from '@/components/ai-elements/shimmer'; +import { cn } from '@/utils/ui'; + +export function ThinkingIndicator({ + label, + className, + ...props +}: Omit, 'children' | 'label'> & { + label: string; +}) { + return ( + + + + {label} + + + ); +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thread.aui.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thread.aui.tsx new file mode 100644 index 00000000000..c754e16930b --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thread.aui.tsx @@ -0,0 +1,478 @@ +import { + ActionBarMorePrimitive, + ActionBarPrimitive, + type AssistantState, + AuiIf, + ComposerPrimitive, + ErrorPrimitive, + groupPartByType, + MessagePrimitive, + SuggestionPrimitive, + ThreadPrimitive, + type ToolCallMessagePartComponent, + useAuiState, +} from '@assistant-ui/react'; +import { + type ComponentType, + createContext, + type FC, + type PropsWithChildren, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { + RiArrowDownLine, + RiArrowUpLine, + RiCheckLine, + RiDownloadLine, + RiFileCopyLine, + RiLoader4Line, + RiMoreLine, +} from 'react-icons/ri'; +import { Button } from '@/components/primitives/button'; +import { Skeleton } from '@/components/primitives/skeleton'; +import { cn } from '@/utils/ui'; +import { MessageChronology } from './day-separator'; +import { MarkdownText } from './markdown-text'; +import { Reasoning, ReasoningContent, ReasoningRoot, ReasoningText, ReasoningTrigger } from './reasoning.aui'; +import { ToolFallback } from './tool-fallback.aui'; +import { ToolGroupContent, ToolGroupRoot, ToolGroupTrigger } from './tool-group.aui'; +import { TooltipIconButton } from './tooltip-icon-button'; + +export type ThreadGroupPart = MessagePrimitive.GroupedParts.GroupPart; + +// biome-ignore lint/style/useComponentExportOnlyModules: grouping helper is used by Thread +export const defaultThreadPartGroupBy = groupPartByType({ + reasoning: ['group-chainOfThought', 'group-reasoning'], + 'tool-call': ['group-chainOfThought', 'group-tool'], + 'standalone-tool-call': [], +}); + +export type ThreadComponents = { + AssistantMessage?: ComponentType | undefined; + UserMessage?: ComponentType | undefined; + Welcome?: ComponentType | undefined; + Indicator?: ComponentType | undefined; + Banner?: ComponentType | undefined; + Composer?: ComponentType<{ autoFocus: boolean }> | undefined; + AfterComposer?: ComponentType | undefined; + ToolFallback?: ToolCallMessagePartComponent | undefined; + ToolGroup?: ComponentType> | undefined; + ReasoningGroup?: ComponentType> | undefined; + groupBy?: typeof defaultThreadPartGroupBy; +}; + +export type ThreadProps = { + components?: ThreadComponents | undefined; + autoFocus?: boolean | undefined; +}; + +const EMPTY_COMPONENTS: ThreadComponents = {}; + +const ThreadComponentsContext = createContext(EMPTY_COMPONENTS); + +const isNewChatView = (s: AssistantState) => + s.thread.messages.length === 0 && (!s.thread.isLoading || s.threads.isLoading); + +const isHistoryLoadingView = (s: AssistantState) => + s.thread.messages.length === 0 && s.thread.isLoading && !s.thread.isDisabled && !s.threads.isLoading; + +const ThreadHistorySkeleton: FC = () => ( + + Loading conversation + +
      + + + +
      + +
      + + +
      +
      +); + +export const Thread: FC = ({ components = EMPTY_COMPONENTS, autoFocus = true }) => { + const isEmpty = useAuiState(isNewChatView); + + return ( + + + + ); +}; + +const ThreadRoot: FC<{ isEmpty: boolean; autoFocus: boolean }> = ({ isEmpty, autoFocus }) => { + const { + Welcome = ThreadWelcome, + Banner, + Composer: ComposerComponent = Composer, + AfterComposer, + } = useContext(ThreadComponentsContext); + + return ( + + +
      + + + + + + + +
      + {() => } +
      + + + + + {Banner ? : null} + + + + {AfterComposer ? : null} + + +
      +
      +
      + ); +}; + +const ThreadMessage: FC = () => { + const { AssistantMessage: AssistantMessageComponent = AssistantMessage } = useContext(ThreadComponentsContext); + const { UserMessage: UserMessageComponent = UserMessage } = useContext(ThreadComponentsContext); + const role = useAuiState((s) => s.message.role); + + return ( + {role === 'user' ? : } + ); +}; + +const ThreadViewportBottomStateSync: FC = () => { + const markerRef = useRef(null); + + useEffect(() => { + const viewport = markerRef.current?.closest('[data-slot="aui_thread-viewport"]'); + if (!viewport) return; + + let frame: number | undefined; + const syncIfAtBottom = () => { + cancelAnimationFrame(frame ?? 0); + frame = requestAnimationFrame(() => { + const bottomDistance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + if (Math.abs(bottomDistance) <= 1) { + viewport.dispatchEvent(new Event('scroll')); + } + }); + }; + + const observer = new MutationObserver((mutations) => { + const reserveChanged = mutations.some((mutation) => { + const target = mutation.target; + if (target instanceof HTMLElement && target.matches('[data-aui-top-anchor-reserve]')) { + return true; + } + + return [...mutation.addedNodes, ...mutation.removedNodes].some( + (node) => + node instanceof HTMLElement && + (node.matches('[data-aui-top-anchor-reserve]') || node.querySelector('[data-aui-top-anchor-reserve]')) + ); + }); + + if (reserveChanged) syncIfAtBottom(); + }); + + observer.observe(viewport, { + attributes: true, + attributeFilter: ['style'], + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + cancelAnimationFrame(frame ?? 0); + }; + }, []); + + return
    2. + ), + td: ({ className, ...props }) => ( + + ), + tr: ({ className, ...props }) => ( +