chore(dashboard): novu copilot chat ui improvements, filter dangling tool calls fixes NV-7227 (#10270)

This commit is contained in:
Paweł Tymczuk
2026-03-12 15:07:45 +01:00
committed by GitHub
parent 0f40688b3a
commit ee2308d5e3
7 changed files with 213 additions and 100 deletions

Submodule .source updated: 0ec418c0cd...fb927690ee

View File

@@ -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 (

View File

@@ -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<AiChatContextValue | null>(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 = <T extends UIMessage>(currentMessages: T[]): T[] => {
let changed = false;
const result = currentMessages.reduce<T[]>((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 });
await 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 ?? '');
}
}, [latestChat, currentEnvironment, isGenerating, stop, messages, refetchLatestChat]);
await refetchLatestChat();
isStoppingRef.current = false;
}, [latestChat, currentEnvironment, isGenerating, stop, refetchLatestChat]);
const isLoading = isResourceLoading || isFetchingAiChat || areEnvironmentsInitialLoading;

View File

@@ -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 (
<Message from={message.role} key={message.id}>
<Message from={message.role}>
{hasDynamicToolParts && <ChatChainOfThought message={message} />}
{textParts.map((text, i) => (
<StyledMessageResponse key={`text-${message.id}-${i}`}>{text}</StyledMessageResponse>

View File

@@ -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<string, 'add' | 'edit' | 'remove'> = {
[AiWorkflowToolsEnum.ADD_STEP]: 'add',
@@ -48,6 +49,10 @@ const BroomIcon = (props: React.ComponentPropsWithoutRef<typeof Broom>) => {
return <Broom {...props} className={cn('p-0.5', props.className)} />;
};
const ErrorCircleIcon = (props: React.ComponentPropsWithoutRef<typeof RiCloseCircleLine>) => {
return <RiCloseCircleLine {...props} className={cn('p-0.5 rounded-full text-destructive', props.className)} />;
};
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 ? (
<Shimmer className={cn('text-label-xs font-medium')}>{labelStreaming}</Shimmer>
) : hasError ? (
<span className="text-label-xs font-medium">{labelError}</span>
) : (
<span className={cn('flex items-center justify-between gap-1')}>
<span className="text-label-xs font-medium text-text-soft">{labelComplete}</span>
</span>
);
return (
<ChainOfThoughtStep
label={
isStreaming ? (
<Shimmer className={cn('text-label-xs font-medium')}>{labelStreaming}</Shimmer>
) : (
<span className={cn('flex items-center justify-between gap-1')}>
<span className="text-label-xs font-medium text-text-soft">{labelComplete}</span>
</span>
)
}
status={isStreaming ? 'active' : 'complete'}
icon={isStreaming ? BroomIcon : CheckCircleIcon}
label={label}
status={status}
icon={icon}
collapsible
defaultOpen={true}
defaultOpen={!hasError}
autoCollapse={hasError}
>
<div className="flex flex-col gap-2 p-2 pl-0 pr-0">
<WorkflowStepItem output={stepOutput} isStreaming={isStreaming} action={action} />
</div>
{hasError ? (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 my-2 px-2 py-1">
<span className="text-label-xs text-destructive">{error}</span>
</div>
) : (
<div className="flex flex-col gap-2 p-2 pl-0 pr-0">
<WorkflowStepItem output={stepOutput} isStreaming={isStreaming} action={action} />
</div>
)}
</ChainOfThoughtStep>
);
}
@@ -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 (
<ChainOfThought open className="text-text-soft">
<ChainOfThoughtContent className="mb-2">
<div className="flex flex-col gap-3">
{parts.map((item) => {
if (item.type.startsWith('dynamic-tool')) {
const tool = item as DynamicToolUIPart;
{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';
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 (
<ChainOfThoughtStep
key={`${tool.toolCallId}-${tool.toolName}`}
icon={isStreaming ? BroomIcon : CheckCircleIcon}
label={
isStreaming ? (
<Shimmer className={cn('text-label-xs font-medium')}>{label}</Shimmer>
) : (
<span className="text-label-xs font-medium text-text-soft">{label}</span>
)
}
collapsible
autoCollapse
status={isStreaming ? 'active' : 'complete'}
defaultOpen={isStreaming}
>
<ScrollableReasoningBody body={body} isStreaming={isStreaming} />
</ChainOfThoughtStep>
);
}
return (
<ChainOfThoughtStep
key={`${tool.toolCallId}-${tool.toolName}`}
icon={isStreaming ? BroomIcon : CheckCircleIcon}
label={
isStreaming ? (
<Shimmer className={cn('text-label-xs font-medium')}>{label}</Shimmer>
) : (
<span className="text-label-xs font-medium text-text-soft">{label}</span>
)
}
collapsible
autoCollapse
status={isStreaming ? 'active' : 'complete'}
defaultOpen={isStreaming}
>
<ScrollableReasoningBody body={body} isStreaming={isStreaming} />
</ChainOfThoughtStep>
);
}
if (tool.toolName === AiWorkflowToolsEnum.SET_WORKFLOW_METADATA) {
return (
<WorkflowInitializedSection
key={`${tool.toolCallId}-${tool.toolName}`}
output={unwrapToolResult<WorkflowMetadataOutput>(tool.output)}
isStreaming={tool.state !== 'output-available'}
/>
);
}
if (tool.toolName === AiWorkflowToolsEnum.SET_WORKFLOW_METADATA) {
return (
<WorkflowInitializedSection
key={`${tool.toolCallId}-${tool.toolName}`}
output={unwrapToolResult<WorkflowMetadataOutput>(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];
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 (
<StepTool
key={`${tool.toolCallId}-${tool.toolName}`}
stepOutput={unwrapToolResult<{ stepId: string; name: string; type: string }>(tool.output)}
isStreaming={tool.state !== 'output-available'}
labelStreaming={streamingLabel}
labelComplete={completeLabel}
action={action}
/>
);
}
return (
<StepTool
key={`${tool.toolCallId}-${tool.toolName}`}
stepOutput={unwrapToolResult<{ stepId: string; name: string; type: string }>(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;

View File

@@ -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<string, unknown>)
);
}
export function unwrapToolResult<T>(output: unknown): T | undefined {
if (output && typeof output === 'object' && 'result' in output) {
return (output as { result: T }).result;

View File

@@ -109,9 +109,9 @@ export function useAiChatStream<D extends UIDataTypes = UIDataTypes, T extends U
.flatMap((m) => m.parts.filter((p) => p.type.startsWith('data-'))) as DataUIPart<D>[];
}, [messages]);
const handleStop = useCallback(() => {
const handleStop = useCallback(async () => {
setIsAborted(true);
stop();
await stop();
}, [stop]);
return {