diff --git a/.cursor/settings.json b/.cursor/settings.json index 15e13fa632c..faa76db02ed 100644 --- a/.cursor/settings.json +++ b/.cursor/settings.json @@ -5,6 +5,9 @@ }, "linear": { "enabled": true + }, + "clickhouse-cursor-plugin": { + "enabled": true } } } diff --git a/.source b/.source index 7a7d5cafac9..07c3fdf70e9 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 7a7d5cafac90258bed07e2afee67b3a133da36bc +Subproject commit 07c3fdf70e9361536abd25708aba1f6fb57eb4a0 diff --git a/README.md b/README.md index ee7e719ea04..2d437b10a6d 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ > NPM - - npm downloads + npm downloads

diff --git a/apps/api/package.json b/apps/api/package.json index f72d34b60ac..48c7c013935 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -101,7 +101,7 @@ "json-schema-faker": "^0.5.6", "json-schema-to-ts": "^3.0.0", "jsonwebtoken": "9.0.3", - "liquidjs": "^10.25.0", + "liquidjs": "^10.25.5", "lodash": "^4.18.0", "lru-cache": "^11.2.4", "nanoid": "^3.1.20", diff --git a/apps/api/src/app/inbox/dtos/get-notifications-count-request.dto.ts b/apps/api/src/app/inbox/dtos/get-notifications-count-request.dto.ts index a1fb3ab46da..d366ab1c664 100644 --- a/apps/api/src/app/inbox/dtos/get-notifications-count-request.dto.ts +++ b/apps/api/src/app/inbox/dtos/get-notifications-count-request.dto.ts @@ -1,10 +1,10 @@ import { BadRequestException } from '@nestjs/common'; -import { type TagsFilter, SeverityLevelEnum } from '@novu/shared'; +import { SeverityLevelEnum, type TagsFilter } from '@novu/shared'; import { plainToClass, Transform, Type } from 'class-transformer'; import { ArrayMaxSize, IsArray, IsBoolean, IsDefined, IsOptional, ValidateNested } from 'class-validator'; import { IsEnumOrArray } from '../../shared/validators/is-enum-or-array'; -import { IsTagsFilter } from '../validators/is-tags-filter.validator'; import { NotificationFilter } from '../utils/types'; +import { IsTagsFilter } from '../validators/is-tags-filter.validator'; export class NotificationsFilter implements NotificationFilter { @IsOptional() diff --git a/apps/api/src/app/inbox/usecases/delete-all-notifications/delete-all-notifications.command.ts b/apps/api/src/app/inbox/usecases/delete-all-notifications/delete-all-notifications.command.ts index 5c0304a36ec..47f0c7d68cd 100644 --- a/apps/api/src/app/inbox/usecases/delete-all-notifications/delete-all-notifications.command.ts +++ b/apps/api/src/app/inbox/usecases/delete-all-notifications/delete-all-notifications.command.ts @@ -3,8 +3,8 @@ import { Type } from 'class-transformer'; import { IsBoolean, IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; -import { IsTagsFilter } from '../../validators/is-tags-filter.validator'; import { NotificationFilter } from '../../utils/types'; +import { IsTagsFilter } from '../../validators/is-tags-filter.validator'; class Filter implements NotificationFilter { @IsOptional() diff --git a/apps/api/src/app/inbox/usecases/get-notifications/get-notifications.command.ts b/apps/api/src/app/inbox/usecases/get-notifications/get-notifications.command.ts index 94d022f7b75..620dcebaefd 100644 --- a/apps/api/src/app/inbox/usecases/get-notifications/get-notifications.command.ts +++ b/apps/api/src/app/inbox/usecases/get-notifications/get-notifications.command.ts @@ -1,4 +1,4 @@ -import { type TagsFilter, SeverityLevelEnum } from '@novu/shared'; +import { SeverityLevelEnum, type TagsFilter } from '@novu/shared'; import { IsBoolean, IsDefined, IsInt, IsMongoId, IsOptional, IsString, Max, Min } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; diff --git a/apps/api/src/app/inbox/usecases/notifications-count/notifications-count.command.ts b/apps/api/src/app/inbox/usecases/notifications-count/notifications-count.command.ts index 16fee6eb681..40beb8ea8c3 100644 --- a/apps/api/src/app/inbox/usecases/notifications-count/notifications-count.command.ts +++ b/apps/api/src/app/inbox/usecases/notifications-count/notifications-count.command.ts @@ -1,10 +1,10 @@ import { SubscriberEntity } from '@novu/dal'; -import { type TagsFilter, SeverityLevelEnum } from '@novu/shared'; +import { SeverityLevelEnum, type TagsFilter } from '@novu/shared'; import { IsArray, IsBoolean, IsDefined, IsOptional } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; import { IsEnumOrArray } from '../../../shared/validators/is-enum-or-array'; -import { IsTagsFilter } from '../../validators/is-tags-filter.validator'; import { NotificationFilter } from '../../utils/types'; +import { IsTagsFilter } from '../../validators/is-tags-filter.validator'; class NotificationsFilter implements NotificationFilter { @IsOptional() diff --git a/apps/api/src/app/inbox/usecases/update-all-notifications/update-all-notifications.command.ts b/apps/api/src/app/inbox/usecases/update-all-notifications/update-all-notifications.command.ts index d6735144e77..d98092df737 100644 --- a/apps/api/src/app/inbox/usecases/update-all-notifications/update-all-notifications.command.ts +++ b/apps/api/src/app/inbox/usecases/update-all-notifications/update-all-notifications.command.ts @@ -3,8 +3,8 @@ import { Type } from 'class-transformer'; import { IsBoolean, IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; -import { IsTagsFilter } from '../../validators/is-tags-filter.validator'; import { NotificationFilter } from '../../utils/types'; +import { IsTagsFilter } from '../../validators/is-tags-filter.validator'; class Filter implements NotificationFilter { @IsOptional() diff --git a/apps/api/src/utils/payload-sanitizer.ts b/apps/api/src/utils/payload-sanitizer.ts index 00d0107a4cc..101326da6e8 100644 --- a/apps/api/src/utils/payload-sanitizer.ts +++ b/apps/api/src/utils/payload-sanitizer.ts @@ -15,18 +15,3 @@ export function sanitizePayload(payload: Record): string { return '[Unserializable Payload]'; } } - -export async function retryWithBackoff(fn: () => Promise, maxAttempts = 3, initialDelayMs = 100): Promise { - let delay = initialDelayMs; - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - try { - return await fn(); - } catch (err) { - if (attempt === maxAttempts - 1) throw err; - const currentDelay = delay; - await new Promise((resolve) => setTimeout(resolve, currentDelay)); - delay *= 2; - } - } - throw new Error('Max attempts reached'); -} diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 1854b3d08c7..5f561ce2f5f 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -110,7 +110,7 @@ "json-schema": "^0.4.0", "json5": "^2.2.3", "launchdarkly-react-client-sdk": "^3.9.0", - "liquidjs": "^10.25.0", + "liquidjs": "^10.25.5", "lodash.debounce": "^4.0.8", "lodash.isequal": "^4.5.0", "lodash.merge": "^4.6.2", 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 e5cb6cba2ab..9ef8c7f5302 100644 --- a/apps/dashboard/src/components/ai-sidekick/ai-chat-context.tsx +++ b/apps/dashboard/src/components/ai-sidekick/ai-chat-context.tsx @@ -1,7 +1,9 @@ import { AiAgentTypeEnum, AiMessageRoleEnum, AiResourceTypeEnum } from '@novu/shared'; import * as Sentry from '@sentry/react'; -import { ChatStatus, DataUIPart, DynamicToolUIPart, generateId, UIMessage } from 'ai'; -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { ChatStatus, DataUIPart, DynamicToolUIPart, UIMessage } from 'ai'; +import { createContext, FC, SVGProps, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { IconType } from 'react-icons'; import { useLocation } from 'react-router-dom'; import { cancelStream } from '@/api/ai'; import { ConfirmationModal } from '@/components/confirmation-modal'; @@ -13,6 +15,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 { useTelemetry } from '@/hooks/use-telemetry'; +import { QueryKeys } from '@/utils/query-keys'; import { TelemetryEvent } from '@/utils/telemetry'; import { showErrorToast } from '../primitives/sonner-helpers'; import { isCancelledToolCall } from './message-utils'; @@ -33,17 +36,20 @@ export type AiChatContextValue = { isActionPending: boolean; isReviewingChanges: boolean; inputText: string; + newChatSuggestions?: { label: string; icon: IconType | FC> }[]; setInputText: (text: string) => void; handleSendMessage: (message: string) => Promise; handleKeepAll: () => Promise; handleTryAgain: (messageId: string) => Promise; handleRevertMessage: (messageId: string) => Promise; handleDiscard: (messageId: string) => Promise; + handleSuggestionClick: (suggestion: string) => void; }; export type AiChatResourceConfig = { resourceType: AiResourceTypeEnum; resourceId?: string; + newChatSuggestions?: { label: string; icon: IconType | FC> }[]; agentType: AiAgentTypeEnum; metadata?: Record; isResourceLoading?: boolean; @@ -118,6 +124,7 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode const { resourceType, resourceId, + newChatSuggestions, agentType, metadata, isResourceLoading = false, @@ -139,8 +146,10 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode const hasHandledInitialResumeRef = useRef(false); const isStoppingRef = useRef(false); const skipMessageSyncRef = useRef(false); + const pendingSendRef = useRef<{ chatId: string; prompt: string; metadata: Record } | null>(null); const location = useLocation(); const { areEnvironmentsInitialLoading, currentEnvironment } = useEnvironment(); + const queryClient = useQueryClient(); const { latestChat, @@ -158,7 +167,7 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode return location.state.chatId as string; } - return latestChat?._id ?? generateId(); + return latestChat?._id; }, [location, latestChat]); const { setMessages, sendPrompt, stop, status, isGenerating, messages, dataParts, isAborted, resume, error } = @@ -203,6 +212,7 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode }, }); const dataRef = useDataRef({ + currentEnvironment, isGenerating, resourceType, resourceId, @@ -249,6 +259,14 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode } }, [latestChat, resume, track, dataRef]); + useEffect(() => { + if (chatId && pendingSendRef.current && chatId === pendingSendRef.current.chatId) { + const pending = pendingSendRef.current; + pendingSendRef.current = null; + sendPrompt({ chatId: pending.chatId, prompt: pending.prompt, metadata: pending.metadata }); + } + }, [chatId, sendPrompt]); + useEffect(() => { isMountedRef.current = true; @@ -278,7 +296,8 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode const handleSendMessage = useCallback( async (message: string) => { - const { resourceType, resourceId, agentType, latestChat, messages, metadata } = dataRef.current; + const { resourceType, resourceId, agentType, latestChat, messages, metadata, currentEnvironment } = + dataRef.current; const isLastUserMessage = messages.length > 0 && messages[messages.length - 1].role === AiMessageRoleEnum.USER; const messageToSend = message.trim(); @@ -286,12 +305,16 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode if (!latestChat) { const newChat = await createAiChat({ resourceType, resourceId }); + queryClient.setQueryData([QueryKeys.fetchChat, currentEnvironment?._id, resourceType, resourceId], newChat); track(TelemetryEvent.COPILOT_CHAT_CREATED, { chatId: newChat._id, resourceType, agentType, }); - sendPrompt({ chatId: newChat._id, prompt: messageToSend, metadata: { ...metadata } }); + // we don't pre-create the chat until the user sends a message and the useAiChatStream hook uses the autogenerated chatId + // if we update the chatId right away here, the useAiChatStream hook will reset its state and the user sent message and stream will be lost + // defer sending the message to the stream until the chat is created and chatId is updated for the useAiChatStream hook + pendingSendRef.current = { chatId: newChat._id, prompt: messageToSend, metadata: { ...metadata } }; } else if (isLastUserMessage) { const lastUserMessage = messages.filter((m) => m.role === AiMessageRoleEnum.USER).pop(); sendPrompt({ @@ -313,7 +336,7 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode setInputText(''); }, - [dataRef, createAiChat, sendPrompt, track] + [dataRef, queryClient, createAiChat, sendPrompt, track] ); const handleKeepAll = useCallback(async () => { @@ -540,12 +563,14 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode isActionPending, isReviewingChanges, inputText, + newChatSuggestions, setInputText, handleSendMessage, handleKeepAll, handleTryAgain, handleRevertMessage, handleDiscard, + handleSuggestionClick: handleSendMessage, }), [ hasNoChatHistory, @@ -561,6 +586,7 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode isActionPending, isReviewingChanges, inputText, + newChatSuggestions, handleSendMessage, handleKeepAll, handleTryAgain, diff --git a/apps/dashboard/src/components/ai-sidekick/chat-body.tsx b/apps/dashboard/src/components/ai-sidekick/chat-body.tsx index 0bd39e9c056..6897ec75ab4 100644 --- a/apps/dashboard/src/components/ai-sidekick/chat-body.tsx +++ b/apps/dashboard/src/components/ai-sidekick/chat-body.tsx @@ -1,5 +1,6 @@ import { ChatStatus, UIMessage } from 'ai'; -import { FormEvent, useMemo } from 'react'; +import { FC, FormEvent, SVGProps, useMemo } from 'react'; +import { IconType } from 'react-icons'; import { Conversation, ConversationContent, ConversationScrollButton } from '../ai-elements/conversation'; import { Message } from '../ai-elements/message'; import { @@ -12,6 +13,7 @@ import { } from '../ai-elements/prompt-input'; import { Broom } from '../icons/broom'; import { BroomSparkle } from '../icons/broom-sparkle'; +import { Badge, BadgeIcon } from '../primitives/badge'; import { Skeleton } from '../primitives/skeleton'; import { AssistantMessage } from './assistant-message'; import { hasKnownMessageParts } from './message-utils'; @@ -57,8 +59,51 @@ export const ChatBodySkeleton = () => { ); }; +const ChatBodyNoHistory = ({ + newChatSuggestions, + isGenerating, + onSuggestionClick, +}: { + newChatSuggestions?: { label: string; icon: IconType | FC> }[]; + isGenerating: boolean; + onSuggestionClick: (suggestion: string) => void; +}) => { + return ( +
+
+
+ + + Novu Copilot + +
+ + Suggests improvements, fills gaps, and applies best practices as you build.{' '} + +
+
+ {newChatSuggestions?.map((suggestion) => ( + onSuggestionClick(suggestion.label)} + > + + {suggestion.label} + + ))} +
+
+ ); +}; + export const ChatBody = ({ hasNoChatHistory, + newChatSuggestions, inputText, onInputChange, isGenerating, @@ -75,8 +120,10 @@ export const ChatBody = ({ onDiscard, onTryAgain, onRevertMessage, + onSuggestionClick, }: { hasNoChatHistory: boolean; + newChatSuggestions?: { label: string; icon: IconType | FC> }[]; inputText: string; onInputChange: (text: string) => void; isGenerating: boolean; @@ -93,6 +140,7 @@ export const ChatBody = ({ onDiscard: (messageId: string) => void; onTryAgain: (messageId: string) => void; onRevertMessage: (messageId: string) => void; + onSuggestionClick: (suggestion: string) => void; }) => { const hasLastUserMessage = messages.length === 0 || messages[messages.length - 1].role === 'user'; const lastMessage = messages[messages.length - 1]; @@ -119,19 +167,11 @@ export const ChatBody = ({ <> {hasNoChatHistory && messages.length === 0 ? ( -
-
-
- - - Novu Copilot - -
- - Suggests improvements, fills gaps, and applies best practices as you build.{' '} - -
-
+ ) : ( + + {showStreaming ? ( + + + + + + ) : ( + setIsDrawerOpen(true) : undefined} + > +
+ +
+ Payload Schema + + {addedCount > 0 && ( + + {addedCount} added + + )} + {removedCount > 0 && ( + + {removedCount} removed + + )} + {addedCount === 0 && removedCount === 0 && ( + + Modified + + )} + +
+ )} +
+ + {isClickable && ( + + )} + + ); +} + +function PayloadSchemaTool({ + output, + error, + isStreaming, +}: { + output?: PayloadSchemaOutput; + error?: string | null; + isStreaming: boolean; +}) { + const hasError = !!error; + const status = hasError ? 'error' : isStreaming ? 'active' : 'complete'; + const icon = hasError ? ErrorCircleIcon : isStreaming ? BroomIcon : CheckCircleIcon; + + const label = isStreaming ? ( + Updating Payload Schema + ) : hasError ? ( + Failed to Update Payload Schema + ) : ( + + Updated Payload Schema + + ); + + return ( + + {hasError ? ( +
+ {error} +
+ ) : ( +
+ +
+ )} +
+ ); +} + const toolNameToStreamingLabel = { [AiWorkflowToolsEnum.ADD_STEP]: 'Drafting Workflow Step', [AiWorkflowToolsEnum.ADD_STEP_IN_BETWEEN]: 'Drafting Workflow Step In Between', @@ -473,6 +588,17 @@ export function ChatChainOfThought({ message }: ChatChainOfThoughtReasoningProps ); } + if (tool.toolName === AiWorkflowToolsEnum.UPDATE_PAYLOAD_SCHEMA) { + return ( + (tool.output)} + isStreaming={tool.state !== 'output-available' && tool.state !== 'output-error'} + error={tool.state === 'output-error' ? tool.errorText : undefined} + /> + ); + } + return null; })} diff --git a/apps/dashboard/src/components/ai-sidekick/novu-copilot-panel.tsx b/apps/dashboard/src/components/ai-sidekick/novu-copilot-panel.tsx index 2e35f78a38f..eafa7403e08 100644 --- a/apps/dashboard/src/components/ai-sidekick/novu-copilot-panel.tsx +++ b/apps/dashboard/src/components/ai-sidekick/novu-copilot-panel.tsx @@ -20,12 +20,14 @@ export function NovuCopilotPanel({ hideHeader }: { hideHeader?: boolean }) { isReviewingChanges, inputText, lastUserMessageId, + newChatSuggestions, setInputText, handleSendMessage, handleKeepAll, handleTryAgain, handleRevertMessage, handleDiscard, + handleSuggestionClick, } = useAiChat(); return ( @@ -97,7 +99,9 @@ export function NovuCopilotPanel({ hideHeader }: { hideHeader?: boolean }) { onDiscard={handleDiscard} onTryAgain={handleTryAgain} onRevertMessage={handleRevertMessage} + onSuggestionClick={handleSuggestionClick} lastUserMessageId={lastUserMessageId} + newChatSuggestions={newChatSuggestions} /> )} diff --git a/apps/dashboard/src/components/analytics/utils/chart-validation.ts b/apps/dashboard/src/components/analytics/utils/chart-validation.ts index 473c601e7db..4633432ece6 100644 --- a/apps/dashboard/src/components/analytics/utils/chart-validation.ts +++ b/apps/dashboard/src/components/analytics/utils/chart-validation.ts @@ -60,11 +60,7 @@ export function createDateBasedHasDataChecker( }; } -function hasMinimumEntries( - data: T[], - hasDataForItem: (item: T) => boolean, - minimumEntries: number = 2 -): boolean { +function hasMinimumEntries(data: T[], hasDataForItem: (item: T) => boolean, minimumEntries: number = 2): boolean { if (!data || data.length === 0) { return false; } diff --git a/apps/dashboard/src/components/icons/code-2.tsx b/apps/dashboard/src/components/icons/code-2.tsx index 26c2e7b42b3..cb0145bdb62 100644 --- a/apps/dashboard/src/components/icons/code-2.tsx +++ b/apps/dashboard/src/components/icons/code-2.tsx @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/correctness/useUniqueElementIds: expected */ export const Code2: React.FC> = (props) => ( @@ -17,37 +18,37 @@ export const Code2: React.FC> = (props) => ( fillRule="evenodd" clipRule="evenodd" d="M3.33689 1.32316C3.83618 1.04377 4.38253 0.953125 4.74922 0.953125C5.05298 0.953125 5.29922 1.19937 5.29922 1.50313C5.29922 1.80688 5.05298 2.05313 4.74922 2.05313C4.53258 2.05313 4.17893 2.11248 3.87405 2.28309C3.58629 2.44412 3.35888 2.69073 3.29174 3.09354C3.24019 3.40283 3.23703 3.77138 3.23329 4.20882C3.23313 4.22768 3.23296 4.24665 3.23279 4.26576C3.22895 4.70422 3.22211 5.20653 3.13308 5.66949C3.04323 6.13668 2.85861 6.62771 2.45011 6.99704C2.03501 7.37233 1.46589 7.55313 0.749219 7.55313C0.445463 7.55313 0.199219 7.30688 0.199219 7.00313C0.199219 6.69937 0.445463 6.45313 0.749219 6.45313C1.28255 6.45313 1.55718 6.32142 1.7124 6.18109C1.87421 6.03479 1.98646 5.80707 2.05287 5.46176C2.12009 5.11222 2.12887 4.70828 2.13284 4.25611C2.13309 4.22683 2.13333 4.19727 2.13356 4.16747C2.13678 3.76154 2.14035 3.31086 2.20671 2.91271C2.33957 2.11552 2.82049 1.61213 3.33689 1.32316Z" - fill="#7D52F4" + fill="currentColor" /> diff --git a/apps/dashboard/src/components/layouts/layout-editor-settings-drawer.tsx b/apps/dashboard/src/components/layouts/layout-editor-settings-drawer.tsx index 09342507ed6..abbeeddccde 100644 --- a/apps/dashboard/src/components/layouts/layout-editor-settings-drawer.tsx +++ b/apps/dashboard/src/components/layouts/layout-editor-settings-drawer.tsx @@ -167,8 +167,7 @@ export const LayoutEditorSettingsDrawer = forwardRef { @@ -183,8 +182,7 @@ export const LayoutEditorSettingsDrawer = forwardRef((props, ref) => { const key = normalizeTag(newTag); if (!newTag || !key) return; - const existingNormalized = new Set( - tags.map((t) => normalizeTag(t ?? '')).filter((n) => n.length > 0) - ); + const existingNormalized = new Set(tags.map((t) => normalizeTag(t ?? '')).filter((n) => n.length > 0)); if (existingNormalized.has(key)) return; const newTags = [...tags, newTag]; diff --git a/apps/dashboard/src/components/schema-editor/components/property-name-input.tsx b/apps/dashboard/src/components/schema-editor/components/property-name-input.tsx index f743803fbb0..434067f7f12 100644 --- a/apps/dashboard/src/components/schema-editor/components/property-name-input.tsx +++ b/apps/dashboard/src/components/schema-editor/components/property-name-input.tsx @@ -34,7 +34,7 @@ export const PropertyNameInput = memo(function PropertyNameInput({ return ( - + - + 0) { - parts.push(`${changes.deleted.length} deleted`); - } - - if (changes.added.length > 0) { - parts.push(`${changes.added.length} added`); - } - - if (changes.typeChanged.length > 0) { - parts.push(`${changes.typeChanged.length} type changed`); - } - - if (changes.requiredChanged.length > 0) { - parts.push(`${changes.requiredChanged.length} required status changed`); - } - - return parts.join(', '); -} diff --git a/apps/dashboard/src/components/template-store/workflow-sidebar.tsx b/apps/dashboard/src/components/template-store/workflow-sidebar.tsx index 16788deaa82..2b3a9d8d21e 100644 --- a/apps/dashboard/src/components/template-store/workflow-sidebar.tsx +++ b/apps/dashboard/src/components/template-store/workflow-sidebar.tsx @@ -220,7 +220,7 @@ export function WorkflowSidebar({ selectedCategory, onCategorySelect }: Workflow }, { key: 'code-based', - icon: , + icon: , label: 'Code-based workflow', hasExternalLink: true, bgColor: 'bg-blue-50', diff --git a/apps/dashboard/src/components/variable/edit-variable-popover.tsx b/apps/dashboard/src/components/variable/edit-variable-popover.tsx index 3242c018d01..860501a2b26 100644 --- a/apps/dashboard/src/components/variable/edit-variable-popover.tsx +++ b/apps/dashboard/src/components/variable/edit-variable-popover.tsx @@ -228,7 +228,7 @@ export const EditVariablePopover = ({ - + { +const parseParams = (input: string) => { if (!input) return ''; return input .split(',') diff --git a/apps/dashboard/src/components/webhooks/webhooks-empty-state-svg.tsx b/apps/dashboard/src/components/webhooks/webhooks-empty-state-svg.tsx index 8ae105cf3ec..a2c58d4346f 100644 --- a/apps/dashboard/src/components/webhooks/webhooks-empty-state-svg.tsx +++ b/apps/dashboard/src/components/webhooks/webhooks-empty-state-svg.tsx @@ -138,25 +138,11 @@ function WebhooksEmptyStateSvg() { - + - + @@ -172,14 +158,7 @@ function WebhooksEmptyStateSvg() { - + @@ -187,25 +166,11 @@ function WebhooksEmptyStateSvg() { - + - + diff --git a/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx b/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx index 2f24d49b906..164fe80304c 100644 --- a/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx +++ b/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx @@ -71,7 +71,7 @@ function stepsByMethod( return manualSteps; } -export const customizationTip = { +const customizationTip = { title: 'Tip:', description: ( <> @@ -89,7 +89,7 @@ export const customizationTip = { ), }; -export const commonInstallStep = (packageName: string): InstallationStep => ({ +const commonInstallStep = (packageName: string): InstallationStep => ({ title: 'Install the package', description: `${packageName} is the package that powers the notification center.`, code: `npm install ${packageName}`, @@ -97,7 +97,7 @@ export const commonInstallStep = (packageName: string): InstallationStep => ({ codeTitle: 'Terminal', }); -export const commonCLIInstallStep = (): InstallationStep => ({ +const commonCLIInstallStep = (): InstallationStep => ({ title: 'Run the CLI command in an existing project', description: `You'll notice a new folder in your project called inbox. This is where you'll find the inbox component boilerplate code. \n You can customize the component to match your app theme.`, code: `npx add-inbox@latest --appId YOUR_APPLICATION_IDENTIFIER --subscriberId YOUR_SUBSCRIBER_ID${cliFlags}`, @@ -105,7 +105,7 @@ export const commonCLIInstallStep = (): InstallationStep => ({ codeTitle: 'Terminal', }); -export const commonAIAssistInstallStep = ( +const commonAIAssistInstallStep = ( frameworkName: string, applicationIdentifier: string, subscriberId: string @@ -428,6 +428,3 @@ novu.mountComponent({ ), }, ]; - -// Export a default frameworks array for backward compatibility -export const frameworks = getFrameworks('manual'); diff --git a/apps/dashboard/src/components/workflow-editor/condition-badge.tsx b/apps/dashboard/src/components/workflow-editor/condition-badge.tsx index 667eb5a1926..97164e23b3f 100644 --- a/apps/dashboard/src/components/workflow-editor/condition-badge.tsx +++ b/apps/dashboard/src/components/workflow-editor/condition-badge.tsx @@ -73,4 +73,3 @@ export const ConditionBadge = ({ conditionsCount, stepSlug, conditionsData, clas ); }; - diff --git a/apps/dashboard/src/components/workflow-editor/editor-breadcrumbs.tsx b/apps/dashboard/src/components/workflow-editor/editor-breadcrumbs.tsx index 1b1d6c99cfd..18469ff8624 100644 --- a/apps/dashboard/src/components/workflow-editor/editor-breadcrumbs.tsx +++ b/apps/dashboard/src/components/workflow-editor/editor-breadcrumbs.tsx @@ -196,7 +196,9 @@ function StepBreadcrumb({ step }: { step: StepResponseDto }) { - {step.name || STEP_TYPE_LABELS[step.type]} + + {step.name || STEP_TYPE_LABELS[step.type]} + diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/configure-email-step-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/configure-email-step-preview.tsx index d5afbf38abd..6a46d115fc5 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/configure-email-step-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/configure-email-step-preview.tsx @@ -30,11 +30,7 @@ const MiniEmailPreview = (props: MiniEmailPreviewProps) => {
-
- {children} -
+
{children}
); diff --git a/apps/dashboard/src/components/workflow-editor/steps/hooks/use-preview-data-initialization.ts b/apps/dashboard/src/components/workflow-editor/steps/hooks/use-preview-data-initialization.ts index 1fc31df5f0a..aa2c22e2463 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/hooks/use-preview-data-initialization.ts +++ b/apps/dashboard/src/components/workflow-editor/steps/hooks/use-preview-data-initialization.ts @@ -9,7 +9,7 @@ type InitializationProps = { stepId?: string; environmentId?: string; value: string; - onChange: (value: string) => void; + onChange: (value: string) => unknown; workflow?: WorkflowResponseDto; isPayloadSchemaEnabled: boolean; loadPersistedPayload: () => PayloadData | null; diff --git a/apps/dashboard/src/components/workflow-editor/steps/http-request/request-endpoint.tsx b/apps/dashboard/src/components/workflow-editor/steps/http-request/request-endpoint.tsx index fdc67e07299..1b50b0f8109 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/http-request/request-endpoint.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/http-request/request-endpoint.tsx @@ -145,11 +145,7 @@ export function RequestEndpoint() { control={control} name="url" render={({ field }) => ( - + )} /> diff --git a/apps/dashboard/src/components/workflow-editor/steps/preview-context-panel.tsx b/apps/dashboard/src/components/workflow-editor/steps/preview-context-panel.tsx index 666906e2691..42f5f5ffd4f 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/preview-context-panel.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/preview-context-panel.tsx @@ -139,44 +139,44 @@ export function PreviewContextPanel({ }); // Use the preview context hook with persistence callback - const { accordionValue, setAccordionValue, errors, previewContext, updatePreviewSection } = usePreviewContext< - ParsedData, - ValidationErrors - >({ - value, - onChange, - defaultAccordionValue: DEFAULT_ACCORDION_VALUES, - defaultErrors: { - subscriber: null, - payload: null, - steps: null, - context: null, - env: null, - }, - parseJsonValue, - onDataPersist: (data: ParsedData) => { - // Persist payload, subscriber and context data - if (data.payload !== undefined) { - savePersistedPayload(data.payload); - } + const { accordionValue, setAccordionValue, errors, previewContext, updatePreviewSection, trackedOnChange } = + usePreviewContext({ + value, + onChange, + defaultAccordionValue: DEFAULT_ACCORDION_VALUES, + defaultErrors: { + subscriber: null, + payload: null, + steps: null, + context: null, + env: null, + }, + parseJsonValue, + onDataPersist: (data: ParsedData) => { + if (data.payload !== undefined) { + savePersistedPayload(data.payload); + } - if (data.subscriber !== undefined) { - savePersistedSubscriber(data.subscriber); - } + if (data.subscriber !== undefined) { + savePersistedSubscriber(data.subscriber); + } - if (data.context !== undefined) { - savePersistedContext(data.context); - } - }, - }); + if (data.context !== undefined) { + savePersistedContext(data.context); + } + }, + }); - // Initialize data using the new simplified hook + // Initialize data using the new simplified hook. + // trackedOnChange keeps a synchronous ref of the latest value so that + // subsequent updatePreviewSection calls in the same render cycle + // (e.g. the subscriber-default effect) read fresh data instead of stale props. usePreviewDataInitialization({ workflowId: workflow?.workflowId, stepId: currentStepId, environmentId: currentEnvironment?._id, value, - onChange, + onChange: trackedOnChange, workflow, isPayloadSchemaEnabled, loadPersistedPayload, diff --git a/apps/dashboard/src/components/workflow-editor/steps/shared/editable-json-viewer/editable-json-viewer.tsx b/apps/dashboard/src/components/workflow-editor/steps/shared/editable-json-viewer/editable-json-viewer.tsx index 774b206c8da..6d6104cc893 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/shared/editable-json-viewer/editable-json-viewer.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/shared/editable-json-viewer/editable-json-viewer.tsx @@ -2,7 +2,7 @@ import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import { CustomNodeDefinition, JsonEditor, UpdateFunctionProps } from 'json-edit-react'; import JSON5 from 'json5'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { InlineToast } from '@/components/primitives/inline-toast'; import { cn } from '@/utils/ui'; import { CUSTOM_THEME } from './constants'; @@ -88,24 +88,21 @@ export function EditableJsonViewer({ } }, [value, validateData]); - const handleUpdate = useMemo( - () => (updatedData: UpdateFunctionProps) => { + const handleUpdate = useCallback( + (updatedData: UpdateFunctionProps) => { validateData(updatedData.newData); onChange(updatedData.newData); }, [onChange, validateData] ); - const handleError = useMemo( - () => (errorData: any) => { - const { error, path } = errorData; - const pathString = Array.isArray(path) ? path.join('.') : path || ''; - const errorMessage = pathString ? `${pathString}: ${error.message}` : error.message; + const handleError = useCallback((errorData: any) => { + const { error, path } = errorData; + const pathString = Array.isArray(path) ? path.join('.') : path || ''; + const errorMessage = pathString ? `${pathString}: ${error.message}` : error.message; - setValidationErrors([errorMessage]); - }, - [] - ); + setValidationErrors([errorMessage]); + }, []); useHideRootNode(containerRef, value); diff --git a/apps/dashboard/src/components/workflow-editor/steps/skip-conditions-button.tsx b/apps/dashboard/src/components/workflow-editor/steps/skip-conditions-button.tsx index 52a58e4dfe9..e8d17cd2375 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/skip-conditions-button.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/skip-conditions-button.tsx @@ -7,8 +7,7 @@ import { Button } from '@/components/primitives/button'; import { useConditionsCount } from '@/hooks/use-conditions-count'; import { cn } from '@/utils/ui'; -const SIDEPANEL_ACTION_ROW_BASE_CLASS = - 'flex h-12 w-full justify-start gap-1.5 rounded-none px-3 text-xs font-medium'; +const SIDEPANEL_ACTION_ROW_BASE_CLASS = 'flex h-12 w-full justify-start gap-1.5 rounded-none px-3 text-xs font-medium'; type SkipConditionsButtonProps = { origin: ResourceOriginEnum; diff --git a/apps/dashboard/src/components/workflow-editor/steps/step-editor-layout.tsx b/apps/dashboard/src/components/workflow-editor/steps/step-editor-layout.tsx index 2e802a5949f..1b36421c719 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/step-editor-layout.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/step-editor-layout.tsx @@ -1,20 +1,33 @@ import { AiAgentTypeEnum, AiResourceTypeEnum, + AiWorkflowSuggestion, ContentIssueEnum, EnvironmentTypeEnum, FeatureFlagsKeysEnum, PermissionsEnum, ResourceOriginEnum, StepResponseDto, + StepTypeEnum, WorkflowResponseDto, } from '@novu/shared'; -import { useMemo, useState } from 'react'; -import { RiCodeBlock, RiEdit2Line, RiEyeLine, RiGitCommitFill, RiLinkUnlinkM, RiPlayCircleLine } from 'react-icons/ri'; +import { FC, SVGProps, useMemo, useState } from 'react'; +import { IconType } from 'react-icons'; +import { + RiCodeBlock, + RiEdit2Line, + RiEyeLine, + RiGitCommitFill, + RiLinkUnlinkM, + RiListCheck3, + RiPlayCircleLine, + RiQuillPenLine, +} from 'react-icons/ri'; import { useNavigate, useParams } from 'react-router-dom'; import { AiChatProvider } from '@/components/ai-sidekick'; import { NovuCopilotPanel } from '@/components/ai-sidekick/novu-copilot-panel'; import { ConfirmationModal } from '@/components/confirmation-modal'; +import { Code2 } from '@/components/icons/code-2'; import { IssuesPanel } from '@/components/issues-panel'; import { Badge, BadgeIcon } from '@/components/primitives/badge'; import { Button } from '@/components/primitives/button'; @@ -32,19 +45,19 @@ import { StepPreviewFactory } from '@/components/workflow-editor/steps/preview/s import { useSaveForm } from '@/components/workflow-editor/steps/save-form-context'; import { StepEditorModeToggle } from '@/components/workflow-editor/steps/shared/step-editor-mode-toggle'; import { useStepResolverHint } from '@/components/workflow-editor/steps/shared/use-step-resolver-hint'; -import { useEnvironment } from '@/context/environment/hooks'; -import { useDisconnectStepResolver } from '@/hooks/use-disconnect-step-resolver'; -import { useFeatureFlag } from '@/hooks/use-feature-flag'; -import { INLINE_CONFIGURABLE_STEP_TYPES, STEP_RESOLVER_SUPPORTED_STEP_TYPES } from '@/utils/constants'; import { parseJsonValue } from '@/components/workflow-editor/steps/utils/preview-context.utils'; import { getEditorTitle } from '@/components/workflow-editor/steps/utils/step-utils'; import { TestWorkflowDrawer } from '@/components/workflow-editor/test-workflow/test-workflow-drawer'; import { TranslationStatus } from '@/components/workflow-editor/translation-status'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; +import { useEnvironment } from '@/context/environment/hooks'; +import { useDisconnectStepResolver } from '@/hooks/use-disconnect-step-resolver'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { useFetchTranslationGroup } from '@/hooks/use-fetch-translation-group'; import { useFetchWorkflowTestData } from '@/hooks/use-fetch-workflow-test-data'; import { useIsTranslationEnabled } from '@/hooks/use-is-translation-enabled'; import { LocalizationResourceEnum } from '@/types/translations'; +import { INLINE_CONFIGURABLE_STEP_TYPES, STEP_RESOLVER_SUPPORTED_STEP_TYPES } from '@/utils/constants'; import { cn } from '@/utils/ui'; import { Protect } from '../../../utils/protect'; @@ -160,10 +173,38 @@ function StepEditorContent() { }; }, [step.issues, controlValues]); + const newChatSuggestions = useMemo(() => { + const suggestions: { label: AiWorkflowSuggestion; icon: IconType | FC> }[] = [ + { label: AiWorkflowSuggestion.AUTOCOMPLETE, icon: RiListCheck3 }, + { label: AiWorkflowSuggestion.APPLY_CONDITIONS, icon: Code2 }, + ]; + + const isContentStep = [ + StepTypeEnum.EMAIL, + StepTypeEnum.SMS, + StepTypeEnum.PUSH, + StepTypeEnum.IN_APP, + StepTypeEnum.CHAT, + ].includes(step.type); + const emptyBody = !step.controlValues?.body; + if (isContentStep && !emptyBody) { + suggestions.push({ label: AiWorkflowSuggestion.IMPROVE_MESSAGING, icon: RiQuillPenLine }); + } else if (isContentStep && emptyBody) { + suggestions.push({ label: AiWorkflowSuggestion.GENERATE_STEP_CONTENT, icon: RiQuillPenLine }); + } + + if (Object.keys(step.issues?.controls ?? {}).length > 0) { + suggestions.push({ label: AiWorkflowSuggestion.FIX_STEP_ISSUES, icon: RiListCheck3 }); + } + + return suggestions; + }, [step]); + const aiChatConfig = useMemo( () => ({ resourceType: AiResourceTypeEnum.WORKFLOW, resourceId: workflow?._id, + newChatSuggestions, agentType: AiAgentTypeEnum.GENERATE_WORKFLOW, metadata: { stepId: step.stepId }, isResourceLoading: isWorkflowPending, @@ -179,13 +220,14 @@ function StepEditorContent() { data.type === 'data-step-updated' || data.type === 'data-step-removed' || data.type === 'data-step-moved' || - data.type === 'data-workflow-metadata-updated' + data.type === 'data-workflow-metadata-updated' || + data.type === 'data-payload-schema-updated' ) { refetchWorkflow({ cancelRefetch: true }); } }, }), - [workflow?._id, step.stepId, isWorkflowPending, refetchWorkflow] + [workflow?._id, step.stepId, newChatSuggestions, isWorkflowPending, refetchWorkflow] ); const currentPayload = parseJsonValue(editorValue).payload; @@ -231,11 +273,9 @@ function StepEditorContent() { {step.stepResolverHash} )} - {isInlineResolverStep ? ( - step.stepResolverHash && - ) : ( - !isExternalWorkflow && - )} + {isInlineResolverStep + ? step.stepResolverHash && + : !isExternalWorkflow && }
diff --git a/apps/dashboard/src/components/workflow-editor/workflow-tabs.tsx b/apps/dashboard/src/components/workflow-editor/workflow-tabs.tsx index b17144c8550..4120a817361 100644 --- a/apps/dashboard/src/components/workflow-editor/workflow-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/workflow-tabs.tsx @@ -1,16 +1,25 @@ import { AiAgentTypeEnum, AiResourceTypeEnum, + AiWorkflowSuggestion, EnvironmentTypeEnum, FeatureFlagsKeysEnum, PermissionsEnum, ResourceOriginEnum, + StepTypeEnum, } from '@novu/shared'; -import { type ReactNode, useCallback, useMemo, useState } from 'react'; -import { RiArrowDownSLine, RiCodeSSlashLine, RiFileCopyLine, RiPlayCircleLine } from 'react-icons/ri'; +import { FC, SVGProps, useCallback, useMemo, useState } from 'react'; +import { IconType } from 'react-icons/lib'; +import { + RiArrowDownSLine, + RiCodeSSlashLine, + RiFileCopyLine, + RiListCheck3, + RiPlayCircleLine, + RiQuillPenLine, +} from 'react-icons/ri'; import { Link, useMatch, useNavigate, useParams } from 'react-router-dom'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; - import { useAuth } from '@/context/auth/hooks'; import { useEnvironment } from '@/context/environment/hooks'; import { useDeleteWorkflow } from '@/hooks/use-delete-workflow'; @@ -26,6 +35,7 @@ import { buildRoute, ROUTES } from '@/utils/routes'; import { AiChatProvider, NovuCopilotPanel, useAiChat } from '../ai-sidekick'; import { SidekickToast } from '../ai-sidekick/sidekick-toast'; import { DeleteWorkflowDialog } from '../delete-workflow-dialog'; +import { Code2 } from '../icons/code-2'; import { Button } from '../primitives/button'; import { ButtonGroupItem, ButtonGroupRoot } from '../primitives/button-group'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../primitives/dropdown-menu'; @@ -320,10 +330,37 @@ export const WorkflowTabs = () => { const { deleteWorkflow, isPending: isDeletePending } = useDeleteWorkflow(); + const newChatSuggestions = useMemo(() => { + const suggestions: { label: AiWorkflowSuggestion; icon: IconType | FC> }[] = [ + { label: AiWorkflowSuggestion.AUTOCOMPLETE, icon: RiListCheck3 }, + ]; + + const hasAnySteps = (workflow?.steps?.length ?? 0) > 0; + if (hasAnySteps) { + suggestions.push({ label: AiWorkflowSuggestion.APPLY_CONDITIONS, icon: Code2 }); + } + + const hasContentSteps = workflow?.steps.some((step) => + [StepTypeEnum.EMAIL, StepTypeEnum.SMS, StepTypeEnum.PUSH, StepTypeEnum.IN_APP, StepTypeEnum.CHAT].includes( + step.type + ) + ); + if (hasContentSteps) { + suggestions.push({ label: AiWorkflowSuggestion.IMPROVE_MESSAGING, icon: RiQuillPenLine }); + } + + if (workflow?.steps.some((step) => Object.keys(step.issues?.controls ?? {}).length > 0)) { + suggestions.push({ label: AiWorkflowSuggestion.FIX_WORKFLOW_ISSUES, icon: RiListCheck3 }); + } + + return suggestions; + }, [workflow]); + const aiChatConfig = useMemo( () => ({ resourceType: AiResourceTypeEnum.WORKFLOW, resourceId: workflow?._id, + newChatSuggestions, agentType: AiAgentTypeEnum.GENERATE_WORKFLOW, metadata: { workflowId: workflow?._id }, isResourceLoading: isWorkflowPending, @@ -335,7 +372,8 @@ export const WorkflowTabs = () => { data.type === 'data-step-updated' || data.type === 'data-step-removed' || data.type === 'data-step-moved' || - data.type === 'data-workflow-metadata-updated' + data.type === 'data-workflow-metadata-updated' || + data.type === 'data-payload-schema-updated' ) { refetchWorkflow({ cancelRefetch: true }); } @@ -364,7 +402,16 @@ export const WorkflowTabs = () => { } : undefined, }), - [workflow, isWorkflowPending, refetchWorkflow, deleteWorkflow, isDeletePending, navigate, currentEnvironment?.slug] + [ + workflow, + isWorkflowPending, + newChatSuggestions, + refetchWorkflow, + deleteWorkflow, + isDeletePending, + navigate, + currentEnvironment?.slug, + ] ); const content = ( @@ -498,7 +545,7 @@ export const WorkflowTabs = () => { return showCopilot ? {content} : content; }; -function WorkflowCopilotSidebar({ children }: { children: ReactNode }) { +function WorkflowCopilotSidebar({ children }: { children: React.ReactNode }) { const { isGenerating } = useAiChat(); return ( diff --git a/apps/dashboard/src/hooks/use-ai-chat-stream.ts b/apps/dashboard/src/hooks/use-ai-chat-stream.ts index a9f5ac823f6..e2fae9c9303 100644 --- a/apps/dashboard/src/hooks/use-ai-chat-stream.ts +++ b/apps/dashboard/src/hooks/use-ai-chat-stream.ts @@ -16,7 +16,7 @@ import { getToken } from '@/utils/auth'; import { useDataRef } from './use-data-ref'; type UseAiChatOptions = { - id: string; + id?: string; agentType: AiAgentTypeEnum; initialMessages?: UIMessage[]; onData?: ChatOnDataCallback; diff --git a/apps/dashboard/src/hooks/use-form-autosave.ts b/apps/dashboard/src/hooks/use-form-autosave.ts index 357619f24e8..14c9e354fa0 100644 --- a/apps/dashboard/src/hooks/use-form-autosave.ts +++ b/apps/dashboard/src/hooks/use-form-autosave.ts @@ -59,9 +59,12 @@ export function useFormAutosave, T extends Fie lastSavedDataRef.current = serializedData; save(values, { onSuccess: () => { - // Reset dirty state after successful save so that polling hooks (e.g. useStepResolverPolling) - // are not permanently blocked. keepValues: true avoids regenerating useFieldArray field IDs. - formRef.current.reset(values, { keepErrors: true, keepValues: true }); + // Reset dirty state after successful save so polling hooks (e.g. useStepResolverPolling) + // are not permanently blocked. We reset with the CURRENT form values (not the stale `values` + // snapshot) to avoid overwriting edits the user made while the request was in-flight. + // keepValues:true prevents regenerating useFieldArray field IDs (row flicker). + const currentValues = formRef.current.getValues(); + formRef.current.reset(currentValues, { keepErrors: true, keepValues: true }); options?.onSuccess?.(); }, }); diff --git a/apps/dashboard/src/hooks/use-preview-context.ts b/apps/dashboard/src/hooks/use-preview-context.ts index 7d666d617ad..1a98cdfabbb 100644 --- a/apps/dashboard/src/hooks/use-preview-context.ts +++ b/apps/dashboard/src/hooks/use-preview-context.ts @@ -37,79 +37,79 @@ export function usePreviewContext>({ localParsedData: parseJsonValue(value), })); const isUpdatingRef = useRef(false); - const lastValueRef = useRef(value); + const lastSyncedValueRef = useRef(value); + const latestValueRef = useRef(value); + latestValueRef.current = value; + const onDataPersistRef = useDataRef(onDataPersist); + const parseJsonValueRef = useDataRef(parseJsonValue); const parsedData = useMemo(() => parseJsonValue(value), [parseJsonValue, value]); + // Wraps onChange to synchronously track the latest value in a ref, + // so that consecutive calls within the same render cycle read fresh data. + const trackedOnChange = useCallback( + (newValue: string): Error | null => { + const error = onChange(newValue); + if (!error) { + latestValueRef.current = newValue; + } + + return error; + }, + [onChange] + ); + // Sync external value changes with local state useEffect(() => { - if (value === lastValueRef.current || isUpdatingRef.current) { + if (value === lastSyncedValueRef.current || isUpdatingRef.current) { return; } - lastValueRef.current = value; + lastSyncedValueRef.current = value; setState((prev) => ({ ...prev, localParsedData: parsedData, })); }, [value, parsedData]); - const setError = useCallback((section: keyof E, error: string | null) => { - setState((prev) => ({ - ...prev, - errors: { ...prev.errors, [section]: error }, - })); - }, []); - - const updateLocalData = useCallback( - (section: keyof D, updatedData: any) => { - setState((prev) => { - const updatedParsedData = { ...prev.localParsedData, [section]: updatedData }; - - onDataPersist?.(updatedParsedData); - - return { - ...prev, - localParsedData: updatedParsedData, - }; - }); - }, - [onDataPersist] - ); - - const updateJsonRef = useDataRef({ onChange, value, setError, updateLocalData, parseJsonValue }); - const updatePreviewSection = useCallback( (section: keyof D, updatedData: any) => { if (isUpdatingRef.current) return; isUpdatingRef.current = true; - const local = updateJsonRef.current; - try { - const currentData = local.parseJsonValue(local.value); + const currentData = parseJsonValueRef.current(latestValueRef.current); const newData = { ...currentData, [section]: updatedData }; const stringified = JSON.stringify(newData, null, 2); - const error = local.onChange(stringified); + const error = trackedOnChange(stringified); if (error) { - local.setError(section, error.message); + setState((prev) => ({ + ...prev, + errors: { ...prev.errors, [section]: error.message }, + })); } else { - local.updateLocalData(section, updatedData); - local.setError(section, null); + onDataPersistRef.current?.(newData); + setState((prev) => ({ + ...prev, + localParsedData: newData, + errors: { ...prev.errors, [section]: null }, + })); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to update JSON'; - local.setError(section, errorMessage); + setState((prev) => ({ + ...prev, + errors: { ...prev.errors, [section]: errorMessage }, + })); } finally { - // Use setTimeout to ensure the ref is reset after the current execution cycle setTimeout(() => { isUpdatingRef.current = false; }, 0); } }, - [updateJsonRef] + [trackedOnChange] ); const setAccordionValue = useCallback((value: string[]) => { @@ -122,5 +122,6 @@ export function usePreviewContext>({ errors: state.errors, previewContext: state.localParsedData, updatePreviewSection, + trackedOnChange, }; } diff --git a/apps/dashboard/src/pages/edit-step-template-v2.tsx b/apps/dashboard/src/pages/edit-step-template-v2.tsx index 7469bba0083..250fd42fdbb 100644 --- a/apps/dashboard/src/pages/edit-step-template-v2.tsx +++ b/apps/dashboard/src/pages/edit-step-template-v2.tsx @@ -27,6 +27,11 @@ export function EditStepTemplateV2Page() { const prevStepIdRef = useRef(undefined); const prevHashRef = useRef(undefined); const prevControlsFingerprintRef = useRef(null); + // Tracks ALL in-flight save fingerprints so that any server response that + // echoes back one of our own saves is recognized and does not reset the form. + // A single ref would be overwritten by rapid successive saves, causing the + // guard to fail for earlier in-flight requests. + const inFlightFingerprintsRef = useRef>(new Set()); useEffect(() => { if (!step) return; @@ -43,10 +48,25 @@ export function EditStepTemplateV2Page() { const controlsChanged = prevControlsFingerprintRef.current !== null && fingerprint !== prevControlsFingerprintRef.current; - if (isFirstBind || stepIdChanged || hashChanged || controlsChanged) { - prevStepIdRef.current = step.stepId; - prevHashRef.current = step.stepResolverHash; - prevControlsFingerprintRef.current = fingerprint; + // If there are any in-flight saves, any server-side change we receive is + // the result of our own edits. Skip the reset so we don't overwrite edits + // the user made while requests were in-flight. The invocationQueue may + // apply pending requests on top, so the server response FP may not exactly + // match any single in-flight FP — checking the count is safer. + const hasInFlightSaves = inFlightFingerprintsRef.current.size > 0; + const isOwnSaveEcho = controlsChanged && (inFlightFingerprintsRef.current.has(fingerprint) || hasInFlightSaves); + + if (inFlightFingerprintsRef.current.has(fingerprint)) { + inFlightFingerprintsRef.current.delete(fingerprint); + } + + const shouldReset = isFirstBind || stepIdChanged || hashChanged || (controlsChanged && !isOwnSaveEcho); + + prevStepIdRef.current = step.stepId; + prevHashRef.current = step.stepResolverHash; + prevControlsFingerprintRef.current = fingerprint; + + if (shouldReset) { hasInitializedRef.current = true; form.reset(getControlsDefaultValues(step), { keepErrors: true }); } @@ -58,10 +78,27 @@ export function EditStepTemplateV2Page() { save: (data, { onSuccess }) => { if (!workflow || !step) return; + const fp = JSON.stringify({ + v: data, + ui: step.controls?.uiSchema, + ds: step.controls?.dataSchema, + }); + + // Add to in-flight set before the request goes out. The fingerprint + // effect will recognize any server response that matches this value and + // skip the form.reset() that would otherwise overwrite in-progress edits. + inFlightFingerprintsRef.current.add(fp); + const updateStepData: Partial = { controlValues: data, }; - update(updateStepInWorkflow(workflow, step.stepId, updateStepData), { onSuccess }); + update(updateStepInWorkflow(workflow, step.stepId, updateStepData), { + onSuccess: () => { + // Clean up the in-flight fingerprint on success. + inFlightFingerprintsRef.current.delete(fp); + onSuccess?.(); + }, + }); }, }); diff --git a/apps/dashboard/src/utils/conditions.ts b/apps/dashboard/src/utils/conditions.ts index 600c017715b..798e5eb0e99 100644 --- a/apps/dashboard/src/utils/conditions.ts +++ b/apps/dashboard/src/utils/conditions.ts @@ -132,4 +132,4 @@ export const getUniqueOperators = (jsonLogic?: RQBJsonLogic): string[] => { }; // Export shared configuration for use in other files -export { customJsonLogicOperations, parseJsonLogicOptions }; +export { parseJsonLogicOptions }; diff --git a/apps/dashboard/src/utils/constants.ts b/apps/dashboard/src/utils/constants.ts index 88e7d66b69a..05a6325556f 100644 --- a/apps/dashboard/src/utils/constants.ts +++ b/apps/dashboard/src/utils/constants.ts @@ -61,7 +61,6 @@ export const DEFAULT_CONTROL_THROTTLE_TYPE = 'fixed'; export const DEFAULT_CONTROL_THROTTLE_WINDOW = 1; export const DEFAULT_CONTROL_THROTTLE_UNIT = TimeUnitEnum.MINUTES; export const DEFAULT_CONTROL_THROTTLE_THRESHOLD = 1; -export const DEFAULT_CONTROL_THROTTLE_KEY = ''; export const DEFAULT_CONTROL_HTTP_REQUEST_METHOD = 'POST'; export const DEFAULT_CONTROL_HTTP_REQUEST_HEADERS: unknown[] = []; diff --git a/apps/dashboard/src/utils/context.ts b/apps/dashboard/src/utils/context.ts index d6b7db85865..89bdb96ca13 100644 --- a/apps/dashboard/src/utils/context.ts +++ b/apps/dashboard/src/utils/context.ts @@ -1,6 +1,6 @@ import React from 'react'; -export function assertContextExists(contextVal: unknown, msgOrCtx: string | React.Context): asserts contextVal { +function assertContextExists(contextVal: unknown, msgOrCtx: string | React.Context): asserts contextVal { if (!contextVal) { throw typeof msgOrCtx === 'string' ? new Error(msgOrCtx) : new Error(`${msgOrCtx.displayName} not found`); } diff --git a/apps/dashboard/src/utils/logs-filters.utils.ts b/apps/dashboard/src/utils/logs-filters.utils.ts index 9ab6491aa48..c1f20d77bed 100644 --- a/apps/dashboard/src/utils/logs-filters.utils.ts +++ b/apps/dashboard/src/utils/logs-filters.utils.ts @@ -4,7 +4,7 @@ import { IS_SELF_HOSTED } from '../config'; type OrganizationLike = { createdAt: Date }; -export const LOGS_DATE_RANGE_OPTIONS = [ +const LOGS_DATE_RANGE_OPTIONS = [ { value: '24', label: 'Last 24 Hours', ms: 24 * 60 * 60 * 1000 }, { value: '168', label: '7 Days', ms: 7 * 24 * 60 * 60 * 1000 }, // 7 * 24 { value: '720', label: '30 Days', ms: 30 * 24 * 60 * 60 * 1000 }, // 30 * 24 diff --git a/apps/dashboard/src/utils/schema.ts b/apps/dashboard/src/utils/schema.ts index a4616ef6c12..3c2aab45f00 100644 --- a/apps/dashboard/src/utils/schema.ts +++ b/apps/dashboard/src/utils/schema.ts @@ -139,7 +139,7 @@ const getZodValueByType = (jsonSchema: JSONSchemaDefinition, key: string): ZodVa * The function will recursively build the schema based on the JSONSchema object. * It removes empty strings and objects with empty required fields during the transformation phase after parsing. */ -export const buildDynamicZodSchema = (obj: JSONSchemaDefinition, key = ''): ZodValue => { +const buildDynamicZodSchema = (obj: JSONSchemaDefinition, key = ''): ZodValue => { if (typeof obj === 'object' && obj.type === 'object') { const properties = obj.properties ?? {}; const requiredFields = obj.required ?? []; diff --git a/enterprise/packages/ai/package.json b/enterprise/packages/ai/package.json index 4042277b25a..f976053002e 100644 --- a/enterprise/packages/ai/package.json +++ b/enterprise/packages/ai/package.json @@ -27,9 +27,10 @@ "@novu/ee-auth": "workspace:*", "@novu/shared": "workspace:*", "@novu/testing": "workspace:*", - "@types/express": "4.17.17", "@sentry/node": "^8.33.1", + "@types/express": "4.17.17", "ai": "6.0.50", + "async-mutex": "^0.5.0", "class-transformer": "0.5.1", "class-validator": "0.14.1", "express": "^5.0.1", diff --git a/enterprise/packages/translation/package.json b/enterprise/packages/translation/package.json index 64847b619bc..a0a1df452e4 100644 --- a/enterprise/packages/translation/package.json +++ b/enterprise/packages/translation/package.json @@ -40,7 +40,7 @@ "sinon": "^9.2.4", "ts-node": "~10.9.1", "typescript": "5.6.2", - "liquidjs": "^10.25.0" + "liquidjs": "^10.25.5" }, "peerDependencies": { "@nestjs/common": "10.4.18", diff --git a/libs/application-generic/package.json b/libs/application-generic/package.json index 1e2cb9f6266..99e5cc014e8 100644 --- a/libs/application-generic/package.json +++ b/libs/application-generic/package.json @@ -95,7 +95,7 @@ "json-schema-to-ts": "^3.0.0", "json-schema-faker": "^0.5.6", "jsonwebtoken": "9.0.3", - "liquidjs": "^10.25.0", + "liquidjs": "^10.25.5", "lodash": "^4.18.0", "lru-cache": "^11.2.4", "mixpanel": "^0.17.0", diff --git a/libs/application-generic/src/factories/sms/handlers/index.ts b/libs/application-generic/src/factories/sms/handlers/index.ts index 14434082e3f..949ef28cc01 100644 --- a/libs/application-generic/src/factories/sms/handlers/index.ts +++ b/libs/application-generic/src/factories/sms/handlers/index.ts @@ -16,6 +16,7 @@ export * from './gupshup.handler'; export * from './imedia.handler'; export * from './infobip.handler'; export * from './isend-sms.handler'; +export * from './isendpro-sms.handler'; export * from './kannel.handler'; export * from './maqsam.handler'; export * from './messagebird.handler'; @@ -34,4 +35,3 @@ export * from './telnyx.handler'; export * from './termii.handler'; export * from './twilio.handler'; export * from './unifonic.handler'; -export * from './isendpro-sms.handler'; diff --git a/libs/application-generic/src/services/sqs/sqs-consumer.service.ts b/libs/application-generic/src/services/sqs/sqs-consumer.service.ts index 659c2b66a7a..cc1eccd7230 100644 --- a/libs/application-generic/src/services/sqs/sqs-consumer.service.ts +++ b/libs/application-generic/src/services/sqs/sqs-consumer.service.ts @@ -3,8 +3,8 @@ import { Logger } from '@nestjs/common'; import { JobTopicNameEnum } from '@novu/shared'; import { Consumer } from 'sqs-consumer'; import { PinoLogger } from '../../logging'; -import { SqsPayloadOffloadService } from './sqs-payload-offload.service'; import { SqsService } from './sqs.service'; +import { SqsPayloadOffloadService } from './sqs-payload-offload.service'; import { ISqsConsumerOptions, ISqsMessageMeta, @@ -234,9 +234,7 @@ export class SqsConsumerService { private async processMessage(message: Message): Promise { const rawBody = message.Body || '{}'; - const resolvedBody = this.payloadOffload - ? await this.payloadOffload.maybeResolve(rawBody) - : rawBody; + const resolvedBody = this.payloadOffload ? await this.payloadOffload.maybeResolve(rawBody) : rawBody; const data = JSON.parse(resolvedBody); const receiveCount = parseInt(message.Attributes?.ApproximateReceiveCount || '1', 10); diff --git a/libs/application-generic/src/services/sqs/sqs-payload-offload.service.ts b/libs/application-generic/src/services/sqs/sqs-payload-offload.service.ts index a6b69e131c7..1492be21c88 100644 --- a/libs/application-generic/src/services/sqs/sqs-payload-offload.service.ts +++ b/libs/application-generic/src/services/sqs/sqs-payload-offload.service.ts @@ -69,11 +69,7 @@ export class SqsPayloadOffloadService { }) ); - Logger.log( - { topic, messageId, groupId, sizeBytes, key }, - 'Large SQS payload offloaded to S3', - LOG_CONTEXT - ); + Logger.log({ topic, messageId, groupId, sizeBytes, key }, 'Large SQS payload offloaded to S3', LOG_CONTEXT); const reference: ISqsLargePayloadReference = { [SQS_LARGE_PAYLOAD_MARKER]: { bucket: this.bucket, key }, @@ -100,9 +96,7 @@ export class SqsPayloadOffloadService { const { bucket, key } = (parsed as ISqsLargePayloadReference)[SQS_LARGE_PAYLOAD_MARKER]; - const response = await this.s3Client.send( - new GetObjectCommand({ Bucket: bucket, Key: key }) - ); + const response = await this.s3Client.send(new GetObjectCommand({ Bucket: bucket, Key: key })); const resolved = await this.streamToString(response.Body as Readable); diff --git a/libs/notifications/src/workflows/usage-report/usage-report.workflow.ts b/libs/notifications/src/workflows/usage-report/usage-report.workflow.ts index 08e8f02c78a..2fc661a9a80 100644 --- a/libs/notifications/src/workflows/usage-report/usage-report.workflow.ts +++ b/libs/notifications/src/workflows/usage-report/usage-report.workflow.ts @@ -16,13 +16,6 @@ export const usageReportWorkflow = workflow( } ); - await step.throttle('throttle', async () => ({ - type: 'fixed', - amount: 7, - unit: 'days', - threshold: 1, - })); - await step.email( 'email', async (controls) => { diff --git a/packages/framework/package.json b/packages/framework/package.json index 60a9e4f8ebb..340e31ebf5e 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -259,7 +259,7 @@ "cross-fetch": "^4.0.0", "json-schema-to-ts": "^3.0.0", "jsonrepair": "^3.13.1", - "liquidjs": "^10.25.0", + "liquidjs": "^10.25.5", "pluralize": "^8.0.0", "sanitize-html": "^2.13.0" }, diff --git a/packages/js/src/event-emitter/types.ts b/packages/js/src/event-emitter/types.ts index c00cf3fb6ba..7be3de74c64 100644 --- a/packages/js/src/event-emitter/types.ts +++ b/packages/js/src/event-emitter/types.ts @@ -19,7 +19,6 @@ import { Preference } from '../preferences/preference'; import { Schedule } from '../preferences/schedule'; import { ListPreferencesArgs, UpdatePreferenceArgs, UpdateScheduleArgs } from '../preferences/types'; import type { InitializeSessionArgs } from '../session'; -import type { TagsFilter } from '../types'; import type { TopicSubscription } from '../subscriptions/subscription'; import { SubscriptionPreference } from '../subscriptions/subscription-preference'; import type { @@ -30,6 +29,7 @@ import type { UpdateSubscriptionArgs, UpdateSubscriptionPreferenceArgs, } from '../subscriptions/types'; +import type { TagsFilter } from '../types'; import { Session, WebSocketEvent } from '../types'; type NovuPendingEvent = { diff --git a/packages/js/src/utils/notification-utils.test.ts b/packages/js/src/utils/notification-utils.test.ts index 4e8f0daf42a..cf0892a61f3 100644 --- a/packages/js/src/utils/notification-utils.test.ts +++ b/packages/js/src/utils/notification-utils.test.ts @@ -10,19 +10,11 @@ describe('normalizeTagGroups', () => { normalizeTagGroups({ and: [{ or: ['a', 'b'] }, { or: ['c'] }], }) - ).toEqual([ - ['a', 'b'], - ['c'], - ]); + ).toEqual([['a', 'b'], ['c']]); }); it('rejects nested string[][]', () => { - expect(() => - normalizeTagGroups([ - ['a', 'b'], - ['c'], - ] as never) - ).toThrow(); + expect(() => normalizeTagGroups([['a', 'b'], ['c']] as never)).toThrow(); }); it('rejects non-array values', () => { diff --git a/packages/providers/package.json b/packages/providers/package.json index e907c686a6e..49d0d14d32f 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -66,7 +66,7 @@ "nanoid": "^3.1.20", "node-fetch": "^3.2.10", "node-mailjet": "^6.0.8", - "nodemailer": "^8.0.4", + "nodemailer": "^8.0.5", "plivo": "^4.70.0", "postmark": "^4.0.2", "proxy-agent": "^6.5.0", diff --git a/packages/providers/src/lib/sms/index.ts b/packages/providers/src/lib/sms/index.ts index 98fa620cac0..7d004c8d1b5 100644 --- a/packages/providers/src/lib/sms/index.ts +++ b/packages/providers/src/lib/sms/index.ts @@ -16,6 +16,7 @@ export * from './gupshup/gupshup.provider'; export * from './imedia/imedia.provider'; export * from './infobip/infobip.provider'; export * from './isend-sms/isend-sms.provider'; +export * from './isendpro-sms/isendpro-sms.provider'; export * from './kannel/kannel.provider'; export * from './maqsam/maqsam.provider'; export * from './messagebird/messagebird.provider'; @@ -36,4 +37,3 @@ export * from './telnyx/telnyx.provider'; export * from './termii/termii.provider'; export * from './twilio/twilio.provider'; export * from './unifonic/unifonic.provider'; -export * from './isendpro-sms/isendpro-sms.provider'; diff --git a/packages/shared/src/consts/providers/channels/sms.ts b/packages/shared/src/consts/providers/channels/sms.ts index 664a03060c9..57677b2be5a 100644 --- a/packages/shared/src/consts/providers/channels/sms.ts +++ b/packages/shared/src/consts/providers/channels/sms.ts @@ -16,9 +16,9 @@ import { fortySixElksConfig, genericSmsConfig, gupshupConfig, + ISendProProviderConfig, iMediaConfig, infobipSMSConfig, - ISendProProviderConfig, iSendSmsConfig, kannelConfig, maqsamConfig, diff --git a/packages/shared/src/types/ai.ts b/packages/shared/src/types/ai.ts index 26fba983191..07a2006eddd 100644 --- a/packages/shared/src/types/ai.ts +++ b/packages/shared/src/types/ai.ts @@ -32,6 +32,7 @@ export enum AiWorkflowToolsEnum { UPDATE_STEP_CONDITIONS = 'updateStepConditions', REMOVE_STEP = 'removeStep', MOVE_STEP = 'moveStep', + UPDATE_PAYLOAD_SCHEMA = 'updatePayloadSchema', } export enum AiWorkflowToolsNameEnum { @@ -44,9 +45,19 @@ export enum AiWorkflowToolsNameEnum { UPDATE_STEP_CONDITIONS = 'tool-updateStepConditions', REMOVE_STEP = 'tool-removeStep', MOVE_STEP = 'tool-moveStep', + UPDATE_PAYLOAD_SCHEMA = 'tool-updatePayloadSchema', } export enum AiResumeActionEnum { TRY_AGAIN = 'tryAgain', REVERT = 'revert', } + +export enum AiWorkflowSuggestion { + AUTOCOMPLETE = 'Autocomplete this workflow', + APPLY_CONDITIONS = 'Apply step conditions', + IMPROVE_MESSAGING = 'Improve messaging', + FIX_WORKFLOW_ISSUES = 'Fix workflow issues', + FIX_STEP_ISSUES = 'Fix step issues', + GENERATE_STEP_CONTENT = 'Generate step content', +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index e3ab6169f29..408ed5661ca 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -5,6 +5,6 @@ export * from './env'; export * from './issues'; export * from './locales'; export * from './normalizeEmail'; -export * from './tags-filter'; export { createMockObjectFromSchema } from './schema/create-mock-object-from-schema'; export { slugify } from './slugify'; +export * from './tags-filter'; diff --git a/packages/shared/src/utils/tags-filter.spec.ts b/packages/shared/src/utils/tags-filter.spec.ts index 16e561a6e6c..cc95bba970d 100644 --- a/packages/shared/src/utils/tags-filter.spec.ts +++ b/packages/shared/src/utils/tags-filter.spec.ts @@ -25,10 +25,7 @@ describe('normalizeTagGroups', () => { normalizeTagGroups({ and: [{ or: ['a', 'b'] }, { or: ['c'] }], }) - ).toEqual([ - ['a', 'b'], - ['c'], - ]); + ).toEqual([['a', 'b'], ['c']]); }); it('returns empty for { and: [] }', () => { @@ -36,18 +33,11 @@ describe('normalizeTagGroups', () => { }); it('rejects nested string[][]', () => { - expect(() => - normalizeTagGroups([ - ['a', 'b'], - ['c'], - ] as never) - ).toThrow(TagsFilterValidationError); + expect(() => normalizeTagGroups([['a', 'b'], ['c']] as never)).toThrow(TagsFilterValidationError); }); it('rejects both or and and on the same object', () => { - expect(() => normalizeTagGroups({ or: ['a'], and: [{ or: ['b'] }] } as never)).toThrow( - TagsFilterValidationError - ); + expect(() => normalizeTagGroups({ or: ['a'], and: [{ or: ['b'] }] } as never)).toThrow(TagsFilterValidationError); }); it('rejects empty inner group in and', () => { @@ -73,12 +63,7 @@ describe('buildTagsQuery', () => { }); it('uses $and of $in for multiple groups', () => { - expect( - buildTagsQuery([ - ['a', 'b'], - ['c'], - ]) - ).toEqual({ + expect(buildTagsQuery([['a', 'b'], ['c']])).toEqual({ $and: [{ tags: { $in: ['a', 'b'] } }, { tags: { $in: ['c'] } }], }); }); diff --git a/playground/nextjs/src/components/ai-elements/code-block.tsx b/playground/nextjs/src/components/ai-elements/code-block.tsx index 76f79c9e58a..2553d50287f 100644 --- a/playground/nextjs/src/components/ai-elements/code-block.tsx +++ b/playground/nextjs/src/components/ai-elements/code-block.tsx @@ -1,34 +1,13 @@ -"use client"; - -import type { ComponentProps, CSSProperties, HTMLAttributes } from "react"; -import type { - BundledLanguage, - BundledTheme, - HighlighterGeneric, - ThemedToken, -} from "shiki"; - -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { cn } from "@/lib/utils"; -import { CheckIcon, CopyIcon } from "lucide-react"; -import { - createContext, - memo, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { createHighlighter } from "shiki"; +'use client'; + +import { CheckIcon, CopyIcon } from 'lucide-react'; +import type { ComponentProps, CSSProperties, HTMLAttributes } from 'react'; +import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import type { BundledLanguage, BundledTheme, HighlighterGeneric, ThemedToken } from 'shiki'; +import { createHighlighter } from 'shiki'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { cn } from '@/lib/utils'; // Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline // biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check @@ -70,9 +49,9 @@ const TokenSpan = ({ token }: { token: ThemedToken }) => ( { backgroundColor: token.bgColor, color: token.color, - fontStyle: isItalic(token.fontStyle) ? "italic" : undefined, - fontWeight: isBold(token.fontStyle) ? "bold" : undefined, - textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined, + fontStyle: isItalic(token.fontStyle) ? 'italic' : undefined, + fontWeight: isBold(token.fontStyle) ? 'bold' : undefined, + textDecoration: isUnderline(token.fontStyle) ? 'underline' : undefined, ...token.htmlStyle, } as CSSProperties } @@ -82,19 +61,11 @@ const TokenSpan = ({ token }: { token: ThemedToken }) => ( ); // Line rendering component -const LineSpan = ({ - keyedLine, - showLineNumbers, -}: { - keyedLine: KeyedLine; - showLineNumbers: boolean; -}) => ( - +const LineSpan = ({ keyedLine, showLineNumbers }: { keyedLine: KeyedLine; showLineNumbers: boolean }) => ( + {keyedLine.tokens.length === 0 - ? "\n" - : keyedLine.tokens.map(({ token, key }) => ( - - ))} + ? '\n' + : keyedLine.tokens.map(({ token, key }) => )} ); @@ -117,14 +88,11 @@ interface CodeBlockContextType { // Context const CodeBlockContext = createContext({ - code: "", + code: '', }); // Highlighter cache (singleton per language) -const highlighterCache = new Map< - string, - Promise> ->(); +const highlighterCache = new Map>>(); // Token cache const tokensCache = new Map(); @@ -134,13 +102,11 @@ const subscribers = new Map void>>(); const getTokensCacheKey = (code: string, language: BundledLanguage) => { const start = code.slice(0, 100); - const end = code.length > 100 ? code.slice(-100) : ""; + const end = code.length > 100 ? code.slice(-100) : ''; return `${language}:${code.length}:${start}:${end}`; }; -const getHighlighter = ( - language: BundledLanguage -): Promise> => { +const getHighlighter = (language: BundledLanguage): Promise> => { const cached = highlighterCache.get(language); if (cached) { return cached; @@ -148,7 +114,7 @@ const getHighlighter = ( const highlighterPromise = createHighlighter({ langs: [language], - themes: ["github-light", "github-dark"], + themes: ['github-light', 'github-dark'], }); highlighterCache.set(language, highlighterPromise); @@ -157,14 +123,14 @@ const getHighlighter = ( // Create raw tokens for immediate display while highlighting loads const createRawTokens = (code: string): TokenizedCode => ({ - bg: "transparent", - fg: "inherit", - tokens: code.split("\n").map((line) => - line === "" + bg: 'transparent', + fg: 'inherit', + tokens: code.split('\n').map((line) => + line === '' ? [] : [ { - color: "inherit", + color: 'inherit', content: line, } as ThemedToken, ] @@ -199,19 +165,19 @@ export const highlightCode = ( // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then) .then((highlighter) => { const availableLangs = highlighter.getLoadedLanguages(); - const langToUse = availableLangs.includes(language) ? language : "text"; + const langToUse = availableLangs.includes(language) ? language : 'text'; const result = highlighter.codeToTokens(code, { lang: langToUse, themes: { - dark: "github-dark", - light: "github-light", + dark: 'github-dark', + light: 'github-light', }, }); const tokenized: TokenizedCode = { - bg: result.bg ?? "transparent", - fg: result.fg ?? "inherit", + bg: result.bg ?? 'transparent', + fg: result.fg ?? 'inherit', tokens: result.tokens, }; @@ -229,7 +195,7 @@ export const highlightCode = ( }) // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks) .catch((error) => { - console.error("Failed to highlight code:", error); + console.error('Failed to highlight code:', error); subscribers.delete(tokensCacheKey); }); @@ -238,16 +204,16 @@ export const highlightCode = ( // Line number styles using CSS counters const LINE_NUMBER_CLASSES = cn( - "block", - "before:content-[counter(line)]", - "before:inline-block", - "before:[counter-increment:line]", - "before:w-8", - "before:mr-4", - "before:text-right", - "before:text-muted-foreground/50", - "before:font-mono", - "before:select-none" + 'block', + 'before:content-[counter(line)]', + 'before:inline-block', + 'before:[counter-increment:line]', + 'before:w-8', + 'before:mr-4', + 'before:text-right', + 'before:text-muted-foreground/50', + 'before:font-mono', + 'before:select-none' ); const CodeBlockBody = memo( @@ -268,31 +234,16 @@ const CodeBlockBody = memo( [tokenized.bg, tokenized.fg] ); - const keyedLines = useMemo( - () => addKeysToTokens(tokenized.tokens), - [tokenized.tokens] - ); + const keyedLines = useMemo(() => addKeysToTokens(tokenized.tokens), [tokenized.tokens]); return (
-        
+        
           {keyedLines.map((keyedLine) => (
-            
+            
           ))}
         
       
@@ -304,7 +255,7 @@ const CodeBlockBody = memo( prevProps.className === nextProps.className ); -CodeBlockBody.displayName = "CodeBlockBody"; +CodeBlockBody.displayName = 'CodeBlockBody'; export const CodeBlockContainer = ({ className, @@ -313,28 +264,21 @@ export const CodeBlockContainer = ({ ...props }: HTMLAttributes & { language: string }) => (
); -export const CodeBlockHeader = ({ - children, - className, - ...props -}: HTMLAttributes) => ( +export const CodeBlockHeader = ({ children, className, ...props }: HTMLAttributes) => (
); -export const CodeBlockTitle = ({ - children, - className, - ...props -}: HTMLAttributes) => ( -
+export const CodeBlockTitle = ({ children, className, ...props }: HTMLAttributes) => ( +
{children}
); -export const CodeBlockFilename = ({ - children, - className, - ...props -}: HTMLAttributes) => ( - +export const CodeBlockFilename = ({ children, className, ...props }: HTMLAttributes) => ( + {children} ); -export const CodeBlockActions = ({ - children, - className, - ...props -}: HTMLAttributes) => ( -
+export const CodeBlockActions = ({ children, className, ...props }: HTMLAttributes) => ( +
{children}
); @@ -389,9 +318,7 @@ export const CodeBlockContent = ({ const rawTokens = useMemo(() => createRawTokens(code), [code]); // Try to get cached result synchronously, otherwise use raw tokens - const [tokenized, setTokenized] = useState( - () => highlightCode(code, language) ?? rawTokens - ); + const [tokenized, setTokenized] = useState(() => highlightCode(code, language) ?? rawTokens); useEffect(() => { let cancelled = false; @@ -432,11 +359,7 @@ export const CodeBlock = ({ {children} - + ); @@ -461,8 +384,8 @@ export const CodeBlockCopyButton = ({ const { code } = useContext(CodeBlockContext); const copyToClipboard = useCallback(async () => { - if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { - onError?.(new Error("Clipboard API not available")); + if (typeof window === 'undefined' || !navigator?.clipboard?.writeText) { + onError?.(new Error('Clipboard API not available')); return; } @@ -471,10 +394,7 @@ export const CodeBlockCopyButton = ({ await navigator.clipboard.writeText(code); setIsCopied(true); onCopy?.(); - timeoutRef.current = window.setTimeout( - () => setIsCopied(false), - timeout - ); + timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout); } } catch (error) { onError?.(error as Error); @@ -491,13 +411,7 @@ export const CodeBlockCopyButton = ({ const Icon = isCopied ? CheckIcon : CopyIcon; return ( - ); @@ -505,50 +419,27 @@ export const CodeBlockCopyButton = ({ export type CodeBlockLanguageSelectorProps = ComponentProps; -export const CodeBlockLanguageSelector = ( - props: CodeBlockLanguageSelectorProps -) => ; -export type CodeBlockLanguageSelectorTriggerProps = ComponentProps< - typeof SelectTrigger ->; +export type CodeBlockLanguageSelectorTriggerProps = ComponentProps; -export const CodeBlockLanguageSelectorTrigger = ({ - className, - ...props -}: CodeBlockLanguageSelectorTriggerProps) => ( - +export const CodeBlockLanguageSelectorTrigger = ({ className, ...props }: CodeBlockLanguageSelectorTriggerProps) => ( + ); -export type CodeBlockLanguageSelectorValueProps = ComponentProps< - typeof SelectValue ->; +export type CodeBlockLanguageSelectorValueProps = ComponentProps; -export const CodeBlockLanguageSelectorValue = ( - props: CodeBlockLanguageSelectorValueProps -) => ; +export const CodeBlockLanguageSelectorValue = (props: CodeBlockLanguageSelectorValueProps) => ( + +); -export type CodeBlockLanguageSelectorContentProps = ComponentProps< - typeof SelectContent ->; +export type CodeBlockLanguageSelectorContentProps = ComponentProps; export const CodeBlockLanguageSelectorContent = ({ - align = "end", + align = 'end', ...props -}: CodeBlockLanguageSelectorContentProps) => ( - -); +}: CodeBlockLanguageSelectorContentProps) => ; -export type CodeBlockLanguageSelectorItemProps = ComponentProps< - typeof SelectItem ->; +export type CodeBlockLanguageSelectorItemProps = ComponentProps; -export const CodeBlockLanguageSelectorItem = ( - props: CodeBlockLanguageSelectorItemProps -) => ; +export const CodeBlockLanguageSelectorItem = (props: CodeBlockLanguageSelectorItemProps) => ; diff --git a/playground/nextjs/src/components/ai-elements/conversation.tsx b/playground/nextjs/src/components/ai-elements/conversation.tsx index 3568f20a8eb..43b398e80c9 100644 --- a/playground/nextjs/src/components/ai-elements/conversation.tsx +++ b/playground/nextjs/src/components/ai-elements/conversation.tsx @@ -1,16 +1,11 @@ -"use client"; +'use client'; -import type { ComponentProps } from "react"; - -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { ArrowDownIcon, DownloadIcon } from "lucide-react"; -import { useCallback } from "react"; -import { - StickToBottom, - useStickToBottomContext, - type StickToBottomProps, -} from "use-stick-to-bottom"; +import { ArrowDownIcon, DownloadIcon } from 'lucide-react'; +import type { ComponentProps } from 'react'; +import { useCallback } from 'react'; +import { StickToBottom, type StickToBottomProps, useStickToBottomContext } from 'use-stick-to-bottom'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; export type ConversationProps = StickToBottomProps; @@ -18,7 +13,7 @@ export type ConversationContentProps = StickToBottom.ContentProps; export const Conversation = ({ className, ...props }: ConversationProps) => ( ( /> ); -export const ConversationContent = ({ - className, - ...props -}: ConversationContentProps) => ( - +export const ConversationContent = ({ className, ...props }: ConversationContentProps) => ( + ); -export type ConversationEmptyStateProps = ComponentProps<"div"> & { +export type ConversationEmptyStateProps = ComponentProps<'div'> & { title?: string; description?: string; icon?: React.ReactNode; @@ -44,17 +33,14 @@ export type ConversationEmptyStateProps = ComponentProps<"div"> & { export const ConversationEmptyState = ({ className, - title = "No messages yet", - description = "Start a conversation to see messages here", + title = 'No messages yet', + description = 'Start a conversation to see messages here', icon, children, ...props }: ConversationEmptyStateProps) => (
{children ?? ( @@ -62,9 +48,7 @@ export const ConversationEmptyState = ({ {icon &&
{icon}
}

{title}

- {description && ( -

{description}

- )} + {description &&

{description}

}
)} @@ -73,10 +57,7 @@ export const ConversationEmptyState = ({ export type ConversationScrollButtonProps = ComponentProps; -export const ConversationScrollButton = ({ - className, - ...props -}: ConversationScrollButtonProps) => { +export const ConversationScrollButton = ({ className, ...props }: ConversationScrollButtonProps) => { const { isAtBottom, scrollToBottom } = useStickToBottomContext(); const handleScrollToBottom = useCallback(() => { @@ -87,7 +68,7 @@ export const ConversationScrollButton = ({ !isAtBottom && (