diff --git a/apps/api/src/app/agents/shared/agent-event-mappers.ts b/apps/api/src/app/agents/shared/agent-event-mappers.ts index 6903b7f6973..bde77104a48 100644 --- a/apps/api/src/app/agents/shared/agent-event-mappers.ts +++ b/apps/api/src/app/agents/shared/agent-event-mappers.ts @@ -13,10 +13,9 @@ import type { EditPayloadDto, ReplyContentDto } from './dtos/agent-reply-payload /** Pure protocol-shape mappers between `AgentEvent` and the internal reply/Thalamus DTOs. No DI, no side effects. */ /** - * `AgentMessageContent['card']` is `Record` on the wire — the protocol can't - * depend on the `chat` package's `CardElement` type — while `ReplyContentDto['card']` is the - * validated Chat SDK shape. This is the one place that crosses that trust boundary; the DTO's - * own `@Validate(IsValidReplyContent)` rejects anything that isn't actually card-shaped. + * `AgentMessageContent['card']` is the Novu protocol `CardElement`. `ReplyContentDto['card']` + * is the Chat SDK type. This is the one place that crosses that authoring ↔ wire boundary; + * the DTO's `@Validate(IsValidReplyContent)` still rejects anything that isn't card-shaped. */ export function toReplyContent(content: AgentMessageContent, files?: AgentFileRef[]): ReplyContentDto | null { const base: ReplyContentDto = diff --git a/apps/api/src/app/agents/web-chat/activity-to-events.spec.ts b/apps/api/src/app/agents/web-chat/activity-to-events.spec.ts index 244a6dbf946..be7a36c8596 100644 --- a/apps/api/src/app/agents/web-chat/activity-to-events.spec.ts +++ b/apps/api/src/app/agents/web-chat/activity-to-events.spec.ts @@ -176,6 +176,32 @@ describe('activity-to-events run lifecycle', () => { ]); }); + it('falls back to markdown when a stored card has no children array', () => { + const envelopes = mapNewestFirstEventActivities( + [ + activity({ + type: ConversationActivityTypeEnum.MESSAGE, + identifier: 'msg_card_empty_1', + platformMessageId: 'act_card_empty_1', + sequence: 1, + content: 'fallback markdown', + richContent: { card: { type: 'card' } }, + }), + ], + context + ); + + expect(envelopes.map((envelope) => envelope.event)).to.deep.equal([ + { + type: 'message', + role: 'assistant', + messageId: 'act_card_empty_1', + content: { markdown: 'fallback markdown' }, + files: undefined, + }, + ]); + }); + it('uses immutable activity ids for approval request messages', () => { const envelopes = mapNewestFirstEventActivities( [ diff --git a/apps/api/src/app/agents/web-chat/activity-to-events.ts b/apps/api/src/app/agents/web-chat/activity-to-events.ts index 65d4667b299..cbe344b8cc7 100644 --- a/apps/api/src/app/agents/web-chat/activity-to-events.ts +++ b/apps/api/src/app/agents/web-chat/activity-to-events.ts @@ -4,6 +4,7 @@ import { type AgentEventEnvelope, type AgentFileRef, type AgentMessageContent, + type CardElement, isDeltaEvent, } from '@novu/agent-event-protocol'; import { @@ -37,8 +38,15 @@ function filesFromRichContent(richContent?: Record) { return files as AgentFileRef[]; } -function isCardTree(value: unknown): value is Record { - return typeof value === 'object' && value !== null && (value as { type?: unknown }).type === 'card'; +/** Stored card JSON: `type: 'card'` plus a `children` array. Not a full `CardElement` tree. */ +function isCardTree(value: unknown): value is { type: 'card'; children: unknown[] } { + if (typeof value !== 'object' || value === null) { + return false; + } + + const card = value as { type?: unknown; children?: unknown }; + + return card.type === 'card' && Array.isArray(card.children); } function isManagedToolApprovalRequest(toolData: ConversationActivityEntity['toolData']): boolean { @@ -77,7 +85,8 @@ export function messageContentFromStored(params: { }): AgentMessageContent { const card = params.richContent?.card; if (isCardTree(card)) { - return { card }; + // `isCardTree` only proves `type` + `children[]`. Stored trees are trusted here. + return { card: card as CardElement }; } return { markdown: params.content ?? '' }; diff --git a/apps/dashboard/src/components/agents/web-chat-panel/web-chat-parts.tsx b/apps/dashboard/src/components/agents/web-chat-panel/web-chat-parts.tsx index bd7b3fc5c34..22f356e74f3 100644 --- a/apps/dashboard/src/components/agents/web-chat-panel/web-chat-parts.tsx +++ b/apps/dashboard/src/components/agents/web-chat-panel/web-chat-parts.tsx @@ -218,7 +218,9 @@ export function ChatMessageRow({ key={`${message.id}-card-${index}`} card={part.card} disabled={cardActionsDisabled} - onAction={onCardAction ? (action) => onCardAction({ ...action, sourceMessageId: message.id }) : undefined} + onAction={ + onCardAction ? (action) => onCardAction({ ...action, sourceMessageId: part.sourceMessageId }) : undefined + } /> ))} {visibleTools.length > 0 ? ( diff --git a/apps/dashboard/src/components/auth/organization-picker.tsx b/apps/dashboard/src/components/auth/organization-picker.tsx index e549218ca30..0c665bea34e 100644 --- a/apps/dashboard/src/components/auth/organization-picker.tsx +++ b/apps/dashboard/src/components/auth/organization-picker.tsx @@ -40,6 +40,18 @@ type OrganizationMembershipLike = { }; }; +type OrganizationSuggestionLike = { + id: string; + status: 'pending' | 'accepted'; + publicOrganizationData: { + id: string; + name: string; + slug: string | null; + imageUrl: string; + }; + accept: () => Promise; +}; + type OrganizationPickerProps = { afterCreateOrganizationUrl: string; afterSelectOrganizationUrl: string; @@ -155,26 +167,89 @@ function OrganizationRow({ membership, onSelect, isBusy, busyId }: OrganizationR ); } +type OrganizationSuggestionRowProps = { + suggestion: OrganizationSuggestionLike; + onRequestJoin: (suggestion: OrganizationSuggestionLike) => void; + isBusy: boolean; + isRequesting: boolean; + isAccepted: boolean; +}; + +function OrganizationSuggestionRow({ + suggestion, + onRequestJoin, + isBusy, + isRequesting, + isAccepted, +}: OrganizationSuggestionRowProps) { + const organization = suggestion.publicOrganizationData; + + return ( + + +
+

{organization.name}

+

Suggested for your verified email domain

