From ee2308d5e3a64b53ea2344e9b5b207c98f248024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Thu, 12 Mar 2026 15:07:45 +0100 Subject: [PATCH 1/2] chore(dashboard): novu copilot chat ui improvements, filter dangling tool calls fixes NV-7227 (#10270) --- .source | 2 +- .../ai-elements/chain-of-thought.tsx | 5 +- .../ai-sidekick/ai-chat-context.tsx | 91 ++++++-- .../ai-sidekick/assistant-message.tsx | 8 +- .../ai-sidekick/chat-chain-of-thought.tsx | 198 +++++++++++------- .../components/ai-sidekick/message-utils.ts | 13 +- .../dashboard/src/hooks/use-ai-chat-stream.ts | 4 +- 7 files changed, 217 insertions(+), 104 deletions(-) diff --git a/.source b/.source index 0ec418c0cd6..fb927690eee 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 0ec418c0cd62075c015afcafb3ec69dfda5edc24 +Subproject commit fb927690eee20391918c383ca79c673107817cda diff --git a/apps/dashboard/src/components/ai-elements/chain-of-thought.tsx b/apps/dashboard/src/components/ai-elements/chain-of-thought.tsx index 0c244b2fa56..59f35cc2b19 100644 --- a/apps/dashboard/src/components/ai-elements/chain-of-thought.tsx +++ b/apps/dashboard/src/components/ai-elements/chain-of-thought.tsx @@ -83,7 +83,7 @@ export type ChainOfThoughtStepProps = ComponentProps<'div'> & { icon?: IconType | LucideIcon; label?: ReactNode; description?: ReactNode; - status?: 'complete' | 'active' | 'pending'; + status?: 'complete' | 'active' | 'pending' | 'error'; collapsible?: boolean; defaultOpen?: boolean; autoCollapse?: boolean; @@ -105,7 +105,7 @@ export const ChainOfThoughtStep = memo( const [isOpen, setIsOpen] = useState(defaultOpen); useEffect(() => { - if (autoCollapse && status === 'complete') { + if (autoCollapse && (status === 'complete' || status === 'error')) { setIsOpen(false); } }, [autoCollapse, status]); @@ -114,6 +114,7 @@ export const ChainOfThoughtStep = memo( complete: 'text-muted-foreground', active: 'text-foreground', pending: 'text-muted-foreground/50', + error: 'text-muted-foreground', }; return ( diff --git a/apps/dashboard/src/components/ai-sidekick/ai-chat-context.tsx b/apps/dashboard/src/components/ai-sidekick/ai-chat-context.tsx index 56bff2d83bd..89319197485 100644 --- a/apps/dashboard/src/components/ai-sidekick/ai-chat-context.tsx +++ b/apps/dashboard/src/components/ai-sidekick/ai-chat-context.tsx @@ -1,5 +1,5 @@ import { AiAgentTypeEnum, AiMessageRoleEnum, AiResourceTypeEnum } from '@novu/shared'; -import { ChatStatus, DataUIPart, generateId, UIMessage } from 'ai'; +import { ChatStatus, DataUIPart, DynamicToolUIPart, generateId, UIMessage } from 'ai'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { cancelStream } from '@/api/ai'; @@ -12,6 +12,7 @@ import { useFetchLatestAiChat } from '@/hooks/use-fetch-latest-ai-chat'; import { useKeepAiChanges } from '@/hooks/use-keep-ai-changes'; import { useRevertMessage } from '@/hooks/use-revert-message'; import { showErrorToast } from '../primitives/sonner-helpers'; +import { isCancelledToolCall } from './message-utils'; export type ReasoningDataPart = DataUIPart<{ reasoning: { toolCallId: string; text: string } }>; @@ -58,6 +59,57 @@ export type AiChatResourceConfig = { const AiChatContext = createContext(null); +/** + * Strip incomplete tool-call parts and step-start markers from all assistant messages. + * Dangling parts are kept in the DB (so toUIMessageStream can match them to the correct + * assistant message via the values stream), but hidden from the user in the UI. + */ +const cleanupIncompleteToolCalls = (currentMessages: T[]): T[] => { + let changed = false; + + const result = currentMessages.reduce((acc, msg) => { + if (msg.role !== 'assistant') { + acc.push(msg); + + return acc; + } + + const cleanedParts = msg.parts.filter((part) => { + if (part.type === 'step-start') return false; + if (part.type.startsWith('dynamic-tool')) { + const tool = part as DynamicToolUIPart; + if (isCancelledToolCall(tool)) return false; + + return tool.state === 'output-available' || tool.state === 'output-error'; + } + + return true; + }); + + if (cleanedParts.length !== msg.parts.length) { + changed = true; + } + + const hasContent = cleanedParts.some( + (p) => + p.type === 'text' || + (p.type.startsWith('dynamic-tool') && + !isCancelledToolCall(p as DynamicToolUIPart) && + ((p as DynamicToolUIPart).state === 'output-available' || (p as DynamicToolUIPart).state === 'output-error')) + ); + + if (hasContent) { + acc.push(changed ? ({ ...msg, parts: cleanedParts } as T) : msg); + } else { + changed = true; + } + + return acc; + }, []); + + return changed ? result : currentMessages; +}; + export function AiChatProvider({ children, config }: { children: React.ReactNode; config: AiChatResourceConfig }) { const { resourceType, @@ -79,6 +131,8 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode } | null>(null); const isMountedRef = useRef(false); const hasHandledInitialResumeRef = useRef(false); + const isStoppingRef = useRef(false); + const skipMessageSyncRef = useRef(false); const location = useLocation(); const { areEnvironmentsInitialLoading, currentEnvironment } = useEnvironment(); @@ -113,11 +167,14 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode onData({ type: dataType }); } }, - onFinish: ({ isAbort, isDisconnect, isError }) => { + onFinish: ({ isAbort, isDisconnect, isError, messages }) => { + setMessages(cleanupIncompleteToolCalls(messages)); + if (isAbort || isDisconnect || isError) { return; } + skipMessageSyncRef.current = true; refetchLatestChat(); }, }); @@ -129,11 +186,18 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode const isActionPending = isKeepPending || isRevertPending; useEffect(() => { - if (!latestChat || isGenerating) { + if (!latestChat || isGenerating || isStoppingRef.current) { return; } - setMessages(latestChat.messages as typeof messages); + if (skipMessageSyncRef.current) { + skipMessageSyncRef.current = false; + + return; + } + + const latestChatMessages = latestChat.messages as typeof messages; + setMessages(cleanupIncompleteToolCalls(latestChatMessages)); }, [latestChat, isGenerating, setMessages]); useEffect(() => { @@ -175,7 +239,7 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode const handleSendMessage = useCallback( async (message: string) => { - const { resourceType, resourceId, isAborted, latestChat, messages } = dataRef.current; + const { resourceType, resourceId, latestChat, messages } = dataRef.current; const isLastUserMessage = messages.length > 0 && messages[messages.length - 1].role === AiMessageRoleEnum.USER; const messageToSend = message.trim(); @@ -184,7 +248,7 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode if (!latestChat) { const newChat = await createAiChat({ resourceType, resourceId }); sendPrompt({ chatId: newChat._id, prompt: messageToSend }); - } else if (isLastUserMessage || isAborted) { + } else if (isLastUserMessage) { const lastUserMessage = messages.filter((m) => m.role === AiMessageRoleEnum.USER).pop(); sendPrompt({ messageId: lastUserMessage?.id, chatId: latestChat._id, prompt: messageToSend }); } else if (messageToSend) { @@ -342,16 +406,15 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode }, [firstMessageRevert]); const handleStop = useCallback(async () => { + isStoppingRef.current = true; + await stop(); if (latestChat && currentEnvironment && isGenerating) { - cancelStream({ environment: currentEnvironment, chatId: latestChat._id }); - } - stop(); - refetchLatestChat(); - const lastUserMessage = messages.filter((m) => m.role === AiMessageRoleEnum.USER).pop(); - if (lastUserMessage) { - setInputText(lastUserMessage.parts.find((p) => p.type === 'text')?.text ?? ''); + await cancelStream({ environment: currentEnvironment, chatId: latestChat._id }); } - }, [latestChat, currentEnvironment, isGenerating, stop, messages, refetchLatestChat]); + + await refetchLatestChat(); + isStoppingRef.current = false; + }, [latestChat, currentEnvironment, isGenerating, stop, refetchLatestChat]); const isLoading = isResourceLoading || isFetchingAiChat || areEnvironmentsInitialLoading; diff --git a/apps/dashboard/src/components/ai-sidekick/assistant-message.tsx b/apps/dashboard/src/components/ai-sidekick/assistant-message.tsx index bbdc88e8b5c..a02c0038e14 100644 --- a/apps/dashboard/src/components/ai-sidekick/assistant-message.tsx +++ b/apps/dashboard/src/components/ai-sidekick/assistant-message.tsx @@ -1,10 +1,10 @@ -import { UIMessage } from 'ai'; +import { DynamicToolUIPart, UIMessage } from 'ai'; import { useMemo } from 'react'; import { Message } from '../ai-elements/message'; import { ChatChainOfThought } from './chat-chain-of-thought'; import { ChatMessageActions } from './chat-message-actions'; import { StyledMessageResponse } from './chat-message-response'; -import { hasKnownMessageParts } from './message-utils'; +import { hasKnownMessageParts, isCancelledToolCall } from './message-utils'; export const AssistantMessage = ({ message, @@ -29,7 +29,7 @@ export const AssistantMessage = ({ }) => { const isAssistantMessageWithKnownParts = useMemo(() => hasKnownMessageParts(message), [message]); const hasDynamicToolParts = useMemo( - () => message.parts.filter((p) => p.type.startsWith('dynamic-tool')).length > 0, + () => message.parts.some((p) => p.type.startsWith('dynamic-tool') && !isCancelledToolCall(p as DynamicToolUIPart)), [message] ); const textParts = useMemo(() => { @@ -48,7 +48,7 @@ export const AssistantMessage = ({ } return ( - + {hasDynamicToolParts && } {textParts.map((text, i) => ( {text} diff --git a/apps/dashboard/src/components/ai-sidekick/chat-chain-of-thought.tsx b/apps/dashboard/src/components/ai-sidekick/chat-chain-of-thought.tsx index 9d6052f9518..f79e0811be9 100644 --- a/apps/dashboard/src/components/ai-sidekick/chat-chain-of-thought.tsx +++ b/apps/dashboard/src/components/ai-sidekick/chat-chain-of-thought.tsx @@ -1,11 +1,12 @@ import { AiWorkflowToolsEnum } from '@novu/shared'; import { DynamicToolUIPart, UIMessage } from 'ai'; import { AnimatePresence, motion } from 'motion/react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { RiAddBoxLine, RiArrowRightSLine, RiCheckLine, + RiCloseCircleLine, RiDeleteBin2Line, RiEdit2Line, RiLoader3Line, @@ -22,7 +23,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../primitiv import { Skeleton } from '../primitives/skeleton'; import { Tag } from '../primitives/tag'; import { StyledMessageResponse } from './chat-message-response'; -import { unwrapToolResult } from './message-utils'; +import { isCancelledToolCall, unwrapToolResult } from './message-utils'; const toolNameToAction: Record = { [AiWorkflowToolsEnum.ADD_STEP]: 'add', @@ -48,6 +49,10 @@ const BroomIcon = (props: React.ComponentPropsWithoutRef) => { return ; }; +const ErrorCircleIcon = (props: React.ComponentPropsWithoutRef) => { + return ; +}; + type WorkflowMetadataOutput = { name: string; description?: string; @@ -231,36 +236,53 @@ function WorkflowStepItem({ function StepTool({ stepOutput, + error, isStreaming, labelStreaming, labelComplete, + labelError, action, }: { stepOutput?: { stepId: string; name: string; type: string }; + error?: string | null; isStreaming: boolean; labelStreaming: string; labelComplete: string; + labelError: string; action: 'add' | 'edit' | 'remove'; }) { + const hasError = !!error; + const status = hasError ? 'error' : isStreaming ? 'active' : 'complete'; + const icon = hasError ? ErrorCircleIcon : isStreaming ? BroomIcon : CheckCircleIcon; + + const label = isStreaming ? ( + {labelStreaming} + ) : hasError ? ( + {labelError} + ) : ( + + {labelComplete} + + ); + return ( {labelStreaming} - ) : ( - - {labelComplete} - - ) - } - status={isStreaming ? 'active' : 'complete'} - icon={isStreaming ? BroomIcon : CheckCircleIcon} + label={label} + status={status} + icon={icon} collapsible - defaultOpen={true} + defaultOpen={!hasError} + autoCollapse={hasError} > -
- -
+ {hasError ? ( +
+ {error} +
+ ) : ( +
+ +
+ )}
); } @@ -283,6 +305,15 @@ const toolNameToCompleteLabel = { [AiWorkflowToolsEnum.MOVE_STEP]: 'Moved Workflow Step', }; +const toolNameToErrorLabel = { + [AiWorkflowToolsEnum.ADD_STEP]: 'Failed to Add Workflow Step', + [AiWorkflowToolsEnum.ADD_STEP_IN_BETWEEN]: 'Failed to Add Workflow Step In Between', + [AiWorkflowToolsEnum.EDIT_STEP_CONTENT]: 'Failed to Update Workflow Step Content', + [AiWorkflowToolsEnum.UPDATE_STEP_CONDITIONS]: 'Failed to Update Workflow Step Conditions', + [AiWorkflowToolsEnum.REMOVE_STEP]: 'Failed to Remove Workflow Step', + [AiWorkflowToolsEnum.MOVE_STEP]: 'Failed to Move Workflow Step', +}; + const STREAMING_MAX_LINES = 4; const STREAMING_LINE_HEIGHT_REM = 1.25; const STREAMING_MAX_HEIGHT = `${STREAMING_MAX_LINES * STREAMING_LINE_HEIGHT_REM}rem`; @@ -331,74 +362,81 @@ type ChatChainOfThoughtReasoningProps = { }; export function ChatChainOfThought({ message }: ChatChainOfThoughtReasoningProps) { - const parts = message.parts ?? []; + const toolParts = useMemo( + () => + (message.parts ?? []).filter( + (p) => p.type.startsWith('dynamic-tool') && !isCancelledToolCall(p as DynamicToolUIPart) + ) as DynamicToolUIPart[], + [message.parts] + ); return (
- {parts.map((item) => { - if (item.type.startsWith('dynamic-tool')) { - const tool = item as DynamicToolUIPart; - - if (tool.toolName === AiWorkflowToolsEnum.REASONING) { - const input = tool.input as { label?: string; thought?: string } | undefined; - const label = input?.label ?? 'Reasoning...'; - const body = input?.thought ?? ''; - const isStreaming = tool.state !== 'output-available'; - - return ( - {label} - ) : ( - {label} - ) - } - collapsible - autoCollapse - status={isStreaming ? 'active' : 'complete'} - defaultOpen={isStreaming} - > - - - ); - } - - if (tool.toolName === AiWorkflowToolsEnum.SET_WORKFLOW_METADATA) { - return ( - (tool.output)} - isStreaming={tool.state !== 'output-available'} - /> - ); - } else if ( - tool.toolName === AiWorkflowToolsEnum.ADD_STEP || - tool.toolName === AiWorkflowToolsEnum.ADD_STEP_IN_BETWEEN || - tool.toolName === AiWorkflowToolsEnum.EDIT_STEP_CONTENT || - tool.toolName === AiWorkflowToolsEnum.UPDATE_STEP_CONDITIONS || - tool.toolName === AiWorkflowToolsEnum.REMOVE_STEP || - tool.toolName === AiWorkflowToolsEnum.MOVE_STEP - ) { - const streamingLabel = toolNameToStreamingLabel[tool.toolName]; - const completeLabel = toolNameToCompleteLabel[tool.toolName]; - const action = toolNameToAction[tool.toolName]; - - return ( - (tool.output)} - isStreaming={tool.state !== 'output-available'} - labelStreaming={streamingLabel} - labelComplete={completeLabel} - action={action} - /> - ); - } + {toolParts.map((tool) => { + if (tool.toolName === AiWorkflowToolsEnum.REASONING) { + const input = tool.input as { label?: string; thought?: string } | undefined; + const label = input?.label ?? 'Reasoning...'; + const body = input?.thought ?? ''; + const isStreaming = tool.state !== 'output-available'; + + return ( + {label} + ) : ( + {label} + ) + } + collapsible + autoCollapse + status={isStreaming ? 'active' : 'complete'} + defaultOpen={isStreaming} + > + + + ); + } + + if (tool.toolName === AiWorkflowToolsEnum.SET_WORKFLOW_METADATA) { + return ( + (tool.output)} + isStreaming={tool.state !== 'output-available'} + /> + ); + } + + if ( + tool.toolName === AiWorkflowToolsEnum.ADD_STEP || + tool.toolName === AiWorkflowToolsEnum.ADD_STEP_IN_BETWEEN || + tool.toolName === AiWorkflowToolsEnum.EDIT_STEP_CONTENT || + tool.toolName === AiWorkflowToolsEnum.UPDATE_STEP_CONDITIONS || + tool.toolName === AiWorkflowToolsEnum.REMOVE_STEP || + tool.toolName === AiWorkflowToolsEnum.MOVE_STEP + ) { + const streamingLabel = toolNameToStreamingLabel[tool.toolName]; + const completeLabel = toolNameToCompleteLabel[tool.toolName]; + const errorLabel = toolNameToErrorLabel[tool.toolName]; + const action = toolNameToAction[tool.toolName]; + + return ( + (tool.output)} + isStreaming={tool.state !== 'output-available' && tool.state !== 'output-error'} + labelStreaming={streamingLabel} + labelComplete={completeLabel} + labelError={errorLabel} + action={action} + error={tool.state === 'output-error' ? tool.errorText : undefined} + /> + ); } return null; diff --git a/apps/dashboard/src/components/ai-sidekick/message-utils.ts b/apps/dashboard/src/components/ai-sidekick/message-utils.ts index d503fbe0d0e..7dfcf7628b7 100644 --- a/apps/dashboard/src/components/ai-sidekick/message-utils.ts +++ b/apps/dashboard/src/components/ai-sidekick/message-utils.ts @@ -6,13 +6,24 @@ export const hasKnownMessageParts = (message: UIMessage): boolean => { return (message.parts ?? []).some( (p) => - p.type?.startsWith?.('text') || + (p.type?.startsWith?.('text') && + typeof (p as { text?: string }).text === 'string' && + !(p as { text: string }).text.startsWith('{')) || (p.type?.startsWith?.('dynamic-tool') && 'toolName' in p && knownToolNames.includes((p as DynamicToolUIPart).toolName)) ); }; +export function isCancelledToolCall(tool: DynamicToolUIPart): boolean { + return ( + tool.state === 'output-available' && + tool.output != null && + typeof tool.output === 'object' && + '__cancelled' in (tool.output as Record) + ); +} + export function unwrapToolResult(output: unknown): T | undefined { if (output && typeof output === 'object' && 'result' in output) { return (output as { result: T }).result; diff --git a/apps/dashboard/src/hooks/use-ai-chat-stream.ts b/apps/dashboard/src/hooks/use-ai-chat-stream.ts index 8065fd4fa6c..473ba69d2dc 100644 --- a/apps/dashboard/src/hooks/use-ai-chat-stream.ts +++ b/apps/dashboard/src/hooks/use-ai-chat-stream.ts @@ -109,9 +109,9 @@ export function useAiChatStream m.parts.filter((p) => p.type.startsWith('data-'))) as DataUIPart[]; }, [messages]); - const handleStop = useCallback(() => { + const handleStop = useCallback(async () => { setIsAborted(true); - stop(); + await stop(); }, [stop]); return { From b4f1a09e70db46b44f9de40dcd942d464beb1dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Thu, 12 Mar 2026 18:33:33 +0100 Subject: [PATCH 2/2] feat(api-service): novu copilot - user input classification based on meaningfulness and relevance fixes NV-7231 (#10273) --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index fb927690eee..8d8b8fbc774 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit fb927690eee20391918c383ca79c673107817cda +Subproject commit 8d8b8fbc7744d27b6a2c9e5c790def8b6e32e217