From dc9caee828136e25db2400433e2f67e2193f964c Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Fri, 4 Sep 2026 15:35:34 +0200 Subject: [PATCH] feat(novu): scaffold Web Chat with assistant-ui fixes NV-8770 (#12565) --- biome.json | 1 + .../novu-connect-web-chat-embed-prompt.txt | 2 +- packages/novu/scripts/build-ui.mjs | 14 +- .../pipeline/ai-sdk/package-install.ts | 4 +- .../pipeline/chat-sdk/package-install.spec.ts | 4 +- .../pipeline/chat-sdk/package-install.ts | 4 +- .../pipeline/langchain/package-install.ts | 4 +- .../web-chat/scaffold-web-chat.spec.ts | 7 +- .../pipeline/web-chat/scaffold-web-chat.ts | 623 +++++- .../assistant-ui/elements/approval-card.tsx | 167 ++ .../elements/connection-state.tsx | 86 + .../assistant-ui/elements/day-separator.tsx | 79 + .../ts/assistant-ui/elements/error-state.tsx | 79 + .../ts/assistant-ui/elements/file.tsx | 265 +++ .../assistant-ui/elements/markdown-text.tsx | 266 +++ .../elements/novu-approval-card.tsx | 76 + .../assistant-ui/elements/reasoning.aui.tsx | 120 ++ .../ts/assistant-ui/elements/reasoning.tsx | 329 +++ .../ts/assistant-ui/elements/surfaces.tsx | 110 + .../elements/thinking-indicator.tsx | 42 + .../assistant-ui/elements/thread-list.aui.tsx | 240 +++ .../ts/assistant-ui/elements/thread.aui.tsx | 516 +++++ .../elements/tool-fallback.aui.tsx | 697 +++++++ .../assistant-ui/elements/tool-group.aui.tsx | 230 +++ .../elements/tooltip-icon-button.tsx | 49 + .../web-chat/ts/assistant-ui/novu-parts.tsx | 141 ++ .../web-chat/ts/assistant-ui/thread-list.tsx | 48 + .../web-chat/ts/assistant-ui/thread.tsx | 179 ++ .../ts/assistant-ui/web-chat-actions.tsx | 31 + .../ts/assistant-ui/web-chat-runtime.tsx | 112 + .../templates/web-chat/ts/chat-panel.tsx | 71 +- .../templates/web-chat/ts/chat-thread.tsx | 120 -- .../templates/web-chat/ts/composer.tsx | 75 - .../templates/web-chat/ts/connect-card.tsx | 87 + .../connect/templates/web-chat/ts/globals.css | 1838 ++++++++++------- .../ts/hooks/use-copy-to-clipboard.ts | 29 + .../connect/templates/web-chat/ts/icons.tsx | 267 --- .../ts/lib/agent-message-to-thread-message.ts | 239 +++ .../web-chat/ts/lib/approval-options.ts | 28 + .../templates/web-chat/ts/lib/card-view.ts | 118 ++ .../web-chat/ts/lib/conversations.ts | 45 + .../web-chat/ts/lib/thread-list-mapper.ts | 27 + .../templates/web-chat/ts/lib/utils.ts | 6 + .../templates/web-chat/ts/markdown.tsx | 18 - .../templates/web-chat/ts/message-bubble.tsx | 399 ---- .../templates/web-chat/ts/message-utils.ts | 17 - .../web-chat/ts/pending-action-card.tsx | 251 --- .../templates/web-chat/ts/ui/button.tsx | 58 + .../templates/web-chat/ts/ui/collapsible.tsx | 21 + .../templates/web-chat/ts/ui/input.tsx | 17 + .../templates/web-chat/ts/ui/skeleton.tsx | 13 + .../templates/web-chat/ts/ui/tooltip.tsx | 66 + .../templates/web-chat/ts/web-chat.tsx | 150 +- .../novu/src/commands/init/helpers/install.ts | 18 +- packages/shared/docs/agent-onboarding.md | 2 +- .../src/utils/connect-embed-prompt.spec.ts | 2 +- .../shared/src/utils/connect-embed-prompt.ts | 62 +- .../src/utils/web-chat-connect-prompt.ts | 4 +- 58 files changed, 6532 insertions(+), 2041 deletions(-) create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/approval-card.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/connection-state.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/day-separator.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/error-state.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/file.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/markdown-text.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/novu-approval-card.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/reasoning.aui.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/reasoning.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/surfaces.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/thinking-indicator.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/thread-list.aui.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/thread.aui.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/tool-fallback.aui.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/tool-group.aui.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/tooltip-icon-button.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/novu-parts.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/thread-list.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/thread.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/web-chat-actions.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/web-chat-runtime.tsx delete mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/chat-thread.tsx delete mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/composer.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/connect-card.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/hooks/use-copy-to-clipboard.ts delete mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/icons.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/lib/agent-message-to-thread-message.ts create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/lib/approval-options.ts create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/lib/card-view.ts create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/lib/conversations.ts create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/lib/thread-list-mapper.ts create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/lib/utils.ts delete mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/markdown.tsx delete mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/message-bubble.tsx delete mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/message-utils.ts delete mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/pending-action-card.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/ui/button.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/ui/collapsible.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/ui/input.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/ui/skeleton.tsx create mode 100644 packages/novu/src/commands/connect/templates/web-chat/ts/ui/tooltip.tsx diff --git a/biome.json b/biome.json index d777af2ab57..e609a66b92c 100644 --- a/biome.json +++ b/biome.json @@ -21,6 +21,7 @@ "!**/pnpm-lock.yaml", "!**/swagger-spec.json", "!playground", + "!packages/novu/src/commands/connect/templates/web-chat", "!libs/internal-sdk", "!.github", "!scripts", diff --git a/libs/agent-evals/src/suites/agent-onboarding/scenarios/dashboard-web-chat/project/novu-connect-web-chat-embed-prompt.txt b/libs/agent-evals/src/suites/agent-onboarding/scenarios/dashboard-web-chat/project/novu-connect-web-chat-embed-prompt.txt index 3fedc2f5241..af9f6c7cec2 100644 --- a/libs/agent-evals/src/suites/agent-onboarding/scenarios/dashboard-web-chat/project/novu-connect-web-chat-embed-prompt.txt +++ b/libs/agent-evals/src/suites/agent-onboarding/scenarios/dashboard-web-chat/project/novu-connect-web-chat-embed-prompt.txt @@ -2,6 +2,6 @@ The CLI already wrote the Novu environment values. -Add a minimal Web Chat UI with `@novu/react` (`NovuProvider` and `useWebChat`). After the UI change, the first line of your final user-facing message must be: +Add a Web Chat UI with `@novu/react` (`NovuProvider` and `useWebChat`) and assistant-ui unless the app already has another chat library. After the UI change, the first line of your final user-facing message must be: ✓ Web Chat connected diff --git a/packages/novu/scripts/build-ui.mjs b/packages/novu/scripts/build-ui.mjs index 1118a1bdc38..23deb445a5a 100644 --- a/packages/novu/scripts/build-ui.mjs +++ b/packages/novu/scripts/build-ui.mjs @@ -16,12 +16,14 @@ const sharedConfig = { jsxImportSource: 'react', sourcemap: false, logLevel: 'info', + // Keep @inkjs/ui in the bundle. It has no `react` peer, so a runtime + // import from this monorepo resolves repo-root React 18 while Ink 7 + // renders React 19. Select/TextInput then crash on useReducer. external: [ 'react', 'react/jsx-runtime', 'ink', 'ink-scroll-view', - '@inkjs/ui', 'ink-spinner', 'chalk', 'marked', @@ -58,6 +60,16 @@ await build({ outfile: resolve(root, 'dist/src/commands/connect/ui/index.mjs'), }); +for (const outfile of [ + resolve(root, 'dist/src/commands/wizard/ui/index.mjs'), + resolve(root, 'dist/src/commands/connect/ui/index.mjs'), +]) { + const source = readFileSync(outfile, 'utf8'); + if (source.includes('from "@inkjs/ui"') || source.includes("from '@inkjs/ui'")) { + throw new Error(`${outfile} still imports @inkjs/ui. It must stay bundled so Select uses the CLI React.`); + } +} + /** * Bundle the CLI entry, replacing the tsc-emitted dist/src/index.js. * diff --git a/packages/novu/src/commands/connect/pipeline/ai-sdk/package-install.ts b/packages/novu/src/commands/connect/pipeline/ai-sdk/package-install.ts index a0f54a0e3ed..6beba4a4b36 100644 --- a/packages/novu/src/commands/connect/pipeline/ai-sdk/package-install.ts +++ b/packages/novu/src/commands/connect/pipeline/ai-sdk/package-install.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { installPackages } from '../../../init/helpers/install'; +import { formatNpmInstallCommand, installPackages } from '../../../init/helpers/install'; import { detectPackageManager } from '../../../step/utils/package-manager'; import { getDependencyVersion, hasDependency, readProjectPackageJson } from '../bridge/project-package'; @@ -49,7 +49,7 @@ function buildInstallCommand(projectDir: string, packages: string[]): string { case 'bun': return `bun add ${packageList}`; default: - return `npm install ${packageList} --no-workspaces`; + return formatNpmInstallCommand(packages); } } diff --git a/packages/novu/src/commands/connect/pipeline/chat-sdk/package-install.spec.ts b/packages/novu/src/commands/connect/pipeline/chat-sdk/package-install.spec.ts index 862b9e66fa8..f69ddb208b9 100644 --- a/packages/novu/src/commands/connect/pipeline/chat-sdk/package-install.spec.ts +++ b/packages/novu/src/commands/connect/pipeline/chat-sdk/package-install.spec.ts @@ -33,7 +33,9 @@ describe('resolveChatSdkPackagesToInstall', () => { ); expect(resolveChatSdkPackagesToInstall(dir)).toEqual(['@novu/chat-sdk-adapter']); - expect(buildChatSdkInstallCommand(dir)).toBe('npm install @novu/chat-sdk-adapter --no-workspaces'); + expect(buildChatSdkInstallCommand(dir)).toBe( + 'npm install @novu/chat-sdk-adapter --no-workspaces --no-audit --fund=false' + ); }); it('includes state-memory only when no state adapter is present', () => { diff --git a/packages/novu/src/commands/connect/pipeline/chat-sdk/package-install.ts b/packages/novu/src/commands/connect/pipeline/chat-sdk/package-install.ts index 009f2654997..03d16aa0552 100644 --- a/packages/novu/src/commands/connect/pipeline/chat-sdk/package-install.ts +++ b/packages/novu/src/commands/connect/pipeline/chat-sdk/package-install.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { installPackages } from '../../../init/helpers/install'; +import { formatNpmInstallCommand, installPackages } from '../../../init/helpers/install'; import { detectPackageManager } from '../../../step/utils/package-manager'; import { hasDependency, readProjectPackageJson } from '../bridge/project-package'; @@ -69,7 +69,7 @@ export function buildChatSdkInstallCommand(projectDir: string): string { case 'bun': return `bun add ${packageList}`; default: - return `npm install ${packageList} --no-workspaces`; + return formatNpmInstallCommand(packages); } } diff --git a/packages/novu/src/commands/connect/pipeline/langchain/package-install.ts b/packages/novu/src/commands/connect/pipeline/langchain/package-install.ts index 6a93cb3a96c..6b870ec7031 100644 --- a/packages/novu/src/commands/connect/pipeline/langchain/package-install.ts +++ b/packages/novu/src/commands/connect/pipeline/langchain/package-install.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { installPackages } from '../../../init/helpers/install'; +import { formatNpmInstallCommand, installPackages } from '../../../init/helpers/install'; import { detectPackageManager } from '../../../step/utils/package-manager'; import { hasDependency, readProjectPackageJson } from '../bridge/project-package'; @@ -34,7 +34,7 @@ function buildInstallCommand(projectDir: string, packages: string[]): string { case 'bun': return `bun add ${packageList}`; default: - return `npm install ${packageList} --no-workspaces`; + return formatNpmInstallCommand(packages); } } diff --git a/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.spec.ts b/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.spec.ts index 3adff568e14..f3c1bcde503 100644 --- a/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.spec.ts +++ b/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.spec.ts @@ -2,8 +2,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { NOVU_STAGING_API_URL } from '@novu/shared'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { CloudRegionEnum } from '../../../dev/enums'; + +vi.mock('../../../init/helpers/is-online', () => ({ + getOnline: vi.fn(async () => false), +})); + import { assertSafeScaffoldDirectoryName, resolveWebChatNovuDependencies, diff --git a/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.ts b/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.ts index 7629de6198d..36cef6b7ab9 100644 --- a/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.ts +++ b/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.ts @@ -1,9 +1,10 @@ -import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { getNovuScaffoldSdkTag } from '@novu/shared'; +import { getNovuScaffoldSdkTag, isNovuLocalApiUrl } from '@novu/shared'; +import { yellow } from 'picocolors'; import { CloudRegionEnum } from '../../../dev/enums'; import { tryGitInit } from '../../../init/helpers/git'; +import { install } from '../../../init/helpers/install'; import { isFolderEmpty } from '../../../init/helpers/is-folder-empty'; import { getOnline } from '../../../init/helpers/is-online'; import { detectBridgeProject } from '../bridge/detect-project'; @@ -33,7 +34,8 @@ export type ScaffoldWebChatProjectResult = { }; /** - * Templates live at /commands/connect/templates/web-chat/ts. + * Official Web Chat connect template at /commands/connect/templates/web-chat/ts. + * Edit the template in-repo directly — it is not synced from playground/web-chat. * Under the module layout (tsc output or ts-node dev) `__dirname` is this * file's directory; from the bundled CLI entry it is `dist/src` — try both. */ @@ -104,6 +106,7 @@ async function mergeWebChatIntoProject(projectDir: string, input: ScaffoldWebCha } const dependenciesChanged = ensureWebChatDependencies(resolved, input.apiUrl, input.region); + ensureWebChatNextConfig(resolved); const componentsDir = path.join(resolved, 'components', 'web-chat'); fs.mkdirSync(componentsDir, { recursive: true }); copyTemplateComponents(componentsDir); @@ -116,10 +119,12 @@ async function mergeWebChatIntoProject(projectDir: string, input: ScaffoldWebCha 'utf8' ); + warnHostTailwindSetup(resolved); + appendEnvExample(resolved, input); if (dependenciesChanged && (await getOnline())) { - execFileSync(resolvePackageManager(resolved), ['install'], { cwd: resolved, stdio: 'inherit' }); + await install(resolvePackageManager(resolved), true, false, resolved); } } @@ -127,16 +132,12 @@ function ensureWebChatDependencies(projectDir: string, apiUrl: string, region?: const packageJsonPath = path.join(projectDir, 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { dependencies?: Record; + devDependencies?: Record; scripts?: Record; }; const dependencies = packageJson.dependencies ?? {}; - const sdk = resolveWebChatNovuDependencies(apiUrl, region); - const required = { - '@novu/react': sdk.react, - ...(sdk.js ? { '@novu/js': sdk.js } : {}), - 'react-markdown': '^10.1.0', - 'remark-gfm': '^4.0.1', - }; + const sdk = resolveWebChatNovuDependenciesForProject(projectDir, apiUrl, region); + const required = webChatRuntimeDependencies(sdk); let changed = false; for (const [name, version] of Object.entries(required)) { @@ -186,26 +187,379 @@ async function writeStandaloneWebChatApp(root: string, input: ScaffoldWebChatPro }), 'utf8' ); - const sdk = resolveWebChatNovuDependencies(input.apiUrl, input.region); + const sdk = resolveWebChatNovuDependenciesForProject(root, input.apiUrl, input.region); fs.writeFileSync(path.join(root, 'package.json'), renderPackageJson(path.basename(root), sdk), 'utf8'); fs.writeFileSync(path.join(root, 'tsconfig.json'), STANDALONE_TSCONFIG, 'utf8'); - fs.writeFileSync(path.join(root, 'next.config.mjs'), STANDALONE_NEXT_CONFIG, 'utf8'); + fs.writeFileSync(path.join(root, 'next.config.mjs'), WEB_CHAT_NEXT_CONFIG, 'utf8'); + fs.writeFileSync(path.join(root, 'postcss.config.mjs'), STANDALONE_POSTCSS_CONFIG, 'utf8'); fs.writeFileSync(path.join(root, '.env.local'), renderEnvLocal(input), 'utf8'); fs.writeFileSync(path.join(root, '.env.example'), renderEnvExample(input), 'utf8'); fs.writeFileSync(path.join(root, '.gitignore'), STANDALONE_GITIGNORE, 'utf8'); const isOnline = await getOnline(); if (isOnline) { - const { execSync } = await import('node:child_process'); - execSync('npm install', { cwd: root, stdio: 'inherit' }); + await install('npm', isOnline, false, root); } } function copyTemplateComponents(targetDir: string): void { - for (const file of fs.readdirSync(TEMPLATE_ROOT)) { - if (!file.endsWith('.tsx') && !file.endsWith('.css') && !file.endsWith('.ts')) continue; - fs.copyFileSync(path.join(TEMPLATE_ROOT, file), path.join(targetDir, file)); + copyTemplateDir(TEMPLATE_ROOT, targetDir); +} + +function copyTemplateDir(from: string, to: string): void { + fs.mkdirSync(to, { recursive: true }); + + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const source = path.join(from, entry.name); + const destination = path.join(to, entry.name); + + if (entry.isDirectory()) { + copyTemplateDir(source, destination); + continue; + } + + if (!/\.(tsx?|jsx?|css)$/.test(entry.name)) { + continue; + } + + fs.copyFileSync(source, destination); + } +} + +const POSTCSS_CONFIG_FILENAMES = ['postcss.config.mjs', 'postcss.config.js', 'postcss.config.cjs', 'postcss.config.ts'] as const; + +const HOST_GLOBALS_CSS_PATHS = ['src/app/globals.css', 'app/globals.css', 'styles/globals.css'] as const; + +function findPostcssConfigPath(projectDir: string): string | null { + for (const filename of POSTCSS_CONFIG_FILENAMES) { + const configPath = path.join(projectDir, filename); + if (fs.existsSync(configPath)) { + return configPath; + } + } + + return null; +} + +/** The connect template targets Tailwind v4 — warn on merge; do not rewrite host toolchain. */ +function detectHostTailwind4Gaps(projectDir: string): string[] { + const warnings: string[] = []; + const packageJson = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8')) as { + dependencies?: Record; + devDependencies?: Record; + }; + const deps = { ...packageJson.dependencies, ...packageJson.devDependencies }; + const tailwindVersion = deps.tailwindcss ?? ''; + const tailwindMajor = tailwindVersion.match(/(\d+)/)?.[1]; + const hasTw4Postcss = Boolean(deps['@tailwindcss/postcss']); + + if (!hasTw4Postcss && tailwindMajor !== '4') { + warnings.push('package.json is missing Tailwind CSS v4 tooling (@tailwindcss/postcss and tailwindcss ^4).'); + } + + const postcssPath = findPostcssConfigPath(projectDir); + if (postcssPath) { + const postcss = fs.readFileSync(postcssPath, 'utf8'); + if (!postcss.includes('@tailwindcss/postcss') && /\btailwindcss\b/.test(postcss)) { + warnings.push(`${path.basename(postcssPath)} still uses the Tailwind v3 PostCSS plugin.`); + } + } + + for (const relativePath of HOST_GLOBALS_CSS_PATHS) { + const globalsPath = path.join(projectDir, relativePath); + if (!fs.existsSync(globalsPath)) { + continue; + } + + const css = fs.readFileSync(globalsPath, 'utf8'); + if ( + /@tailwind\s+(base|components|utilities)/.test(css) && + !css.includes('@import "tailwindcss"') && + !css.includes("@import 'tailwindcss'") + ) { + warnings.push(`${relativePath} uses @tailwind directives; the template expects @import "tailwindcss".`); + break; + } + } + + return warnings; +} + +function warnHostTailwindSetup(projectDir: string): void { + const warnings = detectHostTailwind4Gaps(projectDir); + if (warnings.length === 0) { + return; + } + + console.warn( + yellow( + [ + 'Web Chat template uses Tailwind CSS v4. Connect copied components/web-chat/ but did not modify host PostCSS or global CSS.', + ...warnings.map((warning) => `- ${warning}`), + 'Follow the connect embed prompt to upgrade Tailwind/PostCSS or adapt the template styles to this app.', + ].join('\n') + ) + ); +} + +const NEXT_CONFIG_FILENAMES = ['next.config.ts', 'next.config.js', 'next.config.mjs', 'next.config.cjs'] as const; + +const WEB_CHAT_TRANSPILE_PACKAGES = ['@novu/react', '@novu/js', '@assistant-ui/react'] as const; + +function findNextConfigPath(projectDir: string): string | null { + for (const filename of NEXT_CONFIG_FILENAMES) { + const configPath = path.join(projectDir, filename); + if (fs.existsSync(configPath)) { + return configPath; + } + } + + return null; +} + +function skipLineComment(source: string, index: number): number { + const end = source.indexOf('\n', index); + return end === -1 ? source.length - 1 : end; +} + +function skipBlockComment(source: string, index: number): number | null { + const end = source.indexOf('*/', index + 2); + return end === null ? null : end + 1; +} + +function skipQuotedString(source: string, index: number): number | null { + const quote = source[index]; + + for (let cursor = index + 1; cursor < source.length; cursor++) { + const char = source[cursor]; + if (char === '\\') { + cursor += 1; + continue; + } + + if (quote === '`' && char === '$' && source[cursor + 1] === '{') { + const expressionEnd = findMatchingDelimitedEnd(source, cursor + 1, '{', '}'); + if (expressionEnd === null) { + return null; + } + + cursor = expressionEnd; + continue; + } + + if (char === quote) { + return cursor; + } + } + + return null; +} + +function findMatchingDelimitedEnd( + source: string, + openIndex: number, + openChar: string, + closeChar: string +): number | null { + if (source[openIndex] !== openChar) { + return null; + } + + let depth = 0; + + for (let index = openIndex; index < source.length; index++) { + const char = source[index]; + const next = source[index + 1]; + + if (char === '/' && next === '/') { + index = skipLineComment(source, index); + continue; + } + + if (char === '/' && next === '*') { + const commentEnd = skipBlockComment(source, index); + if (commentEnd === null) { + return null; + } + + index = commentEnd; + continue; + } + + if (char === "'" || char === '"' || char === '`') { + const stringEnd = skipQuotedString(source, index); + if (stringEnd === null) { + return null; + } + + index = stringEnd; + continue; + } + + if (char === openChar) { + depth += 1; + } else if (char === closeChar) { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + + return null; +} + +function findMatchingBracketEnd(source: string, openIndex: number): number | null { + return findMatchingDelimitedEnd(source, openIndex, '[', ']'); +} + +function matchTranspilePackagesInlineArray( + source: string +): { full: string; inner: string } | null { + const labelMatch = source.match(/transpilePackages\s*:\s*\[/); + if (!labelMatch || labelMatch.index === undefined) { + return null; + } + + const openIndex = labelMatch.index + labelMatch[0].length - 1; + const closeIndex = findMatchingBracketEnd(source, openIndex); + if (closeIndex === null) { + return null; + } + + return { + full: source.slice(labelMatch.index, closeIndex + 1), + inner: source.slice(openIndex + 1, closeIndex), + }; +} + +function matchVariableArrayAssignment( + source: string, + variableName: string +): { full: string; prefix: string; inner: string } | null { + const headerPattern = new RegExp( + `((?:const|let|var)\\s+${variableName}(?:\\s*:\\s*[^=]+)?\\s*=\\s*)\\[` + ); + const headerMatch = source.match(headerPattern); + if (!headerMatch || headerMatch.index === undefined) { + return null; + } + + const openIndex = headerMatch.index + headerMatch[0].length - 1; + const closeIndex = findMatchingBracketEnd(source, openIndex); + if (closeIndex === null) { + return null; + } + + return { + full: source.slice(headerMatch.index, closeIndex + 1), + prefix: headerMatch[1], + inner: source.slice(openIndex + 1, closeIndex), + }; +} + +function patchNextConfigSource(source: string): string | null { + const missingPackages = WEB_CHAT_TRANSPILE_PACKAGES.filter( + (pkg) => !source.includes(`'${pkg}'`) && !source.includes(`"${pkg}"`) + ); + + if (missingPackages.length === 0) { + return null; + } + + const transpileArrayMatch = matchTranspilePackagesInlineArray(source); + if (transpileArrayMatch) { + const inner = transpileArrayMatch.inner.replace(/\/\/[^\n]*/g, '').trim(); + const additions = missingPackages.map((pkg) => `'${pkg}'`).join(', '); + const mergedInner = inner ? `${inner.replace(/,\s*$/, '')}, ${additions}` : additions; + + return source.replace(transpileArrayMatch.full, `transpilePackages: [${mergedInner}]`); + } + + const transpileRefMatch = source.match(/transpilePackages\s*:\s*([A-Za-z_$][\w$]*)/); + if (transpileRefMatch) { + const patched = patchTranspilePackagesVariable(source, transpileRefMatch[1], missingPackages); + if (patched) { + return patched; + } + } + + const insertion = ` transpilePackages: ${JSON.stringify([...missingPackages])},`; + const markers = [ + 'const nextConfig: NextConfig = {', + "const nextConfig: import('next').NextConfig = {", + 'const nextConfig = {', + 'module.exports = {', + 'withBundleAnalyzer({', + 'withSentryConfig({', + 'withNextIntl({', + 'export default {', + ]; + + if (/module\.exports\s*=\s*\(\s*phase\b/.test(source) || /export\s+default\s*\(\s*phase\b/.test(source)) { + markers.push('return {'); + } + + const hasTranspilePackages = /transpilePackages\s*:/.test(source); + + for (const marker of markers) { + if (source.includes(marker)) { + if (hasTranspilePackages) { + break; + } + + return source.replace(marker, `${marker}\n${insertion}`); + } + } + + if (hasTranspilePackages) { + return null; + } + + const defaultExportId = source.match(/export\s+default\s+([A-Za-z_$][\w$]*)\s*;/); + if (defaultExportId) { + const patched = patchNextConfigVariableDefinition(source, defaultExportId[1], insertion); + if (patched) { + return patched; + } + } + + const cjsExportId = source.match(/module\.exports\s*=\s*([A-Za-z_$][\w$]*)\s*;/); + if (cjsExportId) { + const patched = patchNextConfigVariableDefinition(source, cjsExportId[1], insertion); + if (patched) { + return patched; + } + } + + return null; +} + +function patchTranspilePackagesVariable( + source: string, + variableName: string, + missingPackages: readonly string[] +): string | null { + const arrayMatch = matchVariableArrayAssignment(source, variableName); + if (!arrayMatch) { + return null; } + + const inner = arrayMatch.inner.replace(/\/\/[^\n]*/g, '').trim(); + const additions = missingPackages.map((pkg) => `'${pkg}'`).join(', '); + const mergedInner = inner ? `${inner.replace(/,\s*$/, '')}, ${additions}` : additions; + + return source.replace(arrayMatch.full, `${arrayMatch.prefix}[${mergedInner}]`); +} + +function patchNextConfigVariableDefinition(source: string, variableName: string, insertion: string): string | null { + const variablePattern = new RegExp(`((?:const|let|var)\\s+${variableName}(?:\\s*:\\s*[^=]+)?\\s*=\\s*\\{)`); + const match = source.match(variablePattern); + if (!match) { + return null; + } + + return source.replace(match[0], `${match[0]}\n${insertion}`); } function renderChatPage(opts: { standalone: boolean; configImport: string }): string { @@ -225,9 +579,7 @@ export default function Page() { apiUrl={config.backendUrl} socketUrl={config.socketUrl} > -
- -
+ ); } @@ -256,9 +608,9 @@ export default function WebChatPage() { {...(apiUrl ? { apiUrl } : {})} {...(socketUrl ? { socketUrl } : {})} > -
+
-
+ ); } @@ -316,12 +668,205 @@ export type WebChatNovuDependencies = { js?: string; }; +/** + * When `novu connect` runs from this monorepo against a local API, pin + * `@novu/react` / `@novu/js` to the built workspace packages. npm `@next` + * lags the monorepo build (no `listConversations`, weaker typing). + * Published CLI has no sibling packages — keep the dist-tag. + */ +export function resolveLocalNovuSdkRoots(fromDir = __dirname): { react: string; js: string } | null { + let dir = fromDir; + + for (let i = 0; i < 12; i += 1) { + const reactDir = path.join(dir, 'packages', 'react'); + const jsDir = path.join(dir, 'packages', 'js'); + const reactPkg = path.join(reactDir, 'package.json'); + const jsPkg = path.join(jsDir, 'package.json'); + + if (fs.existsSync(reactPkg) && fs.existsSync(jsPkg)) { + try { + const react = JSON.parse(fs.readFileSync(reactPkg, 'utf8')) as { name?: string }; + const js = JSON.parse(fs.readFileSync(jsPkg, 'utf8')) as { name?: string }; + if ( + react.name === '@novu/react' && + js.name === '@novu/js' && + fs.existsSync(path.join(reactDir, 'dist')) && + fs.existsSync(path.join(jsDir, 'dist')) + ) { + return { react: reactDir, js: jsDir }; + } + } catch { + return null; + } + } + + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + + dir = parent; + } + + return null; +} + export function resolveWebChatNovuDependencies(apiUrl: string, region?: CloudRegionEnum): WebChatNovuDependencies { const tag = getNovuScaffoldSdkTag(apiUrl, region); return { react: tag, js: tag }; } +function resolveWebChatNovuDependenciesForProject( + projectDir: string, + apiUrl: string, + region?: CloudRegionEnum +): WebChatNovuDependencies { + const local = isNovuLocalApiUrl(apiUrl) ? resolveLocalNovuSdkRoots() : null; + + if (local) { + return vendorLocalNovuSdks(projectDir, local); + } + + return resolveWebChatNovuDependencies(apiUrl, region); +} + +/** + * Copy built workspace SDK packages into the scaffold so Next.js / Turbopack + * resolve them inside the app. Symlinks to the monorepo (`file:/abs/path`) + * break Turbopack; tarballs fail on `workspace:*` deps. + */ +export function vendorLocalNovuSdks( + projectDir: string, + local: { react: string; js: string } +): WebChatNovuDependencies { + const reactVendor = path.join(projectDir, 'vendor', '@novu', 'react'); + const jsVendor = path.join(projectDir, 'vendor', '@novu', 'js'); + + copyBuiltPackageVendor(local.js, jsVendor); + copyBuiltPackageVendor(local.react, reactVendor, { '@novu/js': 'file:../js' }); + + return { + react: 'file:./vendor/@novu/react', + js: 'file:./vendor/@novu/js', + }; +} + +function copyBuiltPackageVendor( + sourceDir: string, + targetDir: string, + dependencyOverrides: Record = {} +): void { + const sourcePkgPath = path.join(sourceDir, 'package.json'); + const sourceDist = path.join(sourceDir, 'dist'); + + if (!fs.existsSync(sourcePkgPath) || !fs.existsSync(sourceDist)) { + throw new Error( + `Cannot vendor ${sourceDir}. Run "pnpm build" in packages/react and packages/js before scaffolding locally.` + ); + } + + const sourcePkg = JSON.parse(fs.readFileSync(sourcePkgPath, 'utf8')) as { + name?: string; + version?: string; + type?: string; + main?: string; + browser?: string; + types?: string; + exports?: unknown; + dependencies?: Record; + }; + + const dependencies: Record = {}; + for (const [name, version] of Object.entries(sourcePkg.dependencies ?? {})) { + if (!version.startsWith('workspace:')) { + dependencies[name] = version; + } + } + + for (const [name, version] of Object.entries(dependencyOverrides)) { + dependencies[name] = version; + } + + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.mkdirSync(targetDir, { recursive: true }); + fs.cpSync(sourceDist, path.join(targetDir, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(targetDir, 'package.json'), + `${JSON.stringify( + { + name: sourcePkg.name, + version: sourcePkg.version, + type: sourcePkg.type, + main: sourcePkg.main, + browser: sourcePkg.browser, + types: sourcePkg.types, + exports: sourcePkg.exports, + dependencies, + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +function ensureWebChatNextConfig(projectDir: string): void { + const existingPath = findNextConfigPath(projectDir); + + if (!existingPath) { + fs.writeFileSync(path.join(projectDir, 'next.config.mjs'), WEB_CHAT_NEXT_CONFIG, 'utf8'); + return; + } + + const source = fs.readFileSync(existingPath, 'utf8'); + const patched = patchNextConfigSource(source); + if (patched) { + fs.writeFileSync(existingPath, patched, 'utf8'); + return; + } + + const configName = path.basename(existingPath); + const missingPackages = WEB_CHAT_TRANSPILE_PACKAGES.filter( + (pkg) => !source.includes(`'${pkg}'`) && !source.includes(`"${pkg}"`) + ); + if (missingPackages.length > 0) { + console.warn( + yellow( + `Web Chat could not patch transpilePackages in ${configName}. Add ${missingPackages.join(', ')} manually so @novu/react and assistant-ui compile.` + ) + ); + } +} + +const WEB_CHAT_UI_DEPENDENCIES = { + '@assistant-ui/react': '^0.15.16', + '@assistant-ui/react-markdown': '^0.14.12', + '@base-ui/react': '^1.7.0', + 'class-variance-authority': '^0.7.1', + clsx: '^2.1.1', + 'lucide-react': '^1.34.0', + 'react-markdown': '^10.1.0', + 'remark-gfm': '^4.0.1', + shadcn: '^4.19.0', + 'tailwind-merge': '^3.6.0', + 'tw-animate-css': '^1.4.0', + 'tw-shimmer': '^0.4.12', +} as const; + +const WEB_CHAT_DEV_DEPENDENCIES = { + '@tailwindcss/postcss': '^4.3.3', + tailwindcss: '^4.3.3', +} as const; + +function webChatRuntimeDependencies(sdk: WebChatNovuDependencies): Record { + return { + '@novu/react': sdk.react, + ...(sdk.js ? { '@novu/js': sdk.js } : {}), + ...WEB_CHAT_UI_DEPENDENCIES, + }; +} + function renderPackageJson(name: string, sdk: WebChatNovuDependencies): string { return JSON.stringify( { @@ -333,15 +878,13 @@ function renderPackageJson(name: string, sdk: WebChatNovuDependencies): string { start: 'next start -p 4012', }, dependencies: { - '@novu/react': sdk.react, - ...(sdk.js ? { '@novu/js': sdk.js } : {}), + ...webChatRuntimeDependencies(sdk), next: '^16.2.11', react: '^18.3.1', 'react-dom': '^18.3.1', - 'react-markdown': '^10.1.0', - 'remark-gfm': '^4.0.1', }, devDependencies: { + ...WEB_CHAT_DEV_DEPENDENCIES, '@types/node': '^22.0.0', '@types/react': '^19.0.0', '@types/react-dom': '^19.0.0', @@ -373,7 +916,7 @@ export const metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} @@ -407,9 +950,27 @@ const STANDALONE_TSCONFIG = JSON.stringify( 2 ); -const STANDALONE_NEXT_CONFIG = `/** @type {import('next').NextConfig} */ -const nextConfig = {}; +const WEB_CHAT_NEXT_CONFIG = `import path from 'path'; +import { fileURLToPath } from 'url'; + +const projectRoot = path.dirname(fileURLToPath(import.meta.url)); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ['@novu/react', '@novu/js', '@assistant-ui/react'], + turbopack: { + root: projectRoot, + }, +}; + export default nextConfig; `; -const STANDALONE_GITIGNORE = `.next\nnode_modules\n.env.local\n`; +const STANDALONE_POSTCSS_CONFIG = `export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; +`; + +const STANDALONE_GITIGNORE = `.next\nnode_modules\nvendor\n.env.local\n`; diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/approval-card.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/approval-card.tsx new file mode 100644 index 00000000000..a2abb6e8321 --- /dev/null +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/approval-card.tsx @@ -0,0 +1,167 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { Menu } from "@base-ui/react/menu"; +import { CheckIcon, ChevronDownIcon, Loader2Icon, TerminalIcon, XIcon } from "lucide-react"; +import { cn } from '../../lib/utils'; +import { field, floating, inkButton, paper } from './surfaces'; + +export type ApprovalState = "request" | "running" | "done" | "denied"; + +export type AlwaysAllowOption = { + label: string; + onSelect: () => void; +}; + +export function ApprovalCard({ + state, + command, + title, + subtitle, + onAllowOnce, + alwaysAllowOptions = [], + onDeny, + className, + ...props +}: Omit< + ComponentProps<"div">, + | "children" + | "state" + | "command" + | "title" + | "subtitle" + | "onAllowOnce" + | "onDeny" +> & { + state: ApprovalState; + command: string; + title: string; + subtitle: string; + onAllowOnce?: () => void; + alwaysAllowOptions?: AlwaysAllowOption[]; + onDeny?: () => void; +}) { + return ( +
+
+ + + +
+

{title}

+

{subtitle}

+
+
+ +
+ {command} +
+ +
+ {state === "request" ? ( + <> + +
+ {alwaysAllowOptions.length === 1 ? ( + + ) : alwaysAllowOptions.length > 1 ? ( + + ) : null} + +
+ + ) : ( +
+ {state === "running" ? ( + <> + + Approved, running + + ) : state === "denied" ? ( + <> + + Denied + + ) : ( + <> + + Approved + + )} +
+ )} +
+
+ ); +} + +function AlwaysAllowMenu({ options }: { options: AlwaysAllowOption[] }) { + return ( + + + Always allow + + + + + + {options.map((option) => ( + + {option.label} + + ))} + + + + + ); +} diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/connection-state.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/connection-state.tsx new file mode 100644 index 00000000000..f7fe474d432 --- /dev/null +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/connection-state.tsx @@ -0,0 +1,86 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { CheckIcon, CloudOffIcon, Loader2Icon } from "lucide-react"; +import { cn } from '../../lib/utils'; +import { mono, paper } from './surfaces'; + +export type ConnectionPhase = "online" | "dropped" | "reconnecting" | "resumed"; + +export function ConnectionState({ + phase, + attempt, + resumedTokens, + onRetry, + className, + ...props +}: Omit< + ComponentProps<"div">, + "children" | "phase" | "attempt" | "resumedTokens" | "onRetry" +> & { + phase: ConnectionPhase; + attempt?: number; + resumedTokens?: number; + onRetry?: () => void; +}) { + if (phase === "online") return null; + + return ( +
+ {phase === "dropped" && ( + <> + + + Connection lost. The run kept going on the server. + + + + )} + + {phase === "reconnecting" && ( + <> + + Reconnecting + {attempt !== undefined && ( + + attempt {attempt} + + )} + + )} + + {phase === "resumed" && ( + <> + + + Picked the stream back up. + + {resumedTokens !== undefined && ( + + +{resumedTokens} tokens + + )} + + )} +
+ ); +} diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/day-separator.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/day-separator.tsx new file mode 100644 index 00000000000..1b92ba91c0f --- /dev/null +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/day-separator.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { cn } from '../../lib/utils'; +import { useAuiState } from "@assistant-ui/react"; +import type { FC, ReactNode } from "react"; + +function asDate(value: Date | string | number | undefined): Date | null { + if (value == null) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function dayKey(date: Date): string { + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; +} + +function dayLabel(date: Date): string { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const day = new Date(date); + day.setHours(0, 0, 0, 0); + const diffDays = Math.round((today.getTime() - day.getTime()) / 86_400_000); + if (diffDays === 0) return "Today"; + if (diffDays === 1) return "Yesterday"; + return date.toLocaleDateString(undefined, { + weekday: "long", + month: "short", + day: "numeric", + }); +} + +function timeLabel(date: Date): string { + return date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); +} + +export const MessageChronology: FC<{ children: ReactNode }> = ({ + children, +}) => { + const role = useAuiState((s) => s.message.role); + const createdAt = useAuiState((s) => s.message.createdAt); + const index = useAuiState((s) => s.message.index); + const prevCreatedAt = useAuiState( + (s) => s.thread.messages[index - 1]?.createdAt, + ); + + const date = asDate(createdAt); + const prev = asDate(prevCreatedAt); + const showDay = date != null && (index === 0 || !prev || dayKey(date) !== dayKey(prev)); + + return ( +
+ {showDay && date ? ( +
+ + {dayLabel(date)} + +
+ ) : null} + {children} + {date ? ( + + ) : null} +
+ ); +}; diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/error-state.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/error-state.tsx new file mode 100644 index 00000000000..502b5d4579a --- /dev/null +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/error-state.tsx @@ -0,0 +1,79 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { CircleAlertIcon, RefreshCwIcon } from "lucide-react"; +import { cn } from '../../lib/utils'; +import { ShimmerLabel } from './surfaces'; + +export interface ErrorStateProps extends Omit< + ComponentProps<"div">, + "children" | "role" +> { + title: string; + detail: string; + retrying: boolean; + /** Omit when nothing can be retried; the button is then not rendered. */ + onRetry?: () => void; +} + +export function ErrorState({ + title, + detail, + retrying, + onRetry, + className, + ...props +}: ErrorStateProps) { + if (retrying) { + return ( +
+ + + Retrying + +
+ ); + } + + return ( +
+ +
+

{title}

+

+ {detail} +

+
+ {onRetry ? ( + + ) : null} +
+ ); +} diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/file.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/file.tsx new file mode 100644 index 00000000000..a51cf2f793b --- /dev/null +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/file.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { memo, type FC } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { + FileIcon, + FileTextIcon, + ImageIcon, + MusicIcon, + VideoIcon, + BracesIcon, + DownloadIcon, +} from "lucide-react"; +import type { FileMessagePartComponent } from "@assistant-ui/react"; +import { cn } from '../../lib/utils'; + +const fileVariants = cva( + "aui-file-root inline-flex items-center gap-3 rounded-lg transition-colors", + { + variants: { + variant: { + outline: "border-border hover:bg-muted/50 border", + ghost: "hover:bg-muted/50", + muted: "bg-muted/50 hover:bg-muted/70", + }, + size: { + sm: "px-2.5 py-1.5 text-xs", + default: "px-3 py-2 text-sm", + lg: "px-4 py-3 text-base", + }, + }, + defaultVariants: { + variant: "outline", + size: "default", + }, + }, +); + +function getMimeTypeIcon(mimeType: string): FC<{ className?: string }> { + const type = mimeType.toLowerCase(); + if (type.startsWith("image/")) { + return ImageIcon; + } + if (type === "application/pdf") { + return FileTextIcon; + } + if (type === "application/json") { + return BracesIcon; + } + if (type.startsWith("text/")) { + return FileTextIcon; + } + if (type.startsWith("audio/")) { + return MusicIcon; + } + if (type.startsWith("video/")) { + return VideoIcon; + } + return FileIcon; +} + +export type FileDataKind = "data-uri" | "url" | "base64" | "id"; + +function getFileDataKind( + data: string, + sourceType?: "url" | "id", +): FileDataKind { + if (sourceType === "url" && /^data:/i.test(data)) return "data-uri"; + if (sourceType) return sourceType; + if (/^data:/i.test(data)) return "data-uri"; + if (/^https?:\/\//i.test(data)) return "url"; + return "base64"; +} + +function getBase64Size(base64: string): number { + const commaIndex = base64.indexOf(","); + const base64Data = commaIndex >= 0 ? base64.slice(commaIndex + 1) : base64; + const padding = (base64Data.match(/=/g) || []).length; + return Math.floor((base64Data.length * 3) / 4) - padding; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export type FileRootProps = React.ComponentProps<"div"> & + VariantProps; + +function FileRoot({ + className, + variant, + size, + children, + ...props +}: FileRootProps) { + return ( +
+ {children} +
+ ); +} + +type FileIconDisplayProps = React.ComponentProps<"span"> & { + mimeType?: string; +}; + +function FileIconDisplay({ + mimeType, + className, + children, + ...props +}: FileIconDisplayProps) { + const IconComponent = mimeType ? getMimeTypeIcon(mimeType) : FileIcon; + + return ( + + {children ?? } + + ); +} + +function FileName({ + className, + children, + ...props +}: React.ComponentProps<"span">) { + return ( + + {children || "Unnamed file"} + + ); +} + +type FileSizeProps = React.ComponentProps<"span"> & { + bytes: number; +}; + +function FileSize({ bytes, className, ...props }: FileSizeProps) { + return ( + + {formatFileSize(bytes)} + + ); +} + +type FileDownloadProps = Omit, "href"> & { + data: string; + mimeType: string; + filename?: string; + sourceType?: "url" | "id"; +}; + +function FileDownload({ + data, + mimeType, + filename, + sourceType, + className, + children, + ...props +}: FileDownloadProps) { + if (typeof data !== "string") return null; + const kind = getFileDataKind(data, sourceType); + if (kind === "id") return null; + if (kind === "url" && !/^(https?:\/\/|blob:)/i.test(data)) return null; + const href = kind === "base64" ? `data:${mimeType};base64,${data}` : data; + + return ( + + {children || } + + ); +} + +const FileImpl: FileMessagePartComponent = ({ + filename, + data, + mimeType, + sourceType, +}) => { + const kind = getFileDataKind(data, sourceType); + const showSize = + typeof data === "string" && (kind === "base64" || kind === "data-uri"); + + return ( + + +
+ {filename} + {showSize && ( + + )} +
+ +
+ ); +}; + +const File = memo(FileImpl) as unknown as FileMessagePartComponent & { + Root: typeof FileRoot; + Icon: typeof FileIconDisplay; + Name: typeof FileName; + Size: typeof FileSize; + Download: typeof FileDownload; +}; + +File.displayName = "File"; +File.Root = FileRoot; +File.Icon = FileIconDisplay; +File.Name = FileName; +File.Size = FileSize; +File.Download = FileDownload; + +export { + File, + FileRoot, + FileIconDisplay, + FileName, + FileSize, + FileDownload, + fileVariants, + getMimeTypeIcon, + getFileDataKind, + getBase64Size, + formatFileSize, +}; diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/markdown-text.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/markdown-text.tsx new file mode 100644 index 00000000000..8c3f2f595c1 --- /dev/null +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/markdown-text.tsx @@ -0,0 +1,266 @@ +"use client"; + +import "@assistant-ui/react-markdown/styles/dot.css"; + +import { + type CodeHeaderProps, + MarkdownTextPrimitive, + unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, + useIsMarkdownCodeBlock, +} from "@assistant-ui/react-markdown"; +import remarkGfm from "remark-gfm"; +import { type FC, memo, useMemo, useRef } from "react"; +import type { TextMessagePartProps } from "@assistant-ui/react"; +import { CheckIcon, CopyIcon } from "lucide-react"; + +import { TooltipIconButton } from './tooltip-icon-button'; +import { useCopyToClipboard } from '../../hooks/use-copy-to-clipboard'; +import { cn } from '../../lib/utils'; + +type MarkdownTextProps = Partial & { + components?: Parameters[0]; +}; + +const useShallowStable = | undefined>( + value: T, +): T => { + const ref = useRef(value); + if (value !== ref.current) { + const prev = ref.current; + const stable = + value !== undefined && + prev !== undefined && + Object.keys(prev).length === Object.keys(value).length && + Object.keys(value).every((key) => prev[key] === value[key]); + if (!stable) ref.current = value; + } + return ref.current; +}; + +const MarkdownTextImpl: FC = ({ components }) => { + const stableComponents = useShallowStable(components); + const markdownComponents = useMemo(() => { + if (!stableComponents) return defaultComponents; + return { + ...defaultComponents, + ...memoizeMarkdownComponents(stableComponents), + }; + }, [stableComponents]); + + return ( + + ); +}; + +export const MarkdownText = memo(MarkdownTextImpl); + +const CodeHeader: FC = ({ language, code }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard(); + const onCopy = () => { + if (!code || isCopied) return; + copyToClipboard(code); + }; + + return ( +
+ + {language} + + + {!isCopied && ( + + )} + {isCopied && ( + + )} + +
+ ); +}; + +const defaultComponents = memoizeMarkdownComponents({ + h1: ({ className, ...props }) => ( +

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

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

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

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

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

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

+ ), + ul: ({ className, ...props }) => ( +
    li]:mt-1", + className, + )} + {...props} + /> + ), + ol: ({ className, ...props }) => ( +
      li]:mt-1", + className, + )} + {...props} + /> + ), + hr: ({ className, ...props }) => ( +
      + ), + table: ({ className, ...props }) => ( + + ), + th: ({ className, ...props }) => ( + td:first-child]:rounded-es-lg [&:last-child>td:last-child]:rounded-ee-lg", + className, + )} + {...props} + /> + ), + li: ({ className, ...props }) => ( +
    1. + ), + strong: ({ className, ...props }) => ( + + ), + sup: ({ className, ...props }) => ( + a]:text-xs [&>a]:no-underline", className)} + {...props} + /> + ), + pre: ({ className, ...props }) => ( +
      +  ),
      +  code: function Code({ className, ...props }) {
      +    const isCodeBlock = useIsMarkdownCodeBlock();
      +    return (
      +      
      +    );
      +  },
      +  CodeHeader,
      +});
      diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/novu-approval-card.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/novu-approval-card.tsx
      new file mode 100644
      index 00000000000..4cda5834a7b
      --- /dev/null
      +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/novu-approval-card.tsx
      @@ -0,0 +1,76 @@
      +"use client";
      +
      +import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
      +import { ApprovalCard, type AlwaysAllowOption, type ApprovalState } from './approval-card';
      +
      +function commandPreview(args: unknown, argsText: string | undefined, toolName: string): string {
      +  if (argsText?.trim()) return argsText.trim();
      +  if (args && typeof args === "object" && Object.keys(args as object).length > 0) {
      +    try {
      +      return JSON.stringify(args, null, 2);
      +    } catch {
      +      return toolName;
      +    }
      +  }
      +  return toolName;
      +}
      +
      +function approvalState(
      +  approved: boolean | undefined,
      +  resolution: "cancelled" | "expired" | undefined,
      +): ApprovalState {
      +  if (resolution === "cancelled" || resolution === "expired" || approved === false) {
      +    return "denied";
      +  }
      +  if (approved === undefined) return "request";
      +  return "done";
      +}
      +
      +export const NovuApprovalCard: ToolCallMessagePartComponent = ({
      +  toolName,
      +  args,
      +  argsText,
      +  approval,
      +  respondToApproval,
      +}) => {
      +  const options = approval?.options ?? [];
      +
      +  const respond = (optionId: string, approved: boolean) => {
      +    respondToApproval?.({ optionId, approved });
      +  };
      +
      +  let onAllowOnce: (() => void) | undefined;
      +  let onDeny: (() => void) | undefined;
      +  const alwaysAllowOptions: AlwaysAllowOption[] = [];
      +
      +  for (const option of options) {
      +    switch (option.kind) {
      +      case "allow-once":
      +        onAllowOnce = () => respond(option.id, true);
      +        break;
      +      case "allow-always":
      +        alwaysAllowOptions.push({
      +          label: option.label ?? "Always allow",
      +          onSelect: () => respond(option.id, true),
      +        });
      +        break;
      +      case "reject-once":
      +        onDeny = () => respond(option.id, false);
      +        break;
      +      default:
      +        break;
      +    }
      +  }
      +
      +  return (
      +    
      +  );
      +};
      diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/reasoning.aui.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/reasoning.aui.tsx
      new file mode 100644
      index 00000000000..5ffec625bcf
      --- /dev/null
      +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/reasoning.aui.tsx
      @@ -0,0 +1,120 @@
      +"use client";
      +
      +import { memo, useCallback, useRef } from "react";
      +import {
      +  useScrollLock,
      +  useAuiState,
      +  type ReasoningMessagePartComponent,
      +  type ReasoningGroupComponent,
      +} from "@assistant-ui/react";
      +import { MarkdownText } from './markdown-text';
      +import {
      +  ANIMATION_DURATION,
      +  ReasoningRoot as ReasoningRootBase,
      +  ReasoningTrigger,
      +  ReasoningContent,
      +  ReasoningText,
      +  ReasoningFade,
      +  reasoningVariants,
      +  type ReasoningRootProps,
      +} from './reasoning';
      +
      +export type { ReasoningRootProps } from './reasoning';
      +
      +/** `ReasoningRoot` with the thread viewport scroll locked during disclosure animations. */
      +function ReasoningRoot({
      +  ref,
      +  onAnimationStart,
      +  ...props
      +}: ReasoningRootProps) {
      +  const collapsibleRef = useRef(null);
      +  const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
      +
      +  const handleAnimationStart = useCallback(() => {
      +    lockScroll();
      +    onAnimationStart?.();
      +  }, [lockScroll, onAnimationStart]);
      +
      +  const composedRef = useCallback(
      +    (node: HTMLDivElement | null) => {
      +      collapsibleRef.current = node;
      +      if (typeof ref === "function") {
      +        ref(node);
      +      } else if (ref) {
      +        ref.current = node;
      +      }
      +    },
      +    [ref],
      +  );
      +
      +  return (
      +    
      +  );
      +}
      +
      +const ReasoningImpl: ReasoningMessagePartComponent = () => ;
      +
      +const ReasoningGroupImpl: ReasoningGroupComponent = ({
      +  children,
      +  startIndex,
      +  endIndex,
      +}) => {
      +  const isReasoningStreaming = useAuiState((s) => {
      +    if (s.message.status?.type !== "running") return false;
      +    for (let index = startIndex; index <= endIndex; index++) {
      +      if (s.message.parts[index]?.status.type === "running") return true;
      +    }
      +    return false;
      +  });
      +
      +  return (
      +    
      +      
      +      
      +        {children}
      +      
      +    
      +  );
      +};
      +
      +const Reasoning = memo(
      +  ReasoningImpl,
      +) as unknown as ReasoningMessagePartComponent & {
      +  Root: typeof ReasoningRoot;
      +  Trigger: typeof ReasoningTrigger;
      +  Content: typeof ReasoningContent;
      +  Text: typeof ReasoningText;
      +  Fade: typeof ReasoningFade;
      +};
      +
      +Reasoning.displayName = "Reasoning";
      +Reasoning.Root = ReasoningRoot;
      +Reasoning.Trigger = ReasoningTrigger;
      +Reasoning.Content = ReasoningContent;
      +Reasoning.Text = ReasoningText;
      +Reasoning.Fade = ReasoningFade;
      +
      +/**
      + * @deprecated This wrapper targets the legacy `components.ReasoningGroup`
      + * prop on ``. Use ``
      + * with a `groupBy` returning `"group-reasoning"` and compose `ReasoningRoot`
      + * / `ReasoningTrigger` / `ReasoningContent` / `ReasoningText` directly.
      + * See `thread.aui.tsx` for an example.
      + */
      +const ReasoningGroup = memo(ReasoningGroupImpl);
      +ReasoningGroup.displayName = "ReasoningGroup";
      +
      +export {
      +  Reasoning,
      +  ReasoningGroup,
      +  ReasoningRoot,
      +  ReasoningTrigger,
      +  ReasoningContent,
      +  ReasoningText,
      +  ReasoningFade,
      +  reasoningVariants,
      +};
      diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/reasoning.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/reasoning.tsx
      new file mode 100644
      index 00000000000..42500cf9179
      --- /dev/null
      +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/reasoning.tsx
      @@ -0,0 +1,329 @@
      +"use client";
      +
      +import {
      +  createContext,
      +  useCallback,
      +  useContext,
      +  useEffect,
      +  useLayoutEffect,
      +  useRef,
      +  useState,
      +} from "react";
      +import { cva, type VariantProps } from "class-variance-authority";
      +import { BrainIcon, ChevronDownIcon } from "lucide-react";
      +import {
      +  Collapsible,
      +  CollapsibleContent,
      +  CollapsibleTrigger,
      +} from '../../ui/collapsible';
      +import { cn } from '../../lib/utils';
      +
      +export const ANIMATION_DURATION = 200;
      +
      +const ReasoningPreviewContext = createContext(false);
      +
      +const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", {
      +  variants: {
      +    variant: {
      +      outline: "rounded-lg border px-3 py-2",
      +      ghost: "",
      +      muted: "bg-muted/50 rounded-lg px-3 py-2",
      +    },
      +  },
      +  defaultVariants: {
      +    variant: "outline",
      +  },
      +});
      +
      +export type ReasoningRootProps = Omit<
      +  React.ComponentProps,
      +  "open" | "onOpenChange"
      +> &
      +  VariantProps & {
      +    open?: boolean;
      +    onOpenChange?: (open: boolean) => void;
      +    defaultOpen?: boolean;
      +    /**
      +     * Whether the reasoning is currently streaming. While `true` the
      +     * disclosure is held open with a bottom-pinned live preview; when
      +     * streaming ends it returns to `defaultOpen`, and the first manual
      +     * toggle takes over the open/close state permanently. The live preview
      +     * keeps following the newest tokens while the disclosure is open during
      +     * streaming, even after a manual toggle, and pauses while the reader is
      +     * scrolled up.
      +     */
      +    streaming?: boolean;
      +    /** Called right before the disclosure animates, on toggle and on streaming transitions. */
      +    onAnimationStart?: () => void;
      +  };
      +
      +function ReasoningRoot({
      +  className,
      +  variant,
      +  open: controlledOpen,
      +  onOpenChange: controlledOnOpenChange,
      +  defaultOpen = false,
      +  streaming,
      +  onAnimationStart,
      +  children,
      +  ...props
      +}: ReasoningRootProps) {
      +  const initialOpenRef = useRef(defaultOpen);
      +  const [userOpen, setUserOpen] = useState(null);
      +
      +  const isControlled = controlledOpen !== undefined;
      +  const isOpen = isControlled
      +    ? controlledOpen
      +    : (userOpen ?? (streaming || initialOpenRef.current));
      +  const isPreview = streaming === true && isOpen;
      +
      +  const prevStreamingRef = useRef(streaming);
      +  useLayoutEffect(() => {
      +    if (prevStreamingRef.current === streaming) return;
      +    prevStreamingRef.current = streaming;
      +    // A streaming transition only animates the panel when the resting state
      +    // is collapsed; with `defaultOpen` the disclosure stays open across it.
      +    if (!isControlled && userOpen === null && !initialOpenRef.current) {
      +      onAnimationStart?.();
      +    }
      +  }, [streaming, isControlled, userOpen, onAnimationStart]);
      +
      +  const handleOpenChange = useCallback(
      +    (open: boolean) => {
      +      onAnimationStart?.();
      +      if (!isControlled) {
      +        setUserOpen(open);
      +      }
      +      controlledOnOpenChange?.(open);
      +    },
      +    [onAnimationStart, isControlled, controlledOnOpenChange],
      +  );
      +
      +  return (
      +    
      +      
      +        {children}
      +      
      +    
      +  );
      +}
      +
      +function ReasoningFade({
      +  side = "bottom",
      +  className,
      +  ...props
      +}: React.ComponentProps<"div"> & { side?: "top" | "bottom" }) {
      +  if (side === "top") {
      +    return (
      +      
      + ); + } + + return ( +
      + ); +} + +function ReasoningTrigger({ + active, + duration, + className, + ...props +}: React.ComponentProps & { + active?: boolean; + duration?: number; +}) { + const durationText = duration ? ` (${duration}s)` : ""; + + return ( + + + + Reasoning{durationText} + + + + ); +} + +function ReasoningContent({ + className, + children, + ...props +}: React.ComponentProps) { + const isPreview = useContext(ReasoningPreviewContext); + + return ( + + + {children} + {isPreview ? : null} + + ); +} + +function ReasoningText({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + const isPreview = useContext(ReasoningPreviewContext); + const scrollRef = useRef(null); + const contentRef = useRef(null); + + useEffect(() => { + if (!isPreview) return; + const scrollEl = scrollRef.current; + const contentEl = contentRef.current; + if (!scrollEl || !contentEl) return; + + let pinned = true; + let lastScrollTop = scrollEl.scrollTop; + let lastScrollHeight = scrollEl.scrollHeight; + const isAtBottom = () => + Math.abs( + scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight, + ) <= 1 || scrollEl.scrollHeight <= scrollEl.clientHeight; + + const pin = () => { + if (!pinned) return; + scrollEl.scrollTop = scrollEl.scrollHeight; + }; + // A pin's own scroll event can arrive after new content grew the scroll + // height and read as "not at bottom"; only an upward move at unchanged + // scroll height is user intent. + const onScroll = () => { + if (isAtBottom()) { + pinned = true; + } else if ( + scrollEl.scrollTop < lastScrollTop && + scrollEl.scrollHeight === lastScrollHeight + ) { + pinned = false; + } + lastScrollTop = scrollEl.scrollTop; + lastScrollHeight = scrollEl.scrollHeight; + }; + + pin(); + scrollEl.addEventListener("scroll", onScroll); + const observer = new ResizeObserver(pin); + observer.observe(contentEl); + return () => { + scrollEl.removeEventListener("scroll", onScroll); + observer.disconnect(); + }; + }, [isPreview]); + + return ( +
      +
      + {children} +
      +
      + ); +} + +export { + ReasoningRoot, + ReasoningTrigger, + ReasoningContent, + ReasoningText, + ReasoningFade, + reasoningVariants, +}; diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/surfaces.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/surfaces.tsx new file mode 100644 index 00000000000..64196680d06 --- /dev/null +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/surfaces.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; +import { cn } from '../../lib/utils'; + +export const paper = "bg-background border border-border/60 dark:bg-popover"; + +export const floating = "bg-background border border-border/60 dark:bg-popover"; + +export const field = "bg-foreground/[0.04] dark:bg-foreground/[0.06]"; + +export const fieldInteractive = + "bg-foreground/[0.04] transition-colors hover:bg-foreground/[0.07] dark:bg-foreground/[0.06] dark:hover:bg-foreground/[0.09]"; + +export const pressable = + "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.96] motion-reduce:transition-none"; + +export const ghostButton = + "flex items-center justify-center rounded-full text-foreground/45 outline-none transition-[background-color,color,scale] duration-150 hover:bg-foreground/[0.06] hover:text-foreground/90 active:scale-[0.96] focus-visible:ring-1 focus-visible:ring-foreground/20 motion-reduce:transition-none dark:hover:bg-foreground/[0.09]"; + +export const inkButton = + "bg-foreground text-background !text-background transition-[opacity,scale] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:opacity-90 active:scale-[0.96] motion-reduce:transition-none"; + +export const iconSwap = + "[grid-area:1/1] transition-[opacity,scale,filter] duration-200 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none"; + +export const iconSwapIn = "scale-100 opacity-100 blur-none"; + +export const iconSwapOut = "scale-[0.25] opacity-0 blur-[4px]"; + +export const labelSwap = + "col-start-1 row-start-1 flex w-max items-center gap-1.5 leading-none transition-[opacity,filter] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"; + +export const labelSwapIn = "opacity-100 blur-none"; + +export const labelSwapOut = + "pointer-events-none select-none opacity-0 blur-[2px]"; + +export const collapsePanel = + "h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] data-[ending-style]:h-0 data-[starting-style]:h-0 motion-reduce:transition-none"; + +export const live = "text-blue-500 dark:text-blue-400"; + +export const mono = "font-mono text-[11px] tracking-tight"; + +export function ShimmerLabel({ + active = true, + className, + ...props +}: ComponentProps<"span"> & { active?: boolean }) { + return ( + + ); +} + +export const codeScroll = "overflow-x-auto"; + +export const codeSurface = "w-max min-w-full"; + +export function SwapLabel({ + active, + children, + className, +}: { + active: 0 | 1; + children: [React.ReactNode, React.ReactNode]; + className?: string; +}) { + const layers = [useRef(null), useRef(null)]; + const [width, setWidth] = useState(null); + + useLayoutEffect(() => { + const target = layers[active]?.current; + if (!target) return undefined; + const measure = () => + setWidth(Math.ceil(target.getBoundingClientRect().width)); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(target); + return () => observer.disconnect(); + }, [active]); + + return ( + + {children.map((layer, index) => ( + + {layer} + + ))} + + ); +} diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/thinking-indicator.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/thinking-indicator.tsx new file mode 100644 index 00000000000..0203ce93d3c --- /dev/null +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/thinking-indicator.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { cn } from '../../lib/utils'; +import { mono, ShimmerLabel } from './surfaces'; + +export function ThinkingIndicator({ + label, + elapsed, + className, + ...props +}: Omit, "children" | "label" | "elapsed"> & { + label: string; + elapsed?: string; +}) { + return ( +
      + + + {label} + + {elapsed !== undefined && ( + + {elapsed} + + )} +
      + ); +} diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/thread-list.aui.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/thread-list.aui.tsx new file mode 100644 index 00000000000..04f18ef28a0 --- /dev/null +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/thread-list.aui.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { Button } from '../../ui/button'; +import { Skeleton } from '../../ui/skeleton'; +import { cn } from '../../lib/utils'; +import { + AuiIf, + ThreadListItemPrimitive, + ThreadListPrimitive, + useAuiState, +} from "@assistant-ui/react"; +import { Loader2Icon, PlusIcon } from "lucide-react"; +import { + forwardRef, + Fragment, + useMemo, + type ComponentPropsWithoutRef, + type FC, +} from "react"; + +export const ThreadList: FC = () => { + return ( + + + + + ); +}; + +export const ThreadListRoot: FC< + ComponentPropsWithoutRef +> = ({ className, ...props }) => { + return ( + + ); +}; + +export const ThreadListItems: FC> = ({ + className, + ...props +}) => { + return ( +
      + s.threads.isLoading}> + + + !s.threads.isLoading}> + + +
      + ); +}; + +const DAY_IN_MS = 86_400_000; + +const dateGroupLabel = ( + date: Date | undefined, + startOfToday: number, +): string => { + if (!date || date.getTime() >= startOfToday) return "Today"; + if (date.getTime() >= startOfToday - DAY_IN_MS) return "Yesterday"; + return "Earlier"; +}; + +type ThreadListGroup = { label: string; indices: number[] }; + +const ThreadListItemGroups: FC = () => { + const threadIds = useAuiState((s) => s.threads.threadIds); + const threadItems = useAuiState((s) => s.threads.threadItems); + + const { indices, groups } = useMemo(() => { + const itemsById = new Map(threadItems.map((item) => [item.id, item])); + const dates = threadIds.map((id) => { + const item = itemsById.get(id); + if (item?.lastMessageAt) return item.lastMessageAt; + const lastActivityAt = ( + item?.custom as { lastActivityAt?: string } | undefined + )?.lastActivityAt; + return lastActivityAt ? new Date(lastActivityAt) : undefined; + }); + const indices = threadIds.map((_, index) => index); + if (!indices.some((index) => dates[index])) { + return { indices, groups: null }; + } + + const now = new Date(); + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ).getTime(); + const time = (index: number) => + dates[index]?.getTime() ?? Number.MAX_SAFE_INTEGER; + const sorted = [...indices].sort((a, b) => time(b) - time(a)); + + const result: ThreadListGroup[] = []; + for (const index of sorted) { + const label = dateGroupLabel(dates[index], startOfToday); + const lastGroup = result[result.length - 1]; + if (lastGroup?.label === label) { + lastGroup.indices.push(index); + } else { + result.push({ label, indices: [index] }); + } + } + return { indices, groups: result }; + }, [threadIds, threadItems]); + + if (!groups) { + return indices.map((index) => ( + + )); + } + + return groups.map((group) => ( + +
      + {group.label} +
      + {group.indices.map((index) => ( + + ))} +
      + )); +}; + +export const ThreadListNew = forwardRef< + HTMLButtonElement, + ComponentPropsWithoutRef & { labelClassName?: string } +>(({ className, labelClassName, children, ...props }, ref) => { + const isNewThreadActive = useAuiState( + (s) => s.threads.newThreadId === s.threads.mainThreadId, + ); + + return ( + + + + ); +}); + +ThreadListNew.displayName = "ThreadListNew"; + +const ThreadListSkeleton: FC = () => { + return ( +
      + {Array.from({ length: 5 }, (_, i) => ( +
      + +
      + ))} +
      + ); +}; + +export const ThreadListItem: FC = () => { + const isRunning = useAuiState((s) => s.threadListItem.isRunning); + + return ( + + + {isRunning && ( + + )} + + + + {isRunning && Running} + + + ); +}; diff --git a/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/thread.aui.tsx b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/thread.aui.tsx new file mode 100644 index 00000000000..eb02a8beead --- /dev/null +++ b/packages/novu/src/commands/connect/templates/web-chat/ts/assistant-ui/elements/thread.aui.tsx @@ -0,0 +1,516 @@ +"use client"; + +import { MessageChronology } from './day-separator'; +import { MarkdownText } from './markdown-text'; +import { + Reasoning, + ReasoningContent, + ReasoningRoot, + ReasoningText, + ReasoningTrigger, +} from './reasoning.aui'; +import { ToolFallback } from './tool-fallback.aui'; +import { + ToolGroupContent, + ToolGroupRoot, + ToolGroupTrigger, +} from './tool-group.aui'; +import { TooltipIconButton } from './tooltip-icon-button'; +import { Button } from '../../ui/button'; +import { Skeleton } from '../../ui/skeleton'; +import { cn } from '../../lib/utils'; +import { + ActionBarMorePrimitive, + ActionBarPrimitive, + AuiIf, + type AssistantState, + ComposerPrimitive, + ErrorPrimitive, + groupPartByType, + MessagePrimitive, + SuggestionPrimitive, + ThreadPrimitive, + type ToolCallMessagePartComponent, + useAuiState, +} from "@assistant-ui/react"; +import { + ArrowDownIcon, + ArrowUpIcon, + CheckIcon, + CopyIcon, + DownloadIcon, + LoaderCircleIcon, + MoreHorizontalIcon, +} from "lucide-react"; +import { + createContext, + useContext, + useEffect, + useRef, + useState, + type ComponentType, + type FC, + type PropsWithChildren, +} from "react"; + +export type ThreadGroupPart = MessagePrimitive.GroupedParts.GroupPart; + +export const defaultThreadPartGroupBy = groupPartByType({ + reasoning: ["group-chainOfThought", "group-reasoning"], + "tool-call": ["group-chainOfThought", "group-tool"], + "standalone-tool-call": [], +}); + +/** + * Optional component overrides for the thread. `AssistantMessage` and + * `Welcome` replace whole sections; the remaining slots override how the + * assistant message renders tool calls and part groups. Tool UIs registered + * by name (toolkit `render`, `useAssistantDataUI`) take precedence over + * `ToolFallback`. + */ +export type ThreadComponents = { + AssistantMessage?: ComponentType | undefined; + UserMessage?: ComponentType | undefined; + Welcome?: ComponentType | undefined; + Indicator?: ComponentType | undefined; + /** Rendered directly above the composer, inside the sticky footer. */ + Banner?: ComponentType | undefined; + ToolFallback?: ToolCallMessagePartComponent | undefined; + ToolGroup?: + | ComponentType> + | undefined; + ReasoningGroup?: + | ComponentType> + | undefined; + groupBy?: typeof defaultThreadPartGroupBy; +}; + +export type ThreadProps = { + components?: ThreadComponents | undefined; + autoFocus?: boolean | undefined; +}; + +const EMPTY_COMPONENTS: ThreadComponents = {}; + +const ThreadComponentsContext = + createContext(EMPTY_COMPONENTS); + +// Startup exposes a loading placeholder thread; treat it as a new chat so +// the composer mounts centered. Loads after startup keep the docked layout. +const isNewChatView = (s: AssistantState) => + s.thread.messages.length === 0 && + (!s.thread.isLoading || s.threads.isLoading); + +// A switched thread that is still fetching its history: skeleton, not welcome. +const isHistoryLoadingView = (s: AssistantState) => + s.thread.messages.length === 0 && + s.thread.isLoading && + !s.thread.isDisabled && + !s.threads.isLoading; + +const ThreadHistorySkeleton: FC = () => ( +
      + Loading conversation + +
      + + + +
      + +
      + + +
      +
      +); + +export const Thread: FC = ({ + components = EMPTY_COMPONENTS, + autoFocus = true, +}) => { + const isEmpty = useAuiState(isNewChatView); + + return ( + + + + ); +}; + +const ThreadRoot: FC<{ isEmpty: boolean; autoFocus: boolean }> = ({ + isEmpty, + autoFocus, +}) => { + const { Welcome = ThreadWelcome, Banner } = useContext(ThreadComponentsContext); + + return ( + + +
      + + + + + + + +
      + + {() => } + +
      + + + + + {Banner ? : null} + + + + + +
      +
      +
      + ); +}; + +const ThreadMessage: FC = () => { + const { AssistantMessage: AssistantMessageComponent = AssistantMessage } = + useContext(ThreadComponentsContext); + const { UserMessage: UserMessageComponent = UserMessage } = + useContext(ThreadComponentsContext); + const role = useAuiState((s) => s.message.role); + + return ( + + {role === "user" ? : } + + ); +}; + +const ThreadViewportBottomStateSync: FC = () => { + const markerRef = useRef(null); + + useEffect(() => { + const viewport = markerRef.current?.closest( + '[data-slot="aui_thread-viewport"]', + ); + if (!viewport) return; + + let frame: number | undefined; + const syncIfAtBottom = () => { + cancelAnimationFrame(frame ?? 0); + frame = requestAnimationFrame(() => { + const bottomDistance = + viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + if (Math.abs(bottomDistance) <= 1) { + viewport.dispatchEvent(new Event("scroll")); + } + }); + }; + + const observer = new MutationObserver((mutations) => { + const reserveChanged = mutations.some((mutation) => { + const target = mutation.target; + if ( + target instanceof HTMLElement && + target.matches("[data-aui-top-anchor-reserve]") + ) { + return true; + } + + return [...mutation.addedNodes, ...mutation.removedNodes].some( + (node) => + node instanceof HTMLElement && + (node.matches("[data-aui-top-anchor-reserve]") || + node.querySelector("[data-aui-top-anchor-reserve]")), + ); + }); + + if (reserveChanged) syncIfAtBottom(); + }); + + observer.observe(viewport, { + attributes: true, + attributeFilter: ["style"], + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + cancelAnimationFrame(frame ?? 0); + }; + }, []); + + return
    2. + ), + td: ({ className, ...props }) => ( + + ), + tr: ({ className, ...props }) => ( +