+
+ {isAccepted ? ( + Request sent + ) : ( + + )} +
+ ); +} + type OrganizationListViewProps = { memberships: OrganizationMembershipLike[]; + suggestions: OrganizationSuggestionLike[]; onSelect: (organizationId: string) => void; + onRequestJoin: (suggestion: OrganizationSuggestionLike) => void; onCreateClick: () => void; isBusy: boolean; busyId: string | null; + requestingSuggestionId: string | null; + acceptedSuggestionIds: Set; // True while additional pages are streaming in from Clerk after page 1 has rendered. isLoadingMore: boolean; }; function OrganizationListView({ memberships, + suggestions, onSelect, + onRequestJoin, onCreateClick, isBusy, busyId, + requestingSuggestionId, + acceptedSuggestionIds, isLoadingMore, }: OrganizationListViewProps) { const productLabel = 'Novu Cloud'; - const shouldScroll = memberships.length > ORG_LIST_VISIBLE_ROWS || isLoadingMore; + const shouldScroll = memberships.length + suggestions.length > ORG_LIST_VISIBLE_ROWS || isLoadingMore; return (
@@ -187,6 +262,24 @@ function OrganizationListView({ {/* `pr-2` reserves room for the overlay scrollbar so the row chevron doesn't get clipped. */}
+ {suggestions.length > 0 ? ( +
+

Suggested organizations

+ + {suggestions.map((suggestion) => ( + + ))} + +
+ ) : null} + {memberships.map((membership) => ( { try { - await userMembershipsRef.current?.revalidate?.(); + await Promise.allSettled([ + userMembershipsRef.current?.revalidate?.(), + userSuggestionsRef.current?.revalidate?.(), + ]); } catch { // Revalidation failures shouldn't strand the user — show whatever is cached. } finally { @@ -394,12 +497,24 @@ export function OrganizationPicker({ // Drain pagination so the picker renders against the full membership list. useEffect(() => { - if (!isLoaded || !userMemberships?.hasNextPage || userMemberships?.isFetching) { - return; + if (!isLoaded) return; + + if (userMemberships?.hasNextPage && !userMemberships.isFetching) { + userMemberships.fetchNext?.(); } - userMemberships.fetchNext?.(); - }, [isLoaded, userMemberships?.hasNextPage, userMemberships?.isFetching, userMemberships]); + if (userSuggestions?.hasNextPage && !userSuggestions.isFetching) { + userSuggestions.fetchNext?.(); + } + }, [ + isLoaded, + userMemberships?.hasNextPage, + userMemberships?.isFetching, + userMemberships, + userSuggestions?.hasNextPage, + userSuggestions?.isFetching, + userSuggestions, + ]); // Two readiness signals: // - `isFirstPageReady` — render the picker as soon as page 1 lands so users with many orgs @@ -407,16 +522,27 @@ export function OrganizationPicker({ // - `isFullListLoaded` — gate the "auto-switch to create view if empty" decision on the // complete list (an org might sit on page 2). const isFirstPageReady = isLoaded && hasRevalidated; - const isFullListLoaded = isFirstPageReady && !userMemberships?.isFetching && userMemberships?.hasNextPage !== true; + const isFullListLoaded = + isFirstPageReady && + !userMemberships?.isFetching && + userMemberships?.hasNextPage !== true && + !userSuggestions?.isFetching && + userSuggestions?.hasNextPage !== true; const filteredMemberships = useMemo( () => (userMemberships?.data ?? []) as OrganizationMembershipLike[], [userMemberships?.data] ); + const filteredSuggestions = useMemo( + () => (userSuggestions?.data ?? []).filter(Boolean) as OrganizationSuggestionLike[], + [userSuggestions?.data] + ); const [view, setView] = useState('picker'); const [isSelecting, setIsSelecting] = useState(false); const [selectingId, setSelectingId] = useState(null); + const [requestingSuggestionId, setRequestingSuggestionId] = useState(null); + const [acceptedSuggestionIds, setAcceptedSuggestionIds] = useState>(() => new Set()); const [isCreating, setIsCreating] = useState(false); const hasTrackedRef = useRef(false); const hasInitializedViewRef = useRef(false); @@ -428,10 +554,10 @@ export function OrganizationPicker({ hasInitializedViewRef.current = true; - if (filteredMemberships.length === 0) { + if (filteredMemberships.length === 0 && filteredSuggestions.length === 0) { setView('create'); } - }, [isFullListLoaded, filteredMemberships.length]); + }, [isFullListLoaded, filteredMemberships.length, filteredSuggestions.length]); const handleSelect = useCallback( async (organizationId: string) => { @@ -517,22 +643,43 @@ export function OrganizationPicker({ [createOrganization, setActive, afterCreateOrganizationUrl, track, navigate] ); + const handleRequestJoin = useCallback( + async (suggestion: OrganizationSuggestionLike) => { + if (requestingSuggestionId) return; + + setRequestingSuggestionId(suggestion.id); + + try { + await suggestion.accept(); + setAcceptedSuggestionIds((current) => new Set(current).add(suggestion.id)); + void userSuggestionsRef.current?.revalidate?.().catch(() => undefined); + } catch (error) { + const message = readClerkErrorMessage(error, 'Unable to request access to this organization.'); + showErrorToast(message, 'Join request failed'); + } finally { + setRequestingSuggestionId(null); + } + }, + [requestingSuggestionId] + ); + const handleCancel = useCallback(() => { - if (filteredMemberships.length > 0) { + if (filteredMemberships.length > 0 || filteredSuggestions.length > 0) { setView('picker'); return; } void onSignOut(); - }, [filteredMemberships.length, onSignOut]); + }, [filteredMemberships.length, filteredSuggestions.length, onSignOut]); // Show the full-screen spinner only while page 1 is in flight. Once page 1 lands we render the // picker and surface the inline "Loading more…" row for any subsequent pages. Exception: if // page 1 yields no orgs but more pages are still streaming, keep the spinner so we don't briefly // render an empty header. const isStreamingMorePages = isFirstPageReady && !isFullListLoaded; - const shouldWaitForMorePages = isStreamingMorePages && filteredMemberships.length === 0; + const shouldWaitForMorePages = + isStreamingMorePages && filteredMemberships.length === 0 && filteredSuggestions.length === 0; if (!isFirstPageReady || shouldWaitForMorePages) { return ( @@ -545,7 +692,7 @@ export function OrganizationPicker({ if (view === 'create') { return ( 0} + hasExistingOrgs={filteredMemberships.length > 0 || filteredSuggestions.length > 0} onCancel={handleCancel} onSubmit={handleCreate} isSubmitting={isCreating} @@ -556,10 +703,14 @@ export function OrganizationPicker({ return ( setView('create')} - isBusy={isSelecting || isCreating} + isBusy={isSelecting || isCreating || requestingSuggestionId !== null} busyId={selectingId} + requestingSuggestionId={requestingSuggestionId} + acceptedSuggestionIds={acceptedSuggestionIds} isLoadingMore={isStreamingMorePages} /> ); diff --git a/apps/inbound-mail/src/server/address-utils.spec.ts b/apps/inbound-mail/src/server/address-utils.spec.ts new file mode 100644 index 00000000000..7b7f34d3204 --- /dev/null +++ b/apps/inbound-mail/src/server/address-utils.spec.ts @@ -0,0 +1,26 @@ +import { expect } from 'chai'; + +import { extractEmailDomain } from './address-utils'; + +describe('extractEmailDomain', () => { + it('returns the domain after the first @', () => { + expect(extractEmailDomain('user@domain.com')).to.equal('domain.com'); + }); + + it('returns everything after the first @ when several @ are present', () => { + expect(extractEmailDomain('user@sub@domain.com')).to.equal('sub@domain.com'); + }); + + it('returns null for an address with no @ instead of throwing', () => { + /* + * RFC 5321 allows a domain-less envelope address such as . + * The previous `/@(.*)/.exec(email)[1]` threw a TypeError on the null match, + * which surfaced as an unhandled error during address validation. + */ + expect(extractEmailDomain('postmaster')).to.equal(null); + }); + + it('returns an empty string when the address ends with @', () => { + expect(extractEmailDomain('user@')).to.equal(''); + }); +}); diff --git a/apps/inbound-mail/src/server/address-utils.ts b/apps/inbound-mail/src/server/address-utils.ts new file mode 100644 index 00000000000..2d7d07f9f7f --- /dev/null +++ b/apps/inbound-mail/src/server/address-utils.ts @@ -0,0 +1,13 @@ +/** + * Returns the domain portion of an email address (everything after the first + * `@`), or `null` when the address has no `@` at all. + * + * SMTP envelope addresses are attacker-controlled and RFC 5321 allows a + * domain-less address such as ``, so callers must handle the `null` + * case rather than assuming a match is always present. + */ +export function extractEmailDomain(email: string): string | null { + const match = /@(.*)/.exec(email); + + return match ? match[1] : null; +} diff --git a/apps/inbound-mail/src/server/index.ts b/apps/inbound-mail/src/server/index.ts index 97bbd0fd019..38ce6e6bdbe 100644 --- a/apps/inbound-mail/src/server/index.ts +++ b/apps/inbound-mail/src/server/index.ts @@ -14,6 +14,7 @@ import { SMTPServer } from 'smtp-server'; import util from 'util'; import { v4 as uuidv4 } from 'uuid'; +import { extractEmailDomain } from './address-utils'; import { uploadAttachmentsToS3 } from './attachment-uploader'; import { collectClientIpSources } from './client-ip-sources'; import { InboundMailService } from './inbound-mail.service'; @@ -134,7 +135,7 @@ class Mailin extends events.EventEmitter { return reject(new Error(localErrorMessage)); } - const domain = /@(.*)/.exec(email)[1]; + const domain = extractEmailDomain(email); const validateViaLocal = () => { if (_this.listeners(validateEvent).length) { @@ -153,6 +154,11 @@ class Mailin extends events.EventEmitter { }; const validateViaDNS = () => { + if (domain === null) { + _this.emit(validationFailedEvent, email); + + return reject(new Error(dnsErrorMessage)); + } try { dns.resolveMx(domain, (err, addresses) => { if (err || !addresses || !addresses.length) { diff --git a/docs/agents/channels/web-chat/chat-ui.mdx b/docs/agents/channels/web-chat/chat-ui.mdx index ec80883f76d..2a6a028d94e 100644 --- a/docs/agents/channels/web-chat/chat-ui.mdx +++ b/docs/agents/channels/web-chat/chat-ui.mdx @@ -22,6 +22,22 @@ Use these fields to drive the UI. See [`useWebChat`](/platform/sdks/react/hooks/ See [Reconnect](#reconnect) (`isRecovering`, `catchUpError`). See [Older messages](#older-messages) (`pagination`). +## Errors + +`error` in the table above is the last failure (load, send, retry, or action). Show that message. + +`sendMessage`, `respondToAction`, `sendAction`, and `retryMessage` do not throw. A `try/catch` around them will not run. + +To react to one call, wait for the result and read `error`: + +```tsx +const result = await sendMessage(text); + +if (result.error) { + // this send failed +} +``` + ## Parts Start with `type === 'text'`. Then handle the other part types. See [`useWebChat`](/platform/sdks/react/hooks/use-web-chat) for part types. diff --git a/docs/platform/concepts/integrations.mdx b/docs/platform/concepts/integrations.mdx index 38d4ac9e797..92b46fddc4d 100644 --- a/docs/platform/concepts/integrations.mdx +++ b/docs/platform/concepts/integrations.mdx @@ -46,6 +46,24 @@ Each environment can support multiple active integrations per channel, but only The primary integration serves as the default route when a message is sent over that channel unless explicitly overridden. You can update which integration is marked as primary or deactivate an integration entirely. +## Conditional routing with integration conditions + +You can attach **conditions** to an integration so Novu only selects it when the notification matches those conditions. This is useful when you run multiple integrations for the same channel, for example a separate email or push provider account per tenant, and want Novu to route each notification to the right one. + +Add conditions from the dashboard when you create or edit an integration, under **Integration conditions**. Conditions are evaluated at send time against the recipient and the notification context, and support these fields: + +- `context.tenant.id` +- `subscriber.subscriberId`, `subscriber.email`, `subscriber.phone`, `subscriber.firstName`, `subscriber.lastName`, `subscriber.locale` +- `subscriber.data` (custom subscriber attributes) + +When a notification is sent over a channel, Novu uses the **first active integration whose conditions match**. If no conditioned integration matches, Novu falls back to the **primary** integration for that channel. + + + An integration with conditions cannot also be the primary integration. Adding conditions to an integration removes its primary flag, because the primary integration is the fallback used when no conditions match. + + +To route by tenant, see [Contexts](/platform/workflow/advanced-features/contexts/contexts-in-workflows) for how `context.tenant` is populated on a trigger. + ## Integration credentials Integrations require credentials to authenticate with third-party providers. These credentials are encrypted at rest and managed securely within Novu. diff --git a/docs/platform/developer/webhooks/event-types.mdx b/docs/platform/developer/webhooks/event-types.mdx index 8232c920629..0131f978e92 100644 --- a/docs/platform/developer/webhooks/event-types.mdx +++ b/docs/platform/developer/webhooks/event-types.mdx @@ -14,7 +14,7 @@ Each event includes detailed information about the affected resource and the cha ## Email events -- `email.received`: Triggered when Novu receives an inbound email that matches an [Inbound Email](/platform/inbound-email/overview) **Webhook** route on a verified domain. The payload includes normalized mail content (from, to, subject, text, html, headers, attachments, threading fields) plus domain and route metadata. Download files from each attachment's `url` before `expiresAt`. See [Attachments](/platform/inbound-email/overview#attachments). +- `email.received`: Triggered when Novu receives an inbound email that matches an [Inbound Email](/platform/inbound-email/overview) **Webhook** route on a verified domain. The payload includes normalized mail content (from, to, subject, text, html, headers, attachments, threading fields) plus domain and route metadata. When the matched route address is only in the SMTP envelope (a **BCC** recipient not present in `to` or `cc`), it appears in `mail.bcc` and `to` is left unchanged; the field is omitted otherwise. Download files from each attachment's `url` before `expiresAt`. See [Attachments](/platform/inbound-email/overview#attachments). `email.received` is for **user mail** received on your domain. It is not the same as delivery or engagement events from your outbound email provider - see [Email Activity Tracking](/platform/integrations/email/activity-tracking) for those. diff --git a/docs/platform/inbound-email/overview.mdx b/docs/platform/inbound-email/overview.mdx index d136f46c00f..29c5e8c2e37 100644 --- a/docs/platform/inbound-email/overview.mdx +++ b/docs/platform/inbound-email/overview.mdx @@ -81,6 +81,10 @@ Enable webhooks in the dashboard under **Webhooks**, create an endpoint, and sub The event payload includes normalized mail fields (from, to, subject, text, html, headers, attachments, threading headers) plus your domain and route metadata. See [Event types - Email events](/platform/developer/webhooks/event-types#email-events) for the full schema. + + When the matched route address is only in the SMTP envelope (a **BCC** recipient not present in the visible `To` or `Cc` headers), Novu adds it to `data.object.mail.bcc` as an array of `{ address, name }` entries and leaves `to` unchanged. The field is omitted when the matched recipient is already visible in the headers. `bcc` only ever contains the matched inbound address, never other envelope recipients. + + ### Attachments Each item in `data.object.mail.attachments` is metadata plus a time-limited download URL. Fetch the file with an HTTP `GET` to `url` before `expiresAt`. After that timestamp the link stops working, so copy the bytes into your own storage if you need them later. diff --git a/packages/agent-event-protocol/src/card-element.types.ts b/packages/agent-event-protocol/src/card-element.types.ts new file mode 100644 index 00000000000..4a571bb899d --- /dev/null +++ b/packages/agent-event-protocol/src/card-element.types.ts @@ -0,0 +1,139 @@ +/** + * Novu-owned card JSON on the agent wire. + * + * Matches the full Chat SDK kit agents emit (`section` / `fields` / `table` / + * `button` / `select` / `radio_select`) plus Novu fields on buttons + * (`actionType`, `callbackUrl`, `disabled`). Chat SDK stays the authoring + * frontend (`@novu/framework`); this type is the contract `@novu/js` and the + * event protocol share. The dashboard Maily editor still authors the smaller + * `@novu/shared` v1 subset (link-buttons only). + * + * Compatibility locks in `packages/framework/src/resources/agent/card-element-compat.test-d.ts`: + * `chat.CardElement` and `@novu/stateless` `CardElement` must assign to this type. + * Do not add `chat` or `@novu/stateless` as a dependency here. + */ + +export type CardElementTextElement = { + type: 'text'; + content: string; + style?: 'plain' | 'bold' | 'muted'; +}; + +export type CardElementImageElement = { + type: 'image'; + url: string; + alt?: string; +}; + +export type CardElementDividerElement = { + type: 'divider'; +}; + +/** Presentational inline hyperlink (Chat SDK `CardLink`). */ +export type CardElementLinkElement = { + type: 'link'; + label: string; + url: string; +}; + +export type CardElementLinkButtonElement = { + type: 'link-button'; + label: string; + url: string; + style?: 'primary' | 'danger' | 'default'; + /** Optional author-provided id; platform serializers use it (e.g. Slack `action_id`). */ + id?: string; +}; + +/** Interactive action button (Chat SDK `Button`). Drives `sendAction` in web chat. */ +export type CardElementButtonElement = { + type: 'button'; + id: string; + label: string; + style?: 'primary' | 'danger' | 'default'; + actionType?: 'action' | 'modal'; + callbackUrl?: string; + value?: string; + disabled?: boolean; +}; + +export type CardElementSelectOptionElement = { + label: string; + value: string; + description?: string; +}; + +/** Chat SDK `Select`. */ +export type CardElementSelectElement = { + type: 'select'; + id: string; + label: string; + options: CardElementSelectOptionElement[]; + initialOption?: string; + optional?: boolean; + placeholder?: string; +}; + +/** Chat SDK `RadioSelect`. */ +export type CardElementRadioSelectElement = { + type: 'radio_select'; + id: string; + label: string; + options: CardElementSelectOptionElement[]; + initialOption?: string; + optional?: boolean; +}; + +export type CardElementActionChild = + | CardElementLinkButtonElement + | CardElementButtonElement + | CardElementSelectElement + | CardElementRadioSelectElement; + +export type CardElementActionsElement = { + type: 'actions'; + children: CardElementActionChild[]; +}; + +export type CardElementFieldElement = { + type: 'field'; + label: string; + value: string; +}; + +export type CardElementFieldsElement = { + type: 'fields'; + children: CardElementFieldElement[]; +}; + +export type CardElementTableElement = { + type: 'table'; + headers: string[]; + rows: string[][]; + align?: Array<'left' | 'center' | 'right'>; +}; + +export type CardElementSectionElement = { + type: 'section'; + children: CardElementChild[]; +}; + +/** Includes top-level `button` — agents emit that shape, not only `actions` wrappers. */ +export type CardElementChild = + | CardElementTextElement + | CardElementImageElement + | CardElementDividerElement + | CardElementLinkElement + | CardElementButtonElement + | CardElementActionsElement + | CardElementSectionElement + | CardElementFieldsElement + | CardElementTableElement; + +export type CardElement = { + type: 'card'; + title?: string; + subtitle?: string; + imageUrl?: string; + children: CardElementChild[]; +}; diff --git a/packages/agent-event-protocol/src/index.ts b/packages/agent-event-protocol/src/index.ts index 5cb5c0269a4..e988c79b747 100644 --- a/packages/agent-event-protocol/src/index.ts +++ b/packages/agent-event-protocol/src/index.ts @@ -13,6 +13,25 @@ export type { AgentSignal, } from './agent-event.types'; export { AGENT_EVENT_PROTOCOL_VERSION, isAgentEventEnvelope, isDeltaEvent } from './agent-event.types'; +export type { + CardElement, + CardElementActionChild, + CardElementActionsElement, + CardElementButtonElement, + CardElementChild, + CardElementDividerElement, + CardElementFieldElement, + CardElementFieldsElement, + CardElementImageElement, + CardElementLinkButtonElement, + CardElementLinkElement, + CardElementRadioSelectElement, + CardElementSectionElement, + CardElementSelectElement, + CardElementSelectOptionElement, + CardElementTableElement, + CardElementTextElement, +} from './card-element.types'; export type { AgentFileRef, AgentMessageContent, diff --git a/packages/agent-event-protocol/src/wire-content.types.ts b/packages/agent-event-protocol/src/wire-content.types.ts index b4cf8892043..7e46b33c49a 100644 --- a/packages/agent-event-protocol/src/wire-content.types.ts +++ b/packages/agent-event-protocol/src/wire-content.types.ts @@ -1,3 +1,5 @@ +import type { CardElement } from './card-element.types'; + export type AgentMessageRole = 'user' | 'assistant'; export type AgentToolSource = { type: 'builtin' } | { type: 'custom' } | { type: 'mcp'; serverName: string }; @@ -9,7 +11,7 @@ export type AgentToolResultContent = | { type: 'media'; mediaType: string; data: string; name?: string } | { type: 'unknown'; providerType: string; data: Record }; -export type AgentMessageContent = { markdown: string } | { card: Record }; +export type AgentMessageContent = { markdown: string } | { card: CardElement }; export interface AgentFileRef { fileId: string; diff --git a/packages/framework/package.json b/packages/framework/package.json index 85f05f17597..3f889caa0dd 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -314,6 +314,7 @@ "@apidevtools/json-schema-ref-parser": "11.6.4", "@arethetypeswrong/cli": "^0.17.4", "@novu/agent-event-protocol": "workspace:*", + "@novu/stateless": "workspace:*", "@langchain/core": "^1.1.44", "@nestjs/common": "11.1.27", "@sveltejs/kit": "^1.27.3", diff --git a/packages/framework/src/resources/agent/agent.context.ts b/packages/framework/src/resources/agent/agent.context.ts index 66382b3accc..1b84cbfad73 100644 --- a/packages/framework/src/resources/agent/agent.context.ts +++ b/packages/framework/src/resources/agent/agent.context.ts @@ -221,9 +221,9 @@ function mint(prefix: string): string { } /** - * `ReplyContent['card']` is the structured `CardElement` type; `AgentMessageContent['card']` is - * `Record` on the wire, since the protocol can't depend on the `chat` package's - * types. This is the one place that crosses that boundary for outbound (SDK → sink) card content. + * `ReplyContent['card']` is Chat SDK `CardElement`. `AgentMessageContent['card']` is the + * Novu-owned protocol `CardElement`. This is the one place that crosses that authoring + * → wire boundary for outbound card content. */ function toAgentMessageContent(reply: ReplyContent): AgentMessageContent { if (reply.markdown !== undefined) { @@ -231,7 +231,7 @@ function toAgentMessageContent(reply: ReplyContent): AgentMessageContent { } if (reply.card !== undefined) { - return { card: reply.card as unknown as Record }; + return { card: reply.card }; } throw new Error('Invalid reply content — expected markdown or card'); diff --git a/packages/framework/src/resources/agent/card-element-compat.test-d.ts b/packages/framework/src/resources/agent/card-element-compat.test-d.ts new file mode 100644 index 00000000000..abd7ca4042d --- /dev/null +++ b/packages/framework/src/resources/agent/card-element-compat.test-d.ts @@ -0,0 +1,32 @@ +import type { CardElement as NovuCardElement } from '@novu/agent-event-protocol'; +import type { CardElement as StatelessCardElement } from '@novu/stateless'; +import type { CardElement as ChatCardElement } from 'chat'; +import { describe, expectTypeOf, it } from 'vitest'; + +type ProtocolChildType = NovuCardElement['children'][number]['type']; +type StatelessChildType = StatelessCardElement['children'][number]['type']; + +/** + * Chat SDK is the authoring kit. The protocol type is Novu-owned and may be a + * structural superset (e.g. top-level `button`). This test locks the one + * direction that must not break: every Chat SDK card is a valid wire card. + * + * If `chat` adds a child variant we do not have, this fails — update + * `packages/agent-event-protocol/src/card-element.types.ts`. + * + * `@novu/stateless` holds a second Novu copy for Slack/Teams renderers. Lock + * Stateless → protocol, and the child `type` union except wire-only `button`. + */ +describe('CardElement chat-sdk compatibility', () => { + it('accepts every Chat SDK card on the Novu wire', () => { + expectTypeOf().toMatchTypeOf(); + }); + + it('accepts every @novu/stateless card on the Novu wire', () => { + expectTypeOf().toMatchTypeOf(); + }); + + it('shares @novu/stateless child variants except the wire-only top-level button', () => { + expectTypeOf>().toEqualTypeOf(); + }); +}); diff --git a/packages/js/scripts/size-limit.mjs b/packages/js/scripts/size-limit.mjs index 4740f6faeab..3ec7502c3ca 100644 --- a/packages/js/scripts/size-limit.mjs +++ b/packages/js/scripts/size-limit.mjs @@ -15,15 +15,12 @@ const modules = [ { name: 'UMD minified', filePath: umdPath, - // Raised for agent conversation runtime (NV-8640), protocol validation (NV-8644), - // web-chat idempotency/retry (NV-8642), publication meta (NV-8641), - // and lazy Web Chat load (NV-8698). limitInBytes: 242_000, }, { name: 'UMD gzip', filePath: umdGzipPath, - limitInBytes: 66_000, + limitInBytes: 67_000, }, ]; diff --git a/packages/js/src/api/web-chat-service.test.ts b/packages/js/src/api/web-chat-service.test.ts index a5ac5e6821c..e50ed52c187 100644 --- a/packages/js/src/api/web-chat-service.test.ts +++ b/packages/js/src/api/web-chat-service.test.ts @@ -108,6 +108,34 @@ describe('WebChatService', () => { ); }); + it('GETs conversations with the session token already on the client', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: [{ identifier: 'conv_abcdefghijkl', title: 'Billing' }], + next: null, + previous: null, + }), + } as Response); + global.fetch = fetchMock as unknown as typeof fetch; + + const httpClient = new HttpClient({ apiUrl: 'https://test.novu.co' }); + httpClient.setAuthorizationToken('session-token'); + const service = new WebChatService({ httpClient }); + + const result = await service.listConversations({ limit: 5, orderBy: 'lastActivityAt', orderDirection: 'DESC' }); + + expect(result.conversations).toEqual([{ identifier: 'conv_abcdefghijkl', title: 'Billing' }]); + expect(fetchMock).toHaveBeenCalledWith( + 'https://test.novu.co/v1/web-chat/conversations?limit=5&orderBy=lastActivityAt&orderDirection=DESC', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ Authorization: 'Bearer session-token' }), + }) + ); + }); + it('GETs conversation events', async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: true, diff --git a/packages/js/src/api/web-chat-service.ts b/packages/js/src/api/web-chat-service.ts index 7b37b3ba0e8..7c8e4aa14cd 100644 --- a/packages/js/src/api/web-chat-service.ts +++ b/packages/js/src/api/web-chat-service.ts @@ -1,7 +1,12 @@ import type { AgentEventEnvelope } from '@novu/agent-event-protocol'; -import { WebChatPlanLimitError, type WebChatPlanLimitReason } from '../web-chat/web-chat-plan-limit-error'; -import type { AgentHashFields } from '../web-chat/types'; +import type { + AgentHashFields, + ListConversationsArgs, + ListConversationsResult, + WebChatConversation, +} from '../web-chat/types'; import { validateHistoryPageResponse } from '../web-chat/validate-envelope'; +import { WebChatPlanLimitError, type WebChatPlanLimitReason } from '../web-chat/web-chat-plan-limit-error'; import { HttpClient } from './http-client'; const WEB_CHAT_CONVERSATIONS_ROUTE = '/web-chat/conversations'; @@ -132,6 +137,37 @@ export class WebChatService { return err; } + async listConversations(args: ListConversationsArgs = {}): Promise { + const params = new URLSearchParams(); + if (args.limit != null) { + params.set('limit', String(args.limit)); + } + if (args.after) { + params.set('after', args.after); + } + if (args.before) { + params.set('before', args.before); + } + if (args.orderBy) { + params.set('orderBy', args.orderBy); + } + if (args.orderDirection) { + params.set('orderDirection', args.orderDirection); + } + + const raw = await this.#httpClient.get<{ + data?: WebChatConversation[]; + next?: string | null; + previous?: string | null; + }>(WEB_CHAT_CONVERSATIONS_ROUTE, params.toString() ? params : undefined, false); + + return { + conversations: raw.data ?? [], + next: raw.next ?? null, + previous: raw.previous ?? null, + }; + } + async getEvents(args: WebChatGetEventsArgs): Promise { const params = new URLSearchParams(); if (args.before) { diff --git a/packages/js/src/event-emitter/types.ts b/packages/js/src/event-emitter/types.ts index 9aa9a5aad4b..a26699e636e 100644 --- a/packages/js/src/event-emitter/types.ts +++ b/packages/js/src/event-emitter/types.ts @@ -172,6 +172,9 @@ type ChannelEndpointLinkEvents = BaseEvents< >; type SocketConnectEvents = BaseEvents<'socket.connect', { socketUrl: string }, undefined>; +type SocketDisconnectEvents = { + 'socket.disconnect.resolved': NovuResolvedEvent<{ socketUrl: string }, undefined>; +}; export type NotificationReceivedEvent = `notifications.${WebSocketEvent.RECEIVED}`; export type NotificationUnseenEvent = `notifications.${WebSocketEvent.UNSEEN}`; export type NotificationUnreadEvent = `notifications.${WebSocketEvent.UNREAD}`; @@ -228,6 +231,7 @@ export type Events = SessionInitializeEvents & ChannelEndpointDeleteEvents & ChannelEndpointLinkEvents & SocketConnectEvents & + SocketDisconnectEvents & SocketEvents & NotificationReadEvents & NotificationUnreadEvents & diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index 4bbf79173ec..d20e8701a37 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -20,6 +20,8 @@ export { Novu } from './novu'; export type { AgentApprovalPart, AgentApprovalPartState, + AgentCardChild, + AgentCardElement, AgentCardPart, AgentConversationPublicationMeta, AgentConversationRunSnapshot, @@ -52,6 +54,8 @@ export type { AgentToolPartState, ConversationArgs, FetchMoreResult, + ListConversationsArgs, + ListConversationsResult, LoadConversationResult, RespondToActionResult, RetryMessageResult, @@ -59,6 +63,7 @@ export type { SendMessageInput, SendMessageResult, WebChat, + WebChatConversation, WebChatDefinition, WebChatPagination, WebChatPaginationStatus, diff --git a/packages/js/src/web-chat/agent-message.types.ts b/packages/js/src/web-chat/agent-message.types.ts index 01c754e4047..60a255476dd 100644 --- a/packages/js/src/web-chat/agent-message.types.ts +++ b/packages/js/src/web-chat/agent-message.types.ts @@ -1,4 +1,15 @@ -import type { AgentMessageRole, AgentToolResultContent, AgentToolSource } from '@novu/agent-event-protocol'; +import type { + AgentMessageRole, + AgentToolResultContent, + AgentToolSource, + CardElement, + CardElementChild, +} from '@novu/agent-event-protocol'; + +/** Wire card JSON. Alias of the protocol `CardElement`. */ +export type AgentCardElement = CardElement; +/** One card child. Alias of the protocol `CardElementChild`. */ +export type AgentCardChild = CardElementChild; export type { AgentMessageRole }; @@ -111,7 +122,9 @@ export type AgentFilePart = { /** Structured Card. Button clicks call `sendAction`. */ export type AgentCardPart = { type: 'card'; - card: Record; + card: AgentCardElement; + /** Id of the message that contains this card. */ + sourceMessageId: string; }; /** Custom payload. The UI decides how to render it. */ diff --git a/packages/js/src/web-chat/apply-envelope.test.ts b/packages/js/src/web-chat/apply-envelope.test.ts index 1757e8f1cf0..aeddde04e87 100644 --- a/packages/js/src/web-chat/apply-envelope.test.ts +++ b/packages/js/src/web-chat/apply-envelope.test.ts @@ -553,9 +553,9 @@ describe('applyEnvelope', () => { it('folds card content into a card part', () => { const card = { - type: 'card', + type: 'card' as const, title: 'Support Agent', - children: [{ type: 'text', content: 'How can I help?' }], + children: [{ type: 'text' as const, content: 'How can I help?' }], }; const next = applyEnvelope( createInitialAgentConversationState(), @@ -567,11 +567,11 @@ describe('applyEnvelope', () => { }) ); - expect(assistantMessages(next.messages)[0]?.parts).toEqual([{ type: 'card', card }]); + expect(assistantMessages(next.messages)[0]?.parts).toEqual([{ type: 'card', card, sourceMessageId: 'm-card' }]); }); it('folds exclusive durable content as markdown or card, not both', () => { - const card = { type: 'card', title: 'Support' }; + const card = { type: 'card' as const, title: 'Support', children: [] as const }; const next = applyEnvelope( createInitialAgentConversationState(), envelope(1, { diff --git a/packages/js/src/web-chat/apply-envelope.ts b/packages/js/src/web-chat/apply-envelope.ts index a88955a09f7..97bb10fa17e 100644 --- a/packages/js/src/web-chat/apply-envelope.ts +++ b/packages/js/src/web-chat/apply-envelope.ts @@ -72,13 +72,13 @@ function applyEvent(state: AgentConversationState, envelope: AgentEventEnvelope) case 'message-end': return withAssistantMessage(state, envelope, event.messageId, (message) => ({ ...message, - parts: finalizeMessageEndParts(message.parts, event.content, event.files), + parts: finalizeMessageEndParts(message.parts, event.messageId, event.content, event.files), })); case 'message': { const next = withMessage(state, envelope, event.messageId, event.role, (message) => ({ ...message, - parts: applyDurableMessageParts(message.parts, event.content, event.files), + parts: applyDurableMessageParts(message.parts, event.content, event.messageId, event.files), status: 'sent', })); @@ -351,6 +351,7 @@ function appendToStreamingTextPart(parts: AgentMessagePart[], delta: string): Ag function applyDurableMessageParts( parts: AgentMessagePart[], content: AgentMessageContent, + messageId: string, files?: AgentFileRef[] ): AgentMessagePart[] { let next = parts.slice(); @@ -364,7 +365,7 @@ function applyDurableMessageParts( next = [...next, { type: 'text', text: content.markdown, state: 'done' }]; } } else { - next = [...next, { type: 'card', card: content.card }]; + next = [...next, { type: 'card', card: content.card, sourceMessageId: messageId }]; } return appendFileParts(next, files); @@ -372,6 +373,7 @@ function applyDurableMessageParts( function finalizeMessageEndParts( parts: AgentMessagePart[], + messageId: string, content?: AgentMessageContent, files?: AgentFileRef[] ): AgentMessagePart[] { @@ -379,7 +381,7 @@ function finalizeMessageEndParts( const streamingIndex = findStreamingTextPartIndex(next); if (content) { - next = applyDurableMessageParts(next, content, files); + next = applyDurableMessageParts(next, content, messageId, files); return next; } @@ -588,7 +590,7 @@ function editMessage( const edited: AgentMessage = { ...existing, createdAt: timestamp, - parts: applyDurableMessageParts([], content, files), + parts: applyDurableMessageParts([], content, messageId, files), }; const nextMessages = state.messages.slice(); diff --git a/packages/js/src/web-chat/index.ts b/packages/js/src/web-chat/index.ts index 09fe43b116c..33637cb3236 100644 --- a/packages/js/src/web-chat/index.ts +++ b/packages/js/src/web-chat/index.ts @@ -2,6 +2,8 @@ export type { AgentConversationRuntime } from './agent-conversation-runtime'; export type { AgentApprovalPart, AgentApprovalPartState, + AgentCardChild, + AgentCardElement, AgentCardPart, AgentConversationStatus, AgentConversationTyping, @@ -36,11 +38,14 @@ export type { AgentEventEnvelope, AgentHashFields, FetchMoreResult, + ListConversationsArgs, + ListConversationsResult, LoadConversationResult, RespondToActionResult, RetryMessageResult, SendActionResult, SendMessageResult, + WebChatConversation, WebChatPagination, WebChatPaginationStatus, } from './types'; diff --git a/packages/js/src/web-chat/types.ts b/packages/js/src/web-chat/types.ts index 67f0ec9e4f3..516e2f28650 100644 --- a/packages/js/src/web-chat/types.ts +++ b/packages/js/src/web-chat/types.ts @@ -124,3 +124,28 @@ export type SendActionArgs = AgentHashFields & { export type SendActionResult = { conversationId: string; }; + +/** One conversation in a `listConversations` page. */ +export type WebChatConversation = { + identifier: string; + title: string; + status: AgentConversationStatus; + agentIdentifier: string; + lastActivityAt: string; + createdAt: string; +}; + +export type ListConversationsArgs = { + limit?: number; + after?: string; + before?: string; + orderBy?: 'lastActivityAt' | 'createdAt'; + orderDirection?: 'ASC' | 'DESC'; +}; + +/** One page of the current subscriber's conversations. */ +export type ListConversationsResult = { + conversations: WebChatConversation[]; + next: string | null; + previous: string | null; +}; diff --git a/packages/js/src/web-chat/web-chat.ts b/packages/js/src/web-chat/web-chat.ts index 2d6843239f5..0ffb0fc62b0 100644 --- a/packages/js/src/web-chat/web-chat.ts +++ b/packages/js/src/web-chat/web-chat.ts @@ -13,6 +13,8 @@ import { runtimeCacheKey } from './runtime-cache-key'; import type { FetchMoreArgs, FetchMoreResult, + ListConversationsArgs, + ListConversationsResult, LoadConversationArgs, LoadConversationResult, RespondToActionArgs, @@ -140,6 +142,19 @@ export class WebChat extends BaseModule { return runtime; } + /** List conversations for the current subscriber. */ + async listConversations(args: ListConversationsArgs = {}): Result { + return this.callWithSession(async () => { + try { + const data = await this.#webChatService.listConversations(args); + + return { data }; + } catch (error) { + return { error: new NovuError('Failed to list conversations', error) }; + } + }); + } + /** @internal */ onMessagesUpdated(listener: (data: WebChatMessagesUpdated) => void): () => void { return this._emitter.on('web_chat.messages.updated', ({ data }) => { diff --git a/packages/js/src/ws/party-socket.ts b/packages/js/src/ws/party-socket.ts index b9ddf6406e0..74cc005c3d0 100644 --- a/packages/js/src/ws/party-socket.ts +++ b/packages/js/src/ws/party-socket.ts @@ -237,6 +237,11 @@ export class PartySocketClient extends BaseModule implements BaseSocketInterface } } + #clearCurrentSocket(): void { + this.#clearHibernationHeartbeat(); + this.#partySocket = undefined; + } + #startHibernationHeartbeat(): void { this.#clearHibernationHeartbeat(); @@ -284,8 +289,8 @@ export class PartySocketClient extends BaseModule implements BaseSocketInterface return; } - this.#clearHibernationHeartbeat(); - this.#partySocket = undefined; + this.#clearCurrentSocket(); + this.#emitter.emit('socket.disconnect.resolved', { args }); }); socket.addEventListener('message', this.#handleMessage); @@ -303,9 +308,13 @@ export class PartySocketClient extends BaseModule implements BaseSocketInterface async #handleDisconnectSocket(): Result { try { - this.#clearHibernationHeartbeat(); - this.#partySocket?.close(); - this.#partySocket = undefined; + const socket = this.#partySocket; + this.#clearCurrentSocket(); + socket?.close(); + + if (socket) { + this.#emitter.emit('socket.disconnect.resolved', { args: { socketUrl: this.#socketUrl } }); + } return {}; } catch (error) { diff --git a/packages/js/src/ws/socket.ts b/packages/js/src/ws/socket.ts index a47ad2817ef..84ed3c90489 100644 --- a/packages/js/src/ws/socket.ts +++ b/packages/js/src/ws/socket.ts @@ -183,6 +183,8 @@ export class Socket extends BaseModule implements BaseSocketInterface { ...(this.#socketOptions ?? {}), }); + const socket = this.#socketIo; + this.#socketIo.on('connect', () => { this.#emitter.emit('socket.connect.resolved', { args }); }); @@ -191,6 +193,14 @@ export class Socket extends BaseModule implements BaseSocketInterface { this.#emitter.emit('socket.connect.resolved', { args, error }); }); + this.#socketIo.on('disconnect', () => { + if (this.#socketIo !== undefined && socket !== this.#socketIo) { + return; + } + + this.#emitter.emit('socket.disconnect.resolved', { args }); + }); + this.#socketIo?.on(WebSocketEvent.RECEIVED, this.#notificationReceived); this.#socketIo?.on(WebSocketEvent.UNSEEN, this.#unseenCountChanged); this.#socketIo?.on(WebSocketEvent.UNREAD, this.#unreadCountChanged); diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/message-bubble.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/message-bubble.tsx index 8e77e84ccfc..2f296207ea3 100644 --- a/packages/novu/src/commands/connect/templates/web-chat/ts/message-bubble.tsx +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/message-bubble.tsx @@ -165,7 +165,7 @@ export function MessageRow({ message, onCardAction, onRespond, cardActionsDisabl key={`${message.id}-card-${index}`} card={part.card} disabled={cardActionsDisabled} - onAction={(action) => void onCardAction({ ...action, sourceMessageId: message.id })} + onAction={(action) => void onCardAction({ ...action, sourceMessageId: part.sourceMessageId })} /> ))} diff --git a/packages/react/src/hooks/useWebChat.ts b/packages/react/src/hooks/useWebChat.ts index 402d2cfd292..0499c225aef 100644 --- a/packages/react/src/hooks/useWebChat.ts +++ b/packages/react/src/hooks/useWebChat.ts @@ -67,7 +67,11 @@ export type UseWebChatProps = UseWebChatCallbacks & } ); -/** State and actions returned by {@link useWebChat}. */ +/** + * State and actions returned by {@link useWebChat}. + * `sendMessage`, `respondToAction`, `sendAction`, and `retryMessage` resolve `{ data, error }` and never reject. + * Inspect `error` on the result, or show hook `error`. + */ export type UseWebChatResult = { /** Conversation timeline. */ messages: AgentMessage[]; @@ -75,7 +79,7 @@ export type UseWebChatResult = { pendingActions: AgentPendingAction[]; /** Server conversation id after create or resume. */ conversationId?: string; - /** Last error from load, send, retry, or action. */ + /** Last error from load, send, retry, or action. Mutations also return `{ error }` for that call. */ error?: NovuError | WebChatPlanLimitError; /** True while Web Chat loads and, for an existing conversation, until the first history fetch completes. */ isLoading: boolean; @@ -100,26 +104,43 @@ export type UseWebChatResult = { catchUpError?: NovuError; /** Reload the newest history page. No-op when there is no conversation id. */ refetch: () => Promise; - /** Send a user message. `input` is a string, or `{ text, metadata }`. Creates a conversation when `conversationId` is omitted. */ + /** + * Send a user message. `input` is a string, or `{ text, metadata }`. Creates a conversation when `conversationId` is omitted. + * Does not throw. Resolves `{ data, error }`. Inspect `error` on the result, or show hook `error`. + */ sendMessage: (input: SendMessageInput) => Promise<{ data?: SendMessageResult; error?: NovuError | WebChatPlanLimitError; }>; - /** Resolve a pending `tool-approval`. Pass `action.id` from `pendingActions`. */ + /** + * Resolve a pending `tool-approval`. Pass `action.id` from `pendingActions`. + * Does not throw. Resolves `{ data, error }`. Inspect `error` on the result, or show hook `error`. + */ respondToAction: (args: { actionId: string; decision: AgentToolApprovalDecision }) => Promise<{ data?: RespondToActionResult; error?: NovuError | WebChatPlanLimitError; }>; - /** Click a Card button. Do not use this for tool approval. */ + /** + * Click a Card button. Do not use this for tool approval. + * Does not throw. Resolves `{ data, error }`. Inspect `error` on the result, or show hook `error`. + */ sendAction: (args: { actionId: string; sourceMessageId: string; value?: string }) => Promise<{ data?: SendActionResult; error?: NovuError | WebChatPlanLimitError; }>; - /** Resend a message whose `status` is `failed`. Reuses the original idempotency key. */ + /** + * Resend a message whose `status` is `failed`. Reuses the original idempotency key. + * Does not throw. Resolves `{ data, error }`. Inspect `error` on the result, or show hook `error`. + */ retryMessage: (messageId: string) => Promise<{ data?: SendMessageResult; error?: NovuError | WebChatPlanLimitError; }>; + /** + * Start a new empty chat for the current `agentId`. The next `sendMessage` creates the server conversation. + * No-op when `conversationId` or `conversation` is provided. + */ + startNewConversation: () => void; }; const EMPTY_SERVER_SNAPSHOT = { @@ -217,13 +238,14 @@ function getCreateFlowKey(agentId: string, agentHash?: string): string { function getManagedRuntimeKey( agentId: string, agentHash: string | undefined, - conversationIdProp: string | undefined + conversationIdProp: string | undefined, + createEpoch: number ): string { if (conversationIdProp) { return `resume:${agentId}\0${agentHash ?? ''}\0${conversationIdProp}`; } - return getCreateFlowKey(agentId, agentHash); + return `${getCreateFlowKey(agentId, agentHash)}\0${createEpoch}`; } type ManagedRuntimeEntry = { @@ -306,9 +328,10 @@ export const useWebChat = (props: UseWebChatProps): UseWebChatResult => { const ownedRuntimeRef = useRef(null); const [managedRuntime, setManagedRuntime] = useState(null); + const [createEpoch, setCreateEpoch] = useState(0); const managedRuntimeKey = sharedRuntime ? null - : getManagedRuntimeKey(agentId, agentHash, conversationIdProp); + : getManagedRuntimeKey(agentId, agentHash, conversationIdProp, createEpoch); useEffect(() => { if (sharedRuntime) { @@ -319,13 +342,13 @@ export const useWebChat = (props: UseWebChatProps): UseWebChatResult => { return; } - if (!webChatReady) { + if (!webChatReady || !managedRuntimeKey) { setManagedRuntime(null); return; } - const key = getManagedRuntimeKey(agentId, agentHash, conversationIdProp); + const key = managedRuntimeKey; const current = ownedRuntimeRef.current; let runtime: AgentConversationRuntime; @@ -346,7 +369,7 @@ export const useWebChat = (props: UseWebChatProps): UseWebChatResult => { ownedRuntimeRef.current = null; setManagedRuntime(null); }; - }, [webChatReady, sharedRuntime, novu, agentId, conversationIdProp, agentHash]); + }, [webChatReady, sharedRuntime, novu, agentId, conversationIdProp, agentHash, createEpoch, managedRuntimeKey]); const runtime = sharedRuntime ?? (managedRuntime?.key === managedRuntimeKey ? managedRuntime.runtime : null); @@ -483,6 +506,13 @@ export const useWebChat = (props: UseWebChatProps): UseWebChatResult => { (messageId: string) => callRuntime((target) => target.retryMessage(messageId)), [callRuntime] ); + const startNewConversation = useCallback(() => { + if (sharedRuntime || conversationIdProp) { + return; + } + + setCreateEpoch((epoch) => epoch + 1); + }, [sharedRuntime, conversationIdProp]); return { messages: [...snapshot.messages], @@ -502,5 +532,6 @@ export const useWebChat = (props: UseWebChatProps): UseWebChatResult => { respondToAction, sendAction, retryMessage, + startNewConversation, }; }; diff --git a/packages/react/src/server/index.tsx b/packages/react/src/server/index.tsx index ccd61131528..596bd4f3b22 100644 --- a/packages/react/src/server/index.tsx +++ b/packages/react/src/server/index.tsx @@ -98,6 +98,7 @@ export function useWebChat(_: UseWebChatProps): UseWebChatResult { respondToAction: () => Promise.resolve({ data: undefined, error: undefined }), sendAction: () => Promise.resolve({ data: undefined, error: undefined }), retryMessage: () => Promise.resolve({ data: undefined, error: undefined }), + startNewConversation: () => {}, }; } diff --git a/playground/web-chat/src/app/playground.tsx b/playground/web-chat/src/app/playground.tsx index d697f0559c5..c3aea357dc2 100644 --- a/playground/web-chat/src/app/playground.tsx +++ b/playground/web-chat/src/app/playground.tsx @@ -25,7 +25,7 @@ function PlaygroundApp() { const { events, clear } = useDebugLog(); const socketStatus = useSocketStatus(); const session = usePlaygroundSession(); - const conversations = useConversations(config.backendUrl); + const conversations = useConversations(); const reloadConversations = useCallback(() => { void conversations.reload(); @@ -37,7 +37,6 @@ function PlaygroundApp() {
; sourceMessageId: string }; type McpPart = Extract; type FilePart = Extract; -export const NovuCardUI = makeAssistantDataUI({ +export const NovuCardUI = makeAssistantDataUI({ name: 'novu-card', render: ({ data }) => , }); @@ -41,10 +40,10 @@ export const NovuFileUI = makeAssistantDataUI({ ), }); -function NovuCard({ data }: { data: NovuCardData }) { +function NovuCard({ data }: { data: AgentCardPart }) { const { sendAction } = useWebChatUi(); const [busyId, setBusyId] = useState(); - const view = cardViewFromRecord(data.card); + const view = cardViewFromElement(data.card); async function clickButton(actionId: string, value?: string) { if (busyId) return; diff --git a/playground/web-chat/src/components/connection-tracker.tsx b/playground/web-chat/src/components/connection-tracker.tsx index 1f68f86f684..f09565ef364 100644 --- a/playground/web-chat/src/components/connection-tracker.tsx +++ b/playground/web-chat/src/components/connection-tracker.tsx @@ -2,12 +2,10 @@ import { useNovu } from '@novu/react'; import { useEffect } from 'react'; -import { setApiToken } from '../lib/api-token'; import { setSocketStatus } from '../lib/socket-status'; /** - * Playground wiring: mirror SDK connect events into the socket status store, and - * capture the session JWT for the recent-conversations list (not wrapped by the SDK). + * Playground wiring: mirror SDK socket events into the socket status store. */ export function ConnectionTracker() { const novu = useNovu(); @@ -17,14 +15,12 @@ export function ConnectionTracker() { const cleanupResolved = novu.on('socket.connect.resolved', ({ error }) => setSocketStatus(error ? 'offline' : 'online') ); - const cleanupSession = novu.on('session.initialize.resolved', ({ data }) => { - if (data?.token) setApiToken(data.token); - }); + const cleanupDisconnected = novu.on('socket.disconnect.resolved', () => setSocketStatus('offline')); return () => { cleanupPending(); cleanupResolved(); - cleanupSession(); + cleanupDisconnected(); }; }, [novu]); diff --git a/playground/web-chat/src/components/web-chat.tsx b/playground/web-chat/src/components/web-chat.tsx index 5ef3cfeeb10..2183ff3d683 100644 --- a/playground/web-chat/src/components/web-chat.tsx +++ b/playground/web-chat/src/components/web-chat.tsx @@ -106,6 +106,7 @@ export function WebChat({ catchUpError, refetch, typing, + startNewConversation, } = useWebChat({ agentId: config.agentId, conversationId, @@ -166,9 +167,12 @@ export function WebChat({ threads: mapConversationsToThreadData(threadList.items), isLoading: threadList.isLoading, onSwitchToThread: threadList.onSwitchToThread, - onSwitchToNewThread: threadList.onSwitchToNewThread, + onSwitchToNewThread: () => { + startNewConversation(); + threadList.onSwitchToNewThread(); + }, }; - }, [activeThreadId, threadList]); + }, [activeThreadId, threadList, startNewConversation]); return ( (); - const remount = useCallback((nextId?: string) => { - setConversationId(nextId); - setSessionKey((key) => key + 1); - }, []); - const onNewChat = useCallback(() => { - remount(undefined); - }, [remount]); + setConversationId(undefined); + }, []); - const onSelectConversation = useCallback( - (identifier: string) => { - remount(identifier); - }, - [remount], - ); + const onSelectConversation = useCallback((identifier: string) => { + setConversationId(identifier); + }, []); return { - sessionKey, conversationId, onNewChat, onSelectConversation, diff --git a/playground/web-chat/src/lib/agent-message-to-thread-message.ts b/playground/web-chat/src/lib/agent-message-to-thread-message.ts index 4251353ca8f..eb3625af41b 100644 --- a/playground/web-chat/src/lib/agent-message-to-thread-message.ts +++ b/playground/web-chat/src/lib/agent-message-to-thread-message.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from '@novu/react'; +import type { AgentCardElement, AgentMessage } from '@novu/react'; import type { ThreadMessageLike, ToolApprovalOption } from '@assistant-ui/react'; import { APPROVAL_OPTIONS } from './approval-options'; @@ -16,21 +16,20 @@ function isPoweredByWatermark(content: string): boolean { } /** Novu-branded empty cards unwrap to plain markdown instead of rendering a card shell. */ -function brandedReplyMarkdown(card: Record): string | null { - if (typeof card.title === 'string' && card.title.trim()) return null; - if (typeof card.subtitle === 'string' && card.subtitle.trim()) return null; - if (typeof card.imageUrl === 'string' && card.imageUrl.trim()) return null; +function brandedReplyMarkdown(card: AgentCardElement): string | null { + if (card.title?.trim()) return null; + if (card.subtitle?.trim()) return null; + if (card.imageUrl?.trim()) return null; - const children = Array.isArray(card.children) ? card.children : []; const texts: string[] = []; let sawWatermark = false; - for (const child of children) { - if (!child || typeof child !== 'object' || !('type' in child) || child.type !== 'text') { + for (const child of card.children) { + if (child.type !== 'text') { return null; } - const content = typeof child.content === 'string' ? child.content : ''; + const content = child.content; if (!content) continue; if (isPoweredByWatermark(content)) { @@ -154,7 +153,7 @@ export function agentMessageToThreadMessage(message: AgentMessage): ThreadMessag content.push({ type: 'data', name: 'novu-card', - data: { card: part.card, sourceMessageId: message.id }, + data: part, }); break; } diff --git a/playground/web-chat/src/lib/api-token.ts b/playground/web-chat/src/lib/api-token.ts deleted file mode 100644 index 0ed177a2ac3..00000000000 --- a/playground/web-chat/src/lib/api-token.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Subscriber JWT captured from session init. - * - * The sidebar calls one endpoint the SDK does not wrap yet - * (`GET /v1/web-chat/conversations`), and it needs the same Bearer token. - */ -let token: string | undefined; - -type Listener = (token: string) => void; - -const listeners = new Set(); - -export function getApiToken(): string | undefined { - return token; -} - -export function setApiToken(next: string): void { - if (next === token) return; - - token = next; - listeners.forEach((listener) => listener(next)); -} - -export function subscribeApiToken(listener: Listener): () => void { - listeners.add(listener); - - return () => { - listeners.delete(listener); - }; -} diff --git a/playground/web-chat/src/lib/card-view.ts b/playground/web-chat/src/lib/card-view.ts index f4773b2e3bd..26cbf24ab89 100644 --- a/playground/web-chat/src/lib/card-view.ts +++ b/playground/web-chat/src/lib/card-view.ts @@ -1,3 +1,5 @@ +import type { AgentCardChild, AgentCardElement } from '@novu/react'; + export type CardButtonView = { id: string; label: string; value?: string; style?: string }; export type CardChildView = @@ -14,14 +16,6 @@ export type CardView = { children: CardChildView[]; }; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function readString(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() ? value : undefined; -} - export function toSafeExternalUrl(url?: string): string | undefined { if (!url) return undefined; @@ -37,66 +31,88 @@ export function toSafeExternalUrl(url?: string): string | undefined { return undefined; } -function cardButtonsFromNode(node: unknown): CardButtonView[] { - if (!isRecord(node)) return []; - - if (node.type === 'button') { - const id = readString(node.id); - const label = readString(node.label); - if (!id || !label) return []; +function linkView(label: string, url: string): CardChildView | null { + const safeUrl = toSafeExternalUrl(url); + const trimmedLabel = label.trim(); - return [{ id, label, value: readString(node.value), style: readString(node.style) }]; - } - - if (node.type === 'actions' && Array.isArray(node.children)) { - return node.children.flatMap((child) => cardButtonsFromNode(child)); - } - - return []; + return safeUrl && trimmedLabel ? { type: 'link', url: safeUrl, label: trimmedLabel } : null; } -function cardChildFromNode(node: unknown): CardChildView | null { - if (!isRecord(node)) return null; - - if (node.type === 'text') { - const content = readString(node.content); - - return content ? { type: 'text', content } : null; - } - - if (node.type === 'divider') { - return { type: 'divider' }; - } - - if (node.type === 'image') { - const url = toSafeExternalUrl(readString(node.url)); +function viewsFromAgentChild(child: AgentCardChild): CardChildView[] { + switch (child.type) { + case 'text': { + const content = child.content.trim(); - return url ? { type: 'image', url, alt: readString(node.alt) ?? '' } : null; - } + return content ? [{ type: 'text', content }] : []; + } + case 'divider': + return [{ type: 'divider' }]; + case 'image': { + const url = toSafeExternalUrl(child.url); - if (node.type === 'link') { - const url = toSafeExternalUrl(readString(node.url)); - const label = readString(node.label); + return url ? [{ type: 'image', url, alt: child.alt ?? '' }] : []; + } + case 'link': { + const view = linkView(child.label, child.url); - return url && label ? { type: 'link', url, label } : null; + return view ? [view] : []; + } + case 'button': + return [ + { + type: 'actions', + buttons: [{ id: child.id, label: child.label, value: child.value, style: child.style }], + }, + ]; + case 'actions': { + const views: CardChildView[] = []; + const buttons: CardButtonView[] = []; + + for (const actionChild of child.children) { + if (actionChild.type === 'button') { + buttons.push({ + id: actionChild.id, + label: actionChild.label, + value: actionChild.value, + style: actionChild.style, + }); + continue; + } + + if (actionChild.type === 'link-button') { + const view = linkView(actionChild.label, actionChild.url); + if (view) { + views.push(view); + } + } + } + + if (buttons.length > 0) { + views.push({ type: 'actions', buttons }); + } + + return views; + } + case 'section': + return child.children.flatMap((nested) => viewsFromAgentChild(nested)); + case 'fields': + return child.children + .map((field) => `${field.label}: ${field.value}`.trim()) + .filter(Boolean) + .map((content) => ({ type: 'text' as const, content })); + case 'table': + return []; } - - const buttons = cardButtonsFromNode(node); - - return buttons.length > 0 ? { type: 'actions', buttons } : null; } -export function cardViewFromRecord(card: Record): CardView { +/** Map a typed agent card to the playground view model. Sanitize http(s) URLs. */ +export function cardViewFromElement(card: AgentCardElement): CardView { const children = Array.isArray(card.children) ? card.children : []; return { - title: readString(card.title), - subtitle: readString(card.subtitle), - imageUrl: toSafeExternalUrl(readString(card.imageUrl)), - children: children.flatMap((child) => { - const view = cardChildFromNode(child); - - return view ? [view] : []; - }), + title: card.title?.trim() || undefined, + subtitle: card.subtitle?.trim() || undefined, + imageUrl: toSafeExternalUrl(card.imageUrl), + children: children.flatMap((child) => viewsFromAgentChild(child)), }; } diff --git a/playground/web-chat/src/lib/conversations.ts b/playground/web-chat/src/lib/conversations.ts index fc87eb45972..ea2be303a8e 100644 --- a/playground/web-chat/src/lib/conversations.ts +++ b/playground/web-chat/src/lib/conversations.ts @@ -1,62 +1,41 @@ 'use client'; -import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; -import { getApiToken, subscribeApiToken } from './api-token'; +import { useNovu, type WebChatConversation } from '@novu/react'; +import { useCallback, useEffect, useState } from 'react'; -/** Mirrors `WebChatConversationMetadataDto` on the API. */ -export type ConversationSummary = { - identifier: string; - title: string; - status: string; - agentIdentifier: string; - lastActivityAt: string; - createdAt: string; -}; +export type ConversationSummary = WebChatConversation; const RECENT_LIMIT = 5; -async function fetchConversations(backendUrl: string, token: string): Promise { - const url = new URL(`${backendUrl.replace(/\/+$/, '')}/v1/web-chat/conversations`); - url.searchParams.set('limit', String(RECENT_LIMIT)); - url.searchParams.set('orderBy', 'lastActivityAt'); - url.searchParams.set('orderDirection', 'DESC'); - - const response = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`list conversations failed: ${response.status}`); - } - - const body = (await response.json()) as { data?: ConversationSummary[] }; - - return body.data ?? []; -} - -/** - * The token arrives asynchronously from session init, so the list subscribes to it - * rather than reading once on mount. - */ -export function useConversations(backendUrl: string) { - const token = useSyncExternalStore(subscribeApiToken, getApiToken, () => undefined); +/** Recent conversations for the sidebar. */ +export function useConversations() { + const novu = useNovu(); const [items, setItems] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); const reload = useCallback(async () => { - if (!token) return; - setIsLoading(true); try { - setItems(await fetchConversations(backendUrl, token)); + await novu.loadWebChat(); + const { data, error: listError } = await novu.webChat.listConversations({ + limit: RECENT_LIMIT, + orderBy: 'lastActivityAt', + orderDirection: 'DESC', + }); + if (listError) { + setError(listError.message); + return; + } + + setItems(data?.conversations ?? []); setError(undefined); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setIsLoading(false); } - }, [backendUrl, token]); + }, [novu]); useEffect(() => { void reload(); diff --git a/playground/web-chat/src/lib/debug-events.ts b/playground/web-chat/src/lib/debug-events.ts index f2ece85c541..24f6e9d3b6b 100644 --- a/playground/web-chat/src/lib/debug-events.ts +++ b/playground/web-chat/src/lib/debug-events.ts @@ -1,5 +1,3 @@ -import { setSocketStatus } from './socket-status'; - export type DebugEventSource = 'http' | 'ws' | 'sdk'; export type DebugEvent = { @@ -28,6 +26,7 @@ export const SDK_DEBUG_EVENTS = [ 'session.initialize.resolved', 'socket.connect.pending', 'socket.connect.resolved', + 'socket.disconnect.resolved', ] as const; /** @@ -184,11 +183,9 @@ export function installNetworkInspector(watchedUrls: string[]): void { const path = shortPath(urlString); - setSocketStatus('connecting'); emitDebugEvent({ source: 'ws', name: `connecting ${path}`, payload: { url: urlString } }); socket.addEventListener('open', () => { - setSocketStatus('online'); emitDebugEvent({ source: 'ws', name: `open ${path}`, payload: { url: urlString } }); }); @@ -201,12 +198,10 @@ export function installNetworkInspector(watchedUrls: string[]): void { }); socket.addEventListener('error', () => { - setSocketStatus('offline'); emitDebugEvent({ source: 'ws', name: `error ${path}`, payload: { url: urlString } }); }); socket.addEventListener('close', (event) => { - setSocketStatus('offline'); emitDebugEvent({ source: 'ws', name: `close ${path}`, diff --git a/playground/web-chat/src/lib/socket-status.ts b/playground/web-chat/src/lib/socket-status.ts index d496910be38..9afc87bd79d 100644 --- a/playground/web-chat/src/lib/socket-status.ts +++ b/playground/web-chat/src/lib/socket-status.ts @@ -1,9 +1,8 @@ /** * Live socket state for the header pill. * - * The SDK only emits `socket.connect.pending` and `socket.connect.resolved`; a socket - * that drops after a successful open emits nothing. The WebSocket patch in - * `debug-events.ts` is the only place that sees `close`, so it reports here. + * ConnectionTracker writes this store from SDK events only: + * `socket.connect.pending`, `socket.connect.resolved`, `socket.disconnect.resolved`. */ export type SocketStatus = 'connecting' | 'online' | 'offline'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b390da03bfb..9f5dec7dc0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3646,6 +3646,9 @@ importers: '@novu/agent-event-protocol': specifier: workspace:* version: link:../agent-event-protocol + '@novu/stateless': + specifier: workspace:* + version: link:../stateless '@sveltejs/kit': specifier: ^2.70.2 version: 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@2.5.3(svelte@5.55.7(@typescript-eslint/types@8.39.1))(vite@6.4.3(@types/node@22.15.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.31.6)(tsx@4.16.2)(yaml@2.9.0)))(svelte@5.55.7(@typescript-eslint/types@8.39.1))(typescript@5.6.2)(vite@6.4.3(@types/node@22.15.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.31.6)(tsx@4.16.2)(yaml@2.9.0)) @@ -4733,13 +4736,13 @@ importers: dependencies: '@assistant-ui/react': specifier: ^0.15.16 - version: 0.15.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + version: 0.15.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(immer@10.1.1)(ioredis@5.11.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) '@assistant-ui/react-markdown': specifier: ^0.14.12 - version: 0.14.12(@assistant-ui/react@0.15.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)))(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.14.12(@assistant-ui/react@0.15.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(immer@10.1.1)(ioredis@5.11.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)))(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@base-ui/react': specifier: ^1.7.0 - version: 1.7.0(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.7.0(@types/react@19.2.8)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@novu/react': specifier: workspace:* version: link:../../packages/react @@ -4754,7 +4757,7 @@ importers: version: 1.34.0(react@18.3.1) next: specifier: ^16.2.11 - version: 16.2.11(@babel/core@7.29.7)(@types/node@22.15.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(@types/node@22.15.13)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -4769,7 +4772,7 @@ importers: version: 4.0.1 shadcn: specifier: ^4.19.0 - version: 4.19.0(typescript@5.6.2) + version: 4.19.0(@cfworker/json-schema@4.1.1)(babel-plugin-macros@3.1.0)(typescript@5.6.2) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -30270,24 +30273,24 @@ snapshots: typescript: 5.6.1-rc validate-npm-package-name: 5.0.1 - '@assistant-ui/core@0.3.15(@assistant-ui/store@0.3.10(@assistant-ui/tap@0.9.14(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(react@18.3.1))(@assistant-ui/tap@0.9.14(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(assistant-cloud@0.1.41)(react@18.3.1)(zustand@5.0.15(@types/react@19.2.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)))': + '@assistant-ui/core@0.3.15(@assistant-ui/store@0.3.10(@assistant-ui/tap@0.9.14(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(react@18.3.1))(@assistant-ui/tap@0.9.14(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(assistant-cloud@0.1.41(ioredis@5.11.1))(ioredis@5.11.1)(react@18.3.1)(zustand@5.0.15(@types/react@19.2.8)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)))': dependencies: '@assistant-ui/store': 0.3.10(@assistant-ui/tap@0.9.14(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(react@18.3.1) '@assistant-ui/tap': 0.9.14(@types/react@19.2.8)(react@18.3.1) - assistant-stream: 0.3.39 + assistant-stream: 0.3.39(ioredis@5.11.1) nanoid: 6.0.1 optionalDependencies: '@types/react': 19.2.8 - assistant-cloud: 0.1.41 + assistant-cloud: 0.1.41(ioredis@5.11.1) react: 18.3.1 - zustand: 5.0.15(@types/react@19.2.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + zustand: 5.0.15(@types/react@19.2.8)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) transitivePeerDependencies: - ioredis - redis - '@assistant-ui/react-markdown@0.14.12(@assistant-ui/react@0.15.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)))(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@assistant-ui/react-markdown@0.14.12(@assistant-ui/react@0.15.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(immer@10.1.1)(ioredis@5.11.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)))(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@assistant-ui/react': 0.15.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + '@assistant-ui/react': 0.15.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(immer@10.1.1)(ioredis@5.11.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.8)(react@18.3.1) classnames: 2.5.1 @@ -30300,9 +30303,9 @@ snapshots: - react-dom - supports-color - '@assistant-ui/react@0.15.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))': + '@assistant-ui/react@0.15.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(immer@10.1.1)(ioredis@5.11.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))': dependencies: - '@assistant-ui/core': 0.3.15(@assistant-ui/store@0.3.10(@assistant-ui/tap@0.9.14(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(react@18.3.1))(@assistant-ui/tap@0.9.14(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(assistant-cloud@0.1.41)(react@18.3.1)(zustand@5.0.15(@types/react@19.2.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))) + '@assistant-ui/core': 0.3.15(@assistant-ui/store@0.3.10(@assistant-ui/tap@0.9.14(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(react@18.3.1))(@assistant-ui/tap@0.9.14(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(assistant-cloud@0.1.41(ioredis@5.11.1))(ioredis@5.11.1)(react@18.3.1)(zustand@5.0.15(@types/react@19.2.8)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))) '@assistant-ui/store': 0.3.10(@assistant-ui/tap@0.9.14(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(react@18.3.1) '@assistant-ui/tap': 0.9.14(@types/react@19.2.8)(react@18.3.1) '@radix-ui/primitive': 1.1.7 @@ -30313,15 +30316,15 @@ snapshots: '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.8)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.8)(react@18.3.1) '@radix-ui/react-use-escape-keydown': 1.1.5(@types/react@19.2.8)(react@18.3.1) - assistant-cloud: 0.1.41 - assistant-stream: 0.3.39 + assistant-cloud: 0.1.41(ioredis@5.11.1) + assistant-stream: 0.3.39(ioredis@5.11.1) radix-ui: 1.6.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-textarea-autosize: 8.5.9(@types/react@19.2.8)(react@18.3.1) safe-content-frame: 0.0.27 zod: 4.4.3 - zustand: 5.0.15(@types/react@19.2.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + zustand: 5.0.15(@types/react@19.2.8)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) optionalDependencies: '@types/react': 19.2.8 '@types/react-dom': 19.2.3(@types/react@19.2.8) @@ -34034,7 +34037,7 @@ snapshots: - debug - supports-color - '@base-ui/react@1.7.0(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@base-ui/react@1.7.0(@types/react@19.2.8)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.7 '@base-ui/utils': 0.3.2(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -34045,6 +34048,7 @@ snapshots: use-sync-external-store: 1.6.0(react@18.3.1) optionalDependencies: '@types/react': 19.2.8 + date-fns: 4.1.0 '@base-ui/utils@0.3.2(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -37223,7 +37227,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@3.25.20)': dependencies: '@hono/node-server': 2.0.11(hono@4.12.34) ajv: 8.20.0 @@ -37240,14 +37244,14 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) + zod: 3.25.20 + zod-to-json-schema: 3.25.2(zod@3.25.20) optionalDependencies: '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.30.0(zod@3.25.20)': + '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3)': dependencies: '@hono/node-server': 2.0.11(hono@4.12.34) ajv: 8.20.0 @@ -37264,8 +37268,10 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 3.25.20 - zod-to-json-schema: 3.25.2(zod@3.25.20) + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: - supports-color @@ -48480,18 +48486,20 @@ snapshots: assertion-error@2.0.1: {} - assistant-cloud@0.1.41: + assistant-cloud@0.1.41(ioredis@5.11.1): dependencies: - assistant-stream: 0.3.39 + assistant-stream: 0.3.39(ioredis@5.11.1) transitivePeerDependencies: - ioredis - redis - assistant-stream@0.3.39: + assistant-stream@0.3.39(ioredis@5.11.1): dependencies: '@standard-schema/spec': 1.1.0 nanoid: 6.0.1 secure-json-parse: 4.1.0 + optionalDependencies: + ioredis: 5.11.1 ast-module-types@6.0.0: {} @@ -50618,8 +50626,6 @@ snapshots: dedent@0.7.0: {} - dedent@1.6.0: {} - dedent@1.6.0(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -57141,31 +57147,6 @@ snapshots: - '@types/node' - babel-plugin-macros - next@16.2.11(@babel/core@7.29.7)(@types/node@22.15.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@next/env': 16.2.11 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.12 - caniuse-lite: 1.0.30001764 - postcss: 8.5.25 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.6(@babel/core@7.29.7)(react@18.3.1) - optionalDependencies: - '@next/swc-darwin-arm64': 16.2.11 - '@next/swc-darwin-x64': 16.2.11 - '@next/swc-linux-arm64-gnu': 16.2.11 - '@next/swc-linux-arm64-musl': 16.2.11 - '@next/swc-linux-x64-gnu': 16.2.11 - '@next/swc-linux-x64-musl': 16.2.11 - '@next/swc-win32-arm64-msvc': 16.2.11 - '@next/swc-win32-x64-msvc': 16.2.11 - sharp: 0.35.3(@types/node@22.15.13) - transitivePeerDependencies: - - '@babel/core' - - '@types/node' - - babel-plugin-macros - nice-try@1.0.5: {} nimma@0.2.3: @@ -60481,19 +60462,19 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.19.0(typescript@5.6.2): + shadcn@4.19.0(@cfworker/json-schema@4.1.1)(babel-plugin-macros@3.1.0)(typescript@5.6.2): dependencies: '@babel/core': 7.29.7 '@babel/parser': 7.29.7 '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) '@dotenvx/dotenvx': 1.75.1 - '@modelcontextprotocol/sdk': 1.30.0(zod@3.25.20) + '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@3.25.20) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.1 commander: 14.0.3 cosmiconfig: 9.0.2(typescript@5.6.2) - dedent: 1.6.0 + dedent: 1.6.0(babel-plugin-macros@3.1.0) deepmerge: 4.3.1 diff: 8.0.4 execa: 9.6.1 @@ -61312,13 +61293,6 @@ snapshots: '@babel/core': 7.29.7 babel-plugin-macros: 3.1.0 - styled-jsx@5.1.6(@babel/core@7.29.7)(react@18.3.1): - dependencies: - client-only: 0.0.1 - react: 18.3.1 - optionalDependencies: - '@babel/core': 7.29.7 - stylehacks@7.0.2(postcss@8.5.25): dependencies: browserslist: 4.28.1 @@ -63911,9 +63885,10 @@ snapshots: immer: 10.1.1 react: 19.2.3 - zustand@5.0.15(@types/react@19.2.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): + zustand@5.0.15(@types/react@19.2.8)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): optionalDependencies: '@types/react': 19.2.8 + immer: 10.1.1 react: 18.3.1 use-sync-external-store: 1.6.0(react@18.3.1)