diff --git a/README.md b/README.md index cad2523e9..5c4b0da24 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,13 @@ Render the generated `` in your root layout and start from a clean co Run the released generator from the application root: ```bash -pnpm dlx @btst/codegen@4.0.0 init \ +pnpm dlx @btst/codegen@4.1.0 init \ --framework nextjs \ --adapter memory \ --plugins blog ``` -The generator shows detected-file and conflict prompts before it writes. It installs `@btst/stack@4.0.0`, `next-themes@0.4.6`, registers Blog on the backend and client, mounts the API and page routes, adds the plugin CSS, and wires the shared provider. +The generator shows detected-file and conflict prompts before it writes. It installs `@btst/stack@4.1.0`, `next-themes@0.4.6`, registers Blog on the backend and client, mounts the API and page routes, adds the plugin CSS, and wires the shared provider. Set the trusted local origins in `.env.local`: diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index 5b580b660..7dc0804f6 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -57,7 +57,7 @@ below lists them explicitly. Run this command from the existing application root: ```bash -npx @btst/codegen@4.0.0 init \ +npx @btst/codegen@4.1.0 init \ --framework nextjs \ --adapter memory \ --plugins blog diff --git a/docs/content/docs/plugins/ai-chat.mdx b/docs/content/docs/plugins/ai-chat.mdx index dd3311f98..009343ea7 100644 --- a/docs/content/docs/plugins/ai-chat.mdx +++ b/docs/content/docs/plugins/ai-chat.mdx @@ -607,6 +607,16 @@ Configure AI Chat-specific overrides and route lifecycle hooks. All lifecycle ho +Use `classNames` in `StackProvider.overrides.aiChat` to style built-in UI slots. Classes merge with the defaults; conflicting Tailwind utilities use your override. Omitting a slot preserves its default styling. + +```tsx +overrides={{ + aiChat: { + classNames: { userMessage: "bg-slate-900 text-white", input: "space-y-4" }, + }, +}} +``` + **Example usage:** ```tsx diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index 14b2b6eb0..163b412ce 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -549,6 +549,16 @@ Configure Blog-specific components, slots, localization, and route lifecycle hoo +Use `classNames` in `StackProvider.overrides.blog` to style built-in UI slots. Classes merge with the defaults; conflicting Tailwind utilities use your override. Omitting a slot preserves its default styling. + +```tsx +overrides={{ + blog: { + classNames: { postTitle: "font-serif text-3xl", postCard: "shadow-lg" }, + }, +}} +``` + **Example usage:** ```tsx diff --git a/docs/content/docs/plugins/cms.mdx b/docs/content/docs/plugins/cms.mdx index d3ffbb1a7..27ef07182 100644 --- a/docs/content/docs/plugins/cms.mdx +++ b/docs/content/docs/plugins/cms.mdx @@ -1378,6 +1378,16 @@ Configure CMS-specific overrides and route lifecycle hooks: +Use `classNames` in `StackProvider.overrides.cms` to style built-in UI slots. Classes merge with the defaults; conflicting Tailwind utilities use your override. Omitting a slot preserves its default styling. + +```tsx +overrides={{ + cms: { + classNames: { contentTypeCard: "shadow-lg", tableRow: "hover:bg-muted" }, + }, +}} +``` + ### Schema Converter Utilities (`@btst/stack/plugins/cms/client`) The CMS plugin re-exports schema converter utilities for converting between Zod schemas and JSON Schema. These are useful when working with content types programmatically: diff --git a/docs/content/docs/plugins/form-builder.mdx b/docs/content/docs/plugins/form-builder.mdx index 9dc116efa..7611e58f3 100644 --- a/docs/content/docs/plugins/form-builder.mdx +++ b/docs/content/docs/plugins/form-builder.mdx @@ -634,9 +634,20 @@ Creates the client plugin with routes and SSR loaders. #### FormBuilderPluginOverrides +Set `classNames` in `StackProvider.overrides.formBuilder` for targeted styling. Classes merge with defaults, with conflicting Tailwind utilities resolved in favor of your override. `table`, `tableRow`, and `pagination` apply to both the forms list and submissions list. `form` styles the `FormRenderer` root, including loading, error, and success states, and merges after its `className` prop. + +```tsx +overrides={{ + formBuilder: { + classNames: { table: "shadow-sm", form: "max-w-xl mx-auto" }, + }, +}} +``` + | Property | Type | Required | Description | |----------|------|----------|-------------| | `fieldComponents` | `Record` | No | Custom field components | +| `classNames` | `{ container?, table?, tableRow?, pagination?, form? }` | No | Class names merged onto specific UI elements | | `localization` | `Partial` | No | Custom labels | | `showAttribution` | `boolean` | No | Show BTST attribution | | `onRouteRender` | `(route, context) => void` | No | Lifecycle hook | diff --git a/packages/cli/package.json b/packages/cli/package.json index fdec5038d..981cd7aff 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@btst/codegen", - "version": "4.0.0", + "version": "4.1.0", "description": "BTST project scaffolding and CLI passthrough commands.", "repository": { "type": "git", diff --git a/packages/cli/src/utils/__tests__/package-installer.test.ts b/packages/cli/src/utils/__tests__/package-installer.test.ts index f5d91864e..d41b559ca 100644 --- a/packages/cli/src/utils/__tests__/package-installer.test.ts +++ b/packages/cli/src/utils/__tests__/package-installer.test.ts @@ -21,7 +21,7 @@ describe("installInitDependencies", () => { }); const installArguments = execa.mock.calls[0]?.[1] as string[]; - expect(installArguments).toContain("@btst/stack@4.0.0"); + expect(installArguments).toContain("@btst/stack@4.1.0"); expect(installArguments).toContain("next-themes@0.4.6"); expect(installArguments).toContain("@btst/adapter-drizzle@3.0.0"); expect(installArguments).toContain("drizzle-orm@0.45.2"); diff --git a/packages/cli/src/utils/package-installer.ts b/packages/cli/src/utils/package-installer.ts index 769caee69..fe82e281f 100644 --- a/packages/cli/src/utils/package-installer.ts +++ b/packages/cli/src/utils/package-installer.ts @@ -44,7 +44,7 @@ export async function installInitDependencies(input: { }); const packages = [ - "@btst/stack@4.0.0", + "@btst/stack@4.1.0", "@btst/yar@1.3.2", "@tanstack/react-query@5.102.0", "@tanstack/query-core@5.102.0", diff --git a/packages/stack/package.json b/packages/stack/package.json index c79498bf3..af1726356 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "4.0.0", + "version": "4.1.0", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", diff --git a/packages/stack/registry/btst-ai-chat.json b/packages/stack/registry/btst-ai-chat.json index 1b6c3883e..f37057548 100644 --- a/packages/stack/registry/btst-ai-chat.json +++ b/packages/stack/registry/btst-ai-chat.json @@ -50,31 +50,31 @@ { "path": "btst/ai-chat/client/components/chat-input.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, useMemo } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Send, Paperclip, X, Loader2, FileText } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tPermissionCheck,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n} from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../overrides\";\nimport {\n\tDEFAULT_ALLOWED_FILE_TYPES,\n\tFILE_TYPE_MIME_MAP,\n\tresolveAiChatMode,\n} from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\nimport type { FormEvent } from \"react\";\nimport { aiChatPermissions } from \"../../permissions\";\n\n/** Represents an attached file with metadata */\nexport interface AttachedFile {\n\t/** Data URL or uploaded URL */\n\turl: string;\n\t/** MIME type of the file */\n\tmediaType: string;\n\t/** Original filename */\n\tfilename: string;\n}\n\n// Max file size: 10MB\nconst MAX_FILE_SIZE = 10 * 1024 * 1024;\n\ninterface ChatInputProps {\n\tinput?: string;\n\thandleInputChange: (e: React.ChangeEvent) => void;\n\thandleSubmit: (e: FormEvent, files?: AttachedFile[]) => void;\n\tisLoading: boolean;\n\tplaceholder?: string;\n\tvariant?: \"default\" | \"compact\";\n\t/** Callback when files are attached (for controlled mode) */\n\tonFilesAttached?: (files: AttachedFile[]) => void;\n\t/** Attached files (for controlled mode) */\n\tattachedFiles?: AttachedFile[];\n\t/** Browser permission hint for showing attachment controls. */\n\tallowAttachments?: boolean;\n\t/** Rendered owner hints for an exact attachment permission check. */\n\tattachmentPermissionFacts?: {\n\t\tconversationId?: string;\n\t\townerId?: string;\n\t};\n}\n\nfunction AttachmentPermissionEffect({\n\tfile,\n\tcan,\n\tisPending,\n\terror,\n\tonAllowed,\n\tonDenied,\n}: {\n\tfile: File;\n\tcan: boolean;\n\tisPending: boolean;\n\terror?: Error;\n\tonAllowed: (file: File) => void;\n\tonDenied: () => void;\n}) {\n\tuseEffect(() => {\n\t\tif (isPending) return;\n\t\tif (can && !error) onAllowed(file);\n\t\telse onDenied();\n\t}, [can, error, file, isPending, onAllowed, onDenied]);\n\treturn null;\n}\n\nexport function ChatInput({\n\tinput = \"\",\n\thandleInputChange,\n\thandleSubmit,\n\tisLoading,\n\tplaceholder,\n\tvariant = \"default\",\n\tonFilesAttached,\n\tattachedFiles: controlledFiles,\n\tallowAttachments = true,\n\tattachmentPermissionFacts,\n}: ChatInputProps) {\n\tconst {\n\t\tuploadFile,\n\t\tlocalization: customLocalization,\n\t\tallowedFileTypes,\n\t} = usePluginOverrides>(\n\t\t\"aiChat\",\n\t\t{},\n\t);\n\tconst { plugins } = useStack();\n\tconst mode = resolveAiChatMode(plugins?.aiChat?.config);\n\n\tconst notify = useNotify();\n\tconst tr = useAiChatTranslation(customLocalization);\n\n\tconst fileInputRef = useRef(null);\n\tconst [internalFiles, setInternalFiles] = useState([]);\n\tconst [isUploading, setIsUploading] = useState(false);\n\tconst [pendingFile, setPendingFile] = useState(null);\n\tconst uploadGeneration = useRef(0);\n\tuseLayoutEffect(\n\t\t() => () => {\n\t\t\tuploadGeneration.current += 1;\n\t\t},\n\t\t[],\n\t);\n\n\t// Use controlled files if provided, otherwise use internal state\n\tconst attachedFiles = controlledFiles ?? internalFiles;\n\tconst isControlled = controlledFiles !== undefined;\n\n\t// Helper to add a file, using functional update for uncontrolled mode\n\tconst addFile = (file: AttachedFile) => {\n\t\tif (isControlled && onFilesAttached) {\n\t\t\t// In controlled mode, parent manages state - pass current + new\n\t\t\tonFilesAttached([...attachedFiles, file]);\n\t\t} else {\n\t\t\t// In uncontrolled mode, use functional update to avoid stale closure\n\t\t\tsetInternalFiles((prev) => [...prev, file]);\n\t\t}\n\t};\n\n\t// Helper to remove a file by index - uses functional update to avoid stale closure\n\tconst removeFileAtIndex = (index: number) => {\n\t\tif (isControlled && onFilesAttached) {\n\t\t\t// For controlled mode, we need to compute the new array based on current state\n\t\t\t// The parent should ideally use functional updates too, but we pass the filtered array\n\t\t\t// based on the latest controlledFiles prop. If rapid clicks occur before re-render,\n\t\t\t// this could still be stale - parent component should handle batched updates appropriately.\n\t\t\tonFilesAttached(attachedFiles.filter((_, i) => i !== index));\n\t\t} else {\n\t\t\t// Uncontrolled mode: use functional update to ensure we always filter from latest state\n\t\t\tsetInternalFiles((prev) => prev.filter((_, i) => i !== index));\n\t\t}\n\t};\n\n\tconst isCompact = variant === \"compact\";\n\tconst isPublicMode = mode === \"public\";\n\n\t// Get effective allowed file types (default to all if not specified)\n\tconst effectiveAllowedTypes = allowedFileTypes ?? DEFAULT_ALLOWED_FILE_TYPES;\n\n\t// Compute the accept string for the file input\n\tconst acceptString = useMemo(() => {\n\t\tif (effectiveAllowedTypes.length === 0) return \"\";\n\t\tconst mimeTypes = effectiveAllowedTypes.flatMap(\n\t\t\t(type) => FILE_TYPE_MIME_MAP[type] || [],\n\t\t);\n\t\treturn mimeTypes.join(\",\");\n\t}, [effectiveAllowedTypes]);\n\n\t// File uploads are disabled in public mode or if no file types are allowed\n\tconst canUploadFiles =\n\t\tallowAttachments &&\n\t\t!isPublicMode &&\n\t\ttypeof uploadFile === \"function\" &&\n\t\teffectiveAllowedTypes.length > 0;\n\n\tconst handleKeyDown = (e: React.KeyboardEvent) => {\n\t\tif (e.key === \"Enter\" && !e.shiftKey) {\n\t\t\te.preventDefault();\n\t\t\t// Pass files to parent and let parent clear them after processing\n\t\t\thandleSubmit(\n\t\t\t\te as unknown as FormEvent,\n\t\t\t\tattachedFiles.length > 0 ? attachedFiles : undefined,\n\t\t\t);\n\t\t\t// Clear internal files if not controlled\n\t\t\tif (!controlledFiles) {\n\t\t\t\tsetInternalFiles([]);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst uploadSelectedFile = async (file: File) => {\n\t\tconst generation = uploadGeneration.current;\n\t\tconst isCurrentUpload = () => generation === uploadGeneration.current;\n\t\tsetPendingFile(null);\n\t\t// Use uploadFile if available, otherwise create data URL\n\t\tif (uploadFile) {\n\t\t\ttry {\n\t\t\t\tsetIsUploading(true);\n\t\t\t\tconst url = await uploadFile(file);\n\t\t\t\tif (!isCurrentUpload()) return;\n\t\t\t\taddFile({ url, mediaType: file.type, filename: file.name });\n\t\t\t\tnotify.success(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"FILE_UPLOAD_SUCCESS\",\n\t\t\t\t\t\t\"aiChat.files.uploadSuccess\",\n\t\t\t\t\t\t\"File attached\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t} catch (error) {\n\t\t\t\tif (!isCurrentUpload()) return;\n\t\t\t\tconsole.error(\"Failed to upload file:\", error);\n\t\t\t\tnotify.error(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"FILE_UPLOAD_FAILURE\",\n\t\t\t\t\t\t\"aiChat.files.uploadFailure\",\n\t\t\t\t\t\t\"Failed to attach file\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t} finally {\n\t\t\t\tif (isCurrentUpload()) setIsUploading(false);\n\t\t\t}\n\t\t} else {\n\t\t\tsetIsUploading(true);\n\t\t\tconst reader = new FileReader();\n\t\t\treader.onload = (event) => {\n\t\t\t\tif (!isCurrentUpload()) return;\n\t\t\t\tconst dataUrl = event.target?.result as string;\n\t\t\t\taddFile({ url: dataUrl, mediaType: file.type, filename: file.name });\n\t\t\t\tsetIsUploading(false);\n\t\t\t};\n\t\t\treader.onerror = () => {\n\t\t\t\tif (!isCurrentUpload()) return;\n\t\t\t\tnotify.error(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"FILE_UPLOAD_FAILURE\",\n\t\t\t\t\t\t\"aiChat.files.uploadFailure\",\n\t\t\t\t\t\t\"Failed to attach file\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tsetIsUploading(false);\n\t\t\t};\n\t\t\treader.readAsDataURL(file);\n\t\t}\n\t};\n\n\tconst denySelectedFile = () => {\n\t\tsetPendingFile(null);\n\t\tnotify.error(\n\t\t\ttr(\n\t\t\t\t\"FILE_UPLOAD_FAILURE\",\n\t\t\t\t\"aiChat.files.uploadFailure\",\n\t\t\t\t\"You do not have permission to attach this file type\",\n\t\t\t),\n\t\t);\n\t};\n\n\tconst handleFileUpload = (event: React.ChangeEvent) => {\n\t\tconst file = event.target.files?.[0];\n\t\tif (!file) return;\n\n\t\tif (file.size > MAX_FILE_SIZE) {\n\t\t\tnotify.error(\n\t\t\t\ttr(\n\t\t\t\t\t\"FILE_UPLOAD_ERROR_TOO_LARGE\",\n\t\t\t\t\t\"aiChat.files.tooLarge\",\n\t\t\t\t\t\"File must be less than 10MB\",\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tsetPendingFile(file);\n\n\t\t// Reset the input\n\t\tif (fileInputRef.current) {\n\t\t\tfileInputRef.current.value = \"\";\n\t\t}\n\t};\n\n\tconst removeFile = (index: number) => {\n\t\tremoveFileAtIndex(index);\n\t};\n\n\tconst handleFormSubmit = (e: FormEvent) => {\n\t\t// Pass files to parent and let parent clear them after processing\n\t\thandleSubmit(e, attachedFiles.length > 0 ? attachedFiles : undefined);\n\t\t// Clear internal files if not controlled\n\t\tif (!controlledFiles) {\n\t\t\tsetInternalFiles([]);\n\t\t}\n\t};\n\n\t// Check if a file is an image based on media type\n\tconst isImageFile = (mediaType: string) => mediaType.startsWith(\"image/\");\n\n\treturn (\n\t\t<>\n\t\t\t{pendingFile && (\n\t\t\t\t\n\t\t\t\t\t{(state) => (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t)}\n\t\t\t
\n\t\t\t\t{/* Attached Files Preview */}\n\t\t\t\t{attachedFiles.length > 0 && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{attachedFiles.map((file, index) => (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{isImageFile(file.mediaType) ? (\n\t\t\t\t\t\t\t\t\t// Image preview\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t// File preview with icon\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{file.filename}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t removeFile(index)}\n\t\t\t\t\t\t\t\t\tclassName=\"absolute top-0.5 right-0.5 p-0.5 rounded-full bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity\"\n\t\t\t\t\t\t\t\t\taria-label={tr(\n\t\t\t\t\t\t\t\t\t\t\"FILE_REMOVE\",\n\t\t\t\t\t\t\t\t\t\t\"aiChat.files.remove\",\n\t\t\t\t\t\t\t\t\t\t\"Remove file\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t)}\n\n\t\t\t\t{/* Input Area */}\n\t\t\t\t
\n\t\t\t\t\t{/* File Upload Button - hidden in public mode */}\n\t\t\t\t\t{canUploadFiles && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t fileInputRef.current?.click()}\n\t\t\t\t\t\t\t\tdisabled={isLoading || isUploading}\n\t\t\t\t\t\t\t\tclassName={cn(\"shrink-0\", isCompact ? \"h-8 w-8\" : \"h-9 w-9\")}\n\t\t\t\t\t\t\t\taria-label={tr(\n\t\t\t\t\t\t\t\t\t\"FILE_UPLOAD_BUTTON\",\n\t\t\t\t\t\t\t\t\t\"aiChat.files.attach\",\n\t\t\t\t\t\t\t\t\t\"Attach file\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isUploading ? (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* Text Input */}\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, useMemo } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Send, Paperclip, X, Loader2, FileText } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tPermissionCheck,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n} from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../overrides\";\nimport {\n\tDEFAULT_ALLOWED_FILE_TYPES,\n\tFILE_TYPE_MIME_MAP,\n\tresolveAiChatMode,\n} from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\nimport type { FormEvent } from \"react\";\nimport { aiChatPermissions } from \"../../permissions\";\n\n/** Represents an attached file with metadata */\nexport interface AttachedFile {\n\t/** Data URL or uploaded URL */\n\turl: string;\n\t/** MIME type of the file */\n\tmediaType: string;\n\t/** Original filename */\n\tfilename: string;\n}\n\n// Max file size: 10MB\nconst MAX_FILE_SIZE = 10 * 1024 * 1024;\n\ninterface ChatInputProps {\n\tinput?: string;\n\thandleInputChange: (e: React.ChangeEvent) => void;\n\thandleSubmit: (e: FormEvent, files?: AttachedFile[]) => void;\n\tisLoading: boolean;\n\tplaceholder?: string;\n\tvariant?: \"default\" | \"compact\";\n\t/** Callback when files are attached (for controlled mode) */\n\tonFilesAttached?: (files: AttachedFile[]) => void;\n\t/** Attached files (for controlled mode) */\n\tattachedFiles?: AttachedFile[];\n\t/** Browser permission hint for showing attachment controls. */\n\tallowAttachments?: boolean;\n\t/** Rendered owner hints for an exact attachment permission check. */\n\tattachmentPermissionFacts?: {\n\t\tconversationId?: string;\n\t\townerId?: string;\n\t};\n}\n\nfunction AttachmentPermissionEffect({\n\tfile,\n\tcan,\n\tisPending,\n\terror,\n\tonAllowed,\n\tonDenied,\n}: {\n\tfile: File;\n\tcan: boolean;\n\tisPending: boolean;\n\terror?: Error;\n\tonAllowed: (file: File) => void;\n\tonDenied: () => void;\n}) {\n\tuseEffect(() => {\n\t\tif (isPending) return;\n\t\tif (can && !error) onAllowed(file);\n\t\telse onDenied();\n\t}, [can, error, file, isPending, onAllowed, onDenied]);\n\treturn null;\n}\n\nexport function ChatInput({\n\tinput = \"\",\n\thandleInputChange,\n\thandleSubmit,\n\tisLoading,\n\tplaceholder,\n\tvariant = \"default\",\n\tonFilesAttached,\n\tattachedFiles: controlledFiles,\n\tallowAttachments = true,\n\tattachmentPermissionFacts,\n}: ChatInputProps) {\n\tconst {\n\t\tuploadFile,\n\t\tlocalization: customLocalization,\n\t\tallowedFileTypes,\n\t\tclassNames,\n\t} = usePluginOverrides>(\n\t\t\"aiChat\",\n\t\t{},\n\t);\n\tconst { plugins } = useStack();\n\tconst mode = resolveAiChatMode(plugins?.aiChat?.config);\n\n\tconst notify = useNotify();\n\tconst tr = useAiChatTranslation(customLocalization);\n\n\tconst fileInputRef = useRef(null);\n\tconst [internalFiles, setInternalFiles] = useState([]);\n\tconst [isUploading, setIsUploading] = useState(false);\n\tconst [pendingFile, setPendingFile] = useState(null);\n\tconst uploadGeneration = useRef(0);\n\tuseLayoutEffect(\n\t\t() => () => {\n\t\t\tuploadGeneration.current += 1;\n\t\t},\n\t\t[],\n\t);\n\n\t// Use controlled files if provided, otherwise use internal state\n\tconst attachedFiles = controlledFiles ?? internalFiles;\n\tconst isControlled = controlledFiles !== undefined;\n\n\t// Helper to add a file, using functional update for uncontrolled mode\n\tconst addFile = (file: AttachedFile) => {\n\t\tif (isControlled && onFilesAttached) {\n\t\t\t// In controlled mode, parent manages state - pass current + new\n\t\t\tonFilesAttached([...attachedFiles, file]);\n\t\t} else {\n\t\t\t// In uncontrolled mode, use functional update to avoid stale closure\n\t\t\tsetInternalFiles((prev) => [...prev, file]);\n\t\t}\n\t};\n\n\t// Helper to remove a file by index - uses functional update to avoid stale closure\n\tconst removeFileAtIndex = (index: number) => {\n\t\tif (isControlled && onFilesAttached) {\n\t\t\t// For controlled mode, we need to compute the new array based on current state\n\t\t\t// The parent should ideally use functional updates too, but we pass the filtered array\n\t\t\t// based on the latest controlledFiles prop. If rapid clicks occur before re-render,\n\t\t\t// this could still be stale - parent component should handle batched updates appropriately.\n\t\t\tonFilesAttached(attachedFiles.filter((_, i) => i !== index));\n\t\t} else {\n\t\t\t// Uncontrolled mode: use functional update to ensure we always filter from latest state\n\t\t\tsetInternalFiles((prev) => prev.filter((_, i) => i !== index));\n\t\t}\n\t};\n\n\tconst isCompact = variant === \"compact\";\n\tconst isPublicMode = mode === \"public\";\n\n\t// Get effective allowed file types (default to all if not specified)\n\tconst effectiveAllowedTypes = allowedFileTypes ?? DEFAULT_ALLOWED_FILE_TYPES;\n\n\t// Compute the accept string for the file input\n\tconst acceptString = useMemo(() => {\n\t\tif (effectiveAllowedTypes.length === 0) return \"\";\n\t\tconst mimeTypes = effectiveAllowedTypes.flatMap(\n\t\t\t(type) => FILE_TYPE_MIME_MAP[type] || [],\n\t\t);\n\t\treturn mimeTypes.join(\",\");\n\t}, [effectiveAllowedTypes]);\n\n\t// File uploads are disabled in public mode or if no file types are allowed\n\tconst canUploadFiles =\n\t\tallowAttachments &&\n\t\t!isPublicMode &&\n\t\ttypeof uploadFile === \"function\" &&\n\t\teffectiveAllowedTypes.length > 0;\n\n\tconst handleKeyDown = (e: React.KeyboardEvent) => {\n\t\tif (e.key === \"Enter\" && !e.shiftKey) {\n\t\t\te.preventDefault();\n\t\t\t// Pass files to parent and let parent clear them after processing\n\t\t\thandleSubmit(\n\t\t\t\te as unknown as FormEvent,\n\t\t\t\tattachedFiles.length > 0 ? attachedFiles : undefined,\n\t\t\t);\n\t\t\t// Clear internal files if not controlled\n\t\t\tif (!controlledFiles) {\n\t\t\t\tsetInternalFiles([]);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst uploadSelectedFile = async (file: File) => {\n\t\tconst generation = uploadGeneration.current;\n\t\tconst isCurrentUpload = () => generation === uploadGeneration.current;\n\t\tsetPendingFile(null);\n\t\t// Use uploadFile if available, otherwise create data URL\n\t\tif (uploadFile) {\n\t\t\ttry {\n\t\t\t\tsetIsUploading(true);\n\t\t\t\tconst url = await uploadFile(file);\n\t\t\t\tif (!isCurrentUpload()) return;\n\t\t\t\taddFile({ url, mediaType: file.type, filename: file.name });\n\t\t\t\tnotify.success(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"FILE_UPLOAD_SUCCESS\",\n\t\t\t\t\t\t\"aiChat.files.uploadSuccess\",\n\t\t\t\t\t\t\"File attached\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t} catch (error) {\n\t\t\t\tif (!isCurrentUpload()) return;\n\t\t\t\tconsole.error(\"Failed to upload file:\", error);\n\t\t\t\tnotify.error(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"FILE_UPLOAD_FAILURE\",\n\t\t\t\t\t\t\"aiChat.files.uploadFailure\",\n\t\t\t\t\t\t\"Failed to attach file\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t} finally {\n\t\t\t\tif (isCurrentUpload()) setIsUploading(false);\n\t\t\t}\n\t\t} else {\n\t\t\tsetIsUploading(true);\n\t\t\tconst reader = new FileReader();\n\t\t\treader.onload = (event) => {\n\t\t\t\tif (!isCurrentUpload()) return;\n\t\t\t\tconst dataUrl = event.target?.result as string;\n\t\t\t\taddFile({ url: dataUrl, mediaType: file.type, filename: file.name });\n\t\t\t\tsetIsUploading(false);\n\t\t\t};\n\t\t\treader.onerror = () => {\n\t\t\t\tif (!isCurrentUpload()) return;\n\t\t\t\tnotify.error(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"FILE_UPLOAD_FAILURE\",\n\t\t\t\t\t\t\"aiChat.files.uploadFailure\",\n\t\t\t\t\t\t\"Failed to attach file\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tsetIsUploading(false);\n\t\t\t};\n\t\t\treader.readAsDataURL(file);\n\t\t}\n\t};\n\n\tconst denySelectedFile = () => {\n\t\tsetPendingFile(null);\n\t\tnotify.error(\n\t\t\ttr(\n\t\t\t\t\"FILE_UPLOAD_FAILURE\",\n\t\t\t\t\"aiChat.files.uploadFailure\",\n\t\t\t\t\"You do not have permission to attach this file type\",\n\t\t\t),\n\t\t);\n\t};\n\n\tconst handleFileUpload = (event: React.ChangeEvent) => {\n\t\tconst file = event.target.files?.[0];\n\t\tif (!file) return;\n\n\t\tif (file.size > MAX_FILE_SIZE) {\n\t\t\tnotify.error(\n\t\t\t\ttr(\n\t\t\t\t\t\"FILE_UPLOAD_ERROR_TOO_LARGE\",\n\t\t\t\t\t\"aiChat.files.tooLarge\",\n\t\t\t\t\t\"File must be less than 10MB\",\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tsetPendingFile(file);\n\n\t\t// Reset the input\n\t\tif (fileInputRef.current) {\n\t\t\tfileInputRef.current.value = \"\";\n\t\t}\n\t};\n\n\tconst removeFile = (index: number) => {\n\t\tremoveFileAtIndex(index);\n\t};\n\n\tconst handleFormSubmit = (e: FormEvent) => {\n\t\t// Pass files to parent and let parent clear them after processing\n\t\thandleSubmit(e, attachedFiles.length > 0 ? attachedFiles : undefined);\n\t\t// Clear internal files if not controlled\n\t\tif (!controlledFiles) {\n\t\t\tsetInternalFiles([]);\n\t\t}\n\t};\n\n\t// Check if a file is an image based on media type\n\tconst isImageFile = (mediaType: string) => mediaType.startsWith(\"image/\");\n\n\treturn (\n\t\t<>\n\t\t\t{pendingFile && (\n\t\t\t\t\n\t\t\t\t\t{(state) => (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t)}\n\t\t\t\n\t\t\t\t{/* Attached Files Preview */}\n\t\t\t\t{attachedFiles.length > 0 && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{attachedFiles.map((file, index) => (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{isImageFile(file.mediaType) ? (\n\t\t\t\t\t\t\t\t\t// Image preview\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t// File preview with icon\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{file.filename}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t removeFile(index)}\n\t\t\t\t\t\t\t\t\tclassName=\"absolute top-0.5 right-0.5 p-0.5 rounded-full bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity\"\n\t\t\t\t\t\t\t\t\taria-label={tr(\n\t\t\t\t\t\t\t\t\t\t\"FILE_REMOVE\",\n\t\t\t\t\t\t\t\t\t\t\"aiChat.files.remove\",\n\t\t\t\t\t\t\t\t\t\t\"Remove file\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t)}\n\n\t\t\t\t{/* Input Area */}\n\t\t\t\t
\n\t\t\t\t\t{/* File Upload Button - hidden in public mode */}\n\t\t\t\t\t{canUploadFiles && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t fileInputRef.current?.click()}\n\t\t\t\t\t\t\t\tdisabled={isLoading || isUploading}\n\t\t\t\t\t\t\t\tclassName={cn(\"shrink-0\", isCompact ? \"h-8 w-8\" : \"h-9 w-9\")}\n\t\t\t\t\t\t\t\taria-label={tr(\n\t\t\t\t\t\t\t\t\t\"FILE_UPLOAD_BUTTON\",\n\t\t\t\t\t\t\t\t\t\"aiChat.files.attach\",\n\t\t\t\t\t\t\t\t\t\"Attach file\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isUploading ? (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* Text Input */}\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/ai-chat/client/components/chat-input.tsx" }, { "path": "btst/ai-chat/client/components/chat-interface.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useChat } from \"@ai-sdk/react\";\nimport {\n\tuseEffect,\n\tuseRef,\n\tuseState,\n\tuseMemo,\n\tuseCallback,\n\tuseLayoutEffect,\n\tuseId,\n} from \"react\";\nimport { hashKey, useQueryClient } from \"@tanstack/react-query\";\nimport { ChatMessage } from \"./chat-message\";\nimport { ChatInput, type AttachedFile } from \"./chat-input\";\nimport { StackAttribution } from \"@/components/ui/stack-attribution\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n\tDefaultChatTransport,\n\tlastAssistantMessageIsCompleteWithToolCalls,\n\ttype UIMessage,\n} from \"ai\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tPermissionCheck,\n\tusePluginOverrides,\n\tuseStack,\n} from \"@btst/stack/context\";\nimport { aiChatPermissions } from \"../../permissions\";\nimport {\n\tresolveAiChatApiUrl,\n\tresolveAiChatMode,\n\tresolveAiChatSiteLocation,\n\ttype AiChatPluginOverrides,\n} from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\nimport { createApiClient } from \"@btst/stack/plugins/client\";\nimport type { AiChatApiRouter } from \"@btst/stack/plugins/ai-chat/api\";\nimport { aiChatIdentityKey, createAiChatQueryKeys } from \"@btst/stack/plugins/ai-chat/api\";\nimport {\n\tuseConversation,\n\tuseConversations,\n\tuseAiChatIdentityPartition,\n\ttype SerializedMessage,\n} from \"@btst/stack/plugins/ai-chat/client/hooks\";\nimport { usePageAIContext } from \"../context/page-ai-context\";\nimport { navigateAiChatCrossOrigin } from \"../navigation\";\n\ninterface ChatInterfaceProps {\n\tinitialMessages?: UIMessage[];\n\tid?: string;\n\t/** Variant: 'full' for full-page layout, 'widget' for embedded widget */\n\tvariant?: \"full\" | \"widget\";\n\tclassName?: string;\n\t/** Called whenever messages change (for persistence). Only fires in public mode. */\n\tonMessagesChange?: (messages: UIMessage[]) => void;\n}\n\ntype ChatAction = \"send\" | \"edit\" | \"retry\";\n\nfunction reconcilePersistedMessageIds(\n\tmessages: UIMessage[],\n\tpersistedMessages: SerializedMessage[],\n): UIMessage[] | null {\n\tconst userMessages = messages.filter((message) => message.role === \"user\");\n\tconst persistedUserMessages = persistedMessages.filter(\n\t\t(message) => message.role === \"user\",\n\t);\n\tif (persistedUserMessages.length !== userMessages.length) return null;\n\tconst persistedIds = new Map();\n\tfor (let index = 0; index < userMessages.length; index++) {\n\t\tconst message = userMessages[index];\n\t\tconst persisted = persistedUserMessages[index];\n\t\tif (!message || !persisted) return null;\n\t\tconst serializedParts = JSON.stringify(message.parts);\n\t\tconst legacyText =\n\t\t\tmessage.parts.length === 1 && message.parts[0]?.type === \"text\"\n\t\t\t\t? message.parts[0].text\n\t\t\t\t: undefined;\n\t\tif (\n\t\t\tpersisted.content !== serializedParts &&\n\t\t\tpersisted.content !== legacyText\n\t\t) {\n\t\t\treturn null;\n\t\t}\n\t\tpersistedIds.set(message, persisted.id);\n\t}\n\treturn messages.map((message) => ({\n\t\t...message,\n\t\tid: persistedIds.get(message) ?? message.id,\n\t}));\n}\n\nfunction persistedMessagesToUiMessages(\n\tmessages: readonly SerializedMessage[],\n): UIMessage[] {\n\treturn messages\n\t\t.filter((message) => message.role !== \"data\")\n\t\t.map((message) => {\n\t\t\tlet parts: UIMessage[\"parts\"];\n\t\t\ttry {\n\t\t\t\tconst parsed = JSON.parse(message.content);\n\t\t\t\tparts = Array.isArray(parsed)\n\t\t\t\t\t? parsed\n\t\t\t\t\t: [{ type: \"text\" as const, text: message.content }];\n\t\t\t} catch {\n\t\t\t\tparts = [{ type: \"text\" as const, text: message.content }];\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tid: message.id,\n\t\t\t\trole: message.role as \"user\" | \"assistant\" | \"system\",\n\t\t\t\tparts,\n\t\t\t};\n\t\t});\n}\n\nfunction ChatActionCheck({\n\tpublicMode,\n\taction,\n\tconversationId,\n\townerId,\n\tmessageId,\n\ttoolNames = [],\n\trouteName,\n\tchildren,\n}: {\n\tpublicMode: boolean;\n\taction: ChatAction;\n\tconversationId?: string;\n\townerId?: string;\n\tmessageId?: string;\n\ttoolNames?: readonly string[];\n\trouteName?: string;\n\tchildren: (allowed: boolean) => React.ReactNode;\n}) {\n\tif (publicMode) return <>{children(true)};\n\tconst base = {\n\t\t...(conversationId ? { conversationId } : {}),\n\t\t...(ownerId ? { ownerId } : {}),\n\t};\n\tconst streamPermission = aiChatPermissions.stream.start({\n\t\t...base,\n\t\tcreatesConversation: !conversationId,\n\t\tintent: action,\n\t});\n\tconst messagePermission =\n\t\taction === \"send\"\n\t\t\t? aiChatPermissions.message.send({\n\t\t\t\t\t...base,\n\t\t\t\t\tcreatesConversation: !conversationId,\n\t\t\t\t})\n\t\t\t: action === \"edit\" && conversationId && messageId\n\t\t\t\t? aiChatPermissions.message.edit({\n\t\t\t\t\t\tconversationId,\n\t\t\t\t\t\t...(ownerId ? { ownerId } : {}),\n\t\t\t\t\t\tmessageId,\n\t\t\t\t\t})\n\t\t\t\t: action === \"retry\" && conversationId && messageId\n\t\t\t\t\t? aiChatPermissions.message.retry({\n\t\t\t\t\t\t\tconversationId,\n\t\t\t\t\t\t\t...(ownerId ? { ownerId } : {}),\n\t\t\t\t\t\t\tmessageId,\n\t\t\t\t\t\t})\n\t\t\t\t\t: null;\n\tif (!messagePermission) return <>{children(false)};\n\n\treturn (\n\t\t\n\t\t\t{(streamState) => (\n\t\t\t\t\n\t\t\t\t\t{(messageState) => {\n\t\t\t\t\t\tconst baseAllowed =\n\t\t\t\t\t\t\tstreamState.can &&\n\t\t\t\t\t\t\t!streamState.isPending &&\n\t\t\t\t\t\t\t!streamState.error &&\n\t\t\t\t\t\t\tmessageState.can &&\n\t\t\t\t\t\t\t!messageState.isPending &&\n\t\t\t\t\t\t\t!messageState.error;\n\t\t\t\t\t\tconst renderToolCheck = (allowed: boolean) => {\n\t\t\t\t\t\t\tif (!allowed || toolNames.length === 0) return children(allowed);\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{(toolState) =>\n\t\t\t\t\t\t\t\t\t\tchildren(\n\t\t\t\t\t\t\t\t\t\t\ttoolState.can && !toolState.isPending && !toolState.error,\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t};\n\t\t\t\t\t\tif (action !== \"send\" || conversationId) {\n\t\t\t\t\t\t\treturn renderToolCheck(Boolean(baseAllowed));\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{(createState) =>\n\t\t\t\t\t\t\t\t\trenderToolCheck(\n\t\t\t\t\t\t\t\t\t\tBoolean(baseAllowed) &&\n\t\t\t\t\t\t\t\t\t\t\tcreateState.can &&\n\t\t\t\t\t\t\t\t\t\t\t!createState.isPending &&\n\t\t\t\t\t\t\t\t\t\t\t!createState.error,\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t);\n\t\t\t\t\t}}\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n\nfunction PermissionedChatMessage({\n\tpublicMode,\n\taction,\n\tconversationId,\n\townerId,\n\tmessageId,\n\t...messageProps\n}: React.ComponentProps & {\n\tpublicMode: boolean;\n\taction?: \"edit\" | \"retry\";\n\tconversationId?: string;\n\townerId?: string;\n\tmessageId?: string;\n}) {\n\tif (!action) return ;\n\treturn (\n\t\t\n\t\t\t{(allowed) => (\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n\nexport function ChatInterface({\n\tinitialMessages,\n\tid,\n\tvariant = \"full\",\n\tclassName,\n\tonMessagesChange,\n}: ChatInterfaceProps) {\n\tconst {\n\t\tlocalization: customLocalization,\n\t\tshowAttribution,\n\t\tchatSuggestions,\n\t} = usePluginOverrides>(\n\t\t\"aiChat\",\n\t\t{ showAttribution: true },\n\t);\n\tconst {\n\t\tapi,\n\t\tbasePath: stackBasePath,\n\t\tplugins,\n\t\tqueryClient: stackQueryClient,\n\t} = useStack();\n\tconst aiChatApi = plugins?.aiChat?.api;\n\tconst apiBaseURL = aiChatApi?.baseURL ?? api?.baseURL;\n\tconst apiBasePath = aiChatApi?.basePath ?? api?.basePath;\n\tconst browserHeaders = aiChatApi?.browserHeaders;\n\tconst credentials = aiChatApi?.credentials;\n\tconst resolvedApiPath = resolveAiChatApiUrl(apiBaseURL, apiBasePath);\n\tconst mode = resolveAiChatMode(plugins?.aiChat?.config);\n\tconst siteBaseURL = plugins?.aiChat?.site.baseURL;\n\tconst siteBasePath = plugins?.aiChat?.site.basePath ?? stackBasePath;\n\tconst isPublicMode = mode === \"public\";\n\n\t// Read page AI context registered by the current page\n\tconst pageAIContext = usePageAIContext();\n\n\tconst tr = useAiChatTranslation(customLocalization);\n\tconst queryClient = useQueryClient(stackQueryClient);\n\tconst identityPartition = useAiChatIdentityPartition();\n\tconst chatInstanceId = useId();\n\tconst identityPartitionKey = hashKey([aiChatIdentityKey(identityPartition)]);\n\tconst latestIdentityPartitionKey = useRef(identityPartitionKey);\n\tconst identitySessionGeneration = useRef(0);\n\tconst [identitySessionVersion, setIdentitySessionVersion] = useState(0);\n\tconst activeStreamPartitionKey = useRef(undefined);\n\tconst nextStreamRequestGeneration = useRef(0);\n\tconst latestStreamRequestGeneration = useRef(0);\n\tconst pendingStreamRequests = useRef<\n\t\tArray<{ generation: number; identityPartitionKey: string }>\n\t>([]);\n\tconst latestMessages = useRef([]);\n\n\tconst conversationsListQueryKey = useMemo(() => {\n\t\t// In public mode, we don't need conversation queries\n\t\tif (isPublicMode) return [\"ai-chat\", \"disabled\"];\n\t\tconst client = createApiClient({\n\t\t\tbaseURL: apiBaseURL,\n\t\t\tbasePath: apiBasePath,\n\t\t\tcredentials,\n\t\t});\n\t\tconst queries = createAiChatQueryKeys(client, browserHeaders);\n\t\treturn queries.conversations.list(identityPartition).queryKey;\n\t}, [\n\t\tapiBaseURL,\n\t\tapiBasePath,\n\t\tbrowserHeaders,\n\t\tcredentials,\n\t\tidentityPartition,\n\t\tisPublicMode,\n\t]);\n\tconst latestIdentityPartition = useRef(identityPartition);\n\tconst latestConversationsListQueryKey = useRef(conversationsListQueryKey);\n\tconst latestHeaders = useRef(browserHeaders);\n\n\t// Track the current conversation ID - initialized from prop, updated after first message\n\t// In public mode, we don't track conversation IDs\n\tconst [currentConversationId, setCurrentConversationId] = useState<\n\t\tstring | undefined\n\t>(isPublicMode ? undefined : id);\n\t// Track if we've sent the first message on a new chat (to trigger navigation)\n\tconst isFirstMessageSentRef = useRef(false);\n\tconst hasNavigatedRef = useRef(false);\n\n\t// Update currentConversationId when id prop changes (e.g., navigating to different conversation)\n\tuseEffect(() => {\n\t\tif (!isPublicMode) {\n\t\t\tsetCurrentConversationId(id);\n\t\t\tisFirstMessageSentRef.current = false;\n\t\t\thasNavigatedRef.current = false;\n\t\t\t// Reset edit flag on navigation\n\t\t\tisEditInProgressRef.current = false;\n\t\t}\n\t}, [id, isPublicMode]);\n\n\t// Fetch existing conversation messages when id is provided (authenticated mode only)\n\tconst { conversation, isLoading: isLoadingConversation } = useConversation(\n\t\tid,\n\t\t{ enabled: !!id && !isPublicMode },\n\t);\n\n\t// Fetch conversations list for navigation after first message (authenticated mode only)\n\tconst { conversations } = useConversations({ enabled: !isPublicMode });\n\tconst currentConversationOwnerId =\n\t\tconversation?.userId ??\n\t\tconversations.find((item) => item.id === currentConversationId)?.userId ??\n\t\t(!isPublicMode &&\n\t\tcurrentConversationId &&\n\t\ttypeof identityPartition === \"object\"\n\t\t\t? identityPartition.id\n\t\t\t: undefined);\n\n\t// Use a ref to track the conversation ID for the transport body\n\t// This ensures the transport always uses the latest value\n\t// In public mode, always undefined\n\tconst conversationIdRef = useRef(\n\t\tisPublicMode ? undefined : id,\n\t);\n\tuseEffect(() => {\n\t\tif (!isPublicMode) {\n\t\t\tconversationIdRef.current = currentConversationId;\n\t\t}\n\t}, [currentConversationId, isPublicMode]);\n\n\t// Ref to track edit operation with messages to use\n\tconst editMessagesRef = useRef(null);\n\n\t// Flag to prevent load conversation effect from overwriting messages during edit\n\tconst isEditInProgressRef = useRef(false);\n\n\t// Track if we've finished initializing messages\n\t// This prevents onMessagesChange from firing with an empty array before initialMessages are loaded\n\t// Without this guard, the effect would fire on mount with [], overwriting any saved messages\n\tconst [isMessagesInitialized, setIsMessagesInitialized] = useState(\n\t\t() =>\n\t\t\t// Start as initialized if there are no initialMessages to load\n\t\t\t!initialMessages || initialMessages.length === 0,\n\t);\n\tconst [input, setInput] = useState(\"\");\n\tconst [attachedFiles, setAttachedFiles] = useState([]);\n\tconst [pendingEdit, setPendingEdit] = useState<{\n\t\ttext: string;\n\t\texpectedLength: number;\n\t\tidentityPartitionKey: string;\n\t\tstreamRequestGeneration: number;\n\t} | null>(null);\n\tconst [historySyncError, setHistorySyncError] = useState(null);\n\tconst [isHistorySyncRetrying, setIsHistorySyncRetrying] = useState(false);\n\tuseEffect(() => {\n\t\tsetHistorySyncError(null);\n\t\tsetIsHistorySyncRetrying(false);\n\t}, [id, isPublicMode]);\n\n\t// Ref to always have the latest pageAIContext in the transport callback\n\t// without recreating the transport on every context change\n\tconst pageAIContextRef = useRef(pageAIContext);\n\tuseEffect(() => {\n\t\tpageAIContextRef.current = pageAIContext;\n\t}, [pageAIContext]);\n\n\t// Memoize the transport to prevent recreation on every render\n\tconst transport = useMemo(() => {\n\t\tconst trackedFetch = Object.assign(\n\t\t\tasync (\n\t\t\t\trequest: Parameters[0],\n\t\t\t\tinit?: Parameters[1],\n\t\t\t) => {\n\t\t\t\tconst requestIdentityPartitionKey = latestIdentityPartitionKey.current;\n\t\t\t\tconst requestGeneration = latestStreamRequestGeneration.current;\n\t\t\t\tconst response = await globalThis.fetch(request, init);\n\t\t\t\tif (\n\t\t\t\t\t!isPublicMode &&\n\t\t\t\t\trequestIdentityPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\t\t\trequestGeneration === latestStreamRequestGeneration.current\n\t\t\t\t) {\n\t\t\t\t\tconst conversationId = response.headers.get(\"x-conversation-id\");\n\t\t\t\t\tif (conversationId) conversationIdRef.current = conversationId;\n\t\t\t\t}\n\t\t\t\treturn response;\n\t\t\t},\n\t\t\tglobalThis.fetch,\n\t\t) as typeof globalThis.fetch;\n\t\treturn new DefaultChatTransport({\n\t\t\tapi: resolvedApiPath,\n\t\t\theaders: browserHeaders,\n\t\t\tcredentials,\n\t\t\tfetch: trackedFetch,\n\t\t\t// In public mode, don't send conversationId\n\t\t\tbody: isPublicMode\n\t\t\t\t? undefined\n\t\t\t\t: () => ({ conversationId: conversationIdRef.current }),\n\t\t\t// Handle edit operations and inject page context\n\t\t\tprepareSendMessagesRequest: ({ messages: hookMessages }) => {\n\t\t\t\tif (!isPublicMode) {\n\t\t\t\t\tconst generation = ++nextStreamRequestGeneration.current;\n\t\t\t\t\tlatestStreamRequestGeneration.current = generation;\n\t\t\t\t\tconst request = {\n\t\t\t\t\t\tgeneration,\n\t\t\t\t\t\tidentityPartitionKey: latestIdentityPartitionKey.current,\n\t\t\t\t\t};\n\t\t\t\t\tpendingStreamRequests.current.push(request);\n\t\t\t\t\tactiveStreamPartitionKey.current = request.identityPartitionKey;\n\t\t\t\t}\n\t\t\t\tconst currentPageContext = pageAIContextRef.current;\n\n\t\t\t\t// Build page context fields to include in every request\n\t\t\t\tconst pageContextBody = currentPageContext?.pageDescription\n\t\t\t\t\t? {\n\t\t\t\t\t\t\tpageContext: currentPageContext.pageDescription,\n\t\t\t\t\t\t\tavailableTools: Object.keys(currentPageContext.clientTools ?? {}),\n\t\t\t\t\t\t\trouteName: currentPageContext.routeName,\n\t\t\t\t\t\t}\n\t\t\t\t\t: {};\n\n\t\t\t\t// If we're in an edit operation, use the truncated messages + new user message\n\t\t\t\tif (editMessagesRef.current !== null) {\n\t\t\t\t\tconst newUserMessage = hookMessages[hookMessages.length - 1];\n\t\t\t\t\tconst messagesToSend = [...editMessagesRef.current];\n\t\t\t\t\tif (newUserMessage) {\n\t\t\t\t\t\tmessagesToSend.push(newUserMessage);\n\t\t\t\t\t}\n\t\t\t\t\t// Clear the ref after use\n\t\t\t\t\teditMessagesRef.current = null;\n\t\t\t\t\treturn {\n\t\t\t\t\t\tbody: {\n\t\t\t\t\t\t\tmessages: messagesToSend,\n\t\t\t\t\t\t\tconversationId: conversationIdRef.current,\n\t\t\t\t\t\t\t...pageContextBody,\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\t// Normal case - use the messages as-is\n\t\t\t\treturn {\n\t\t\t\t\tbody: {\n\t\t\t\t\t\tmessages: hookMessages,\n\t\t\t\t\t\tconversationId: conversationIdRef.current,\n\t\t\t\t\t\t...pageContextBody,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t},\n\t\t});\n\t}, [browserHeaders, credentials, isPublicMode, resolvedApiPath]);\n\n\t// Use a ref so addToolOutput is always current inside the onToolCall closure\n\tconst addToolOutputRef = useRef<\n\t\tReturnType[\"addToolOutput\"] | null\n\t>(null);\n\n\tconst {\n\t\tmessages,\n\t\tsendMessage,\n\t\tstatus,\n\t\terror,\n\t\tsetMessages,\n\t\tregenerate,\n\t\taddToolOutput,\n\t\tstop,\n\t} = useChat({\n\t\tid: `${chatInstanceId}:${isPublicMode ? \"public\" : identitySessionVersion}`,\n\t\ttransport,\n\t\t// Automatically resubmit after all client-side tool results are provided\n\t\tsendAutomaticallyWhen: (options) =>\n\t\t\t(isPublicMode ||\n\t\t\t\tidentitySessionVersion === identitySessionGeneration.current) &&\n\t\t\tlastAssistantMessageIsCompleteWithToolCalls(options),\n\t\tonToolCall: async ({ toolCall }) => {\n\t\t\tconst toolRequest = pendingStreamRequests.current.at(-1);\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\t(identitySessionVersion !== identitySessionGeneration.current ||\n\t\t\t\t\t!toolRequest ||\n\t\t\t\t\ttoolRequest.identityPartitionKey !==\n\t\t\t\t\t\tlatestIdentityPartitionKey.current ||\n\t\t\t\t\ttoolRequest.generation !== latestStreamRequestGeneration.current ||\n\t\t\t\t\tactiveStreamPartitionKey.current !== toolRequest.identityPartitionKey)\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst toolIdentityPartitionKey =\n\t\t\t\ttoolRequest?.identityPartitionKey ?? latestIdentityPartitionKey.current;\n\t\t\tconst toolStreamRequestGeneration =\n\t\t\t\ttoolRequest?.generation ?? latestStreamRequestGeneration.current;\n\t\t\tconst toolRequestIsCurrent = () =>\n\t\t\t\tisPublicMode ||\n\t\t\t\t(identitySessionVersion === identitySessionGeneration.current &&\n\t\t\t\t\ttoolIdentityPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\t\t\ttoolStreamRequestGeneration ===\n\t\t\t\t\t\tlatestStreamRequestGeneration.current);\n\t\t\t// Dispatch client-side tool calls to the handler registered by the current page.\n\t\t\t// In AI SDK v5, onToolCall returns void — addToolOutput must be called explicitly.\n\t\t\tconst toolName = toolCall.toolName;\n\t\t\tconst handler = pageAIContextRef.current?.clientTools?.[toolName];\n\t\t\tif (handler) {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await handler(toolCall.input);\n\t\t\t\t\tif (!toolRequestIsCurrent()) return;\n\t\t\t\t\t// No await — avoids potential deadlocks with sendAutomaticallyWhen\n\t\t\t\t\taddToolOutputRef.current?.({\n\t\t\t\t\t\ttool: toolName,\n\t\t\t\t\t\ttoolCallId: toolCall.toolCallId,\n\t\t\t\t\t\toutput: result,\n\t\t\t\t\t});\n\t\t\t\t} catch (err) {\n\t\t\t\t\tif (!toolRequestIsCurrent()) return;\n\t\t\t\t\taddToolOutputRef.current?.({\n\t\t\t\t\t\ttool: toolName,\n\t\t\t\t\t\ttoolCallId: toolCall.toolCallId,\n\t\t\t\t\t\tstate: \"output-error\",\n\t\t\t\t\t\terrorText:\n\t\t\t\t\t\t\terr instanceof Error\n\t\t\t\t\t\t\t\t? err.message\n\t\t\t\t\t\t\t\t: tr(\n\t\t\t\t\t\t\t\t\t\t\"TOOL_EXECUTION_FAILED\",\n\t\t\t\t\t\t\t\t\t\t\"aiChat.tools.executionFailed\",\n\t\t\t\t\t\t\t\t\t\t\"Tool execution failed\",\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// No handler found — this happens when the user navigates away while a\n\t\t\t\t// tool-call response is streaming and the page context changes. Always\n\t\t\t\t// call addToolOutput so sendAutomaticallyWhen can unblock; without this\n\t\t\t\t// the conversation gets permanently stuck waiting for a missing output.\n\t\t\t\taddToolOutputRef.current?.({\n\t\t\t\t\ttool: toolName,\n\t\t\t\t\ttoolCallId: toolCall.toolCallId,\n\t\t\t\t\tstate: \"output-error\",\n\t\t\t\t\terrorText: tr(\n\t\t\t\t\t\t\"TOOL_HANDLER_MISSING\",\n\t\t\t\t\t\t\"aiChat.tools.handlerMissing\",\n\t\t\t\t\t\t'No client-side handler registered for tool \"{{toolName}}\". The page context may have changed while the response was streaming.',\n\t\t\t\t\t\t{ toolName },\n\t\t\t\t\t),\n\t\t\t\t});\n\t\t\t}\n\t\t},\n\t\tonError: (err) => {\n\t\t\tconsole.error(\"useChat onError:\", err);\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\tidentitySessionVersion !== identitySessionGeneration.current\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst failedRequest = pendingStreamRequests.current[0];\n\t\t\tconst failedPartitionKey =\n\t\t\t\tfailedRequest?.identityPartitionKey ?? activeStreamPartitionKey.current;\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\tfailedPartitionKey !== latestIdentityPartitionKey.current\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// AI SDK invokes onFinish after onError. Keep this request queued so\n\t\t\t// onFinish can reconcile a user message that the backend persisted before\n\t\t\t// the provider or response stream failed.\n\t\t},\n\t\tonFinish: async () => {\n\t\t\t// In public mode, skip all persistence-related operations\n\t\t\tif (isPublicMode) {\n\t\t\t\tactiveStreamPartitionKey.current = undefined;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (identitySessionVersion !== identitySessionGeneration.current) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst finishedRequest = pendingStreamRequests.current.shift();\n\t\t\tconst finishingPartitionKey =\n\t\t\t\tfinishedRequest?.identityPartitionKey ??\n\t\t\t\tactiveStreamPartitionKey.current;\n\t\t\tif (finishingPartitionKey !== latestIdentityPartitionKey.current) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (\n\t\t\t\t!finishedRequest ||\n\t\t\t\tfinishedRequest.generation === latestStreamRequestGeneration.current\n\t\t\t) {\n\t\t\t\tactiveStreamPartitionKey.current = undefined;\n\t\t\t}\n\t\t\tconst identityIsCurrent = () =>\n\t\t\t\tfinishingPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\t\t(!finishedRequest ||\n\t\t\t\t\tfinishedRequest.generation === latestStreamRequestGeneration.current);\n\t\t\tconst finishingIdentityPartition = latestIdentityPartition.current;\n\t\t\tconst finishingConversationsListQueryKey =\n\t\t\t\tlatestConversationsListQueryKey.current;\n\t\t\tconst finishingHeaders = latestHeaders.current;\n\n\t\t\ttry {\n\t\t\t\t// Invalidate conversation list to show new/updated conversations\n\t\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: finishingConversationsListQueryKey,\n\t\t\t\t});\n\t\t\t\tif (!identityIsCurrent()) return;\n\n\t\t\t\t// If this was the first message on a new chat, update the URL without full navigation\n\t\t\t\t// This avoids losing the in-memory messages during component remount\n\t\t\t\tif (isFirstMessageSentRef.current && !hasNavigatedRef.current) {\n\t\t\t\t\tconst discoveredConversationId = conversationIdRef.current;\n\t\t\t\t\tif (discoveredConversationId) {\n\t\t\t\t\t\thasNavigatedRef.current = true;\n\t\t\t\t\t\tsetCurrentConversationId(discoveredConversationId);\n\t\t\t\t\t\tconversationIdRef.current = discoveredConversationId;\n\t\t\t\t\t\t// Only update the URL in full-page mode; in widget mode the chat is\n\t\t\t\t\t\t// embedded in another page and clobbering the URL is disruptive.\n\t\t\t\t\t\tif (variant === \"full\") {\n\t\t\t\t\t\t\tconst newLocation = resolveAiChatSiteLocation(\n\t\t\t\t\t\t\t\t{ baseURL: siteBaseURL, basePath: siteBasePath },\n\t\t\t\t\t\t\t\ttypeof window === \"undefined\"\n\t\t\t\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t\t\t\t: window.location.origin,\n\t\t\t\t\t\t\t\t\"chat\",\n\t\t\t\t\t\t\t\tdiscoveredConversationId,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tif (typeof window !== \"undefined\") {\n\t\t\t\t\t\t\t\tif (newLocation.crossOrigin) {\n\t\t\t\t\t\t\t\t\tnavigateAiChatCrossOrigin(newLocation.href, {\n\t\t\t\t\t\t\t\t\t\treplace: true,\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\twindow.history.replaceState(\n\t\t\t\t\t\t\t\t\t\t{ ...window.history.state },\n\t\t\t\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\t\t\t\tnewLocation.path,\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst persistedConversationId = conversationIdRef.current;\n\t\t\t\tif (!persistedConversationId || !identityIsCurrent()) {\n\t\t\t\t\tif (isFirstMessageSentRef.current) {\n\t\t\t\t\t\tisFirstMessageSentRef.current = false;\n\t\t\t\t\t\thasNavigatedRef.current = false;\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t\"The persisted conversation id could not be discovered.\",\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst client = createApiClient({\n\t\t\t\t\tbaseURL: apiBaseURL,\n\t\t\t\t\tbasePath: apiBasePath,\n\t\t\t\t\tcredentials,\n\t\t\t\t});\n\t\t\t\tconst detailQuery = createAiChatQueryKeys(\n\t\t\t\t\tclient,\n\t\t\t\t\tfinishingHeaders,\n\t\t\t\t).conversations.detail(\n\t\t\t\t\tpersistedConversationId,\n\t\t\t\t\tfinishingIdentityPartition,\n\t\t\t\t);\n\t\t\t\tawait queryClient.cancelQueries({\n\t\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\t\texact: true,\n\t\t\t\t});\n\t\t\t\tif (!identityIsCurrent()) return;\n\t\t\t\tqueryClient.removeQueries({\n\t\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\t\texact: true,\n\t\t\t\t});\n\t\t\t\tconst persistedConversation = await queryClient.fetchQuery({\n\t\t\t\t\t...detailQuery,\n\t\t\t\t\tstaleTime: 0,\n\t\t\t\t});\n\t\t\t\tif (!identityIsCurrent() || !persistedConversation) return;\n\t\t\t\tconst reconciled = reconcilePersistedMessageIds(\n\t\t\t\t\tlatestMessages.current,\n\t\t\t\t\tpersistedConversation.messages,\n\t\t\t\t);\n\t\t\t\tif (!reconciled) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\"Persisted chat history did not match the streamed transcript.\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tsetMessages(reconciled);\n\t\t\t\tsetHistorySyncError(null);\n\t\t\t} catch (error) {\n\t\t\t\tif (!identityIsCurrent()) return;\n\t\t\t\tconst syncError =\n\t\t\t\t\terror instanceof Error ? error : new Error(String(error));\n\t\t\t\tconsole.error(\"Failed to reconcile persisted chat history:\", syncError);\n\t\t\t\tsetHistorySyncError(syncError);\n\t\t\t\tsetMessages([]);\n\t\t\t}\n\t\t},\n\t});\n\tconst handleHistorySyncRetry = useCallback(async () => {\n\t\tif (isPublicMode || isHistorySyncRetrying) return;\n\t\tconst retryConversationId = conversationIdRef.current;\n\t\tif (!retryConversationId) {\n\t\t\tsetHistorySyncError(null);\n\t\t\treturn;\n\t\t}\n\n\t\tconst retryPartitionKey = latestIdentityPartitionKey.current;\n\t\tconst retrySessionGeneration = identitySessionGeneration.current;\n\t\tconst retryIsCurrent = () =>\n\t\t\tretryPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\tretrySessionGeneration === identitySessionGeneration.current &&\n\t\t\tretryConversationId === conversationIdRef.current;\n\t\tsetIsHistorySyncRetrying(true);\n\t\ttry {\n\t\t\tconst client = createApiClient({\n\t\t\t\tbaseURL: apiBaseURL,\n\t\t\t\tbasePath: apiBasePath,\n\t\t\t\tcredentials,\n\t\t\t});\n\t\t\tconst detailQuery = createAiChatQueryKeys(\n\t\t\t\tclient,\n\t\t\t\tlatestHeaders.current,\n\t\t\t).conversations.detail(\n\t\t\t\tretryConversationId,\n\t\t\t\tlatestIdentityPartition.current,\n\t\t\t);\n\t\t\tawait queryClient.cancelQueries({\n\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\texact: true,\n\t\t\t});\n\t\t\tif (!retryIsCurrent()) return;\n\t\t\tqueryClient.removeQueries({\n\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\texact: true,\n\t\t\t});\n\t\t\tconst persistedConversation = await queryClient.fetchQuery({\n\t\t\t\t...detailQuery,\n\t\t\t\tstaleTime: 0,\n\t\t\t});\n\t\t\tif (!retryIsCurrent() || !persistedConversation) return;\n\t\t\tif (isFirstMessageSentRef.current && !hasNavigatedRef.current) {\n\t\t\t\thasNavigatedRef.current = true;\n\t\t\t\tsetCurrentConversationId(retryConversationId);\n\t\t\t\tif (variant === \"full\" && typeof window !== \"undefined\") {\n\t\t\t\t\tconst newLocation = resolveAiChatSiteLocation(\n\t\t\t\t\t\t{ baseURL: siteBaseURL, basePath: siteBasePath },\n\t\t\t\t\t\twindow.location.origin,\n\t\t\t\t\t\t\"chat\",\n\t\t\t\t\t\tretryConversationId,\n\t\t\t\t\t);\n\t\t\t\t\tif (newLocation.crossOrigin) {\n\t\t\t\t\t\tnavigateAiChatCrossOrigin(newLocation.href, { replace: true });\n\t\t\t\t\t} else {\n\t\t\t\t\t\twindow.history.replaceState(\n\t\t\t\t\t\t\t{ ...window.history.state },\n\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\tnewLocation.path,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tsetMessages(\n\t\t\t\tpersistedMessagesToUiMessages(persistedConversation.messages),\n\t\t\t);\n\t\t\tsetHistorySyncError(null);\n\t\t} catch (error) {\n\t\t\tif (!retryIsCurrent()) return;\n\t\t\tconst syncError =\n\t\t\t\terror instanceof Error ? error : new Error(String(error));\n\t\t\tconsole.error(\"Failed to reload persisted chat history:\", syncError);\n\t\t\tsetHistorySyncError(syncError);\n\t\t} finally {\n\t\t\tif (retryIsCurrent()) setIsHistorySyncRetrying(false);\n\t\t}\n\t}, [\n\t\tapiBasePath,\n\t\tapiBaseURL,\n\t\tcredentials,\n\t\tisHistorySyncRetrying,\n\t\tisPublicMode,\n\t\tqueryClient,\n\t\tsetMessages,\n\t\tsiteBasePath,\n\t\tsiteBaseURL,\n\t\tvariant,\n\t]);\n\tuseLayoutEffect(() => {\n\t\tlatestMessages.current = messages;\n\t}, [messages]);\n\n\tconst previousIdentityPartition = useRef(identityPartitionKey);\n\tuseLayoutEffect(() => {\n\t\tconst nextPartition = identityPartitionKey;\n\t\tlatestIdentityPartitionKey.current = nextPartition;\n\t\tlatestIdentityPartition.current = identityPartition;\n\t\tlatestConversationsListQueryKey.current = conversationsListQueryKey;\n\t\tlatestHeaders.current = browserHeaders;\n\t\tif (isPublicMode) return;\n\t\tif (previousIdentityPartition.current === nextPartition) return;\n\t\tpreviousIdentityPartition.current = nextPartition;\n\t\tactiveStreamPartitionKey.current = undefined;\n\t\tpendingStreamRequests.current = [];\n\t\tidentitySessionGeneration.current += 1;\n\t\tsetIdentitySessionVersion(identitySessionGeneration.current);\n\t\tlatestStreamRequestGeneration.current =\n\t\t\t++nextStreamRequestGeneration.current;\n\t\tvoid stop();\n\t\tsetMessages([]);\n\t\tsetInput(\"\");\n\t\tsetAttachedFiles([]);\n\t\tsetPendingEdit(null);\n\t\tsetHistorySyncError(null);\n\t\tsetIsHistorySyncRetrying(false);\n\t\teditMessagesRef.current = null;\n\t\tisEditInProgressRef.current = false;\n\t\tsetIsMessagesInitialized(true);\n\t\tsetCurrentConversationId(id);\n\t\tconversationIdRef.current = id;\n\t\tisFirstMessageSentRef.current = false;\n\t\thasNavigatedRef.current = false;\n\t}, [\n\t\tconversationsListQueryKey,\n\t\tbrowserHeaders,\n\t\tid,\n\t\tidentityPartition,\n\t\tidentityPartitionKey,\n\t\tisPublicMode,\n\t\tsetMessages,\n\t\tstop,\n\t]);\n\n\t// Keep addToolOutputRef in sync so onToolCall always has the latest reference\n\tuseEffect(() => {\n\t\taddToolOutputRef.current = addToolOutput;\n\t}, [addToolOutput]);\n\n\t// Load existing conversation messages when navigating to a conversation\n\tuseEffect(() => {\n\t\t// Don't overwrite messages if an edit is in progress\n\t\tif (isEditInProgressRef.current) {\n\t\t\treturn;\n\t\t}\n\t\tif (\n\t\t\tconversation?.messages &&\n\t\t\tconversation.messages.length > 0 &&\n\t\t\tmessages.length === 0\n\t\t) {\n\t\t\tsetMessages(persistedMessagesToUiMessages(conversation.messages));\n\t\t}\n\t}, [conversation, messages.length, setMessages]);\n\n\t// Set initial messages on mount (for SSR hydration)\n\tuseEffect(() => {\n\t\tif (\n\t\t\t!isMessagesInitialized &&\n\t\t\tinitialMessages &&\n\t\t\tinitialMessages.length > 0 &&\n\t\t\tmessages.length === 0\n\t\t) {\n\t\t\tsetMessages(initialMessages);\n\t\t\t// Mark as initialized - this is batched with setMessages so both take effect in the same render\n\t\t\tsetIsMessagesInitialized(true);\n\t\t}\n\t}, [initialMessages, isMessagesInitialized, setMessages, messages.length]);\n\n\tconst scrollRef = useRef(null);\n\n\t// Track whether the user has manually scrolled away from the bottom.\n\t// When true, auto-scroll is paused so the user can read earlier context.\n\tconst userHasScrolledRef = useRef(false);\n\tconst prevStatusRef = useRef(status);\n\n\t// Reset the scroll lock when a new generation starts so auto-scroll\n\t// resumes for the next assistant response.\n\tuseEffect(() => {\n\t\tif (\n\t\t\tstatus !== prevStatusRef.current &&\n\t\t\t(status === \"streaming\" || status === \"submitted\")\n\t\t) {\n\t\t\tuserHasScrolledRef.current = false;\n\t\t}\n\t\tprevStatusRef.current = status;\n\t}, [status]);\n\n\t// Attach a scroll listener to detect when the user scrolls away from the bottom.\n\tuseEffect(() => {\n\t\tconst viewport = scrollRef.current?.querySelector(\n\t\t\t\"[data-radix-scroll-area-viewport]\",\n\t\t);\n\t\tif (!viewport) return;\n\n\t\tconst handleScroll = () => {\n\t\t\tconst { scrollTop, scrollHeight, clientHeight } = viewport;\n\t\t\tconst isNearBottom = scrollHeight - scrollTop - clientHeight < 50;\n\t\t\tuserHasScrolledRef.current = !isNearBottom;\n\t\t};\n\n\t\tviewport.addEventListener(\"scroll\", handleScroll);\n\t\treturn () => viewport.removeEventListener(\"scroll\", handleScroll);\n\t}, []);\n\n\t// Auto-scroll to bottom when messages change, unless the user has scrolled away\n\tuseEffect(() => {\n\t\tif (userHasScrolledRef.current) return;\n\t\tif (scrollRef.current) {\n\t\t\tconst scrollElement = scrollRef.current.querySelector(\n\t\t\t\t\"[data-radix-scroll-area-viewport]\",\n\t\t\t);\n\t\t\tif (scrollElement) {\n\t\t\t\tscrollElement.scrollTop = scrollElement.scrollHeight;\n\t\t\t}\n\t\t}\n\t}, [messages]);\n\n\t// Notify parent when messages change (for persistence in public mode)\n\t// Only fire after initialization to prevent overwriting saved messages with an empty array\n\tuseEffect(() => {\n\t\tif (isPublicMode && onMessagesChange && isMessagesInitialized) {\n\t\t\tonMessagesChange(messages);\n\t\t}\n\t}, [messages, isPublicMode, onMessagesChange, isMessagesInitialized]);\n\n\tconst handleInputChange = (e: React.ChangeEvent) => {\n\t\tsetInput(e.target.value);\n\t};\n\n\tconst handleSubmit = async (\n\t\te: React.FormEvent,\n\t\tfiles?: AttachedFile[],\n\t) => {\n\t\te.preventDefault();\n\t\tconst text = input.trim();\n\t\t// Allow submit if there's text OR files\n\t\tif (!text && (!files || files.length === 0)) return;\n\n\t\t// Track if this is the first message on a new chat (authenticated mode only)\n\t\tif (!isPublicMode && !id && messages.length === 0) {\n\t\t\tisFirstMessageSentRef.current = true;\n\t\t}\n\n\t\t// Re-enable auto-scroll so the user's own message (and any subsequent\n\t\t// error indicator or assistant reply) is scrolled into view. Without\n\t\t// this, if the user had scrolled up earlier, userHasScrolledRef stays\n\t\t// true and none of the new content would be auto-scrolled to — and if\n\t\t// the request fails before reaching \"streaming\" status the ref would\n\t\t// remain stuck permanently.\n\t\tuserHasScrolledRef.current = false;\n\n\t\t// Save current values before clearing - we'll restore them if send fails\n\t\tconst savedInput = input;\n\t\tconst savedFiles = files ? [...files] : [];\n\t\tconst sendIdentityPartitionKey = latestIdentityPartitionKey.current;\n\t\tconst sendIdentitySessionGeneration = identitySessionGeneration.current;\n\n\t\t// Capture the message count before sending so we can restore to this\n\t\t// exact point on failure. The SDK may append both a user message and a\n\t\t// partial assistant message during streaming — using a fixed snapshot\n\t\t// length removes all of them instead of just the last one.\n\t\tconst messageCountBeforeSend = messages.length;\n\n\t\t// Clear input immediately (optimistically) - the AI SDK renders messages optimistically,\n\t\t// so we need to clear the input before the message appears to avoid duplicate text\n\t\tsetInput(\"\");\n\t\tsetAttachedFiles([]);\n\t\tsetHistorySyncError(null);\n\n\t\ttry {\n\t\t\tactiveStreamPartitionKey.current = latestIdentityPartitionKey.current;\n\t\t\t// Use AI SDK's file attachment format\n\t\t\t// The SDK automatically converts supported file types (images, text) to the correct format\n\t\t\tif (files && files.length > 0) {\n\t\t\t\t// Convert AttachedFile[] to FileUIPart[] format expected by AI SDK\n\t\t\t\tconst fileUIParts = files.map((file) => ({\n\t\t\t\t\ttype: \"file\" as const,\n\t\t\t\t\tmediaType: file.mediaType,\n\t\t\t\t\turl: file.url,\n\t\t\t\t\tfilename: file.filename,\n\t\t\t\t}));\n\n\t\t\t\tconst request = sendMessage({\n\t\t\t\t\ttext: text || \"\", // AI SDK requires text, even if empty\n\t\t\t\t\tfiles: fileUIParts,\n\t\t\t\t});\n\t\t\t\tawait request;\n\t\t\t} else {\n\t\t\t\tconst request = sendMessage({ text });\n\t\t\t\tawait request;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tif (\n\t\t\t\tactiveStreamPartitionKey.current === sendIdentityPartitionKey &&\n\t\t\t\tsendIdentitySessionGeneration === identitySessionGeneration.current\n\t\t\t) {\n\t\t\t\tactiveStreamPartitionKey.current = undefined;\n\t\t\t}\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\t(sendIdentityPartitionKey !== latestIdentityPartitionKey.current ||\n\t\t\t\t\tsendIdentitySessionGeneration !== identitySessionGeneration.current)\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// Restore input on failure so user can retry\n\t\t\tsetInput(savedInput);\n\t\t\tsetAttachedFiles(savedFiles);\n\t\t\t// Reset first-message tracking so the next attempt still triggers navigation\n\t\t\tif (isFirstMessageSentRef.current && !hasNavigatedRef.current) {\n\t\t\t\tisFirstMessageSentRef.current = false;\n\t\t\t}\n\t\t\t// Remove all messages the SDK added after our send attempt (optimistic\n\t\t\t// user message AND any partial assistant message from a mid-stream failure).\n\t\t\tsetMessages((prev) => prev.slice(0, messageCountBeforeSend));\n\t\t\tconsole.error(\"Error sending message:\", error);\n\t\t}\n\t};\n\n\tconst isLoading = status === \"streaming\" || status === \"submitted\";\n\n\t// Handler for retrying/regenerating the last AI response\n\tconst handleRetry = useCallback(() => {\n\t\tsetHistorySyncError(null);\n\t\tactiveStreamPartitionKey.current = latestIdentityPartitionKey.current;\n\t\tregenerate();\n\t}, [regenerate]);\n\n\t// Effect to send the edited message after React has processed the truncation\n\tuseEffect(() => {\n\t\tif (\n\t\t\tpendingEdit &&\n\t\t\tpendingEdit.identityPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\tpendingEdit.streamRequestGeneration ===\n\t\t\t\tlatestStreamRequestGeneration.current &&\n\t\t\tmessages.length === pendingEdit.expectedLength\n\t\t) {\n\t\t\tconst textToSend = pendingEdit.text;\n\t\t\tsetPendingEdit(null);\n\t\t\t// Clear edit in progress flag - the new message will now be sent\n\t\t\t// and we want subsequent effects to work normally\n\t\t\tisEditInProgressRef.current = false;\n\t\t\tactiveStreamPartitionKey.current = latestIdentityPartitionKey.current;\n\t\t\tsendMessage({ text: textToSend });\n\t\t}\n\t}, [messages.length, pendingEdit, sendMessage]);\n\n\t// Handler for editing a user message - replaces the message and all subsequent messages\n\tconst handleEditMessage = useCallback(\n\t\t(messageId: string, newText: string) => {\n\t\t\tsetHistorySyncError(null);\n\t\t\tconst messageIndex = messages.findIndex((m) => m.id === messageId);\n\t\t\tif (messageIndex === -1) return;\n\n\t\t\t// Get the message to edit\n\t\t\tconst messageToEdit = messages[messageIndex];\n\t\t\tif (!messageToEdit || messageToEdit.role !== \"user\") return;\n\n\t\t\t// Truncate to BEFORE the edited message (remove it and all subsequent)\n\t\t\tconst truncatedMessages = messages.slice(0, messageIndex);\n\n\t\t\t// Mark edit in progress to prevent load conversation effect from overwriting\n\t\t\tisEditInProgressRef.current = true;\n\n\t\t\t// Store the truncated messages in the ref for the transport to use\n\t\t\teditMessagesRef.current = truncatedMessages;\n\n\t\t\t// Set the pending edit - the useEffect will send after truncation is processed\n\t\t\tsetPendingEdit({\n\t\t\t\ttext: newText,\n\t\t\t\texpectedLength: messageIndex,\n\t\t\t\tidentityPartitionKey: latestIdentityPartitionKey.current,\n\t\t\t\tstreamRequestGeneration: latestStreamRequestGeneration.current,\n\t\t\t});\n\n\t\t\t// Truncate the messages - React will batch this and the useEffect will fire after\n\t\t\tsetMessages(truncatedMessages);\n\t\t},\n\t\t[messages, setMessages],\n\t);\n\n\tconst isWidget = variant === \"widget\";\n\n\treturn (\n\t\t<>\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t{/* Messages Area */}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{messages.length === 0 ? (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\t\t\t\t\"CHAT_EMPTY_STATE\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.chat.emptyState\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"Start a conversation...\",\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{(() => {\n\t\t\t\t\t\t\t\t\t\t// Merge static suggestions from overrides with dynamic ones from page context.\n\t\t\t\t\t\t\t\t\t\t// Page context suggestions appear first (most relevant to current page).\n\t\t\t\t\t\t\t\t\t\tconst pageSuggestions = pageAIContext?.suggestions ?? [];\n\t\t\t\t\t\t\t\t\t\tconst allSuggestions = [\n\t\t\t\t\t\t\t\t\t\t\t...pageSuggestions,\n\t\t\t\t\t\t\t\t\t\t\t...(chatSuggestions ?? []),\n\t\t\t\t\t\t\t\t\t\t];\n\t\t\t\t\t\t\t\t\t\treturn allSuggestions.length > 0 ? (\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\t{allSuggestions.map((suggestion, index) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t setInput(suggestion)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-2 text-sm rounded-lg border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-colors text-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{suggestion}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t) : null;\n\t\t\t\t\t\t\t\t\t})()}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\tmessages.map((m, index) => (\n\t\t\t\t\t\t\t\t\t message.role === \"user\")?.id\n\t\t\t\t\t\t\t\t\t\t\t\t: m.id\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tmessage={m}\n\t\t\t\t\t\t\t\t\t\tisStreaming={\n\t\t\t\t\t\t\t\t\t\t\tstatus === \"streaming\" &&\n\t\t\t\t\t\t\t\t\t\t\tm.id === messages[messages.length - 1]?.id &&\n\t\t\t\t\t\t\t\t\t\t\tm.role === \"assistant\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tvariant={isWidget ? \"compact\" : \"default\"}\n\t\t\t\t\t\t\t\t\t\tonRetry={\n\t\t\t\t\t\t\t\t\t\t\t// Only show retry on the last assistant message\n\t\t\t\t\t\t\t\t\t\t\tm.role === \"assistant\" && index === messages.length - 1\n\t\t\t\t\t\t\t\t\t\t\t\t? handleRetry\n\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tonEdit={\n\t\t\t\t\t\t\t\t\t\t\t// Allow editing user messages\n\t\t\t\t\t\t\t\t\t\t\tm.role === \"user\"\n\t\t\t\t\t\t\t\t\t\t\t\t? (newText) => handleEditMessage(m.id, newText)\n\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tisRetrying={isLoading && m.role === \"assistant\"}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{isLoading &&\n\t\t\t\t\t\t\t\tmessages[messages.length - 1]?.role !== \"assistant\" && (\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t{tr(\"CHAT_LOADING\", \"aiChat.chat.loading\", \"Thinking...\")}\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{(error || historySyncError) && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\t\t\t\"CHAT_ERROR\",\n\t\t\t\t\t\t\t\t\t\t\t\"aiChat.chat.error\",\n\t\t\t\t\t\t\t\t\t\t\t\"Something went wrong. Please try again.\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{historySyncError && (\n\t\t\t\t\t\t\t\t\t\t void handleHistorySyncRetry()}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isHistorySyncRetrying}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"rounded-md border border-destructive/30 px-2.5 py-1 font-medium hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{tr(\"CHAT_RETRY\", \"aiChat.chat.retry\", \"Retry\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t{/* Input Area */}\n\t\t\t\n\t\t\t\t{(allowed) =>\n\t\t\t\t\tallowed && !historySyncError ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{showAttribution && }\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t) : null\n\t\t\t\t}\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useChat } from \"@ai-sdk/react\";\nimport {\n\tuseEffect,\n\tuseRef,\n\tuseState,\n\tuseMemo,\n\tuseCallback,\n\tuseLayoutEffect,\n\tuseId,\n} from \"react\";\nimport { hashKey, useQueryClient } from \"@tanstack/react-query\";\nimport { ChatMessage } from \"./chat-message\";\nimport { ChatInput, type AttachedFile } from \"./chat-input\";\nimport { StackAttribution } from \"@/components/ui/stack-attribution\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n\tDefaultChatTransport,\n\tlastAssistantMessageIsCompleteWithToolCalls,\n\ttype UIMessage,\n} from \"ai\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tPermissionCheck,\n\tusePluginOverrides,\n\tuseStack,\n} from \"@btst/stack/context\";\nimport { aiChatPermissions } from \"../../permissions\";\nimport {\n\tresolveAiChatApiUrl,\n\tresolveAiChatMode,\n\tresolveAiChatSiteLocation,\n\ttype AiChatPluginOverrides,\n} from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\nimport { createApiClient } from \"@btst/stack/plugins/client\";\nimport type { AiChatApiRouter } from \"@btst/stack/plugins/ai-chat/api\";\nimport { aiChatIdentityKey, createAiChatQueryKeys } from \"@btst/stack/plugins/ai-chat/api\";\nimport {\n\tuseConversation,\n\tuseConversations,\n\tuseAiChatIdentityPartition,\n\ttype SerializedMessage,\n} from \"@btst/stack/plugins/ai-chat/client/hooks\";\nimport { usePageAIContext } from \"../context/page-ai-context\";\nimport { navigateAiChatCrossOrigin } from \"../navigation\";\n\ninterface ChatInterfaceProps {\n\tinitialMessages?: UIMessage[];\n\tid?: string;\n\t/** Variant: 'full' for full-page layout, 'widget' for embedded widget */\n\tvariant?: \"full\" | \"widget\";\n\tclassName?: string;\n\t/** Called whenever messages change (for persistence). Only fires in public mode. */\n\tonMessagesChange?: (messages: UIMessage[]) => void;\n}\n\ntype ChatAction = \"send\" | \"edit\" | \"retry\";\n\nfunction reconcilePersistedMessageIds(\n\tmessages: UIMessage[],\n\tpersistedMessages: SerializedMessage[],\n): UIMessage[] | null {\n\tconst userMessages = messages.filter((message) => message.role === \"user\");\n\tconst persistedUserMessages = persistedMessages.filter(\n\t\t(message) => message.role === \"user\",\n\t);\n\tif (persistedUserMessages.length !== userMessages.length) return null;\n\tconst persistedIds = new Map();\n\tfor (let index = 0; index < userMessages.length; index++) {\n\t\tconst message = userMessages[index];\n\t\tconst persisted = persistedUserMessages[index];\n\t\tif (!message || !persisted) return null;\n\t\tconst serializedParts = JSON.stringify(message.parts);\n\t\tconst legacyText =\n\t\t\tmessage.parts.length === 1 && message.parts[0]?.type === \"text\"\n\t\t\t\t? message.parts[0].text\n\t\t\t\t: undefined;\n\t\tif (\n\t\t\tpersisted.content !== serializedParts &&\n\t\t\tpersisted.content !== legacyText\n\t\t) {\n\t\t\treturn null;\n\t\t}\n\t\tpersistedIds.set(message, persisted.id);\n\t}\n\treturn messages.map((message) => ({\n\t\t...message,\n\t\tid: persistedIds.get(message) ?? message.id,\n\t}));\n}\n\nfunction persistedMessagesToUiMessages(\n\tmessages: readonly SerializedMessage[],\n): UIMessage[] {\n\treturn messages\n\t\t.filter((message) => message.role !== \"data\")\n\t\t.map((message) => {\n\t\t\tlet parts: UIMessage[\"parts\"];\n\t\t\ttry {\n\t\t\t\tconst parsed = JSON.parse(message.content);\n\t\t\t\tparts = Array.isArray(parsed)\n\t\t\t\t\t? parsed\n\t\t\t\t\t: [{ type: \"text\" as const, text: message.content }];\n\t\t\t} catch {\n\t\t\t\tparts = [{ type: \"text\" as const, text: message.content }];\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tid: message.id,\n\t\t\t\trole: message.role as \"user\" | \"assistant\" | \"system\",\n\t\t\t\tparts,\n\t\t\t};\n\t\t});\n}\n\nfunction ChatActionCheck({\n\tpublicMode,\n\taction,\n\tconversationId,\n\townerId,\n\tmessageId,\n\ttoolNames = [],\n\trouteName,\n\tchildren,\n}: {\n\tpublicMode: boolean;\n\taction: ChatAction;\n\tconversationId?: string;\n\townerId?: string;\n\tmessageId?: string;\n\ttoolNames?: readonly string[];\n\trouteName?: string;\n\tchildren: (allowed: boolean) => React.ReactNode;\n}) {\n\tif (publicMode) return <>{children(true)};\n\tconst base = {\n\t\t...(conversationId ? { conversationId } : {}),\n\t\t...(ownerId ? { ownerId } : {}),\n\t};\n\tconst streamPermission = aiChatPermissions.stream.start({\n\t\t...base,\n\t\tcreatesConversation: !conversationId,\n\t\tintent: action,\n\t});\n\tconst messagePermission =\n\t\taction === \"send\"\n\t\t\t? aiChatPermissions.message.send({\n\t\t\t\t\t...base,\n\t\t\t\t\tcreatesConversation: !conversationId,\n\t\t\t\t})\n\t\t\t: action === \"edit\" && conversationId && messageId\n\t\t\t\t? aiChatPermissions.message.edit({\n\t\t\t\t\t\tconversationId,\n\t\t\t\t\t\t...(ownerId ? { ownerId } : {}),\n\t\t\t\t\t\tmessageId,\n\t\t\t\t\t})\n\t\t\t\t: action === \"retry\" && conversationId && messageId\n\t\t\t\t\t? aiChatPermissions.message.retry({\n\t\t\t\t\t\t\tconversationId,\n\t\t\t\t\t\t\t...(ownerId ? { ownerId } : {}),\n\t\t\t\t\t\t\tmessageId,\n\t\t\t\t\t\t})\n\t\t\t\t\t: null;\n\tif (!messagePermission) return <>{children(false)};\n\n\treturn (\n\t\t\n\t\t\t{(streamState) => (\n\t\t\t\t\n\t\t\t\t\t{(messageState) => {\n\t\t\t\t\t\tconst baseAllowed =\n\t\t\t\t\t\t\tstreamState.can &&\n\t\t\t\t\t\t\t!streamState.isPending &&\n\t\t\t\t\t\t\t!streamState.error &&\n\t\t\t\t\t\t\tmessageState.can &&\n\t\t\t\t\t\t\t!messageState.isPending &&\n\t\t\t\t\t\t\t!messageState.error;\n\t\t\t\t\t\tconst renderToolCheck = (allowed: boolean) => {\n\t\t\t\t\t\t\tif (!allowed || toolNames.length === 0) return children(allowed);\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{(toolState) =>\n\t\t\t\t\t\t\t\t\t\tchildren(\n\t\t\t\t\t\t\t\t\t\t\ttoolState.can && !toolState.isPending && !toolState.error,\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t};\n\t\t\t\t\t\tif (action !== \"send\" || conversationId) {\n\t\t\t\t\t\t\treturn renderToolCheck(Boolean(baseAllowed));\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{(createState) =>\n\t\t\t\t\t\t\t\t\trenderToolCheck(\n\t\t\t\t\t\t\t\t\t\tBoolean(baseAllowed) &&\n\t\t\t\t\t\t\t\t\t\t\tcreateState.can &&\n\t\t\t\t\t\t\t\t\t\t\t!createState.isPending &&\n\t\t\t\t\t\t\t\t\t\t\t!createState.error,\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t);\n\t\t\t\t\t}}\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n\nfunction PermissionedChatMessage({\n\tpublicMode,\n\taction,\n\tconversationId,\n\townerId,\n\tmessageId,\n\t...messageProps\n}: React.ComponentProps & {\n\tpublicMode: boolean;\n\taction?: \"edit\" | \"retry\";\n\tconversationId?: string;\n\townerId?: string;\n\tmessageId?: string;\n}) {\n\tif (!action) return ;\n\treturn (\n\t\t\n\t\t\t{(allowed) => (\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n\nexport function ChatInterface({\n\tinitialMessages,\n\tid,\n\tvariant = \"full\",\n\tclassName,\n\tonMessagesChange,\n}: ChatInterfaceProps) {\n\tconst {\n\t\tlocalization: customLocalization,\n\t\tshowAttribution,\n\t\tchatSuggestions,\n\t\tclassNames,\n\t} = usePluginOverrides>(\n\t\t\"aiChat\",\n\t\t{ showAttribution: true },\n\t);\n\tconst {\n\t\tapi,\n\t\tbasePath: stackBasePath,\n\t\tplugins,\n\t\tqueryClient: stackQueryClient,\n\t} = useStack();\n\tconst aiChatApi = plugins?.aiChat?.api;\n\tconst apiBaseURL = aiChatApi?.baseURL ?? api?.baseURL;\n\tconst apiBasePath = aiChatApi?.basePath ?? api?.basePath;\n\tconst browserHeaders = aiChatApi?.browserHeaders;\n\tconst credentials = aiChatApi?.credentials;\n\tconst resolvedApiPath = resolveAiChatApiUrl(apiBaseURL, apiBasePath);\n\tconst mode = resolveAiChatMode(plugins?.aiChat?.config);\n\tconst siteBaseURL = plugins?.aiChat?.site.baseURL;\n\tconst siteBasePath = plugins?.aiChat?.site.basePath ?? stackBasePath;\n\tconst isPublicMode = mode === \"public\";\n\n\t// Read page AI context registered by the current page\n\tconst pageAIContext = usePageAIContext();\n\n\tconst tr = useAiChatTranslation(customLocalization);\n\tconst queryClient = useQueryClient(stackQueryClient);\n\tconst identityPartition = useAiChatIdentityPartition();\n\tconst chatInstanceId = useId();\n\tconst identityPartitionKey = hashKey([aiChatIdentityKey(identityPartition)]);\n\tconst latestIdentityPartitionKey = useRef(identityPartitionKey);\n\tconst identitySessionGeneration = useRef(0);\n\tconst [identitySessionVersion, setIdentitySessionVersion] = useState(0);\n\tconst activeStreamPartitionKey = useRef(undefined);\n\tconst nextStreamRequestGeneration = useRef(0);\n\tconst latestStreamRequestGeneration = useRef(0);\n\tconst pendingStreamRequests = useRef<\n\t\tArray<{ generation: number; identityPartitionKey: string }>\n\t>([]);\n\tconst latestMessages = useRef([]);\n\n\tconst conversationsListQueryKey = useMemo(() => {\n\t\t// In public mode, we don't need conversation queries\n\t\tif (isPublicMode) return [\"ai-chat\", \"disabled\"];\n\t\tconst client = createApiClient({\n\t\t\tbaseURL: apiBaseURL,\n\t\t\tbasePath: apiBasePath,\n\t\t\tcredentials,\n\t\t});\n\t\tconst queries = createAiChatQueryKeys(client, browserHeaders);\n\t\treturn queries.conversations.list(identityPartition).queryKey;\n\t}, [\n\t\tapiBaseURL,\n\t\tapiBasePath,\n\t\tbrowserHeaders,\n\t\tcredentials,\n\t\tidentityPartition,\n\t\tisPublicMode,\n\t]);\n\tconst latestIdentityPartition = useRef(identityPartition);\n\tconst latestConversationsListQueryKey = useRef(conversationsListQueryKey);\n\tconst latestHeaders = useRef(browserHeaders);\n\n\t// Track the current conversation ID - initialized from prop, updated after first message\n\t// In public mode, we don't track conversation IDs\n\tconst [currentConversationId, setCurrentConversationId] = useState<\n\t\tstring | undefined\n\t>(isPublicMode ? undefined : id);\n\t// Track if we've sent the first message on a new chat (to trigger navigation)\n\tconst isFirstMessageSentRef = useRef(false);\n\tconst hasNavigatedRef = useRef(false);\n\n\t// Update currentConversationId when id prop changes (e.g., navigating to different conversation)\n\tuseEffect(() => {\n\t\tif (!isPublicMode) {\n\t\t\tsetCurrentConversationId(id);\n\t\t\tisFirstMessageSentRef.current = false;\n\t\t\thasNavigatedRef.current = false;\n\t\t\t// Reset edit flag on navigation\n\t\t\tisEditInProgressRef.current = false;\n\t\t}\n\t}, [id, isPublicMode]);\n\n\t// Fetch existing conversation messages when id is provided (authenticated mode only)\n\tconst { conversation, isLoading: isLoadingConversation } = useConversation(\n\t\tid,\n\t\t{ enabled: !!id && !isPublicMode },\n\t);\n\n\t// Fetch conversations list for navigation after first message (authenticated mode only)\n\tconst { conversations } = useConversations({ enabled: !isPublicMode });\n\tconst currentConversationOwnerId =\n\t\tconversation?.userId ??\n\t\tconversations.find((item) => item.id === currentConversationId)?.userId ??\n\t\t(!isPublicMode &&\n\t\tcurrentConversationId &&\n\t\ttypeof identityPartition === \"object\"\n\t\t\t? identityPartition.id\n\t\t\t: undefined);\n\n\t// Use a ref to track the conversation ID for the transport body\n\t// This ensures the transport always uses the latest value\n\t// In public mode, always undefined\n\tconst conversationIdRef = useRef(\n\t\tisPublicMode ? undefined : id,\n\t);\n\tuseEffect(() => {\n\t\tif (!isPublicMode) {\n\t\t\tconversationIdRef.current = currentConversationId;\n\t\t}\n\t}, [currentConversationId, isPublicMode]);\n\n\t// Ref to track edit operation with messages to use\n\tconst editMessagesRef = useRef(null);\n\n\t// Flag to prevent load conversation effect from overwriting messages during edit\n\tconst isEditInProgressRef = useRef(false);\n\n\t// Track if we've finished initializing messages\n\t// This prevents onMessagesChange from firing with an empty array before initialMessages are loaded\n\t// Without this guard, the effect would fire on mount with [], overwriting any saved messages\n\tconst [isMessagesInitialized, setIsMessagesInitialized] = useState(\n\t\t() =>\n\t\t\t// Start as initialized if there are no initialMessages to load\n\t\t\t!initialMessages || initialMessages.length === 0,\n\t);\n\tconst [input, setInput] = useState(\"\");\n\tconst [attachedFiles, setAttachedFiles] = useState([]);\n\tconst [pendingEdit, setPendingEdit] = useState<{\n\t\ttext: string;\n\t\texpectedLength: number;\n\t\tidentityPartitionKey: string;\n\t\tstreamRequestGeneration: number;\n\t} | null>(null);\n\tconst [historySyncError, setHistorySyncError] = useState(null);\n\tconst [isHistorySyncRetrying, setIsHistorySyncRetrying] = useState(false);\n\tuseEffect(() => {\n\t\tsetHistorySyncError(null);\n\t\tsetIsHistorySyncRetrying(false);\n\t}, [id, isPublicMode]);\n\n\t// Ref to always have the latest pageAIContext in the transport callback\n\t// without recreating the transport on every context change\n\tconst pageAIContextRef = useRef(pageAIContext);\n\tuseEffect(() => {\n\t\tpageAIContextRef.current = pageAIContext;\n\t}, [pageAIContext]);\n\n\t// Memoize the transport to prevent recreation on every render\n\tconst transport = useMemo(() => {\n\t\tconst trackedFetch = Object.assign(\n\t\t\tasync (\n\t\t\t\trequest: Parameters[0],\n\t\t\t\tinit?: Parameters[1],\n\t\t\t) => {\n\t\t\t\tconst requestIdentityPartitionKey = latestIdentityPartitionKey.current;\n\t\t\t\tconst requestGeneration = latestStreamRequestGeneration.current;\n\t\t\t\tconst response = await globalThis.fetch(request, init);\n\t\t\t\tif (\n\t\t\t\t\t!isPublicMode &&\n\t\t\t\t\trequestIdentityPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\t\t\trequestGeneration === latestStreamRequestGeneration.current\n\t\t\t\t) {\n\t\t\t\t\tconst conversationId = response.headers.get(\"x-conversation-id\");\n\t\t\t\t\tif (conversationId) conversationIdRef.current = conversationId;\n\t\t\t\t}\n\t\t\t\treturn response;\n\t\t\t},\n\t\t\tglobalThis.fetch,\n\t\t) as typeof globalThis.fetch;\n\t\treturn new DefaultChatTransport({\n\t\t\tapi: resolvedApiPath,\n\t\t\theaders: browserHeaders,\n\t\t\tcredentials,\n\t\t\tfetch: trackedFetch,\n\t\t\t// In public mode, don't send conversationId\n\t\t\tbody: isPublicMode\n\t\t\t\t? undefined\n\t\t\t\t: () => ({ conversationId: conversationIdRef.current }),\n\t\t\t// Handle edit operations and inject page context\n\t\t\tprepareSendMessagesRequest: ({ messages: hookMessages }) => {\n\t\t\t\tif (!isPublicMode) {\n\t\t\t\t\tconst generation = ++nextStreamRequestGeneration.current;\n\t\t\t\t\tlatestStreamRequestGeneration.current = generation;\n\t\t\t\t\tconst request = {\n\t\t\t\t\t\tgeneration,\n\t\t\t\t\t\tidentityPartitionKey: latestIdentityPartitionKey.current,\n\t\t\t\t\t};\n\t\t\t\t\tpendingStreamRequests.current.push(request);\n\t\t\t\t\tactiveStreamPartitionKey.current = request.identityPartitionKey;\n\t\t\t\t}\n\t\t\t\tconst currentPageContext = pageAIContextRef.current;\n\n\t\t\t\t// Build page context fields to include in every request\n\t\t\t\tconst pageContextBody = currentPageContext?.pageDescription\n\t\t\t\t\t? {\n\t\t\t\t\t\t\tpageContext: currentPageContext.pageDescription,\n\t\t\t\t\t\t\tavailableTools: Object.keys(currentPageContext.clientTools ?? {}),\n\t\t\t\t\t\t\trouteName: currentPageContext.routeName,\n\t\t\t\t\t\t}\n\t\t\t\t\t: {};\n\n\t\t\t\t// If we're in an edit operation, use the truncated messages + new user message\n\t\t\t\tif (editMessagesRef.current !== null) {\n\t\t\t\t\tconst newUserMessage = hookMessages[hookMessages.length - 1];\n\t\t\t\t\tconst messagesToSend = [...editMessagesRef.current];\n\t\t\t\t\tif (newUserMessage) {\n\t\t\t\t\t\tmessagesToSend.push(newUserMessage);\n\t\t\t\t\t}\n\t\t\t\t\t// Clear the ref after use\n\t\t\t\t\teditMessagesRef.current = null;\n\t\t\t\t\treturn {\n\t\t\t\t\t\tbody: {\n\t\t\t\t\t\t\tmessages: messagesToSend,\n\t\t\t\t\t\t\tconversationId: conversationIdRef.current,\n\t\t\t\t\t\t\t...pageContextBody,\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\t// Normal case - use the messages as-is\n\t\t\t\treturn {\n\t\t\t\t\tbody: {\n\t\t\t\t\t\tmessages: hookMessages,\n\t\t\t\t\t\tconversationId: conversationIdRef.current,\n\t\t\t\t\t\t...pageContextBody,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t},\n\t\t});\n\t}, [browserHeaders, credentials, isPublicMode, resolvedApiPath]);\n\n\t// Use a ref so addToolOutput is always current inside the onToolCall closure\n\tconst addToolOutputRef = useRef<\n\t\tReturnType[\"addToolOutput\"] | null\n\t>(null);\n\n\tconst {\n\t\tmessages,\n\t\tsendMessage,\n\t\tstatus,\n\t\terror,\n\t\tsetMessages,\n\t\tregenerate,\n\t\taddToolOutput,\n\t\tstop,\n\t} = useChat({\n\t\tid: `${chatInstanceId}:${isPublicMode ? \"public\" : identitySessionVersion}`,\n\t\ttransport,\n\t\t// Automatically resubmit after all client-side tool results are provided\n\t\tsendAutomaticallyWhen: (options) =>\n\t\t\t(isPublicMode ||\n\t\t\t\tidentitySessionVersion === identitySessionGeneration.current) &&\n\t\t\tlastAssistantMessageIsCompleteWithToolCalls(options),\n\t\tonToolCall: async ({ toolCall }) => {\n\t\t\tconst toolRequest = pendingStreamRequests.current.at(-1);\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\t(identitySessionVersion !== identitySessionGeneration.current ||\n\t\t\t\t\t!toolRequest ||\n\t\t\t\t\ttoolRequest.identityPartitionKey !==\n\t\t\t\t\t\tlatestIdentityPartitionKey.current ||\n\t\t\t\t\ttoolRequest.generation !== latestStreamRequestGeneration.current ||\n\t\t\t\t\tactiveStreamPartitionKey.current !== toolRequest.identityPartitionKey)\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst toolIdentityPartitionKey =\n\t\t\t\ttoolRequest?.identityPartitionKey ?? latestIdentityPartitionKey.current;\n\t\t\tconst toolStreamRequestGeneration =\n\t\t\t\ttoolRequest?.generation ?? latestStreamRequestGeneration.current;\n\t\t\tconst toolRequestIsCurrent = () =>\n\t\t\t\tisPublicMode ||\n\t\t\t\t(identitySessionVersion === identitySessionGeneration.current &&\n\t\t\t\t\ttoolIdentityPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\t\t\ttoolStreamRequestGeneration ===\n\t\t\t\t\t\tlatestStreamRequestGeneration.current);\n\t\t\t// Dispatch client-side tool calls to the handler registered by the current page.\n\t\t\t// In AI SDK v5, onToolCall returns void — addToolOutput must be called explicitly.\n\t\t\tconst toolName = toolCall.toolName;\n\t\t\tconst handler = pageAIContextRef.current?.clientTools?.[toolName];\n\t\t\tif (handler) {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await handler(toolCall.input);\n\t\t\t\t\tif (!toolRequestIsCurrent()) return;\n\t\t\t\t\t// No await — avoids potential deadlocks with sendAutomaticallyWhen\n\t\t\t\t\taddToolOutputRef.current?.({\n\t\t\t\t\t\ttool: toolName,\n\t\t\t\t\t\ttoolCallId: toolCall.toolCallId,\n\t\t\t\t\t\toutput: result,\n\t\t\t\t\t});\n\t\t\t\t} catch (err) {\n\t\t\t\t\tif (!toolRequestIsCurrent()) return;\n\t\t\t\t\taddToolOutputRef.current?.({\n\t\t\t\t\t\ttool: toolName,\n\t\t\t\t\t\ttoolCallId: toolCall.toolCallId,\n\t\t\t\t\t\tstate: \"output-error\",\n\t\t\t\t\t\terrorText:\n\t\t\t\t\t\t\terr instanceof Error\n\t\t\t\t\t\t\t\t? err.message\n\t\t\t\t\t\t\t\t: tr(\n\t\t\t\t\t\t\t\t\t\t\"TOOL_EXECUTION_FAILED\",\n\t\t\t\t\t\t\t\t\t\t\"aiChat.tools.executionFailed\",\n\t\t\t\t\t\t\t\t\t\t\"Tool execution failed\",\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// No handler found — this happens when the user navigates away while a\n\t\t\t\t// tool-call response is streaming and the page context changes. Always\n\t\t\t\t// call addToolOutput so sendAutomaticallyWhen can unblock; without this\n\t\t\t\t// the conversation gets permanently stuck waiting for a missing output.\n\t\t\t\taddToolOutputRef.current?.({\n\t\t\t\t\ttool: toolName,\n\t\t\t\t\ttoolCallId: toolCall.toolCallId,\n\t\t\t\t\tstate: \"output-error\",\n\t\t\t\t\terrorText: tr(\n\t\t\t\t\t\t\"TOOL_HANDLER_MISSING\",\n\t\t\t\t\t\t\"aiChat.tools.handlerMissing\",\n\t\t\t\t\t\t'No client-side handler registered for tool \"{{toolName}}\". The page context may have changed while the response was streaming.',\n\t\t\t\t\t\t{ toolName },\n\t\t\t\t\t),\n\t\t\t\t});\n\t\t\t}\n\t\t},\n\t\tonError: (err) => {\n\t\t\tconsole.error(\"useChat onError:\", err);\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\tidentitySessionVersion !== identitySessionGeneration.current\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst failedRequest = pendingStreamRequests.current[0];\n\t\t\tconst failedPartitionKey =\n\t\t\t\tfailedRequest?.identityPartitionKey ?? activeStreamPartitionKey.current;\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\tfailedPartitionKey !== latestIdentityPartitionKey.current\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// AI SDK invokes onFinish after onError. Keep this request queued so\n\t\t\t// onFinish can reconcile a user message that the backend persisted before\n\t\t\t// the provider or response stream failed.\n\t\t},\n\t\tonFinish: async () => {\n\t\t\t// In public mode, skip all persistence-related operations\n\t\t\tif (isPublicMode) {\n\t\t\t\tactiveStreamPartitionKey.current = undefined;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (identitySessionVersion !== identitySessionGeneration.current) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst finishedRequest = pendingStreamRequests.current.shift();\n\t\t\tconst finishingPartitionKey =\n\t\t\t\tfinishedRequest?.identityPartitionKey ??\n\t\t\t\tactiveStreamPartitionKey.current;\n\t\t\tif (finishingPartitionKey !== latestIdentityPartitionKey.current) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (\n\t\t\t\t!finishedRequest ||\n\t\t\t\tfinishedRequest.generation === latestStreamRequestGeneration.current\n\t\t\t) {\n\t\t\t\tactiveStreamPartitionKey.current = undefined;\n\t\t\t}\n\t\t\tconst identityIsCurrent = () =>\n\t\t\t\tfinishingPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\t\t(!finishedRequest ||\n\t\t\t\t\tfinishedRequest.generation === latestStreamRequestGeneration.current);\n\t\t\tconst finishingIdentityPartition = latestIdentityPartition.current;\n\t\t\tconst finishingConversationsListQueryKey =\n\t\t\t\tlatestConversationsListQueryKey.current;\n\t\t\tconst finishingHeaders = latestHeaders.current;\n\n\t\t\ttry {\n\t\t\t\t// Invalidate conversation list to show new/updated conversations\n\t\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: finishingConversationsListQueryKey,\n\t\t\t\t});\n\t\t\t\tif (!identityIsCurrent()) return;\n\n\t\t\t\t// If this was the first message on a new chat, update the URL without full navigation\n\t\t\t\t// This avoids losing the in-memory messages during component remount\n\t\t\t\tif (isFirstMessageSentRef.current && !hasNavigatedRef.current) {\n\t\t\t\t\tconst discoveredConversationId = conversationIdRef.current;\n\t\t\t\t\tif (discoveredConversationId) {\n\t\t\t\t\t\thasNavigatedRef.current = true;\n\t\t\t\t\t\tsetCurrentConversationId(discoveredConversationId);\n\t\t\t\t\t\tconversationIdRef.current = discoveredConversationId;\n\t\t\t\t\t\t// Only update the URL in full-page mode; in widget mode the chat is\n\t\t\t\t\t\t// embedded in another page and clobbering the URL is disruptive.\n\t\t\t\t\t\tif (variant === \"full\") {\n\t\t\t\t\t\t\tconst newLocation = resolveAiChatSiteLocation(\n\t\t\t\t\t\t\t\t{ baseURL: siteBaseURL, basePath: siteBasePath },\n\t\t\t\t\t\t\t\ttypeof window === \"undefined\"\n\t\t\t\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t\t\t\t: window.location.origin,\n\t\t\t\t\t\t\t\t\"chat\",\n\t\t\t\t\t\t\t\tdiscoveredConversationId,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tif (typeof window !== \"undefined\") {\n\t\t\t\t\t\t\t\tif (newLocation.crossOrigin) {\n\t\t\t\t\t\t\t\t\tnavigateAiChatCrossOrigin(newLocation.href, {\n\t\t\t\t\t\t\t\t\t\treplace: true,\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\twindow.history.replaceState(\n\t\t\t\t\t\t\t\t\t\t{ ...window.history.state },\n\t\t\t\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\t\t\t\tnewLocation.path,\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst persistedConversationId = conversationIdRef.current;\n\t\t\t\tif (!persistedConversationId || !identityIsCurrent()) {\n\t\t\t\t\tif (isFirstMessageSentRef.current) {\n\t\t\t\t\t\tisFirstMessageSentRef.current = false;\n\t\t\t\t\t\thasNavigatedRef.current = false;\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t\"The persisted conversation id could not be discovered.\",\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst client = createApiClient({\n\t\t\t\t\tbaseURL: apiBaseURL,\n\t\t\t\t\tbasePath: apiBasePath,\n\t\t\t\t\tcredentials,\n\t\t\t\t});\n\t\t\t\tconst detailQuery = createAiChatQueryKeys(\n\t\t\t\t\tclient,\n\t\t\t\t\tfinishingHeaders,\n\t\t\t\t).conversations.detail(\n\t\t\t\t\tpersistedConversationId,\n\t\t\t\t\tfinishingIdentityPartition,\n\t\t\t\t);\n\t\t\t\tawait queryClient.cancelQueries({\n\t\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\t\texact: true,\n\t\t\t\t});\n\t\t\t\tif (!identityIsCurrent()) return;\n\t\t\t\tqueryClient.removeQueries({\n\t\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\t\texact: true,\n\t\t\t\t});\n\t\t\t\tconst persistedConversation = await queryClient.fetchQuery({\n\t\t\t\t\t...detailQuery,\n\t\t\t\t\tstaleTime: 0,\n\t\t\t\t});\n\t\t\t\tif (!identityIsCurrent() || !persistedConversation) return;\n\t\t\t\tconst reconciled = reconcilePersistedMessageIds(\n\t\t\t\t\tlatestMessages.current,\n\t\t\t\t\tpersistedConversation.messages,\n\t\t\t\t);\n\t\t\t\tif (!reconciled) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\"Persisted chat history did not match the streamed transcript.\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tsetMessages(reconciled);\n\t\t\t\tsetHistorySyncError(null);\n\t\t\t} catch (error) {\n\t\t\t\tif (!identityIsCurrent()) return;\n\t\t\t\tconst syncError =\n\t\t\t\t\terror instanceof Error ? error : new Error(String(error));\n\t\t\t\tconsole.error(\"Failed to reconcile persisted chat history:\", syncError);\n\t\t\t\tsetHistorySyncError(syncError);\n\t\t\t\tsetMessages([]);\n\t\t\t}\n\t\t},\n\t});\n\tconst handleHistorySyncRetry = useCallback(async () => {\n\t\tif (isPublicMode || isHistorySyncRetrying) return;\n\t\tconst retryConversationId = conversationIdRef.current;\n\t\tif (!retryConversationId) {\n\t\t\tsetHistorySyncError(null);\n\t\t\treturn;\n\t\t}\n\n\t\tconst retryPartitionKey = latestIdentityPartitionKey.current;\n\t\tconst retrySessionGeneration = identitySessionGeneration.current;\n\t\tconst retryIsCurrent = () =>\n\t\t\tretryPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\tretrySessionGeneration === identitySessionGeneration.current &&\n\t\t\tretryConversationId === conversationIdRef.current;\n\t\tsetIsHistorySyncRetrying(true);\n\t\ttry {\n\t\t\tconst client = createApiClient({\n\t\t\t\tbaseURL: apiBaseURL,\n\t\t\t\tbasePath: apiBasePath,\n\t\t\t\tcredentials,\n\t\t\t});\n\t\t\tconst detailQuery = createAiChatQueryKeys(\n\t\t\t\tclient,\n\t\t\t\tlatestHeaders.current,\n\t\t\t).conversations.detail(\n\t\t\t\tretryConversationId,\n\t\t\t\tlatestIdentityPartition.current,\n\t\t\t);\n\t\t\tawait queryClient.cancelQueries({\n\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\texact: true,\n\t\t\t});\n\t\t\tif (!retryIsCurrent()) return;\n\t\t\tqueryClient.removeQueries({\n\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\texact: true,\n\t\t\t});\n\t\t\tconst persistedConversation = await queryClient.fetchQuery({\n\t\t\t\t...detailQuery,\n\t\t\t\tstaleTime: 0,\n\t\t\t});\n\t\t\tif (!retryIsCurrent() || !persistedConversation) return;\n\t\t\tif (isFirstMessageSentRef.current && !hasNavigatedRef.current) {\n\t\t\t\thasNavigatedRef.current = true;\n\t\t\t\tsetCurrentConversationId(retryConversationId);\n\t\t\t\tif (variant === \"full\" && typeof window !== \"undefined\") {\n\t\t\t\t\tconst newLocation = resolveAiChatSiteLocation(\n\t\t\t\t\t\t{ baseURL: siteBaseURL, basePath: siteBasePath },\n\t\t\t\t\t\twindow.location.origin,\n\t\t\t\t\t\t\"chat\",\n\t\t\t\t\t\tretryConversationId,\n\t\t\t\t\t);\n\t\t\t\t\tif (newLocation.crossOrigin) {\n\t\t\t\t\t\tnavigateAiChatCrossOrigin(newLocation.href, { replace: true });\n\t\t\t\t\t} else {\n\t\t\t\t\t\twindow.history.replaceState(\n\t\t\t\t\t\t\t{ ...window.history.state },\n\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\tnewLocation.path,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tsetMessages(\n\t\t\t\tpersistedMessagesToUiMessages(persistedConversation.messages),\n\t\t\t);\n\t\t\tsetHistorySyncError(null);\n\t\t} catch (error) {\n\t\t\tif (!retryIsCurrent()) return;\n\t\t\tconst syncError =\n\t\t\t\terror instanceof Error ? error : new Error(String(error));\n\t\t\tconsole.error(\"Failed to reload persisted chat history:\", syncError);\n\t\t\tsetHistorySyncError(syncError);\n\t\t} finally {\n\t\t\tif (retryIsCurrent()) setIsHistorySyncRetrying(false);\n\t\t}\n\t}, [\n\t\tapiBasePath,\n\t\tapiBaseURL,\n\t\tcredentials,\n\t\tisHistorySyncRetrying,\n\t\tisPublicMode,\n\t\tqueryClient,\n\t\tsetMessages,\n\t\tsiteBasePath,\n\t\tsiteBaseURL,\n\t\tvariant,\n\t]);\n\tuseLayoutEffect(() => {\n\t\tlatestMessages.current = messages;\n\t}, [messages]);\n\n\tconst previousIdentityPartition = useRef(identityPartitionKey);\n\tuseLayoutEffect(() => {\n\t\tconst nextPartition = identityPartitionKey;\n\t\tlatestIdentityPartitionKey.current = nextPartition;\n\t\tlatestIdentityPartition.current = identityPartition;\n\t\tlatestConversationsListQueryKey.current = conversationsListQueryKey;\n\t\tlatestHeaders.current = browserHeaders;\n\t\tif (isPublicMode) return;\n\t\tif (previousIdentityPartition.current === nextPartition) return;\n\t\tpreviousIdentityPartition.current = nextPartition;\n\t\tactiveStreamPartitionKey.current = undefined;\n\t\tpendingStreamRequests.current = [];\n\t\tidentitySessionGeneration.current += 1;\n\t\tsetIdentitySessionVersion(identitySessionGeneration.current);\n\t\tlatestStreamRequestGeneration.current =\n\t\t\t++nextStreamRequestGeneration.current;\n\t\tvoid stop();\n\t\tsetMessages([]);\n\t\tsetInput(\"\");\n\t\tsetAttachedFiles([]);\n\t\tsetPendingEdit(null);\n\t\tsetHistorySyncError(null);\n\t\tsetIsHistorySyncRetrying(false);\n\t\teditMessagesRef.current = null;\n\t\tisEditInProgressRef.current = false;\n\t\tsetIsMessagesInitialized(true);\n\t\tsetCurrentConversationId(id);\n\t\tconversationIdRef.current = id;\n\t\tisFirstMessageSentRef.current = false;\n\t\thasNavigatedRef.current = false;\n\t}, [\n\t\tconversationsListQueryKey,\n\t\tbrowserHeaders,\n\t\tid,\n\t\tidentityPartition,\n\t\tidentityPartitionKey,\n\t\tisPublicMode,\n\t\tsetMessages,\n\t\tstop,\n\t]);\n\n\t// Keep addToolOutputRef in sync so onToolCall always has the latest reference\n\tuseEffect(() => {\n\t\taddToolOutputRef.current = addToolOutput;\n\t}, [addToolOutput]);\n\n\t// Load existing conversation messages when navigating to a conversation\n\tuseEffect(() => {\n\t\t// Don't overwrite messages if an edit is in progress\n\t\tif (isEditInProgressRef.current) {\n\t\t\treturn;\n\t\t}\n\t\tif (\n\t\t\tconversation?.messages &&\n\t\t\tconversation.messages.length > 0 &&\n\t\t\tmessages.length === 0\n\t\t) {\n\t\t\tsetMessages(persistedMessagesToUiMessages(conversation.messages));\n\t\t}\n\t}, [conversation, messages.length, setMessages]);\n\n\t// Set initial messages on mount (for SSR hydration)\n\tuseEffect(() => {\n\t\tif (\n\t\t\t!isMessagesInitialized &&\n\t\t\tinitialMessages &&\n\t\t\tinitialMessages.length > 0 &&\n\t\t\tmessages.length === 0\n\t\t) {\n\t\t\tsetMessages(initialMessages);\n\t\t\t// Mark as initialized - this is batched with setMessages so both take effect in the same render\n\t\t\tsetIsMessagesInitialized(true);\n\t\t}\n\t}, [initialMessages, isMessagesInitialized, setMessages, messages.length]);\n\n\tconst scrollRef = useRef(null);\n\n\t// Track whether the user has manually scrolled away from the bottom.\n\t// When true, auto-scroll is paused so the user can read earlier context.\n\tconst userHasScrolledRef = useRef(false);\n\tconst prevStatusRef = useRef(status);\n\n\t// Reset the scroll lock when a new generation starts so auto-scroll\n\t// resumes for the next assistant response.\n\tuseEffect(() => {\n\t\tif (\n\t\t\tstatus !== prevStatusRef.current &&\n\t\t\t(status === \"streaming\" || status === \"submitted\")\n\t\t) {\n\t\t\tuserHasScrolledRef.current = false;\n\t\t}\n\t\tprevStatusRef.current = status;\n\t}, [status]);\n\n\t// Attach a scroll listener to detect when the user scrolls away from the bottom.\n\tuseEffect(() => {\n\t\tconst viewport = scrollRef.current?.querySelector(\n\t\t\t\"[data-radix-scroll-area-viewport]\",\n\t\t);\n\t\tif (!viewport) return;\n\n\t\tconst handleScroll = () => {\n\t\t\tconst { scrollTop, scrollHeight, clientHeight } = viewport;\n\t\t\tconst isNearBottom = scrollHeight - scrollTop - clientHeight < 50;\n\t\t\tuserHasScrolledRef.current = !isNearBottom;\n\t\t};\n\n\t\tviewport.addEventListener(\"scroll\", handleScroll);\n\t\treturn () => viewport.removeEventListener(\"scroll\", handleScroll);\n\t}, []);\n\n\t// Auto-scroll to bottom when messages change, unless the user has scrolled away\n\tuseEffect(() => {\n\t\tif (userHasScrolledRef.current) return;\n\t\tif (scrollRef.current) {\n\t\t\tconst scrollElement = scrollRef.current.querySelector(\n\t\t\t\t\"[data-radix-scroll-area-viewport]\",\n\t\t\t);\n\t\t\tif (scrollElement) {\n\t\t\t\tscrollElement.scrollTop = scrollElement.scrollHeight;\n\t\t\t}\n\t\t}\n\t}, [messages]);\n\n\t// Notify parent when messages change (for persistence in public mode)\n\t// Only fire after initialization to prevent overwriting saved messages with an empty array\n\tuseEffect(() => {\n\t\tif (isPublicMode && onMessagesChange && isMessagesInitialized) {\n\t\t\tonMessagesChange(messages);\n\t\t}\n\t}, [messages, isPublicMode, onMessagesChange, isMessagesInitialized]);\n\n\tconst handleInputChange = (e: React.ChangeEvent) => {\n\t\tsetInput(e.target.value);\n\t};\n\n\tconst handleSubmit = async (\n\t\te: React.FormEvent,\n\t\tfiles?: AttachedFile[],\n\t) => {\n\t\te.preventDefault();\n\t\tconst text = input.trim();\n\t\t// Allow submit if there's text OR files\n\t\tif (!text && (!files || files.length === 0)) return;\n\n\t\t// Track if this is the first message on a new chat (authenticated mode only)\n\t\tif (!isPublicMode && !id && messages.length === 0) {\n\t\t\tisFirstMessageSentRef.current = true;\n\t\t}\n\n\t\t// Re-enable auto-scroll so the user's own message (and any subsequent\n\t\t// error indicator or assistant reply) is scrolled into view. Without\n\t\t// this, if the user had scrolled up earlier, userHasScrolledRef stays\n\t\t// true and none of the new content would be auto-scrolled to — and if\n\t\t// the request fails before reaching \"streaming\" status the ref would\n\t\t// remain stuck permanently.\n\t\tuserHasScrolledRef.current = false;\n\n\t\t// Save current values before clearing - we'll restore them if send fails\n\t\tconst savedInput = input;\n\t\tconst savedFiles = files ? [...files] : [];\n\t\tconst sendIdentityPartitionKey = latestIdentityPartitionKey.current;\n\t\tconst sendIdentitySessionGeneration = identitySessionGeneration.current;\n\n\t\t// Capture the message count before sending so we can restore to this\n\t\t// exact point on failure. The SDK may append both a user message and a\n\t\t// partial assistant message during streaming — using a fixed snapshot\n\t\t// length removes all of them instead of just the last one.\n\t\tconst messageCountBeforeSend = messages.length;\n\n\t\t// Clear input immediately (optimistically) - the AI SDK renders messages optimistically,\n\t\t// so we need to clear the input before the message appears to avoid duplicate text\n\t\tsetInput(\"\");\n\t\tsetAttachedFiles([]);\n\t\tsetHistorySyncError(null);\n\n\t\ttry {\n\t\t\tactiveStreamPartitionKey.current = latestIdentityPartitionKey.current;\n\t\t\t// Use AI SDK's file attachment format\n\t\t\t// The SDK automatically converts supported file types (images, text) to the correct format\n\t\t\tif (files && files.length > 0) {\n\t\t\t\t// Convert AttachedFile[] to FileUIPart[] format expected by AI SDK\n\t\t\t\tconst fileUIParts = files.map((file) => ({\n\t\t\t\t\ttype: \"file\" as const,\n\t\t\t\t\tmediaType: file.mediaType,\n\t\t\t\t\turl: file.url,\n\t\t\t\t\tfilename: file.filename,\n\t\t\t\t}));\n\n\t\t\t\tconst request = sendMessage({\n\t\t\t\t\ttext: text || \"\", // AI SDK requires text, even if empty\n\t\t\t\t\tfiles: fileUIParts,\n\t\t\t\t});\n\t\t\t\tawait request;\n\t\t\t} else {\n\t\t\t\tconst request = sendMessage({ text });\n\t\t\t\tawait request;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tif (\n\t\t\t\tactiveStreamPartitionKey.current === sendIdentityPartitionKey &&\n\t\t\t\tsendIdentitySessionGeneration === identitySessionGeneration.current\n\t\t\t) {\n\t\t\t\tactiveStreamPartitionKey.current = undefined;\n\t\t\t}\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\t(sendIdentityPartitionKey !== latestIdentityPartitionKey.current ||\n\t\t\t\t\tsendIdentitySessionGeneration !== identitySessionGeneration.current)\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// Restore input on failure so user can retry\n\t\t\tsetInput(savedInput);\n\t\t\tsetAttachedFiles(savedFiles);\n\t\t\t// Reset first-message tracking so the next attempt still triggers navigation\n\t\t\tif (isFirstMessageSentRef.current && !hasNavigatedRef.current) {\n\t\t\t\tisFirstMessageSentRef.current = false;\n\t\t\t}\n\t\t\t// Remove all messages the SDK added after our send attempt (optimistic\n\t\t\t// user message AND any partial assistant message from a mid-stream failure).\n\t\t\tsetMessages((prev) => prev.slice(0, messageCountBeforeSend));\n\t\t\tconsole.error(\"Error sending message:\", error);\n\t\t}\n\t};\n\n\tconst isLoading = status === \"streaming\" || status === \"submitted\";\n\n\t// Handler for retrying/regenerating the last AI response\n\tconst handleRetry = useCallback(() => {\n\t\tsetHistorySyncError(null);\n\t\tactiveStreamPartitionKey.current = latestIdentityPartitionKey.current;\n\t\tregenerate();\n\t}, [regenerate]);\n\n\t// Effect to send the edited message after React has processed the truncation\n\tuseEffect(() => {\n\t\tif (\n\t\t\tpendingEdit &&\n\t\t\tpendingEdit.identityPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\tpendingEdit.streamRequestGeneration ===\n\t\t\t\tlatestStreamRequestGeneration.current &&\n\t\t\tmessages.length === pendingEdit.expectedLength\n\t\t) {\n\t\t\tconst textToSend = pendingEdit.text;\n\t\t\tsetPendingEdit(null);\n\t\t\t// Clear edit in progress flag - the new message will now be sent\n\t\t\t// and we want subsequent effects to work normally\n\t\t\tisEditInProgressRef.current = false;\n\t\t\tactiveStreamPartitionKey.current = latestIdentityPartitionKey.current;\n\t\t\tsendMessage({ text: textToSend });\n\t\t}\n\t}, [messages.length, pendingEdit, sendMessage]);\n\n\t// Handler for editing a user message - replaces the message and all subsequent messages\n\tconst handleEditMessage = useCallback(\n\t\t(messageId: string, newText: string) => {\n\t\t\tsetHistorySyncError(null);\n\t\t\tconst messageIndex = messages.findIndex((m) => m.id === messageId);\n\t\t\tif (messageIndex === -1) return;\n\n\t\t\t// Get the message to edit\n\t\t\tconst messageToEdit = messages[messageIndex];\n\t\t\tif (!messageToEdit || messageToEdit.role !== \"user\") return;\n\n\t\t\t// Truncate to BEFORE the edited message (remove it and all subsequent)\n\t\t\tconst truncatedMessages = messages.slice(0, messageIndex);\n\n\t\t\t// Mark edit in progress to prevent load conversation effect from overwriting\n\t\t\tisEditInProgressRef.current = true;\n\n\t\t\t// Store the truncated messages in the ref for the transport to use\n\t\t\teditMessagesRef.current = truncatedMessages;\n\n\t\t\t// Set the pending edit - the useEffect will send after truncation is processed\n\t\t\tsetPendingEdit({\n\t\t\t\ttext: newText,\n\t\t\t\texpectedLength: messageIndex,\n\t\t\t\tidentityPartitionKey: latestIdentityPartitionKey.current,\n\t\t\t\tstreamRequestGeneration: latestStreamRequestGeneration.current,\n\t\t\t});\n\n\t\t\t// Truncate the messages - React will batch this and the useEffect will fire after\n\t\t\tsetMessages(truncatedMessages);\n\t\t},\n\t\t[messages, setMessages],\n\t);\n\n\tconst isWidget = variant === \"widget\";\n\n\treturn (\n\t\t<>\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t{/* Messages Area */}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{messages.length === 0 ? (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\t\t\t\t\"CHAT_EMPTY_STATE\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.chat.emptyState\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"Start a conversation...\",\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{(() => {\n\t\t\t\t\t\t\t\t\t\t// Merge static suggestions from overrides with dynamic ones from page context.\n\t\t\t\t\t\t\t\t\t\t// Page context suggestions appear first (most relevant to current page).\n\t\t\t\t\t\t\t\t\t\tconst pageSuggestions = pageAIContext?.suggestions ?? [];\n\t\t\t\t\t\t\t\t\t\tconst allSuggestions = [\n\t\t\t\t\t\t\t\t\t\t\t...pageSuggestions,\n\t\t\t\t\t\t\t\t\t\t\t...(chatSuggestions ?? []),\n\t\t\t\t\t\t\t\t\t\t];\n\t\t\t\t\t\t\t\t\t\treturn allSuggestions.length > 0 ? (\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\t{allSuggestions.map((suggestion, index) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t setInput(suggestion)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-2 text-sm rounded-lg border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-colors text-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{suggestion}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t) : null;\n\t\t\t\t\t\t\t\t\t})()}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\tmessages.map((m, index) => (\n\t\t\t\t\t\t\t\t\t message.role === \"user\")?.id\n\t\t\t\t\t\t\t\t\t\t\t\t: m.id\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tmessage={m}\n\t\t\t\t\t\t\t\t\t\tisStreaming={\n\t\t\t\t\t\t\t\t\t\t\tstatus === \"streaming\" &&\n\t\t\t\t\t\t\t\t\t\t\tm.id === messages[messages.length - 1]?.id &&\n\t\t\t\t\t\t\t\t\t\t\tm.role === \"assistant\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tvariant={isWidget ? \"compact\" : \"default\"}\n\t\t\t\t\t\t\t\t\t\tonRetry={\n\t\t\t\t\t\t\t\t\t\t\t// Only show retry on the last assistant message\n\t\t\t\t\t\t\t\t\t\t\tm.role === \"assistant\" && index === messages.length - 1\n\t\t\t\t\t\t\t\t\t\t\t\t? handleRetry\n\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tonEdit={\n\t\t\t\t\t\t\t\t\t\t\t// Allow editing user messages\n\t\t\t\t\t\t\t\t\t\t\tm.role === \"user\"\n\t\t\t\t\t\t\t\t\t\t\t\t? (newText) => handleEditMessage(m.id, newText)\n\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tisRetrying={isLoading && m.role === \"assistant\"}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{isLoading &&\n\t\t\t\t\t\t\t\tmessages[messages.length - 1]?.role !== \"assistant\" && (\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t{tr(\"CHAT_LOADING\", \"aiChat.chat.loading\", \"Thinking...\")}\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{(error || historySyncError) && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\t\t\t\"CHAT_ERROR\",\n\t\t\t\t\t\t\t\t\t\t\t\"aiChat.chat.error\",\n\t\t\t\t\t\t\t\t\t\t\t\"Something went wrong. Please try again.\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{historySyncError && (\n\t\t\t\t\t\t\t\t\t\t void handleHistorySyncRetry()}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isHistorySyncRetrying}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"rounded-md border border-destructive/30 px-2.5 py-1 font-medium hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{tr(\"CHAT_RETRY\", \"aiChat.chat.retry\", \"Retry\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t{/* Input Area */}\n\t\t\t\n\t\t\t\t{(allowed) =>\n\t\t\t\t\tallowed && !historySyncError ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{showAttribution && }\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t) : null\n\t\t\t\t}\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/ai-chat/client/components/chat-interface.tsx" }, { "path": "btst/ai-chat/client/components/chat-layout.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tSheet,\n\tSheetContent,\n\tSheetTrigger,\n} from \"@/components/ui/sheet\";\nimport {\n\tMenu,\n\tPanelLeftClose,\n\tPanelLeft,\n\tSparkles,\n\tTrash2,\n\tX,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { ChatSidebar } from \"./chat-sidebar\";\nimport { ChatInterface } from \"./chat-interface\";\nimport type { UIMessage } from \"ai\";\nimport { usePageAIContext } from \"../context/page-ai-context\";\nimport { usePluginOverrides, useStack } from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../overrides\";\nimport { resolveAiChatMode } from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\n\ninterface ChatLayoutBaseProps {\n\t/** Current conversation ID (if viewing existing conversation) */\n\tconversationId?: string;\n\t/** Additional class name for the container */\n\tclassName?: string;\n\t/** Whether to show the sidebar */\n\tshowSidebar?: boolean;\n\t/** Initial messages to populate the chat (useful for localStorage persistence in public mode) */\n\tinitialMessages?: UIMessage[];\n\t/** Called whenever messages change (for persistence). Only fires in public mode. */\n\tonMessagesChange?: (messages: UIMessage[]) => void;\n\t/** Called when the user clears the chat (e.g. clicks the trash button in widget mode). Use this to clear persisted messages. */\n\tonClear?: () => void;\n}\n\ninterface ChatLayoutWidgetProps extends ChatLayoutBaseProps {\n\t/** Widget mode: compact embeddable panel with a floating trigger button */\n\tlayout: \"widget\";\n\t/** Height of the widget panel. Default: `\"600px\"` */\n\twidgetHeight?: string | number;\n\t/** Width of the widget panel. Default: `\"380px\"` */\n\twidgetWidth?: string | number;\n\t/**\n\t * Whether the widget panel starts open. Default: `false`.\n\t * Set to `true` when embedding inside an already-open container such as a\n\t * Next.js intercepting-route modal — the panel will be immediately visible\n\t * without the user needing to click the trigger button.\n\t */\n\tdefaultOpen?: boolean;\n\t/**\n\t * Whether to render the built-in floating trigger button. Default: `true`.\n\t * Set to `false` when you control open/close externally (e.g. a Next.js\n\t * parallel-route slot, a custom button, or a `router.back()` dismiss action)\n\t * so that the built-in button does not appear alongside your own UI.\n\t */\n\tshowTrigger?: boolean;\n}\n\ninterface ChatLayoutFullProps extends ChatLayoutBaseProps {\n\t/** Full-page mode with sidebar navigation (default) */\n\tlayout?: \"full\";\n}\n\n/** Props for the ChatLayout component */\nexport type ChatLayoutProps = ChatLayoutWidgetProps | ChatLayoutFullProps;\n\n/**\n * ChatLayout component that provides a full-page chat experience with sidebar\n * or a compact widget mode for embedding.\n */\nexport function ChatLayout(props: ChatLayoutProps) {\n\tconst {\n\t\tconversationId,\n\t\tlayout = \"full\",\n\t\tclassName,\n\t\tshowSidebar: requestedShowSidebar = true,\n\t\tinitialMessages,\n\t\tonMessagesChange,\n\t\tonClear,\n\t} = props;\n\tconst { localization } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"aiChat\", {});\n\tconst { plugins } = useStack();\n\tconst resolvedMode = resolveAiChatMode(plugins?.aiChat?.config);\n\tconst showSidebar = requestedShowSidebar && resolvedMode !== \"public\";\n\tconst tr = useAiChatTranslation(localization);\n\n\t// Widget-specific props — TypeScript narrows props to ChatLayoutWidgetProps here\n\tconst widgetHeight =\n\t\tprops.layout === \"widget\" ? (props.widgetHeight ?? \"600px\") : \"600px\";\n\tconst widgetWidth =\n\t\tprops.layout === \"widget\" ? (props.widgetWidth ?? \"380px\") : \"380px\";\n\tconst defaultOpen =\n\t\tprops.layout === \"widget\" ? (props.defaultOpen ?? false) : false;\n\tconst showTrigger =\n\t\tprops.layout === \"widget\" ? (props.showTrigger ?? true) : true;\n\n\tconst [sidebarOpen, setSidebarOpen] = useState(true);\n\tconst [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);\n\t// Key to force ChatInterface remount when starting a new chat\n\tconst [chatResetKey, setChatResetKey] = useState(0);\n\t// Widget open/closed state — starts with defaultOpen value\n\tconst [widgetOpen, setWidgetOpen] = useState(defaultOpen);\n\t// Key to force widget ChatInterface remount on clear\n\tconst [widgetResetKey, setWidgetResetKey] = useState(0);\n\t// Only mount the widget ChatInterface after the widget has been opened at least once.\n\t// This ensures pageAIContext is already registered before ChatInterface first renders,\n\t// so suggestion chips and tool hints appear immediately on first open.\n\t// When defaultOpen is true the widget is pre-opened, so we mark it as ever-opened immediately.\n\tconst [widgetEverOpened, setWidgetEverOpened] = useState(defaultOpen);\n\n\t// Read page AI context to show badge in header\n\tconst pageAIContext = usePageAIContext();\n\n\t// Handler for \"New chat\" button - increments key to force remount\n\tconst handleNewChat = useCallback(() => {\n\t\t// Only needed when we're already on the \"new chat\" route (/chat).\n\t\t// If we're on /chat/:id, navigation to /chat will remount ChatLayout/ChatInterface anyway.\n\t\tif (!conversationId) {\n\t\t\tsetChatResetKey((prev) => prev + 1);\n\t\t}\n\t}, [conversationId]);\n\n\tif (layout === \"widget\") {\n\t\treturn (\n\t\t\t
\n\t\t\t\t{/* Chat panel — always mounted to preserve conversation state, hidden when closed */}\n\t\t\t\t\n\t\t\t\t\t{/* Widget header with page context badge and action buttons */}\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t{pageAIContext ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{pageAIContext.routeName}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{tr(\"A11Y_CHAT_TITLE\", \"aiChat.a11y.title\", \"AI Chat\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tonClear?.();\n\t\t\t\t\t\t\t\tsetWidgetResetKey((prev) => prev + 1);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\taria-label={tr(\n\t\t\t\t\t\t\t\t\"A11Y_CLEAR_CHAT\",\n\t\t\t\t\t\t\t\t\"aiChat.a11y.clearChat\",\n\t\t\t\t\t\t\t\t\"Clear chat\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\ttitle={tr(\n\t\t\t\t\t\t\t\t\"A11Y_CLEAR_CHAT\",\n\t\t\t\t\t\t\t\t\"aiChat.a11y.clearChat\",\n\t\t\t\t\t\t\t\t\"Clear chat\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t setWidgetOpen(false)}\n\t\t\t\t\t\t\taria-label={tr(\n\t\t\t\t\t\t\t\t\"A11Y_CLOSE_CHAT\",\n\t\t\t\t\t\t\t\t\"aiChat.a11y.closeChat\",\n\t\t\t\t\t\t\t\t\"Close chat\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t{widgetEverOpened && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{/* Trigger button — rendered only when showTrigger is true */}\n\t\t\t\t{showTrigger && (\n\t\t\t\t\t {\n\t\t\t\t\t\t\tsetWidgetOpen((prev) => !prev);\n\t\t\t\t\t\t\tsetWidgetEverOpened(true);\n\t\t\t\t\t\t}}\n\t\t\t\t\t\taria-label={\n\t\t\t\t\t\t\twidgetOpen\n\t\t\t\t\t\t\t\t? tr(\"A11Y_CLOSE_CHAT\", \"aiChat.a11y.closeChat\", \"Close chat\")\n\t\t\t\t\t\t\t\t: tr(\"A11Y_OPEN_CHAT\", \"aiChat.a11y.openChat\", \"Open chat\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tdata-testid=\"widget-trigger\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{widgetOpen ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t);\n\t}\n\n\t// Full layout with sidebar\n\treturn (\n\t\t\n\t\t\t{/* Desktop Sidebar */}\n\t\t\t{showSidebar && (\n\t\t\t\t\n\t\t\t\t\t{sidebarOpen && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{/* Main Chat Area */}\n\t\t\t
\n\t\t\t\t{/* Header */}\n\t\t\t\t
\n\t\t\t\t\t{/* Mobile menu button */}\n\t\t\t\t\t{showSidebar && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\thandleNewChat();\n\t\t\t\t\t\t\t\t\t\tsetMobileSidebarOpen(false);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* Desktop sidebar toggle */}\n\t\t\t\t\t{showSidebar && (\n\t\t\t\t\t\t setSidebarOpen(!sidebarOpen)}\n\t\t\t\t\t\t\taria-label={\n\t\t\t\t\t\t\t\tsidebarOpen\n\t\t\t\t\t\t\t\t\t? tr(\n\t\t\t\t\t\t\t\t\t\t\t\"A11Y_CLOSE_SIDEBAR\",\n\t\t\t\t\t\t\t\t\t\t\t\"aiChat.a11y.closeSidebar\",\n\t\t\t\t\t\t\t\t\t\t\t\"Close sidebar\",\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t: tr(\n\t\t\t\t\t\t\t\t\t\t\t\"A11Y_OPEN_SIDEBAR\",\n\t\t\t\t\t\t\t\t\t\t\t\"aiChat.a11y.openSidebar\",\n\t\t\t\t\t\t\t\t\t\t\t\"Open sidebar\",\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{sidebarOpen ? (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\n\t\t\t\t\t
\n\n\t\t\t\t\t{/* Page context badge — shown when a page has registered AI context */}\n\t\t\t\t\t{pageAIContext && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{pageAIContext.routeName}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\n\t\t\t\t\t{onClear && (\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tonClear();\n\t\t\t\t\t\t\t\tsetChatResetKey((prev) => prev + 1);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\taria-label={tr(\n\t\t\t\t\t\t\t\t\"A11Y_CLEAR_CHAT\",\n\t\t\t\t\t\t\t\t\"aiChat.a11y.clearChat\",\n\t\t\t\t\t\t\t\t\"Clear chat\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\ttitle={tr(\n\t\t\t\t\t\t\t\t\"A11Y_CLEAR_CHAT\",\n\t\t\t\t\t\t\t\t\"aiChat.a11y.clearChat\",\n\t\t\t\t\t\t\t\t\"Clear chat\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t\n\t\t\t
\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tSheet,\n\tSheetContent,\n\tSheetTrigger,\n} from \"@/components/ui/sheet\";\nimport {\n\tMenu,\n\tPanelLeftClose,\n\tPanelLeft,\n\tSparkles,\n\tTrash2,\n\tX,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { ChatSidebar } from \"./chat-sidebar\";\nimport { ChatInterface } from \"./chat-interface\";\nimport type { UIMessage } from \"ai\";\nimport { usePageAIContext } from \"../context/page-ai-context\";\nimport { usePluginOverrides, useStack } from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../overrides\";\nimport { resolveAiChatMode } from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\n\ninterface ChatLayoutBaseProps {\n\t/** Current conversation ID (if viewing existing conversation) */\n\tconversationId?: string;\n\t/** Additional class name for the container */\n\tclassName?: string;\n\t/** Whether to show the sidebar */\n\tshowSidebar?: boolean;\n\t/** Initial messages to populate the chat (useful for localStorage persistence in public mode) */\n\tinitialMessages?: UIMessage[];\n\t/** Called whenever messages change (for persistence). Only fires in public mode. */\n\tonMessagesChange?: (messages: UIMessage[]) => void;\n\t/** Called when the user clears the chat (e.g. clicks the trash button in widget mode). Use this to clear persisted messages. */\n\tonClear?: () => void;\n}\n\ninterface ChatLayoutWidgetProps extends ChatLayoutBaseProps {\n\t/** Widget mode: compact embeddable panel with a floating trigger button */\n\tlayout: \"widget\";\n\t/** Height of the widget panel. Default: `\"600px\"` */\n\twidgetHeight?: string | number;\n\t/** Width of the widget panel. Default: `\"380px\"` */\n\twidgetWidth?: string | number;\n\t/**\n\t * Whether the widget panel starts open. Default: `false`.\n\t * Set to `true` when embedding inside an already-open container such as a\n\t * Next.js intercepting-route modal — the panel will be immediately visible\n\t * without the user needing to click the trigger button.\n\t */\n\tdefaultOpen?: boolean;\n\t/**\n\t * Whether to render the built-in floating trigger button. Default: `true`.\n\t * Set to `false` when you control open/close externally (e.g. a Next.js\n\t * parallel-route slot, a custom button, or a `router.back()` dismiss action)\n\t * so that the built-in button does not appear alongside your own UI.\n\t */\n\tshowTrigger?: boolean;\n}\n\ninterface ChatLayoutFullProps extends ChatLayoutBaseProps {\n\t/** Full-page mode with sidebar navigation (default) */\n\tlayout?: \"full\";\n}\n\n/** Props for the ChatLayout component */\nexport type ChatLayoutProps = ChatLayoutWidgetProps | ChatLayoutFullProps;\n\n/**\n * ChatLayout component that provides a full-page chat experience with sidebar\n * or a compact widget mode for embedding.\n */\nexport function ChatLayout(props: ChatLayoutProps) {\n\tconst {\n\t\tconversationId,\n\t\tlayout = \"full\",\n\t\tclassName,\n\t\tshowSidebar: requestedShowSidebar = true,\n\t\tinitialMessages,\n\t\tonMessagesChange,\n\t\tonClear,\n\t} = props;\n\tconst { localization, classNames } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"aiChat\", {});\n\tconst { plugins } = useStack();\n\tconst resolvedMode = resolveAiChatMode(plugins?.aiChat?.config);\n\tconst showSidebar = requestedShowSidebar && resolvedMode !== \"public\";\n\tconst tr = useAiChatTranslation(localization);\n\n\t// Widget-specific props — TypeScript narrows props to ChatLayoutWidgetProps here\n\tconst widgetHeight =\n\t\tprops.layout === \"widget\" ? (props.widgetHeight ?? \"600px\") : \"600px\";\n\tconst widgetWidth =\n\t\tprops.layout === \"widget\" ? (props.widgetWidth ?? \"380px\") : \"380px\";\n\tconst defaultOpen =\n\t\tprops.layout === \"widget\" ? (props.defaultOpen ?? false) : false;\n\tconst showTrigger =\n\t\tprops.layout === \"widget\" ? (props.showTrigger ?? true) : true;\n\n\tconst [sidebarOpen, setSidebarOpen] = useState(true);\n\tconst [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);\n\t// Key to force ChatInterface remount when starting a new chat\n\tconst [chatResetKey, setChatResetKey] = useState(0);\n\t// Widget open/closed state — starts with defaultOpen value\n\tconst [widgetOpen, setWidgetOpen] = useState(defaultOpen);\n\t// Key to force widget ChatInterface remount on clear\n\tconst [widgetResetKey, setWidgetResetKey] = useState(0);\n\t// Only mount the widget ChatInterface after the widget has been opened at least once.\n\t// This ensures pageAIContext is already registered before ChatInterface first renders,\n\t// so suggestion chips and tool hints appear immediately on first open.\n\t// When defaultOpen is true the widget is pre-opened, so we mark it as ever-opened immediately.\n\tconst [widgetEverOpened, setWidgetEverOpened] = useState(defaultOpen);\n\n\t// Read page AI context to show badge in header\n\tconst pageAIContext = usePageAIContext();\n\n\t// Handler for \"New chat\" button - increments key to force remount\n\tconst handleNewChat = useCallback(() => {\n\t\t// Only needed when we're already on the \"new chat\" route (/chat).\n\t\t// If we're on /chat/:id, navigation to /chat will remount ChatLayout/ChatInterface anyway.\n\t\tif (!conversationId) {\n\t\t\tsetChatResetKey((prev) => prev + 1);\n\t\t}\n\t}, [conversationId]);\n\n\tif (layout === \"widget\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{/* Chat panel — always mounted to preserve conversation state, hidden when closed */}\n\t\t\t\t\n\t\t\t\t\t{/* Widget header with page context badge and action buttons */}\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t{pageAIContext ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{pageAIContext.routeName}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{tr(\"A11Y_CHAT_TITLE\", \"aiChat.a11y.title\", \"AI Chat\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tonClear?.();\n\t\t\t\t\t\t\t\tsetWidgetResetKey((prev) => prev + 1);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\taria-label={tr(\n\t\t\t\t\t\t\t\t\"A11Y_CLEAR_CHAT\",\n\t\t\t\t\t\t\t\t\"aiChat.a11y.clearChat\",\n\t\t\t\t\t\t\t\t\"Clear chat\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\ttitle={tr(\n\t\t\t\t\t\t\t\t\"A11Y_CLEAR_CHAT\",\n\t\t\t\t\t\t\t\t\"aiChat.a11y.clearChat\",\n\t\t\t\t\t\t\t\t\"Clear chat\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t setWidgetOpen(false)}\n\t\t\t\t\t\t\taria-label={tr(\n\t\t\t\t\t\t\t\t\"A11Y_CLOSE_CHAT\",\n\t\t\t\t\t\t\t\t\"aiChat.a11y.closeChat\",\n\t\t\t\t\t\t\t\t\"Close chat\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t{widgetEverOpened && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{/* Trigger button — rendered only when showTrigger is true */}\n\t\t\t\t{showTrigger && (\n\t\t\t\t\t {\n\t\t\t\t\t\t\tsetWidgetOpen((prev) => !prev);\n\t\t\t\t\t\t\tsetWidgetEverOpened(true);\n\t\t\t\t\t\t}}\n\t\t\t\t\t\taria-label={\n\t\t\t\t\t\t\twidgetOpen\n\t\t\t\t\t\t\t\t? tr(\"A11Y_CLOSE_CHAT\", \"aiChat.a11y.closeChat\", \"Close chat\")\n\t\t\t\t\t\t\t\t: tr(\"A11Y_OPEN_CHAT\", \"aiChat.a11y.openChat\", \"Open chat\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tdata-testid=\"widget-trigger\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{widgetOpen ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t);\n\t}\n\n\t// Full layout with sidebar\n\treturn (\n\t\t\n\t\t\t{/* Desktop Sidebar */}\n\t\t\t{showSidebar && (\n\t\t\t\t\n\t\t\t\t\t{sidebarOpen && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{/* Main Chat Area */}\n\t\t\t
\n\t\t\t\t{/* Header */}\n\t\t\t\t
\n\t\t\t\t\t{/* Mobile menu button */}\n\t\t\t\t\t{showSidebar && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\thandleNewChat();\n\t\t\t\t\t\t\t\t\t\tsetMobileSidebarOpen(false);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* Desktop sidebar toggle */}\n\t\t\t\t\t{showSidebar && (\n\t\t\t\t\t\t setSidebarOpen(!sidebarOpen)}\n\t\t\t\t\t\t\taria-label={\n\t\t\t\t\t\t\t\tsidebarOpen\n\t\t\t\t\t\t\t\t\t? tr(\n\t\t\t\t\t\t\t\t\t\t\t\"A11Y_CLOSE_SIDEBAR\",\n\t\t\t\t\t\t\t\t\t\t\t\"aiChat.a11y.closeSidebar\",\n\t\t\t\t\t\t\t\t\t\t\t\"Close sidebar\",\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t: tr(\n\t\t\t\t\t\t\t\t\t\t\t\"A11Y_OPEN_SIDEBAR\",\n\t\t\t\t\t\t\t\t\t\t\t\"aiChat.a11y.openSidebar\",\n\t\t\t\t\t\t\t\t\t\t\t\"Open sidebar\",\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{sidebarOpen ? (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\n\t\t\t\t\t
\n\n\t\t\t\t\t{/* Page context badge — shown when a page has registered AI context */}\n\t\t\t\t\t{pageAIContext && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{pageAIContext.routeName}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\n\t\t\t\t\t{onClear && (\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tonClear();\n\t\t\t\t\t\t\t\tsetChatResetKey((prev) => prev + 1);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\taria-label={tr(\n\t\t\t\t\t\t\t\t\"A11Y_CLEAR_CHAT\",\n\t\t\t\t\t\t\t\t\"aiChat.a11y.clearChat\",\n\t\t\t\t\t\t\t\t\"Clear chat\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\ttitle={tr(\n\t\t\t\t\t\t\t\t\"A11Y_CLEAR_CHAT\",\n\t\t\t\t\t\t\t\t\"aiChat.a11y.clearChat\",\n\t\t\t\t\t\t\t\t\"Clear chat\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t\n\t\t\t
\n\t\t
\n\t);\n}\n", "target": "src/components/btst/ai-chat/client/components/chat-layout.tsx" }, { "path": "btst/ai-chat/client/components/chat-message.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { MarkdownContent } from \"@/components/ui/markdown-content\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n\tUser,\n\tBot,\n\tFileText,\n\tCopy,\n\tCheck,\n\tRefreshCw,\n\tPencil,\n\tSend,\n\tX,\n} from \"lucide-react\";\nimport type { UIMessage } from \"ai\";\nimport { isToolUIPart, getToolName } from \"ai\";\nimport {\n\tuseMemo,\n\tuseState,\n\tuseRef,\n\tuseEffect,\n\ttype ComponentType,\n} from \"react\";\nimport { usePluginOverrides, useStack } from \"@btst/stack/context\";\nimport type {\n\tAiChatPluginOverrides,\n\tToolCallProps,\n\tToolCallState,\n} from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\nimport { ToolCallDisplay } from \"./tool-call-display\";\n\n// Import shared markdown + syntax highlighting styles (same pattern as blog plugin)\nimport \"@/styles/markdown-content.css\";\nimport \"highlight.js/styles/panda-syntax-light.css\";\n\n// Import remend for streaming markdown\nimport completeMarkdown from \"remend\";\n\n// Fallback components (so consumers don't have to provide them)\nconst DefaultLink = (props: React.ComponentProps<\"a\">) => ;\nconst DefaultImage = (props: React.ImgHTMLAttributes) => (\n\t\n);\n\n// Clickable image component that opens in a dialog\ninterface ClickableImageProps {\n\tsrc: string;\n\talt: string;\n\t/** Width in pixels for the thumbnail */\n\twidth?: number;\n\t/** Height in pixels for the thumbnail */\n\theight?: number;\n\tclassName?: string;\n\tImageComponent: ComponentType>;\n}\n\nfunction ClickableImage({\n\tsrc,\n\talt,\n\twidth = 200,\n\theight = 200,\n\tclassName,\n\tImageComponent,\n}: ClickableImageProps) {\n\tconst [isOpen, setIsOpen] = useState(false);\n\n\treturn (\n\t\t<>\n\t\t\t setIsOpen(true)}\n\t\t\t\tclassName=\"cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-md overflow-hidden\"\n\t\t\t\tstyle={{ width, height }}\n\t\t\t>\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{alt}\n\t\t\t\t\t
\n\t\t\t\t\t\t{/* Use native img for full-size preview to avoid Next.js Image constraints */}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n}\n\ninterface ChatMessageProps {\n\tmessage: UIMessage;\n\tisStreaming?: boolean;\n\tvariant?: \"default\" | \"compact\";\n\t/** Callback when user wants to retry/regenerate an AI response */\n\tonRetry?: () => void;\n\t/** Callback when user edits their message - receives the new text */\n\tonEdit?: (newText: string) => void;\n\t/** Whether retry is currently in progress */\n\tisRetrying?: boolean;\n}\n\nexport function ChatMessage({\n\tmessage,\n\tisStreaming = false,\n\tvariant = \"default\",\n\tonRetry,\n\tonEdit,\n\tisRetrying = false,\n}: ChatMessageProps) {\n\tconst isUser = message.role === \"user\";\n\n\tconst { localization: customLocalization, toolRenderers } =\n\t\tusePluginOverrides>(\n\t\t\t\"aiChat\",\n\t\t\t{},\n\t\t);\n\tconst { router } = useStack();\n\tconst Link = router?.Link;\n\tconst Image = router?.Image;\n\n\tconst tr = useAiChatTranslation(customLocalization);\n\n\t// Use provided Image component or fallback to default\n\tconst ImageComponent = Image ?? DefaultImage;\n\n\t// State for copy button\n\tconst [copied, setCopied] = useState(false);\n\n\t// State for editing user messages\n\tconst [isEditing, setIsEditing] = useState(false);\n\tconst [editText, setEditText] = useState(\"\");\n\tconst editTextareaRef = useRef(null);\n\n\t// Auto-resize textarea when editing\n\tuseEffect(() => {\n\t\tif (isEditing && editTextareaRef.current) {\n\t\t\teditTextareaRef.current.style.height = \"auto\";\n\t\t\teditTextareaRef.current.style.height = `${editTextareaRef.current.scrollHeight}px`;\n\t\t\teditTextareaRef.current.focus();\n\t\t}\n\t}, [isEditing, editText]);\n\n\t// Copy message text to clipboard\n\tconst handleCopy = async () => {\n\t\ttry {\n\t\t\tawait navigator.clipboard.writeText(textContent);\n\t\t\tsetCopied(true);\n\t\t\tsetTimeout(() => setCopied(false), 2000);\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to copy text:\", err);\n\t\t}\n\t};\n\n\t// Start editing\n\tconst handleStartEdit = () => {\n\t\tsetEditText(textContent);\n\t\tsetIsEditing(true);\n\t};\n\n\t// Cancel editing\n\tconst handleCancelEdit = () => {\n\t\tsetIsEditing(false);\n\t\tsetEditText(\"\");\n\t};\n\n\t// Save edited message\n\tconst handleSaveEdit = () => {\n\t\tif (editText.trim() && onEdit) {\n\t\t\tonEdit(editText.trim());\n\t\t\tsetIsEditing(false);\n\t\t\tsetEditText(\"\");\n\t\t}\n\t};\n\n\t// Handle keyboard shortcuts in edit mode\n\tconst handleEditKeyDown = (e: React.KeyboardEvent) => {\n\t\tif (e.key === \"Enter\" && !e.shiftKey) {\n\t\t\te.preventDefault();\n\t\t\thandleSaveEdit();\n\t\t} else if (e.key === \"Escape\") {\n\t\t\thandleCancelEdit();\n\t\t}\n\t};\n\n\t// Extract text content from message parts\n\tconst textContent = useMemo(() => {\n\t\tif (message.parts && Array.isArray(message.parts)) {\n\t\t\treturn message.parts\n\t\t\t\t.filter((part: any) => part.type === \"text\")\n\t\t\t\t.map((part: any) => part.text)\n\t\t\t\t.join(\"\");\n\t\t}\n\t\treturn \"\";\n\t}, [message.parts]);\n\n\t// Extract image parts from message\n\tconst imageParts = useMemo(() => {\n\t\tif (message.parts && Array.isArray(message.parts)) {\n\t\t\treturn message.parts.filter(\n\t\t\t\t(part: any) =>\n\t\t\t\t\tpart.type === \"file\" && part.mediaType?.startsWith(\"image/\"),\n\t\t\t);\n\t\t}\n\t\treturn [];\n\t}, [message.parts]);\n\n\t// Extract non-image file parts from message (PDFs, text files, etc.)\n\tconst fileParts = useMemo(() => {\n\t\tif (message.parts && Array.isArray(message.parts)) {\n\t\t\treturn message.parts.filter(\n\t\t\t\t(part: any) =>\n\t\t\t\t\tpart.type === \"file\" && !part.mediaType?.startsWith(\"image/\"),\n\t\t\t);\n\t\t}\n\t\treturn [];\n\t}, [message.parts]);\n\n\t// Extract tool call parts from message (AI SDK v5 format: type is \"tool-{toolName}\")\n\tconst toolParts = useMemo(() => {\n\t\tif (message.parts && Array.isArray(message.parts)) {\n\t\t\treturn message.parts.filter((part: any) => isToolUIPart(part));\n\t\t}\n\t\treturn [];\n\t}, [message.parts]);\n\n\t// Use remend to complete partial markdown when streaming\n\tconst displayContent = useMemo(() => {\n\t\tif (!textContent) return \"\";\n\t\tif (isStreaming && !isUser) {\n\t\t\t// Complete any unclosed markdown elements during streaming\n\t\t\treturn completeMarkdown(textContent);\n\t\t}\n\t\treturn textContent;\n\t}, [textContent, isStreaming, isUser]);\n\n\tconst isCompact = variant === \"compact\";\n\n\treturn (\n\t\t\n\t\t\t{/* Assistant Avatar */}\n\t\t\t{!isUser && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{/* Message Content with Action Buttons */}\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t{isUser ? (\n\t\t\t\t\t\t// User messages: files + images + text (or edit mode)\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{/* Attached files (non-images) */}\n\t\t\t\t\t\t\t{fileParts.length > 0 && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Attached images */}\n\t\t\t\t\t\t\t{imageParts.length > 0 && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{imageParts.map((part: any, index: number) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Text content or edit mode */}\n\t\t\t\t\t\t\t{isEditing ? (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t setEditText(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tonKeyDown={handleEditKeyDown}\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"w-full min-w-[200px] resize-none rounded-md border-0 bg-primary-foreground/10 p-2 text-sm\",\n\t\t\t\t\t\t\t\t\t\t\t\"focus:outline-none focus:ring-1 focus:ring-primary-foreground/30\",\n\t\t\t\t\t\t\t\t\t\t\t\"text-primary-foreground placeholder:text-primary-foreground/50\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\trows={1}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\ttextContent && (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{textContent}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t) : (\n\t\t\t\t\t\t// Assistant messages: rendered markdown + files + images + tool calls\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{/* Any attached files (non-images) */}\n\t\t\t\t\t\t\t{fileParts.length > 0 && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{fileParts.map((part: any, index: number) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{part.filename ||\n\t\t\t\t\t\t\t\t\t\t\t\t\ttr(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"FILE_FALLBACK_NAME\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.files.fallbackName\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"File\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Any attached/generated images */}\n\t\t\t\t\t\t\t{imageParts.length > 0 && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{imageParts.map((part: any, index: number) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Tool calls */}\n\t\t\t\t\t\t\t{toolParts.length > 0 && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{toolParts.map((part: any) => {\n\t\t\t\t\t\t\t\t\t\tconst toolName = getToolName(part);\n\t\t\t\t\t\t\t\t\t\tconst toolCallId = part.toolCallId;\n\t\t\t\t\t\t\t\t\t\tconst state = part.state as ToolCallState;\n\t\t\t\t\t\t\t\t\t\tconst input = part.input;\n\t\t\t\t\t\t\t\t\t\tconst output = part.output;\n\t\t\t\t\t\t\t\t\t\tconst errorText = part.errorText;\n\t\t\t\t\t\t\t\t\t\tconst isLoading =\n\t\t\t\t\t\t\t\t\t\t\tstate === \"input-streaming\" ||\n\t\t\t\t\t\t\t\t\t\t\tstate === \"input-available\";\n\n\t\t\t\t\t\t\t\t\t\tconst toolCallProps: ToolCallProps = {\n\t\t\t\t\t\t\t\t\t\t\ttoolCallId,\n\t\t\t\t\t\t\t\t\t\t\ttoolName,\n\t\t\t\t\t\t\t\t\t\t\tstate,\n\t\t\t\t\t\t\t\t\t\t\tinput,\n\t\t\t\t\t\t\t\t\t\t\toutput,\n\t\t\t\t\t\t\t\t\t\t\terrorText,\n\t\t\t\t\t\t\t\t\t\t\tisLoading,\n\t\t\t\t\t\t\t\t\t\t};\n\n\t\t\t\t\t\t\t\t\t\t// Check if there's a custom renderer for this tool\n\t\t\t\t\t\t\t\t\t\tconst CustomRenderer = toolRenderers?.[toolName];\n\t\t\t\t\t\t\t\t\t\tif (CustomRenderer) {\n\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\t// Use default tool call display\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Text content */}\n\t\t\t\t\t\t\t{displayContent ? (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t) : imageParts.length === 0 &&\n\t\t\t\t\t\t\t\tfileParts.length === 0 &&\n\t\t\t\t\t\t\t\ttoolParts.length === 0 ? (\n\t\t\t\t\t\t\t\t...\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{/* Action Buttons */}\n\t\t\t\t{!isStreaming && !isEditing && (\n\t\t\t\t\t\n\t\t\t\t\t\t{isUser ? (\n\t\t\t\t\t\t\t// User message actions: Edit\n\t\t\t\t\t\t\tonEdit && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t// Assistant message actions: Copy and Retry\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{copied ? (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{onRetry && (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\n\t\t\t{/* User Avatar */}\n\t\t\t{isUser && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { MarkdownContent } from \"@/components/ui/markdown-content\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n\tUser,\n\tBot,\n\tFileText,\n\tCopy,\n\tCheck,\n\tRefreshCw,\n\tPencil,\n\tSend,\n\tX,\n} from \"lucide-react\";\nimport type { UIMessage } from \"ai\";\nimport { isToolUIPart, getToolName } from \"ai\";\nimport {\n\tuseMemo,\n\tuseState,\n\tuseRef,\n\tuseEffect,\n\ttype ComponentType,\n} from \"react\";\nimport { usePluginOverrides, useStack } from \"@btst/stack/context\";\nimport type {\n\tAiChatPluginOverrides,\n\tToolCallProps,\n\tToolCallState,\n} from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\nimport { ToolCallDisplay } from \"./tool-call-display\";\n\n// Import shared markdown + syntax highlighting styles (same pattern as blog plugin)\nimport \"@/styles/markdown-content.css\";\nimport \"highlight.js/styles/panda-syntax-light.css\";\n\n// Import remend for streaming markdown\nimport completeMarkdown from \"remend\";\n\n// Fallback components (so consumers don't have to provide them)\nconst DefaultLink = (props: React.ComponentProps<\"a\">) => ;\nconst DefaultImage = (props: React.ImgHTMLAttributes) => (\n\t\n);\n\n// Clickable image component that opens in a dialog\ninterface ClickableImageProps {\n\tsrc: string;\n\talt: string;\n\t/** Width in pixels for the thumbnail */\n\twidth?: number;\n\t/** Height in pixels for the thumbnail */\n\theight?: number;\n\tclassName?: string;\n\tImageComponent: ComponentType>;\n}\n\nfunction ClickableImage({\n\tsrc,\n\talt,\n\twidth = 200,\n\theight = 200,\n\tclassName,\n\tImageComponent,\n}: ClickableImageProps) {\n\tconst [isOpen, setIsOpen] = useState(false);\n\n\treturn (\n\t\t<>\n\t\t\t setIsOpen(true)}\n\t\t\t\tclassName=\"cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-md overflow-hidden\"\n\t\t\t\tstyle={{ width, height }}\n\t\t\t>\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{alt}\n\t\t\t\t\t
\n\t\t\t\t\t\t{/* Use native img for full-size preview to avoid Next.js Image constraints */}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n}\n\ninterface ChatMessageProps {\n\tmessage: UIMessage;\n\tisStreaming?: boolean;\n\tvariant?: \"default\" | \"compact\";\n\t/** Callback when user wants to retry/regenerate an AI response */\n\tonRetry?: () => void;\n\t/** Callback when user edits their message - receives the new text */\n\tonEdit?: (newText: string) => void;\n\t/** Whether retry is currently in progress */\n\tisRetrying?: boolean;\n}\n\nexport function ChatMessage({\n\tmessage,\n\tisStreaming = false,\n\tvariant = \"default\",\n\tonRetry,\n\tonEdit,\n\tisRetrying = false,\n}: ChatMessageProps) {\n\tconst isUser = message.role === \"user\";\n\n\tconst {\n\t\tlocalization: customLocalization,\n\t\ttoolRenderers,\n\t\tclassNames,\n\t} = usePluginOverrides>(\n\t\t\"aiChat\",\n\t\t{},\n\t);\n\tconst { router } = useStack();\n\tconst Link = router?.Link;\n\tconst Image = router?.Image;\n\n\tconst tr = useAiChatTranslation(customLocalization);\n\n\t// Use provided Image component or fallback to default\n\tconst ImageComponent = Image ?? DefaultImage;\n\n\t// State for copy button\n\tconst [copied, setCopied] = useState(false);\n\n\t// State for editing user messages\n\tconst [isEditing, setIsEditing] = useState(false);\n\tconst [editText, setEditText] = useState(\"\");\n\tconst editTextareaRef = useRef(null);\n\n\t// Auto-resize textarea when editing\n\tuseEffect(() => {\n\t\tif (isEditing && editTextareaRef.current) {\n\t\t\teditTextareaRef.current.style.height = \"auto\";\n\t\t\teditTextareaRef.current.style.height = `${editTextareaRef.current.scrollHeight}px`;\n\t\t\teditTextareaRef.current.focus();\n\t\t}\n\t}, [isEditing, editText]);\n\n\t// Copy message text to clipboard\n\tconst handleCopy = async () => {\n\t\ttry {\n\t\t\tawait navigator.clipboard.writeText(textContent);\n\t\t\tsetCopied(true);\n\t\t\tsetTimeout(() => setCopied(false), 2000);\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to copy text:\", err);\n\t\t}\n\t};\n\n\t// Start editing\n\tconst handleStartEdit = () => {\n\t\tsetEditText(textContent);\n\t\tsetIsEditing(true);\n\t};\n\n\t// Cancel editing\n\tconst handleCancelEdit = () => {\n\t\tsetIsEditing(false);\n\t\tsetEditText(\"\");\n\t};\n\n\t// Save edited message\n\tconst handleSaveEdit = () => {\n\t\tif (editText.trim() && onEdit) {\n\t\t\tonEdit(editText.trim());\n\t\t\tsetIsEditing(false);\n\t\t\tsetEditText(\"\");\n\t\t}\n\t};\n\n\t// Handle keyboard shortcuts in edit mode\n\tconst handleEditKeyDown = (e: React.KeyboardEvent) => {\n\t\tif (e.key === \"Enter\" && !e.shiftKey) {\n\t\t\te.preventDefault();\n\t\t\thandleSaveEdit();\n\t\t} else if (e.key === \"Escape\") {\n\t\t\thandleCancelEdit();\n\t\t}\n\t};\n\n\t// Extract text content from message parts\n\tconst textContent = useMemo(() => {\n\t\tif (message.parts && Array.isArray(message.parts)) {\n\t\t\treturn message.parts\n\t\t\t\t.filter((part: any) => part.type === \"text\")\n\t\t\t\t.map((part: any) => part.text)\n\t\t\t\t.join(\"\");\n\t\t}\n\t\treturn \"\";\n\t}, [message.parts]);\n\n\t// Extract image parts from message\n\tconst imageParts = useMemo(() => {\n\t\tif (message.parts && Array.isArray(message.parts)) {\n\t\t\treturn message.parts.filter(\n\t\t\t\t(part: any) =>\n\t\t\t\t\tpart.type === \"file\" && part.mediaType?.startsWith(\"image/\"),\n\t\t\t);\n\t\t}\n\t\treturn [];\n\t}, [message.parts]);\n\n\t// Extract non-image file parts from message (PDFs, text files, etc.)\n\tconst fileParts = useMemo(() => {\n\t\tif (message.parts && Array.isArray(message.parts)) {\n\t\t\treturn message.parts.filter(\n\t\t\t\t(part: any) =>\n\t\t\t\t\tpart.type === \"file\" && !part.mediaType?.startsWith(\"image/\"),\n\t\t\t);\n\t\t}\n\t\treturn [];\n\t}, [message.parts]);\n\n\t// Extract tool call parts from message (AI SDK v5 format: type is \"tool-{toolName}\")\n\tconst toolParts = useMemo(() => {\n\t\tif (message.parts && Array.isArray(message.parts)) {\n\t\t\treturn message.parts.filter((part: any) => isToolUIPart(part));\n\t\t}\n\t\treturn [];\n\t}, [message.parts]);\n\n\t// Use remend to complete partial markdown when streaming\n\tconst displayContent = useMemo(() => {\n\t\tif (!textContent) return \"\";\n\t\tif (isStreaming && !isUser) {\n\t\t\t// Complete any unclosed markdown elements during streaming\n\t\t\treturn completeMarkdown(textContent);\n\t\t}\n\t\treturn textContent;\n\t}, [textContent, isStreaming, isUser]);\n\n\tconst isCompact = variant === \"compact\";\n\n\treturn (\n\t\t\n\t\t\t{/* Assistant Avatar */}\n\t\t\t{!isUser && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{/* Message Content with Action Buttons */}\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t{isUser ? (\n\t\t\t\t\t\t// User messages: files + images + text (or edit mode)\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{/* Attached files (non-images) */}\n\t\t\t\t\t\t\t{fileParts.length > 0 && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Attached images */}\n\t\t\t\t\t\t\t{imageParts.length > 0 && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{imageParts.map((part: any, index: number) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Text content or edit mode */}\n\t\t\t\t\t\t\t{isEditing ? (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t setEditText(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tonKeyDown={handleEditKeyDown}\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"w-full min-w-[200px] resize-none rounded-md border-0 bg-primary-foreground/10 p-2 text-sm\",\n\t\t\t\t\t\t\t\t\t\t\t\"focus:outline-none focus:ring-1 focus:ring-primary-foreground/30\",\n\t\t\t\t\t\t\t\t\t\t\t\"text-primary-foreground placeholder:text-primary-foreground/50\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\trows={1}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\ttextContent && (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{textContent}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t) : (\n\t\t\t\t\t\t// Assistant messages: rendered markdown + files + images + tool calls\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{/* Any attached files (non-images) */}\n\t\t\t\t\t\t\t{fileParts.length > 0 && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{fileParts.map((part: any, index: number) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{part.filename ||\n\t\t\t\t\t\t\t\t\t\t\t\t\ttr(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"FILE_FALLBACK_NAME\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.files.fallbackName\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"File\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Any attached/generated images */}\n\t\t\t\t\t\t\t{imageParts.length > 0 && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{imageParts.map((part: any, index: number) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Tool calls */}\n\t\t\t\t\t\t\t{toolParts.length > 0 && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{toolParts.map((part: any) => {\n\t\t\t\t\t\t\t\t\t\tconst toolName = getToolName(part);\n\t\t\t\t\t\t\t\t\t\tconst toolCallId = part.toolCallId;\n\t\t\t\t\t\t\t\t\t\tconst state = part.state as ToolCallState;\n\t\t\t\t\t\t\t\t\t\tconst input = part.input;\n\t\t\t\t\t\t\t\t\t\tconst output = part.output;\n\t\t\t\t\t\t\t\t\t\tconst errorText = part.errorText;\n\t\t\t\t\t\t\t\t\t\tconst isLoading =\n\t\t\t\t\t\t\t\t\t\t\tstate === \"input-streaming\" ||\n\t\t\t\t\t\t\t\t\t\t\tstate === \"input-available\";\n\n\t\t\t\t\t\t\t\t\t\tconst toolCallProps: ToolCallProps = {\n\t\t\t\t\t\t\t\t\t\t\ttoolCallId,\n\t\t\t\t\t\t\t\t\t\t\ttoolName,\n\t\t\t\t\t\t\t\t\t\t\tstate,\n\t\t\t\t\t\t\t\t\t\t\tinput,\n\t\t\t\t\t\t\t\t\t\t\toutput,\n\t\t\t\t\t\t\t\t\t\t\terrorText,\n\t\t\t\t\t\t\t\t\t\t\tisLoading,\n\t\t\t\t\t\t\t\t\t\t};\n\n\t\t\t\t\t\t\t\t\t\t// Check if there's a custom renderer for this tool\n\t\t\t\t\t\t\t\t\t\tconst CustomRenderer = toolRenderers?.[toolName];\n\t\t\t\t\t\t\t\t\t\tif (CustomRenderer) {\n\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\t// Use default tool call display\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Text content */}\n\t\t\t\t\t\t\t{displayContent ? (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t) : imageParts.length === 0 &&\n\t\t\t\t\t\t\t\tfileParts.length === 0 &&\n\t\t\t\t\t\t\t\ttoolParts.length === 0 ? (\n\t\t\t\t\t\t\t\t...\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{/* Action Buttons */}\n\t\t\t\t{!isStreaming && !isEditing && (\n\t\t\t\t\t\n\t\t\t\t\t\t{isUser ? (\n\t\t\t\t\t\t\t// User message actions: Edit\n\t\t\t\t\t\t\tonEdit && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t// Assistant message actions: Copy and Retry\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{copied ? (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{onRetry && (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\n\t\t\t{/* User Avatar */}\n\t\t\t{isUser && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n", "target": "src/components/btst/ai-chat/client/components/chat-message.tsx" }, { "path": "btst/ai-chat/client/components/chat-sidebar.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n\tMessageSquarePlus,\n\tMoreHorizontal,\n\tPencil,\n\tTrash2,\n\tMessageSquare,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n} from \"@btst/stack/context\";\nimport { aiChatPermissions } from \"../../permissions\";\nimport {\n\tresolveAiChatSiteLocation,\n\ttype AiChatPluginOverrides,\n} from \"../overrides\";\nimport type { SerializedConversation } from \"../../types\";\nimport {\n\tuseConversations,\n\tuseRenameConversationForm,\n\tuseDeleteConversation,\n} from \"@btst/stack/plugins/ai-chat/client/hooks\";\nimport { useAiChatTranslation } from \"../localization\";\nimport { navigateAiChatCrossOrigin } from \"../navigation\";\n\ninterface ChatSidebarProps {\n\tcurrentConversationId?: string;\n\tonNewChat?: () => void;\n\tclassName?: string;\n}\n\nexport function ChatSidebar({\n\tcurrentConversationId,\n\tonNewChat,\n\tclassName,\n}: ChatSidebarProps) {\n\tconst { localization: customLocalization } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"aiChat\", {});\n\tconst { basePath: stackBasePath, plugins, router } = useStack();\n\tconst navigate = router?.navigate;\n\tconst siteBaseURL = plugins?.aiChat?.site.baseURL;\n\tconst siteBasePath = plugins?.aiChat?.site.basePath ?? stackBasePath;\n\tconst notify = useNotify();\n\tconst tr = useAiChatTranslation(customLocalization);\n\tconst { conversations, isLoading } = useConversations();\n\tconst deleteMutation = useDeleteConversation();\n\n\tconst [renameDialogOpen, setRenameDialogOpen] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst [selectedConversation, setSelectedConversation] =\n\t\tuseState(null);\n\tconst [newTitle, setNewTitle] = useState(\"\");\n\tconst renameForm = useRenameConversationForm({\n\t\tconversation: selectedConversation,\n\t\tonSuccess: () => {\n\t\t\tsetRenameDialogOpen(false);\n\t\t\tsetSelectedConversation(null);\n\t\t\tsetNewTitle(\"\");\n\t\t},\n\t});\n\tconst navigateToChat = (...segments: string[]) => {\n\t\tconst location = resolveAiChatSiteLocation(\n\t\t\t{ baseURL: siteBaseURL, basePath: siteBasePath },\n\t\t\ttypeof window === \"undefined\" ? undefined : window.location.origin,\n\t\t\t\"chat\",\n\t\t\t...segments,\n\t\t);\n\t\tif (location.crossOrigin && typeof window !== \"undefined\") {\n\t\t\tnavigateAiChatCrossOrigin(location.href);\n\t\t\treturn;\n\t\t}\n\t\treturn navigate?.(location.path);\n\t};\n\n\tconst handleNewChat = () => {\n\t\t// Use the StackProvider router when available.\n\t\t// Also run onNewChat to support \"reset chat\" behavior when already on /chat.\n\t\tvoid navigateToChat();\n\t\tif (onNewChat) {\n\t\t\tonNewChat();\n\t\t}\n\t};\n\n\tconst handleConversationClick = (conversation: SerializedConversation) => {\n\t\tvoid navigateToChat(conversation.id);\n\t};\n\n\tconst handleRenameClick = (conversation: SerializedConversation) => {\n\t\trenameForm.clearErrors();\n\t\tsetSelectedConversation(conversation);\n\t\tsetNewTitle(conversation.title);\n\t\tsetRenameDialogOpen(true);\n\t};\n\n\tconst handleDeleteClick = (conversation: SerializedConversation) => {\n\t\tsetSelectedConversation(conversation);\n\t\tsetDeleteDialogOpen(true);\n\t};\n\n\tconst handleRenameConfirm = async () => {\n\t\tif (selectedConversation) {\n\t\t\tawait renameForm.submit({ title: newTitle });\n\t\t}\n\t};\n\n\tconst handleDeleteConfirm = async () => {\n\t\tif (selectedConversation) {\n\t\t\ttry {\n\t\t\t\tconst deletingCurrentConversation =\n\t\t\t\t\tselectedConversation.id === currentConversationId;\n\t\t\t\tif (deletingCurrentConversation) {\n\t\t\t\t\tawait deleteMutation.mutateAsync(\n\t\t\t\t\t\t{ id: selectedConversation.id },\n\t\t\t\t\t\t{ onSuccess: () => navigateToChat() },\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tawait deleteMutation.mutateAsync({ id: selectedConversation.id });\n\t\t\t\t}\n\t\t\t\tnotify.success(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"CONVERSATION_DELETE_SUCCESS\",\n\t\t\t\t\t\t\"aiChat.toasts.deleteSuccess\",\n\t\t\t\t\t\t\"Conversation deleted\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tsetDeleteDialogOpen(false);\n\t\t\t\tsetSelectedConversation(null);\n\t\t\t} catch {\n\t\t\t\tnotify.error(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"CONVERSATION_DELETE_FAILURE\",\n\t\t\t\t\t\t\"aiChat.toasts.deleteFailure\",\n\t\t\t\t\t\t\"Failed to delete conversation\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst formatDate = (dateString: string) => {\n\t\tconst date = new Date(dateString);\n\t\tconst now = new Date();\n\t\tconst diffMs = now.getTime() - date.getTime();\n\t\tconst diffMins = Math.floor(diffMs / 60000);\n\t\tconst diffHours = Math.floor(diffMs / 3600000);\n\t\tconst diffDays = Math.floor(diffMs / 86400000);\n\n\t\tif (diffMins < 1)\n\t\t\treturn tr(\"TIME_JUST_NOW\", \"aiChat.time.justNow\", \"Just now\");\n\t\tif (diffMins < 60)\n\t\t\treturn tr(\n\t\t\t\t\"TIME_MINUTES_AGO\",\n\t\t\t\t\"aiChat.time.minutesAgo\",\n\t\t\t\t\"{{count}} minutes ago\",\n\t\t\t\t{ count: diffMins },\n\t\t\t);\n\t\tif (diffHours < 24)\n\t\t\treturn tr(\n\t\t\t\t\"TIME_HOURS_AGO\",\n\t\t\t\t\"aiChat.time.hoursAgo\",\n\t\t\t\t\"{{count}} hours ago\",\n\t\t\t\t{ count: diffHours },\n\t\t\t);\n\t\tif (diffDays === 1)\n\t\t\treturn tr(\"TIME_YESTERDAY\", \"aiChat.time.yesterday\", \"Yesterday\");\n\t\tif (diffDays < 7)\n\t\t\treturn tr(\"TIME_DAYS_AGO\", \"aiChat.time.daysAgo\", \"{{count}} days ago\", {\n\t\t\t\tcount: diffDays,\n\t\t\t});\n\t\treturn date.toLocaleDateString();\n\t};\n\n\treturn (\n\t\t\n\t\t\t{/* Header */}\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{tr(\"SIDEBAR_NEW_CHAT\", \"aiChat.sidebar.newChat\", \"New chat\")}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\n\t\t\t{/* Conversations List */}\n\t\t\tdiv]:!block\">\n\t\t\t\t
\n\t\t\t\t\t{isLoading ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{tr(\"CHAT_LOADING\", \"aiChat.chat.loading\", \"Thinking...\")}\n\t\t\t\t\t\t
\n\t\t\t\t\t) : conversations.length === 0 ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"SIDEBAR_NO_CONVERSATIONS\",\n\t\t\t\t\t\t\t\t\"aiChat.sidebar.empty\",\n\t\t\t\t\t\t\t\t\"No conversations yet\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t) : (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{conversations.map((conversation) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t handleConversationClick(conversation)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t\t{conversation.title}\n\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t\t{formatDate(conversation.updatedAt)}\n\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleRenameClick(conversation);\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.conversation.rename\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Rename\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleDeleteClick(conversation);\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-destructive focus:text-destructive\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.conversation.delete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Delete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t
\n\n\t\t\t{/* Rename Dialog */}\n\t\t\t {\n\t\t\t\t\tsetRenameDialogOpen(open);\n\t\t\t\t\tif (!open) renameForm.clearErrors();\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.rename\",\n\t\t\t\t\t\t\t\t\"Rename\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME_DESCRIPTION\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.renameDescription\",\n\t\t\t\t\t\t\t\t\"Enter a new title for this conversation.\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t setNewTitle(e.target.value)}\n\t\t\t\t\t\tplaceholder={tr(\n\t\t\t\t\t\t\t\"CONVERSATION_RENAME_PLACEHOLDER\",\n\t\t\t\t\t\t\t\"aiChat.conversation.renamePlaceholder\",\n\t\t\t\t\t\t\t\"Enter conversation name\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\taria-invalid={!!renameForm.fieldErrors.title}\n\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\tif (e.key === \"Enter\") {\n\t\t\t\t\t\t\t\tvoid handleRenameConfirm();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t\t{renameForm.fieldErrors.title && (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_TITLE_REQUIRED\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.titleRequired\",\n\t\t\t\t\t\t\t\t\"Title is required\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t

\n\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t\t setRenameDialogOpen(false)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME_CANCEL\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.renameCancel\",\n\t\t\t\t\t\t\t\t\"Cancel\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME_SAVE\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.renameSave\",\n\t\t\t\t\t\t\t\t\"Save\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\n\t\t\t{/* Delete Confirmation Dialog */}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CONFIRM_TITLE\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteConfirmTitle\",\n\t\t\t\t\t\t\t\t\"Delete conversation\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CONFIRM_DESCRIPTION\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteConfirmDescription\",\n\t\t\t\t\t\t\t\t\"Are you sure you want to delete this conversation? This action cannot be undone.\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CANCEL\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteCancel\",\n\t\t\t\t\t\t\t\t\"Cancel\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\t\tvoid handleDeleteConfirm();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tdisabled={deleteMutation.isPending}\n\t\t\t\t\t\t\tclassName=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CONFIRM_BUTTON\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteConfirmButton\",\n\t\t\t\t\t\t\t\t\"Delete\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n\tMessageSquarePlus,\n\tMoreHorizontal,\n\tPencil,\n\tTrash2,\n\tMessageSquare,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n} from \"@btst/stack/context\";\nimport { aiChatPermissions } from \"../../permissions\";\nimport {\n\tresolveAiChatSiteLocation,\n\ttype AiChatPluginOverrides,\n} from \"../overrides\";\nimport type { SerializedConversation } from \"../../types\";\nimport {\n\tuseConversations,\n\tuseRenameConversationForm,\n\tuseDeleteConversation,\n} from \"@btst/stack/plugins/ai-chat/client/hooks\";\nimport { useAiChatTranslation } from \"../localization\";\nimport { navigateAiChatCrossOrigin } from \"../navigation\";\n\ninterface ChatSidebarProps {\n\tcurrentConversationId?: string;\n\tonNewChat?: () => void;\n\tclassName?: string;\n}\n\nexport function ChatSidebar({\n\tcurrentConversationId,\n\tonNewChat,\n\tclassName,\n}: ChatSidebarProps) {\n\tconst { localization: customLocalization, classNames } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"aiChat\", {});\n\tconst { basePath: stackBasePath, plugins, router } = useStack();\n\tconst navigate = router?.navigate;\n\tconst siteBaseURL = plugins?.aiChat?.site.baseURL;\n\tconst siteBasePath = plugins?.aiChat?.site.basePath ?? stackBasePath;\n\tconst notify = useNotify();\n\tconst tr = useAiChatTranslation(customLocalization);\n\tconst { conversations, isLoading } = useConversations();\n\tconst deleteMutation = useDeleteConversation();\n\n\tconst [renameDialogOpen, setRenameDialogOpen] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst [selectedConversation, setSelectedConversation] =\n\t\tuseState(null);\n\tconst [newTitle, setNewTitle] = useState(\"\");\n\tconst renameForm = useRenameConversationForm({\n\t\tconversation: selectedConversation,\n\t\tonSuccess: () => {\n\t\t\tsetRenameDialogOpen(false);\n\t\t\tsetSelectedConversation(null);\n\t\t\tsetNewTitle(\"\");\n\t\t},\n\t});\n\tconst navigateToChat = (...segments: string[]) => {\n\t\tconst location = resolveAiChatSiteLocation(\n\t\t\t{ baseURL: siteBaseURL, basePath: siteBasePath },\n\t\t\ttypeof window === \"undefined\" ? undefined : window.location.origin,\n\t\t\t\"chat\",\n\t\t\t...segments,\n\t\t);\n\t\tif (location.crossOrigin && typeof window !== \"undefined\") {\n\t\t\tnavigateAiChatCrossOrigin(location.href);\n\t\t\treturn;\n\t\t}\n\t\treturn navigate?.(location.path);\n\t};\n\n\tconst handleNewChat = () => {\n\t\t// Use the StackProvider router when available.\n\t\t// Also run onNewChat to support \"reset chat\" behavior when already on /chat.\n\t\tvoid navigateToChat();\n\t\tif (onNewChat) {\n\t\t\tonNewChat();\n\t\t}\n\t};\n\n\tconst handleConversationClick = (conversation: SerializedConversation) => {\n\t\tvoid navigateToChat(conversation.id);\n\t};\n\n\tconst handleRenameClick = (conversation: SerializedConversation) => {\n\t\trenameForm.clearErrors();\n\t\tsetSelectedConversation(conversation);\n\t\tsetNewTitle(conversation.title);\n\t\tsetRenameDialogOpen(true);\n\t};\n\n\tconst handleDeleteClick = (conversation: SerializedConversation) => {\n\t\tsetSelectedConversation(conversation);\n\t\tsetDeleteDialogOpen(true);\n\t};\n\n\tconst handleRenameConfirm = async () => {\n\t\tif (selectedConversation) {\n\t\t\tawait renameForm.submit({ title: newTitle });\n\t\t}\n\t};\n\n\tconst handleDeleteConfirm = async () => {\n\t\tif (selectedConversation) {\n\t\t\ttry {\n\t\t\t\tconst deletingCurrentConversation =\n\t\t\t\t\tselectedConversation.id === currentConversationId;\n\t\t\t\tif (deletingCurrentConversation) {\n\t\t\t\t\tawait deleteMutation.mutateAsync(\n\t\t\t\t\t\t{ id: selectedConversation.id },\n\t\t\t\t\t\t{ onSuccess: () => navigateToChat() },\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tawait deleteMutation.mutateAsync({ id: selectedConversation.id });\n\t\t\t\t}\n\t\t\t\tnotify.success(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"CONVERSATION_DELETE_SUCCESS\",\n\t\t\t\t\t\t\"aiChat.toasts.deleteSuccess\",\n\t\t\t\t\t\t\"Conversation deleted\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tsetDeleteDialogOpen(false);\n\t\t\t\tsetSelectedConversation(null);\n\t\t\t} catch {\n\t\t\t\tnotify.error(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"CONVERSATION_DELETE_FAILURE\",\n\t\t\t\t\t\t\"aiChat.toasts.deleteFailure\",\n\t\t\t\t\t\t\"Failed to delete conversation\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst formatDate = (dateString: string) => {\n\t\tconst date = new Date(dateString);\n\t\tconst now = new Date();\n\t\tconst diffMs = now.getTime() - date.getTime();\n\t\tconst diffMins = Math.floor(diffMs / 60000);\n\t\tconst diffHours = Math.floor(diffMs / 3600000);\n\t\tconst diffDays = Math.floor(diffMs / 86400000);\n\n\t\tif (diffMins < 1)\n\t\t\treturn tr(\"TIME_JUST_NOW\", \"aiChat.time.justNow\", \"Just now\");\n\t\tif (diffMins < 60)\n\t\t\treturn tr(\n\t\t\t\t\"TIME_MINUTES_AGO\",\n\t\t\t\t\"aiChat.time.minutesAgo\",\n\t\t\t\t\"{{count}} minutes ago\",\n\t\t\t\t{ count: diffMins },\n\t\t\t);\n\t\tif (diffHours < 24)\n\t\t\treturn tr(\n\t\t\t\t\"TIME_HOURS_AGO\",\n\t\t\t\t\"aiChat.time.hoursAgo\",\n\t\t\t\t\"{{count}} hours ago\",\n\t\t\t\t{ count: diffHours },\n\t\t\t);\n\t\tif (diffDays === 1)\n\t\t\treturn tr(\"TIME_YESTERDAY\", \"aiChat.time.yesterday\", \"Yesterday\");\n\t\tif (diffDays < 7)\n\t\t\treturn tr(\"TIME_DAYS_AGO\", \"aiChat.time.daysAgo\", \"{{count}} days ago\", {\n\t\t\t\tcount: diffDays,\n\t\t\t});\n\t\treturn date.toLocaleDateString();\n\t};\n\n\treturn (\n\t\t\n\t\t\t{/* Header */}\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{tr(\"SIDEBAR_NEW_CHAT\", \"aiChat.sidebar.newChat\", \"New chat\")}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\n\t\t\t{/* Conversations List */}\n\t\t\tdiv]:!block\">\n\t\t\t\t
\n\t\t\t\t\t{isLoading ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{tr(\"CHAT_LOADING\", \"aiChat.chat.loading\", \"Thinking...\")}\n\t\t\t\t\t\t
\n\t\t\t\t\t) : conversations.length === 0 ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"SIDEBAR_NO_CONVERSATIONS\",\n\t\t\t\t\t\t\t\t\"aiChat.sidebar.empty\",\n\t\t\t\t\t\t\t\t\"No conversations yet\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t) : (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{conversations.map((conversation) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t handleConversationClick(conversation)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t\t{conversation.title}\n\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t\t{formatDate(conversation.updatedAt)}\n\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleRenameClick(conversation);\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.conversation.rename\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Rename\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleDeleteClick(conversation);\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-destructive focus:text-destructive\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.conversation.delete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Delete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t
\n\n\t\t\t{/* Rename Dialog */}\n\t\t\t {\n\t\t\t\t\tsetRenameDialogOpen(open);\n\t\t\t\t\tif (!open) renameForm.clearErrors();\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.rename\",\n\t\t\t\t\t\t\t\t\"Rename\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME_DESCRIPTION\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.renameDescription\",\n\t\t\t\t\t\t\t\t\"Enter a new title for this conversation.\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t setNewTitle(e.target.value)}\n\t\t\t\t\t\tplaceholder={tr(\n\t\t\t\t\t\t\t\"CONVERSATION_RENAME_PLACEHOLDER\",\n\t\t\t\t\t\t\t\"aiChat.conversation.renamePlaceholder\",\n\t\t\t\t\t\t\t\"Enter conversation name\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\taria-invalid={!!renameForm.fieldErrors.title}\n\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\tif (e.key === \"Enter\") {\n\t\t\t\t\t\t\t\tvoid handleRenameConfirm();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t\t{renameForm.fieldErrors.title && (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_TITLE_REQUIRED\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.titleRequired\",\n\t\t\t\t\t\t\t\t\"Title is required\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t

\n\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t\t setRenameDialogOpen(false)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME_CANCEL\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.renameCancel\",\n\t\t\t\t\t\t\t\t\"Cancel\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME_SAVE\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.renameSave\",\n\t\t\t\t\t\t\t\t\"Save\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\n\t\t\t{/* Delete Confirmation Dialog */}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CONFIRM_TITLE\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteConfirmTitle\",\n\t\t\t\t\t\t\t\t\"Delete conversation\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CONFIRM_DESCRIPTION\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteConfirmDescription\",\n\t\t\t\t\t\t\t\t\"Are you sure you want to delete this conversation? This action cannot be undone.\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CANCEL\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteCancel\",\n\t\t\t\t\t\t\t\t\"Cancel\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\t\tvoid handleDeleteConfirm();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tdisabled={deleteMutation.isPending}\n\t\t\t\t\t\t\tclassName=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CONFIRM_BUTTON\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteConfirmButton\",\n\t\t\t\t\t\t\t\t\"Delete\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/ai-chat/client/components/chat-sidebar.tsx" }, { @@ -146,7 +146,7 @@ { "path": "btst/ai-chat/client/overrides.ts", "type": "registry:lib", - "content": "import type { ComponentType } from \"react\";\nimport { normalizePath } from \"@btst/stack/client\";\nimport type { AiChatLocalization } from \"./localization\";\n\n/**\n * Plugin mode for AI Chat\n * - 'authenticated': Conversations persisted with userId (default)\n * - 'public': Stateless chat, no persistence (ideal for public chatbots)\n */\nexport type AiChatMode = \"authenticated\" | \"public\";\n\n/** Browser-safe AI Chat factory values carried by the resolved client stack. */\nexport interface AiChatProviderConfig {\n\t/** Conversation behavior selected by `aiChatClientPlugin()`. */\n\treadonly mode: AiChatMode;\n}\n\n/** Resolve the registered AI Chat client factory mode. */\nexport function resolveAiChatMode(\n\tproviderConfig: Readonly> | undefined,\n): AiChatMode {\n\treturn providerConfig?.mode === \"public\" ? \"public\" : \"authenticated\";\n}\n\n/** Join an AI Chat route onto its resolved site mount without duplicate slashes. */\nexport function resolveAiChatSitePath(\n\tbasePath: string,\n\t...segments: string[]\n): string {\n\treturn normalizePath([basePath, ...segments].join(\"/\"));\n}\n\n/** Resolve the browser streaming URL from the effective AI Chat API mount. */\nexport function resolveAiChatApiUrl(\n\tbaseURL: string | undefined,\n\tbasePath: string | undefined,\n): string {\n\treturn `${baseURL ?? \"\"}${normalizePath([basePath ?? \"\", \"chat\"].join(\"/\"))}`;\n}\n\n/** Resolve path and absolute URL forms for an AI Chat page destination. */\nexport function resolveAiChatSiteLocation(\n\tsite: { baseURL?: string; basePath: string },\n\tcurrentOrigin: string | undefined,\n\t...segments: string[]\n): { path: string; href: string; crossOrigin: boolean } {\n\tconst path = resolveAiChatSitePath(site.basePath, ...segments);\n\tconst href = site.baseURL ? `${site.baseURL}${path}` : path;\n\treturn {\n\t\tpath,\n\t\thref,\n\t\tcrossOrigin:\n\t\t\tBoolean(site.baseURL && currentOrigin) &&\n\t\t\tnew URL(href).origin !== currentOrigin,\n\t};\n}\n\n/**\n * State of a tool call execution\n */\nexport type ToolCallState =\n\t| \"input-streaming\"\n\t| \"input-available\"\n\t| \"output-available\"\n\t| \"output-error\";\n\n/**\n * Props passed to custom tool call renderer components\n */\nexport interface ToolCallProps {\n\t/** Unique identifier for this tool call */\n\ttoolCallId: string;\n\t/** Name of the tool being called */\n\ttoolName: string;\n\t/** Current state of the tool call execution */\n\tstate: ToolCallState;\n\t/** Input arguments passed to the tool (may be partial during streaming) */\n\tinput: TInput | undefined;\n\t/** Output from the tool (only available when state is 'output-available') */\n\toutput: TOutput | undefined;\n\t/** Error message (only available when state is 'output-error') */\n\terrorText?: string;\n\t/** Whether the tool call is currently in progress */\n\tisLoading: boolean;\n}\n\n/**\n * A component that renders a custom UI for a specific tool call.\n * Return `null` to fall back to the default tool call accordion.\n */\nexport type ToolCallRenderer<\n\tTInput = unknown,\n\tTOutput = unknown,\n> = ComponentType>;\n\n/**\n * Allowed file type categories for uploads\n */\nexport type AllowedFileType =\n\t| \"image\"\n\t| \"text\"\n\t| \"pdf\"\n\t| \"markdown\"\n\t| \"csv\"\n\t| \"json\";\n\n/**\n * Default allowed file types (images only for best AI model compatibility)\n * Consumers can expand this by passing allowedFileTypes in overrides\n */\nexport const DEFAULT_ALLOWED_FILE_TYPES: AllowedFileType[] = [\"image\"];\n\n/**\n * MIME type mappings for each file type category\n */\nexport const FILE_TYPE_MIME_MAP: Record = {\n\timage: [\"image/*\"],\n\ttext: [\"text/plain\"],\n\tmarkdown: [\"text/markdown\"],\n\tcsv: [\"text/csv\"],\n\tpdf: [\"application/pdf\"],\n\tjson: [\"application/json\"],\n};\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { id: \"abc123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the AI Chat plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface AiChatPluginOverrides {\n\t/**\n\t * Function used to upload a file and return its URL.\n\t * Called for images, PDFs, text files, and other supported file types.\n\t */\n\tuploadFile?: (file: File) => Promise;\n\n\t/**\n\t * Allowed file types for upload.\n\t * By default, all types are enabled: image, text, pdf, markdown, csv, json\n\t * Set to empty array to disable file uploads entirely.\n\t * @default ['image', 'text', 'pdf', 'markdown', 'csv', 'json']\n\t */\n\tallowedFileTypes?: AllowedFileType[];\n\n\t/**\n\t * Localization object for the AI Chat plugin\n\t */\n\tlocalization?: Partial;\n\n\t/**\n\t * Whether to show the attribution\n\t * @default true\n\t */\n\tshowAttribution?: boolean;\n\n\t/**\n\t * Suggested prompts to display in the empty chat state.\n\t * When provided, these appear as clickable buttons that populate the input field.\n\t *\n\t * @example\n\t * ```tsx\n\t * chatSuggestions: [\n\t * \"What can you help me with?\",\n\t * \"Tell me about your features\",\n\t * \"How do I get started?\",\n\t * ]\n\t * ```\n\t */\n\tchatSuggestions?: string[];\n\n\t/**\n\t * Custom renderers for tool calls. Keys should match tool names.\n\t * Each renderer receives ToolCallProps and can return custom UI.\n\t *\n\t * @example\n\t * ```tsx\n\t * toolRenderers: {\n\t * getWeather: ({ toolName, input, output, state, isLoading }) => (\n\t * \n\t * ),\n\t * searchDocs: ({ input, output, isLoading }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\ttoolRenderers?: Record;\n\n\t// ============== Lifecycle Hooks (optional) ==============\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'chat', 'chatConversation')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", + "content": "import type { ComponentType } from \"react\";\nimport { normalizePath } from \"@btst/stack/client\";\nimport type { AiChatLocalization } from \"./localization\";\n\n/**\n * Plugin mode for AI Chat\n * - 'authenticated': Conversations persisted with userId (default)\n * - 'public': Stateless chat, no persistence (ideal for public chatbots)\n */\nexport type AiChatMode = \"authenticated\" | \"public\";\n\n/** Browser-safe AI Chat factory values carried by the resolved client stack. */\nexport interface AiChatProviderConfig {\n\t/** Conversation behavior selected by `aiChatClientPlugin()`. */\n\treadonly mode: AiChatMode;\n}\n\n/** Resolve the registered AI Chat client factory mode. */\nexport function resolveAiChatMode(\n\tproviderConfig: Readonly> | undefined,\n): AiChatMode {\n\treturn providerConfig?.mode === \"public\" ? \"public\" : \"authenticated\";\n}\n\n/** Join an AI Chat route onto its resolved site mount without duplicate slashes. */\nexport function resolveAiChatSitePath(\n\tbasePath: string,\n\t...segments: string[]\n): string {\n\treturn normalizePath([basePath, ...segments].join(\"/\"));\n}\n\n/** Resolve the browser streaming URL from the effective AI Chat API mount. */\nexport function resolveAiChatApiUrl(\n\tbaseURL: string | undefined,\n\tbasePath: string | undefined,\n): string {\n\treturn `${baseURL ?? \"\"}${normalizePath([basePath ?? \"\", \"chat\"].join(\"/\"))}`;\n}\n\n/** Resolve path and absolute URL forms for an AI Chat page destination. */\nexport function resolveAiChatSiteLocation(\n\tsite: { baseURL?: string; basePath: string },\n\tcurrentOrigin: string | undefined,\n\t...segments: string[]\n): { path: string; href: string; crossOrigin: boolean } {\n\tconst path = resolveAiChatSitePath(site.basePath, ...segments);\n\tconst href = site.baseURL ? `${site.baseURL}${path}` : path;\n\treturn {\n\t\tpath,\n\t\thref,\n\t\tcrossOrigin:\n\t\t\tBoolean(site.baseURL && currentOrigin) &&\n\t\t\tnew URL(href).origin !== currentOrigin,\n\t};\n}\n\n/**\n * State of a tool call execution\n */\nexport type ToolCallState =\n\t| \"input-streaming\"\n\t| \"input-available\"\n\t| \"output-available\"\n\t| \"output-error\";\n\n/**\n * Props passed to custom tool call renderer components\n */\nexport interface ToolCallProps {\n\t/** Unique identifier for this tool call */\n\ttoolCallId: string;\n\t/** Name of the tool being called */\n\ttoolName: string;\n\t/** Current state of the tool call execution */\n\tstate: ToolCallState;\n\t/** Input arguments passed to the tool (may be partial during streaming) */\n\tinput: TInput | undefined;\n\t/** Output from the tool (only available when state is 'output-available') */\n\toutput: TOutput | undefined;\n\t/** Error message (only available when state is 'output-error') */\n\terrorText?: string;\n\t/** Whether the tool call is currently in progress */\n\tisLoading: boolean;\n}\n\n/**\n * A component that renders a custom UI for a specific tool call.\n * Return `null` to fall back to the default tool call accordion.\n */\nexport type ToolCallRenderer<\n\tTInput = unknown,\n\tTOutput = unknown,\n> = ComponentType>;\n\n/**\n * Allowed file type categories for uploads\n */\nexport type AllowedFileType =\n\t| \"image\"\n\t| \"text\"\n\t| \"pdf\"\n\t| \"markdown\"\n\t| \"csv\"\n\t| \"json\";\n\n/**\n * Default allowed file types (images only for best AI model compatibility)\n * Consumers can expand this by passing allowedFileTypes in overrides\n */\nexport const DEFAULT_ALLOWED_FILE_TYPES: AllowedFileType[] = [\"image\"];\n\n/**\n * MIME type mappings for each file type category\n */\nexport const FILE_TYPE_MIME_MAP: Record = {\n\timage: [\"image/*\"],\n\ttext: [\"text/plain\"],\n\tmarkdown: [\"text/markdown\"],\n\tcsv: [\"text/csv\"],\n\tpdf: [\"application/pdf\"],\n\tjson: [\"application/json\"],\n};\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { id: \"abc123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the AI Chat plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface AiChatPluginOverrides {\n\t/**\n\t * Function used to upload a file and return its URL.\n\t * Called for images, PDFs, text files, and other supported file types.\n\t */\n\tuploadFile?: (file: File) => Promise;\n\n\t/**\n\t * Allowed file types for upload.\n\t * By default, all types are enabled: image, text, pdf, markdown, csv, json\n\t * Set to empty array to disable file uploads entirely.\n\t * @default ['image', 'text', 'pdf', 'markdown', 'csv', 'json']\n\t */\n\tallowedFileTypes?: AllowedFileType[];\n\n\t/**\n\t * Localization object for the AI Chat plugin\n\t */\n\tlocalization?: Partial;\n\n\t/**\n\t * Whether to show the attribution\n\t * @default true\n\t */\n\tshowAttribution?: boolean;\n\n\t/**\n\t * Suggested prompts to display in the empty chat state.\n\t * When provided, these appear as clickable buttons that populate the input field.\n\t *\n\t * @example\n\t * ```tsx\n\t * chatSuggestions: [\n\t * \"What can you help me with?\",\n\t * \"Tell me about your features\",\n\t * \"How do I get started?\",\n\t * ]\n\t * ```\n\t */\n\tchatSuggestions?: string[];\n\n\t/**\n\t * Custom renderers for tool calls. Keys should match tool names.\n\t * Each renderer receives ToolCallProps and can return custom UI.\n\t *\n\t * @example\n\t * ```tsx\n\t * toolRenderers: {\n\t * getWeather: ({ toolName, input, output, state, isLoading }) => (\n\t * \n\t * ),\n\t * searchDocs: ({ input, output, isLoading }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\ttoolRenderers?: Record;\n\n\t/**\n\t * Optional class names merged onto specific UI elements.\n\t * Use these for targeted styling without replacing whole components.\n\t */\n\tclassNames?: {\n\t\t/** Root of `ChatLayout` (full-page or widget mode) */\n\t\tcontainer?: string;\n\t\t/** Conversation sidebar root */\n\t\tsidebar?: string;\n\t\t/** Wrapper around the list of messages */\n\t\tmessageList?: string;\n\t\t/** Each message row (user and assistant) */\n\t\tmessage?: string;\n\t\t/** The bubble of a user message */\n\t\tuserMessage?: string;\n\t\t/** The bubble of an assistant message */\n\t\tassistantMessage?: string;\n\t\t/** The composer form at the bottom of the chat */\n\t\tinput?: string;\n\t};\n\n\t// ============== Lifecycle Hooks (optional) ==============\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'chat', 'chatConversation')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", "target": "src/components/btst/ai-chat/client/overrides.ts" }, { diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json index 04147aebd..14a89b839 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -286,13 +286,13 @@ { "path": "btst/blog/client/components/shared/page-wrapper.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(BLOG_PLUGIN_ID, {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution, classNames } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(BLOG_PLUGIN_ID, {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/shared/page-wrapper.tsx" }, { "path": "btst/blog/client/components/shared/post-card.tsx", "type": "registry:component", - "content": "\"use client\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tuseBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { formatPostDate } from \"./format-post-date\";\nimport type { SerializedPost } from \"../../../types\";\nimport { CalendarIcon } from \"lucide-react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { DefaultLink, DefaultImage } from \"./defaults\";\n\n// Beautiful gradient color combinations\nconst GRADIENT_PALETTES = [\n\t{\n\t\tfrom: \"from-purple-500\",\n\t\tvia: \"via-pink-500\",\n\t\tto: \"to-orange-500\",\n\t\toverlay: \"from-blue-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-blue-500\",\n\t\tvia: \"via-cyan-500\",\n\t\tto: \"to-teal-500\",\n\t\toverlay: \"from-indigo-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-pink-500\",\n\t\tvia: \"via-rose-500\",\n\t\tto: \"to-orange-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-green-500\",\n\t\tvia: \"via-emerald-500\",\n\t\tto: \"to-teal-500\",\n\t\toverlay: \"from-cyan-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-500\",\n\t\tvia: \"via-purple-500\",\n\t\tto: \"to-pink-500\",\n\t\toverlay: \"from-violet-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-orange-500\",\n\t\tvia: \"via-amber-500\",\n\t\tto: \"to-yellow-500\",\n\t\toverlay: \"from-orange-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-500\",\n\t\tvia: \"via-blue-500\",\n\t\tto: \"to-indigo-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-violet-500\",\n\t\tvia: \"via-purple-500\",\n\t\tto: \"to-fuchsia-500\",\n\t\toverlay: \"from-pink-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-emerald-500\",\n\t\tvia: \"via-green-500\",\n\t\tto: \"to-lime-500\",\n\t\toverlay: \"from-teal-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-rose-500\",\n\t\tvia: \"via-pink-500\",\n\t\tto: \"to-fuchsia-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-sky-500\",\n\t\tvia: \"via-blue-500\",\n\t\tto: \"to-indigo-600\",\n\t\toverlay: \"from-purple-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-fuchsia-600\",\n\t\tvia: \"via-purple-600\",\n\t\tto: \"to-indigo-600\",\n\t\toverlay: \"from-pink-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-teal-500\",\n\t\tvia: \"via-emerald-500\",\n\t\tto: \"to-green-600\",\n\t\toverlay: \"from-cyan-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-amber-500\",\n\t\tvia: \"via-orange-500\",\n\t\tto: \"to-pink-500\",\n\t\toverlay: \"from-yellow-600/40\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-600\",\n\t\tvia: \"via-blue-600\",\n\t\tto: \"to-purple-600\",\n\t\toverlay: \"from-violet-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-pink-600\",\n\t\tvia: \"via-rose-600\",\n\t\tto: \"to-orange-600\",\n\t\toverlay: \"from-fuchsia-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-600\",\n\t\tvia: \"via-teal-600\",\n\t\tto: \"to-emerald-600\",\n\t\toverlay: \"from-blue-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-purple-600\",\n\t\tvia: \"via-fuchsia-600\",\n\t\tto: \"to-pink-600\",\n\t\toverlay: \"from-violet-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-blue-600\",\n\t\tvia: \"via-indigo-600\",\n\t\tto: \"to-purple-600\",\n\t\toverlay: \"from-cyan-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-green-600\",\n\t\tvia: \"via-emerald-600\",\n\t\tto: \"to-teal-600\",\n\t\toverlay: \"from-lime-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-rose-600\",\n\t\tvia: \"via-pink-600\",\n\t\tto: \"to-fuchsia-600\",\n\t\toverlay: \"from-pink-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-orange-600\",\n\t\tvia: \"via-pink-600\",\n\t\tto: \"to-rose-600\",\n\t\toverlay: \"from-amber-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-violet-600\",\n\t\tvia: \"via-purple-600\",\n\t\tto: \"to-fuchsia-600\",\n\t\toverlay: \"from-indigo-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-teal-600\",\n\t\tvia: \"via-cyan-600\",\n\t\tto: \"to-blue-600\",\n\t\toverlay: \"from-emerald-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-emerald-600\",\n\t\tvia: \"via-teal-600\",\n\t\tto: \"to-cyan-600\",\n\t\toverlay: \"from-green-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-700\",\n\t\tvia: \"via-purple-700\",\n\t\tto: \"to-pink-700\",\n\t\toverlay: \"from-violet-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-blue-700\",\n\t\tvia: \"via-cyan-700\",\n\t\tto: \"to-teal-700\",\n\t\toverlay: \"from-indigo-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-purple-700\",\n\t\tvia: \"via-fuchsia-700\",\n\t\tto: \"to-pink-700\",\n\t\toverlay: \"from-violet-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-pink-700\",\n\t\tvia: \"via-rose-700\",\n\t\tto: \"to-orange-700\",\n\t\toverlay: \"from-fuchsia-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-700\",\n\t\tvia: \"via-blue-700\",\n\t\tto: \"to-indigo-700\",\n\t\toverlay: \"from-teal-800/30\",\n\t},\n] as const;\n\n// Simple deterministic hash function\nfunction hashString(str: string): number {\n\tlet hash = 0;\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst char = str.charCodeAt(i);\n\t\thash = (hash << 5) - hash + char;\n\t\thash = hash & hash; // Convert to 32-bit integer\n\t}\n\treturn Math.abs(hash);\n}\n\nfunction getGradientFromTitle(title: string) {\n\tconst hash = hashString(title);\n\tconst palette = GRADIENT_PALETTES[hash % GRADIENT_PALETTES.length];\n\treturn palette;\n}\n\nexport function PostCard({ post }: { post: SerializedPost }) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(BLOG_PLUGIN_ID, {});\n\tconst { router } = useStack();\n\tconst Link = router?.Link ?? DefaultLink;\n\tconst Image = router?.Image ?? DefaultImage;\n\tconst basePath = useBasePath();\n\tconst blogPath = `${basePath}/blog/${post.slug}`;\n\tconst timestamp = post.publishedAt || post.createdAt;\n\tconst postDate = formatPostDate(timestamp);\n\tconst gradient = post.image ? null : getGradientFromTitle(post.title);\n\n\treturn (\n\t\t\n\t\t\t{/* Image or Placeholder */}\n\t\t\t\n\t\t\t\t{post.image ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{post.title}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t\n\n\t\t\t{!post.published && (\n\t\t\t\t\n\t\t\t\t\t{localization?.BLOG_CARD_DRAFT_BADGE ??\n\t\t\t\t\t\tt(\"blog.card.draftBadge\", \"Draft\")}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{post.title}\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t{post.tags &&\n\t\t\t\t\t\t\tpost.tags.length > 0 &&\n\t\t\t\t\t\t\tpost.tags.map((tag) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{tag.name}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tuseBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { formatPostDate } from \"./format-post-date\";\nimport type { SerializedPost } from \"../../../types\";\nimport { CalendarIcon } from \"lucide-react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { DefaultLink, DefaultImage } from \"./defaults\";\nimport { cn } from \"../../../utils\";\n// Beautiful gradient color combinations\nconst GRADIENT_PALETTES = [\n\t{\n\t\tfrom: \"from-purple-500\",\n\t\tvia: \"via-pink-500\",\n\t\tto: \"to-orange-500\",\n\t\toverlay: \"from-blue-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-blue-500\",\n\t\tvia: \"via-cyan-500\",\n\t\tto: \"to-teal-500\",\n\t\toverlay: \"from-indigo-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-pink-500\",\n\t\tvia: \"via-rose-500\",\n\t\tto: \"to-orange-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-green-500\",\n\t\tvia: \"via-emerald-500\",\n\t\tto: \"to-teal-500\",\n\t\toverlay: \"from-cyan-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-500\",\n\t\tvia: \"via-purple-500\",\n\t\tto: \"to-pink-500\",\n\t\toverlay: \"from-violet-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-orange-500\",\n\t\tvia: \"via-amber-500\",\n\t\tto: \"to-yellow-500\",\n\t\toverlay: \"from-orange-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-500\",\n\t\tvia: \"via-blue-500\",\n\t\tto: \"to-indigo-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-violet-500\",\n\t\tvia: \"via-purple-500\",\n\t\tto: \"to-fuchsia-500\",\n\t\toverlay: \"from-pink-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-emerald-500\",\n\t\tvia: \"via-green-500\",\n\t\tto: \"to-lime-500\",\n\t\toverlay: \"from-teal-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-rose-500\",\n\t\tvia: \"via-pink-500\",\n\t\tto: \"to-fuchsia-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-sky-500\",\n\t\tvia: \"via-blue-500\",\n\t\tto: \"to-indigo-600\",\n\t\toverlay: \"from-purple-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-fuchsia-600\",\n\t\tvia: \"via-purple-600\",\n\t\tto: \"to-indigo-600\",\n\t\toverlay: \"from-pink-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-teal-500\",\n\t\tvia: \"via-emerald-500\",\n\t\tto: \"to-green-600\",\n\t\toverlay: \"from-cyan-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-amber-500\",\n\t\tvia: \"via-orange-500\",\n\t\tto: \"to-pink-500\",\n\t\toverlay: \"from-yellow-600/40\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-600\",\n\t\tvia: \"via-blue-600\",\n\t\tto: \"to-purple-600\",\n\t\toverlay: \"from-violet-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-pink-600\",\n\t\tvia: \"via-rose-600\",\n\t\tto: \"to-orange-600\",\n\t\toverlay: \"from-fuchsia-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-600\",\n\t\tvia: \"via-teal-600\",\n\t\tto: \"to-emerald-600\",\n\t\toverlay: \"from-blue-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-purple-600\",\n\t\tvia: \"via-fuchsia-600\",\n\t\tto: \"to-pink-600\",\n\t\toverlay: \"from-violet-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-blue-600\",\n\t\tvia: \"via-indigo-600\",\n\t\tto: \"to-purple-600\",\n\t\toverlay: \"from-cyan-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-green-600\",\n\t\tvia: \"via-emerald-600\",\n\t\tto: \"to-teal-600\",\n\t\toverlay: \"from-lime-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-rose-600\",\n\t\tvia: \"via-pink-600\",\n\t\tto: \"to-fuchsia-600\",\n\t\toverlay: \"from-pink-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-orange-600\",\n\t\tvia: \"via-pink-600\",\n\t\tto: \"to-rose-600\",\n\t\toverlay: \"from-amber-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-violet-600\",\n\t\tvia: \"via-purple-600\",\n\t\tto: \"to-fuchsia-600\",\n\t\toverlay: \"from-indigo-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-teal-600\",\n\t\tvia: \"via-cyan-600\",\n\t\tto: \"to-blue-600\",\n\t\toverlay: \"from-emerald-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-emerald-600\",\n\t\tvia: \"via-teal-600\",\n\t\tto: \"to-cyan-600\",\n\t\toverlay: \"from-green-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-700\",\n\t\tvia: \"via-purple-700\",\n\t\tto: \"to-pink-700\",\n\t\toverlay: \"from-violet-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-blue-700\",\n\t\tvia: \"via-cyan-700\",\n\t\tto: \"to-teal-700\",\n\t\toverlay: \"from-indigo-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-purple-700\",\n\t\tvia: \"via-fuchsia-700\",\n\t\tto: \"to-pink-700\",\n\t\toverlay: \"from-violet-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-pink-700\",\n\t\tvia: \"via-rose-700\",\n\t\tto: \"to-orange-700\",\n\t\toverlay: \"from-fuchsia-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-700\",\n\t\tvia: \"via-blue-700\",\n\t\tto: \"to-indigo-700\",\n\t\toverlay: \"from-teal-800/30\",\n\t},\n] as const;\n\n// Simple deterministic hash function\nfunction hashString(str: string): number {\n\tlet hash = 0;\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst char = str.charCodeAt(i);\n\t\thash = (hash << 5) - hash + char;\n\t\thash = hash & hash; // Convert to 32-bit integer\n\t}\n\treturn Math.abs(hash);\n}\n\nfunction getGradientFromTitle(title: string) {\n\tconst hash = hashString(title);\n\tconst palette = GRADIENT_PALETTES[hash % GRADIENT_PALETTES.length];\n\treturn palette;\n}\n\nexport function PostCard({ post }: { post: SerializedPost }) {\n\tconst t = useTranslate();\n\tconst { localization, classNames } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(BLOG_PLUGIN_ID, {});\n\tconst { router } = useStack();\n\tconst Link = router?.Link ?? DefaultLink;\n\tconst Image = router?.Image ?? DefaultImage;\n\tconst basePath = useBasePath();\n\tconst blogPath = `${basePath}/blog/${post.slug}`;\n\tconst timestamp = post.publishedAt || post.createdAt;\n\tconst postDate = formatPostDate(timestamp);\n\tconst gradient = post.image ? null : getGradientFromTitle(post.title);\n\tconst tags = post.tags?.map((tag) => (\n\t\t\n\t\t\t{tag.name}\n\t\t\n\t));\n\n\treturn (\n\t\t\n\t\t\t{/* Image or Placeholder */}\n\t\t\t\n\t\t\t\t{post.image ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{post.title}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t\n\n\t\t\t{!post.published && (\n\t\t\t\t\n\t\t\t\t\t{localization?.BLOG_CARD_DRAFT_BADGE ??\n\t\t\t\t\t\tt(\"blog.card.draftBadge\", \"Draft\")}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{post.title}\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t{classNames?.tagsList ? (\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{tags}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\ttags\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/shared/post-card.tsx" }, { @@ -304,7 +304,7 @@ { "path": "btst/blog/client/components/shared/posts-list.tsx", "type": "registry:component", - "content": "import { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { SerializedPost } from \"../../../types\";\nimport { Button } from \"@/components/ui/button\";\nimport { EmptyList } from \"./empty-list\";\nimport { SearchInput } from \"./search-input\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { PostCard as DefaultPostCard } from \"./post-card\";\n\ninterface PostsListProps {\n\tposts: SerializedPost[];\n\tonLoadMore?: () => void;\n\thasMore?: boolean;\n\tisLoadingMore?: boolean;\n}\n\nexport function PostsList({\n\tposts,\n\tonLoadMore,\n\thasMore,\n\tisLoadingMore,\n}: PostsListProps) {\n\tconst t = useTranslate();\n\tconst { localization, PostCard } =\n\t\tusePluginOverrides(BLOG_PLUGIN_ID);\n\n\tconst PostCardComponent = PostCard || DefaultPostCard;\n\tif (posts.length === 0) {\n\t\treturn (\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t
\n\t\t\t\t{posts.map((post) => (\n\t\t\t\t\t\n\t\t\t\t))}\n\t\t\t
\n\n\t\t\t{onLoadMore && hasMore && (\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{isLoadingMore\n\t\t\t\t\t\t\t? (localization?.BLOG_LIST_LOADING_MORE ??\n\t\t\t\t\t\t\t\tt(\"blog.list.loadingMore\", \"Loading more...\"))\n\t\t\t\t\t\t\t: (localization?.BLOG_LIST_LOAD_MORE ??\n\t\t\t\t\t\t\t\tt(\"blog.list.loadMore\", \"Load more posts\"))}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n", + "content": "import { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { SerializedPost } from \"../../../types\";\nimport { Button } from \"@/components/ui/button\";\nimport { EmptyList } from \"./empty-list\";\nimport { SearchInput } from \"./search-input\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { PostCard as DefaultPostCard } from \"./post-card\";\nimport { cn } from \"../../../utils\";\n\ninterface PostsListProps {\n\tposts: SerializedPost[];\n\tonLoadMore?: () => void;\n\thasMore?: boolean;\n\tisLoadingMore?: boolean;\n}\n\nexport function PostsList({\n\tposts,\n\tonLoadMore,\n\thasMore,\n\tisLoadingMore,\n}: PostsListProps) {\n\tconst t = useTranslate();\n\tconst { localization, PostCard, classNames } =\n\t\tusePluginOverrides(BLOG_PLUGIN_ID);\n\n\tconst PostCardComponent = PostCard || DefaultPostCard;\n\tif (posts.length === 0) {\n\t\treturn (\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t
\n\t\t\t\t{posts.map((post) => (\n\t\t\t\t\t\n\t\t\t\t))}\n\t\t\t
\n\n\t\t\t{onLoadMore && hasMore && (\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{isLoadingMore\n\t\t\t\t\t\t\t? (localization?.BLOG_LIST_LOADING_MORE ??\n\t\t\t\t\t\t\t\tt(\"blog.list.loadingMore\", \"Loading more...\"))\n\t\t\t\t\t\t\t: (localization?.BLOG_LIST_LOAD_MORE ??\n\t\t\t\t\t\t\t\tt(\"blog.list.loadMore\", \"Load more posts\"))}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n", "target": "src/components/btst/blog/client/components/shared/posts-list.tsx" }, { @@ -376,7 +376,7 @@ { "path": "btst/blog/client/overrides.ts", "type": "registry:lib", - "content": "import type { SerializedPost } from \"../types\";\nimport type { ComponentType, ReactNode } from \"react\";\nimport type { BlogLocalization } from \"./localization\";\n\n/**\n * Props for the overridable blog featured image input component.\n */\nexport interface BlogImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the Blog plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface BlogPluginOverrides {\n\t/**\n\t * Post card component for displaying a post\n\t */\n\tPostCard?: ComponentType<{\n\t\tpost: SerializedPost;\n\t}>;\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * This is separate from `imagePicker`, which selects an existing asset URL.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Optional custom component for the featured image field.\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * Typical use case: render a preview when a value is set, and a media-picker\n\t * trigger when no value is set.\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered adjacent to the Markdown editor and allows\n\t * users to browse and select previously uploaded assets.\n\t * Receives `onSelect(url)` — insert the chosen URL into the editor.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\t/**\n\t * Localization object for the blog plugin\n\t */\n\tlocalization?: Partial;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t// Lifecycle Hooks (optional)\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'posts', 'post', 'newPost')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered below the blog post body.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the blog plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * blog: {\n\t * postBottomSlot: (post) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tpostBottomSlot?: (post: SerializedPost) => ReactNode;\n}\n", + "content": "import type { SerializedPost } from \"../types\";\nimport type { ComponentType, ReactNode } from \"react\";\nimport type { BlogLocalization } from \"./localization\";\n\n/**\n * Props for the overridable blog featured image input component.\n */\nexport interface BlogImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the Blog plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface BlogPluginOverrides {\n\t/**\n\t * Post card component for displaying a post\n\t */\n\tPostCard?: ComponentType<{\n\t\tpost: SerializedPost;\n\t}>;\n\t/**\n\t * Optional class names merged into the blog UI's structural elements,\n\t * allowing styling without replacing whole components.\n\t */\n\tclassNames?: {\n\t\t/** Page container shared by blog pages. */\n\t\tcontainer?: string;\n\t\t/** Each built-in post card. */\n\t\tpostCard?: string;\n\t\t/** Title inside a post card. */\n\t\tpostTitle?: string;\n\t\t/** Date metadata inside a post card. */\n\t\tpostMeta?: string;\n\t\t/** Optional flex wrapper around post-card tags; omitted when unset. */\n\t\ttagsList?: string;\n\t\t/** Wrapper around the load-more control. */\n\t\tpagination?: string;\n\t};\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * This is separate from `imagePicker`, which selects an existing asset URL.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Optional custom component for the featured image field.\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * Typical use case: render a preview when a value is set, and a media-picker\n\t * trigger when no value is set.\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered adjacent to the Markdown editor and allows\n\t * users to browse and select previously uploaded assets.\n\t * Receives `onSelect(url)` — insert the chosen URL into the editor.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\t/**\n\t * Localization object for the blog plugin\n\t */\n\tlocalization?: Partial;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t// Lifecycle Hooks (optional)\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'posts', 'post', 'newPost')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered below the blog post body.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the blog plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * blog: {\n\t * postBottomSlot: (post) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tpostBottomSlot?: (post: SerializedPost) => ReactNode;\n}\n", "target": "src/components/btst/blog/client/overrides.ts" }, { diff --git a/packages/stack/registry/btst-cms.json b/packages/stack/registry/btst-cms.json index c81d9673f..34dad7398 100644 --- a/packages/stack/registry/btst-cms.json +++ b/packages/stack/registry/btst-cms.json @@ -121,7 +121,7 @@ { "path": "btst/cms/client/components/pages/content-list-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { Plus, ArrowLeft, Pencil, Trash2, Loader2, Search } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tjoinBasePath,\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n\ttype TranslateFn,\n} from \"@btst/stack/context\";\nimport { cmsPermissions } from \"../../../permissions\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client/hooks\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport type { SerializedContentItemWithType } from \"../../../types\";\nimport {\n\tuseContent,\n\tuseSuspenseContent,\n\tuseSuspenseContentTypes,\n\tuseDeleteContent,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\ninterface ContentListPageProps {\n\ttypeSlug: string;\n}\n\n// URL-synced search state: `?q=...` while typing (history: replace), clean\n// URL when the query is empty (the default is omitted from the URL).\nconst LIST_STATE_SCHEMA = {\n\tq: { type: \"string\", default: \"\", history: \"replace\" },\n} as const satisfies ListStateSchema;\n\nconst SEARCH_DEBOUNCE_MS = 300;\n\nexport function ContentListPage({ typeSlug }: ContentListPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization } = overrides;\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst Link = router?.Link;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"contentList\",\n\t\tcontext: {\n\t\t\tpath: `/cms/${typeSlug}`,\n\t\t\tparams: { typeSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\n\tconst limit = 20;\n\n\tconst [{ q: search }, setListState] = useListState(\n\t\t`cms-${typeSlug}`,\n\t\tLIST_STATE_SCHEMA,\n\t);\n\n\t// Local input state debounced into the URL-synced query, so the list\n\t// query (and URL) only update after the user pauses typing.\n\tconst [searchInput, setSearchInput] = useState(search);\n\n\t// External `q` changes (hydration after SSR-empty search params,\n\t// back/forward navigation) re-seed the input instead of being clobbered\n\t// by the debounced write below, which only reflects user edits.\n\tconst lastSyncedSearch = useRef(search);\n\tuseEffect(() => {\n\t\tif (search !== lastSyncedSearch.current) {\n\t\t\tlastSyncedSearch.current = search;\n\t\t\tsetSearchInput(search);\n\t\t}\n\t}, [search]);\n\n\tuseEffect(() => {\n\t\tif (searchInput === search) return;\n\t\tconst timeout = setTimeout(() => {\n\t\t\tlastSyncedSearch.current = searchInput;\n\t\t\tsetListState({ q: searchInput });\n\t\t}, SEARCH_DEBOUNCE_MS);\n\t\treturn () => clearTimeout(timeout);\n\t}, [searchInput, search, setListState]);\n\n\tconst hasSearch = search.trim().length > 0;\n\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\tconst contentType = contentTypes.find((ct) => ct.slug === typeSlug);\n\n\t// The default (unsearched) list stays on the suspense hook so SSR/SSG\n\t// hydration works; the searched list uses the non-suspense hook so\n\t// typing shows an inline loading state instead of suspending the page.\n\tconst defaultList = useSuspenseContent(typeSlug, { limit });\n\tconst searchedList = useContent(typeSlug, {\n\t\tlimit,\n\t\tsearch,\n\t\tenabled: hasSearch,\n\t});\n\n\tconst activeList = hasSearch ? searchedList : defaultList;\n\tconst { items, total, loadMore, hasMore, isLoadingMore } = activeList;\n\tconst isSearchLoading = hasSearch && searchedList.isLoading;\n\n\tconst deleteContent = useDeleteContent(typeSlug);\n\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async (id: string) => {\n\t\ttry {\n\t\t\tawait deleteContent.mutateAsync(id);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.CMS_TOAST_ERROR ??\n\t\t\t\t\tt(\"cms.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.CMS_TOAST_DELETE_SUCCESS ??\n\t\t\t\tt(\"cms.toasts.deleteSuccess\", \"Item deleted successfully\"),\n\t\t);\n\t};\n\n\tconst formatDate = (dateString: string) => {\n\t\treturn new Date(dateString).toLocaleDateString();\n\t};\n\n\tif (!contentType) {\n\t\treturn (\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t void navigate?.(joinBasePath(basePath, \"/cms\"))}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{contentType.name}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{contentType.description && (\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{contentType.description}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${typeSlug}/new`))\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_BUTTON_NEW_ITEM ??\n\t\t\t\t\t\t\t\tt(\"cms.common.newItem\", \"New Item\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t setSearchInput(e.target.value)}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tlocalization?.CMS_LIST_SEARCH_PLACEHOLDER ??\n\t\t\t\t\t\t\tt(\"cms.list.searchPlaceholder\", \"Search items...\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"pl-9\"\n\t\t\t\t\t/>\n\t\t\t\t\t{isSearchLoading && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{items.length === 0 ? (\n\t\t\t\t\tisSearchLoading ? null : hasSearch ? (\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\tvoid navigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\tjoinBasePath(basePath, `/cms/${typeSlug}/new`),\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.CMS_BUTTON_CREATE ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"cms.common.create\", \"Create\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\nfunction ContentTable({\n\titems,\n\ttotal,\n\ttypeSlug,\n\tbasePath,\n\tLinkComponent,\n\tnavigate,\n\tonDelete,\n\tisDeleting,\n\tformatDate,\n\tloadMore,\n\thasMore,\n\tisLoadingMore,\n\tlocalization,\n\tt,\n}: {\n\titems: SerializedContentItemWithType[];\n\ttotal: number;\n\ttypeSlug: string;\n\tbasePath: string;\n\tLinkComponent: React.ElementType;\n\tnavigate?: (path: string) => void | Promise;\n\tonDelete: (id: string) => void;\n\tisDeleting: boolean;\n\tformatDate: (dateString: string) => string;\n\tloadMore: () => void;\n\thasMore: boolean;\n\tisLoadingMore: boolean;\n\tlocalization: CMSPluginOverrides[\"localization\"];\n\tt: TranslateFn;\n}) {\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_SLUG ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnSlug\", \"Slug\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_CREATED ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnCreated\", \"Created\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_UPDATED ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnUpdated\", \"Updated\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnActions\", \"Actions\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{items.map((item) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{item.slug}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{formatDate(item.createdAt)}\n\t\t\t\t\t\t\t{formatDate(item.updatedAt)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\tvoid navigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\t\tjoinBasePath(basePath, `/cms/${typeSlug}/${item.id}`),\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t onDelete(item.id)}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isDeleting}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t
\n\t\t\t{/* Load More and pagination info */}\n\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{(\n\t\t\t\t\t\tlocalization?.CMS_LIST_PAGINATION_SHOWING ??\n\t\t\t\t\t\tt(\"cms.list.paginationShowing\", \"Showing {from}-{to} of {total}\")\n\t\t\t\t\t)\n\t\t\t\t\t\t.replace(\"{from}\", \"1\")\n\t\t\t\t\t\t.replace(\"{to}\", String(items.length))\n\t\t\t\t\t\t.replace(\"{total}\", String(total))}\n\t\t\t\t

\n\t\t\t\t{hasMore && (\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore && }\n\t\t\t\t\t\t{localization?.CMS_LIST_PAGINATION_NEXT ??\n\t\t\t\t\t\t\tt(\"cms.list.paginationNext\", \"Next\")}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { Plus, ArrowLeft, Pencil, Trash2, Loader2, Search } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tjoinBasePath,\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n\ttype TranslateFn,\n} from \"@btst/stack/context\";\nimport { cmsPermissions } from \"../../../permissions\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client/hooks\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\nimport type { SerializedContentItemWithType } from \"../../../types\";\nimport {\n\tuseContent,\n\tuseSuspenseContent,\n\tuseSuspenseContentTypes,\n\tuseDeleteContent,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\ninterface ContentListPageProps {\n\ttypeSlug: string;\n}\n\n// URL-synced search state: `?q=...` while typing (history: replace), clean\n// URL when the query is empty (the default is omitted from the URL).\nconst LIST_STATE_SCHEMA = {\n\tq: { type: \"string\", default: \"\", history: \"replace\" },\n} as const satisfies ListStateSchema;\n\nconst SEARCH_DEBOUNCE_MS = 300;\n\nexport function ContentListPage({ typeSlug }: ContentListPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization, classNames } = overrides;\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst Link = router?.Link;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"contentList\",\n\t\tcontext: {\n\t\t\tpath: `/cms/${typeSlug}`,\n\t\t\tparams: { typeSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\n\tconst limit = 20;\n\n\tconst [{ q: search }, setListState] = useListState(\n\t\t`cms-${typeSlug}`,\n\t\tLIST_STATE_SCHEMA,\n\t);\n\n\t// Local input state debounced into the URL-synced query, so the list\n\t// query (and URL) only update after the user pauses typing.\n\tconst [searchInput, setSearchInput] = useState(search);\n\n\t// External `q` changes (hydration after SSR-empty search params,\n\t// back/forward navigation) re-seed the input instead of being clobbered\n\t// by the debounced write below, which only reflects user edits.\n\tconst lastSyncedSearch = useRef(search);\n\tuseEffect(() => {\n\t\tif (search !== lastSyncedSearch.current) {\n\t\t\tlastSyncedSearch.current = search;\n\t\t\tsetSearchInput(search);\n\t\t}\n\t}, [search]);\n\n\tuseEffect(() => {\n\t\tif (searchInput === search) return;\n\t\tconst timeout = setTimeout(() => {\n\t\t\tlastSyncedSearch.current = searchInput;\n\t\t\tsetListState({ q: searchInput });\n\t\t}, SEARCH_DEBOUNCE_MS);\n\t\treturn () => clearTimeout(timeout);\n\t}, [searchInput, search, setListState]);\n\n\tconst hasSearch = search.trim().length > 0;\n\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\tconst contentType = contentTypes.find((ct) => ct.slug === typeSlug);\n\n\t// The default (unsearched) list stays on the suspense hook so SSR/SSG\n\t// hydration works; the searched list uses the non-suspense hook so\n\t// typing shows an inline loading state instead of suspending the page.\n\tconst defaultList = useSuspenseContent(typeSlug, { limit });\n\tconst searchedList = useContent(typeSlug, {\n\t\tlimit,\n\t\tsearch,\n\t\tenabled: hasSearch,\n\t});\n\n\tconst activeList = hasSearch ? searchedList : defaultList;\n\tconst { items, total, loadMore, hasMore, isLoadingMore } = activeList;\n\tconst isSearchLoading = hasSearch && searchedList.isLoading;\n\n\tconst deleteContent = useDeleteContent(typeSlug);\n\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async (id: string) => {\n\t\ttry {\n\t\t\tawait deleteContent.mutateAsync(id);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.CMS_TOAST_ERROR ??\n\t\t\t\t\tt(\"cms.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.CMS_TOAST_DELETE_SUCCESS ??\n\t\t\t\tt(\"cms.toasts.deleteSuccess\", \"Item deleted successfully\"),\n\t\t);\n\t};\n\n\tconst formatDate = (dateString: string) => {\n\t\treturn new Date(dateString).toLocaleDateString();\n\t};\n\n\tif (!contentType) {\n\t\treturn (\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t void navigate?.(joinBasePath(basePath, \"/cms\"))}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{contentType.name}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{contentType.description && (\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{contentType.description}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${typeSlug}/new`))\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_BUTTON_NEW_ITEM ??\n\t\t\t\t\t\t\t\tt(\"cms.common.newItem\", \"New Item\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t setSearchInput(e.target.value)}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tlocalization?.CMS_LIST_SEARCH_PLACEHOLDER ??\n\t\t\t\t\t\t\tt(\"cms.list.searchPlaceholder\", \"Search items...\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"pl-9\"\n\t\t\t\t\t/>\n\t\t\t\t\t{isSearchLoading && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{items.length === 0 ? (\n\t\t\t\t\tisSearchLoading ? null : hasSearch ? (\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\tvoid navigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\tjoinBasePath(basePath, `/cms/${typeSlug}/new`),\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.CMS_BUTTON_CREATE ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"cms.common.create\", \"Create\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\nfunction ContentTable({\n\titems,\n\ttotal,\n\ttypeSlug,\n\tbasePath,\n\tLinkComponent,\n\tnavigate,\n\tonDelete,\n\tisDeleting,\n\tformatDate,\n\tloadMore,\n\thasMore,\n\tisLoadingMore,\n\tlocalization,\n\tclassNames,\n\tt,\n}: {\n\titems: SerializedContentItemWithType[];\n\ttotal: number;\n\ttypeSlug: string;\n\tbasePath: string;\n\tLinkComponent: React.ElementType;\n\tnavigate?: (path: string) => void | Promise;\n\tonDelete: (id: string) => void;\n\tisDeleting: boolean;\n\tformatDate: (dateString: string) => string;\n\tloadMore: () => void;\n\thasMore: boolean;\n\tisLoadingMore: boolean;\n\tlocalization: CMSPluginOverrides[\"localization\"];\n\tclassNames: CMSPluginOverrides[\"classNames\"];\n\tt: TranslateFn;\n}) {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_SLUG ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnSlug\", \"Slug\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_CREATED ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnCreated\", \"Created\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_UPDATED ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnUpdated\", \"Updated\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnActions\", \"Actions\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{items.map((item) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{item.slug}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{formatDate(item.createdAt)}\n\t\t\t\t\t\t\t{formatDate(item.updatedAt)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\tvoid navigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\t\tjoinBasePath(basePath, `/cms/${typeSlug}/${item.id}`),\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t onDelete(item.id)}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isDeleting}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t
\n\t\t\t{/* Load More and pagination info */}\n\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{(\n\t\t\t\t\t\tlocalization?.CMS_LIST_PAGINATION_SHOWING ??\n\t\t\t\t\t\tt(\"cms.list.paginationShowing\", \"Showing {from}-{to} of {total}\")\n\t\t\t\t\t)\n\t\t\t\t\t\t.replace(\"{from}\", \"1\")\n\t\t\t\t\t\t.replace(\"{to}\", String(items.length))\n\t\t\t\t\t\t.replace(\"{total}\", String(total))}\n\t\t\t\t

\n\t\t\t\t{hasMore && (\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore && }\n\t\t\t\t\t\t{localization?.CMS_LIST_PAGINATION_NEXT ??\n\t\t\t\t\t\t\tt(\"cms.list.paginationNext\", \"Next\")}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/cms/client/components/pages/content-list-page.internal.tsx" }, { @@ -133,7 +133,7 @@ { "path": "btst/cms/client/components/pages/dashboard-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { FileText } from \"lucide-react\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tjoinBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport { useSuspenseContentTypes } from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\nexport function DashboardPage() {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization } = overrides;\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"dashboard\",\n\t\tcontext: {\n\t\t\tpath: \"/cms\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\n\tconst title =\n\t\tlocalization?.CMS_DASHBOARD_TITLE ?? t(\"cms.dashboard.title\", \"Content\");\n\tconst subtitle =\n\t\tlocalization?.CMS_DASHBOARD_SUBTITLE ??\n\t\tt(\"cms.dashboard.subtitle\", \"Manage your content types\");\n\n\tif (contentTypes.length === 0) {\n\t\treturn (\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t

{title}

\n\t\t\t\t\t\t

{subtitle}

\n\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\t\t);\n\t}\n\n\tconst formatItemCount = (count: number) => {\n\t\tif (count === 0)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ZERO ??\n\t\t\t\tt(\"cms.dashboard.itemsCountZero\", \"No items\")\n\t\t\t);\n\t\tif (count === 1)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ONE ??\n\t\t\t\tt(\"cms.dashboard.itemsCountOne\", \"1 item\")\n\t\t\t);\n\t\treturn (\n\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT ??\n\t\t\tt(\"cms.dashboard.itemsCount\", \"{count} items\")\n\t\t).replace(\"{count}\", String(count));\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t

{title}

\n\t\t\t\t\t

{subtitle}

\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t{contentTypes.map((ct) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${ct.slug}`))\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{ct.name}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
{ct.itemCount}
\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{formatItemCount(ct.itemCount)}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{ct.description && (\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{ct.description}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { FileText } from \"lucide-react\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tjoinBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport { useSuspenseContentTypes } from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { cn } from \"../../../utils\";\n\nexport function DashboardPage() {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization, classNames } = overrides;\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"dashboard\",\n\t\tcontext: {\n\t\t\tpath: \"/cms\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\n\tconst title =\n\t\tlocalization?.CMS_DASHBOARD_TITLE ?? t(\"cms.dashboard.title\", \"Content\");\n\tconst subtitle =\n\t\tlocalization?.CMS_DASHBOARD_SUBTITLE ??\n\t\tt(\"cms.dashboard.subtitle\", \"Manage your content types\");\n\n\tif (contentTypes.length === 0) {\n\t\treturn (\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t

{title}

\n\t\t\t\t\t\t

{subtitle}

\n\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\t\t);\n\t}\n\n\tconst formatItemCount = (count: number) => {\n\t\tif (count === 0)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ZERO ??\n\t\t\t\tt(\"cms.dashboard.itemsCountZero\", \"No items\")\n\t\t\t);\n\t\tif (count === 1)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ONE ??\n\t\t\t\tt(\"cms.dashboard.itemsCountOne\", \"1 item\")\n\t\t\t);\n\t\treturn (\n\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT ??\n\t\t\tt(\"cms.dashboard.itemsCount\", \"{count} items\")\n\t\t).replace(\"{count}\", String(count));\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t

{title}

\n\t\t\t\t\t

{subtitle}

\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t{contentTypes.map((ct) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${ct.slug}`))\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{ct.name}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
{ct.itemCount}
\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{formatItemCount(ct.itemCount)}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{ct.description && (\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{ct.description}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n", "target": "src/components/btst/cms/client/components/pages/dashboard-page.internal.tsx" }, { @@ -157,7 +157,7 @@ { "path": "btst/cms/client/components/shared/page-wrapper.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution } = usePluginOverrides<\n\t\tCMSPluginOverrides,\n\t\tPartial\n\t>(CMS_PLUGIN_ID, {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution, classNames } = usePluginOverrides<\n\t\tCMSPluginOverrides,\n\t\tPartial\n\t>(CMS_PLUGIN_ID, {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", "target": "src/components/btst/cms/client/components/shared/page-wrapper.tsx" }, { @@ -211,7 +211,7 @@ { "path": "btst/cms/client/overrides.ts", "type": "registry:lib", - "content": "import type { ComponentType } from \"react\";\nimport type { CMSLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport type { ContentTypeConfig } from \"../types\";\n\n/** Browser-safe CMS factory values carried by the resolved client stack. */\nexport interface CMSProviderConfig {\n\t/** Declared content types, used to preserve application-defined UI order. */\n\treadonly contentTypes?: readonly ContentTypeConfig[];\n}\n\n/**\n * Apply the registered content-type order while keeping HTTP data authoritative.\n * Types not declared by this client are appended in their server-returned order.\n */\nexport function orderCMSContentTypes(\n\tcontentTypes: readonly T[],\n\tproviderConfig: Readonly> | undefined,\n): T[] {\n\tconst configured = providerConfig?.contentTypes;\n\tif (!Array.isArray(configured) || configured.length === 0) {\n\t\treturn [...contentTypes];\n\t}\n\tconst positions = new Map();\n\tfor (const [index, value] of configured.entries()) {\n\t\tif (\n\t\t\tvalue !== null &&\n\t\t\ttypeof value === \"object\" &&\n\t\t\t\"slug\" in value &&\n\t\t\ttypeof value.slug === \"string\"\n\t\t) {\n\t\t\tpositions.set(value.slug, index);\n\t\t}\n\t}\n\treturn contentTypes\n\t\t.map((value, index) => ({ value, index }))\n\t\t.sort((left, right) => {\n\t\t\tconst leftPosition = positions.get(left.value.slug);\n\t\t\tconst rightPosition = positions.get(right.value.slug);\n\t\t\tif (leftPosition === undefined && rightPosition === undefined) {\n\t\t\t\treturn left.index - right.index;\n\t\t\t}\n\t\t\tif (leftPosition === undefined) return 1;\n\t\t\tif (rightPosition === undefined) return -1;\n\t\t\treturn leftPosition - rightPosition;\n\t\t})\n\t\t.map(({ value }) => value);\n}\n\n/**\n * Props for the overridable CMS image input field component.\n */\nexport interface CmsImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { typeSlug: \"product\", id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the CMS plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface CMSPluginOverrides {\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * Used by the default \"file\" field component when not selecting an existing\n\t * asset via `imagePicker` or `imageInputField`.\n\t */\n\tuploadImage?: (file: File) => Promise;\n\n\t/**\n\t * Optional custom component for image fields (fieldType: \"file\").\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered inside the default \"file\" field component as a\n\t * \"Browse media\" option, letting users select a previously uploaded asset.\n\t * Receives `onSelect(url)` — the URL is set as the field value.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your content types\n\t *\n\t * The component receives AutoFormInputComponentProps which includes:\n\t * - field: react-hook-form field controller\n\t * - label: the field label\n\t * - isRequired: whether the field is required\n\t * - fieldConfigItem: the field config (description, inputProps, etc.)\n\t * - fieldProps: additional props from fieldConfig.inputProps\n\t * - zodItem: the Zod schema for this field\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the CMS plugin\n\t */\n\tlocalization?: Partial;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'dashboard', 'contentList', 'contentEditor')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", + "content": "import type { ComponentType } from \"react\";\nimport type { CMSLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport type { ContentTypeConfig } from \"../types\";\n\n/** Browser-safe CMS factory values carried by the resolved client stack. */\nexport interface CMSProviderConfig {\n\t/** Declared content types, used to preserve application-defined UI order. */\n\treadonly contentTypes?: readonly ContentTypeConfig[];\n}\n\n/**\n * Apply the registered content-type order while keeping HTTP data authoritative.\n * Types not declared by this client are appended in their server-returned order.\n */\nexport function orderCMSContentTypes(\n\tcontentTypes: readonly T[],\n\tproviderConfig: Readonly> | undefined,\n): T[] {\n\tconst configured = providerConfig?.contentTypes;\n\tif (!Array.isArray(configured) || configured.length === 0) {\n\t\treturn [...contentTypes];\n\t}\n\tconst positions = new Map();\n\tfor (const [index, value] of configured.entries()) {\n\t\tif (\n\t\t\tvalue !== null &&\n\t\t\ttypeof value === \"object\" &&\n\t\t\t\"slug\" in value &&\n\t\t\ttypeof value.slug === \"string\"\n\t\t) {\n\t\t\tpositions.set(value.slug, index);\n\t\t}\n\t}\n\treturn contentTypes\n\t\t.map((value, index) => ({ value, index }))\n\t\t.sort((left, right) => {\n\t\t\tconst leftPosition = positions.get(left.value.slug);\n\t\t\tconst rightPosition = positions.get(right.value.slug);\n\t\t\tif (leftPosition === undefined && rightPosition === undefined) {\n\t\t\t\treturn left.index - right.index;\n\t\t\t}\n\t\t\tif (leftPosition === undefined) return 1;\n\t\t\tif (rightPosition === undefined) return -1;\n\t\t\treturn leftPosition - rightPosition;\n\t\t})\n\t\t.map(({ value }) => value);\n}\n\n/**\n * Props for the overridable CMS image input field component.\n */\nexport interface CmsImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { typeSlug: \"product\", id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the CMS plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface CMSPluginOverrides {\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * Used by the default \"file\" field component when not selecting an existing\n\t * asset via `imagePicker` or `imageInputField`.\n\t */\n\tuploadImage?: (file: File) => Promise;\n\n\t/**\n\t * Optional custom component for image fields (fieldType: \"file\").\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered inside the default \"file\" field component as a\n\t * \"Browse media\" option, letting users select a previously uploaded asset.\n\t * Receives `onSelect(url)` — the URL is set as the field value.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\n\t/**\n\t * Optional class names merged onto specific UI elements.\n\t * Use these for targeted styling without replacing whole components.\n\t */\n\tclassNames?: {\n\t\t/** Page container shared by every CMS page */\n\t\tcontainer?: string;\n\t\t/** Each content-type card on the dashboard */\n\t\tcontentTypeCard?: string;\n\t\t/** Title inside a content-type card */\n\t\tcontentTypeTitle?: string;\n\t\t/** Wrapper around the content entries table */\n\t\ttable?: string;\n\t\t/** Each row in the content entries table */\n\t\ttableRow?: string;\n\t\t/** Pagination footer under the content entries table */\n\t\tpagination?: string;\n\t};\n\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your content types\n\t *\n\t * The component receives AutoFormInputComponentProps which includes:\n\t * - field: react-hook-form field controller\n\t * - label: the field label\n\t * - isRequired: whether the field is required\n\t * - fieldConfigItem: the field config (description, inputProps, etc.)\n\t * - fieldProps: additional props from fieldConfig.inputProps\n\t * - zodItem: the Zod schema for this field\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the CMS plugin\n\t */\n\tlocalization?: Partial;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'dashboard', 'contentList', 'contentEditor')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", "target": "src/components/btst/cms/client/overrides.ts" }, { diff --git a/packages/stack/registry/btst-form-builder.json b/packages/stack/registry/btst-form-builder.json index 9d0c3a9b9..971f38eff 100644 --- a/packages/stack/registry/btst-form-builder.json +++ b/packages/stack/registry/btst-form-builder.json @@ -55,7 +55,7 @@ { "path": "btst/form-builder/client/components/forms/form-renderer.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useMemo, type ComponentType } from \"react\";\nimport {\n\tPermissionAccess,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { SteppedAutoForm } from \"@/components/ui/auto-form/stepped-auto-form\";\nimport { buildFieldConfigFromJsonSchema } from \"@/components/ui/auto-form/helpers\";\nimport { formSchemaToZod } from \"@/lib/schema-converter\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { AlertCircle, CheckCircle } from \"lucide-react\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\n\nimport { useFormBySlug, useSubmitForm } from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport type { SerializedFormSubmission } from \"../../../types\";\nimport { formBuilderPermissions } from \"../../../permissions\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\n\nexport interface FormRendererProps {\n\t/** Form slug to render */\n\tslug: string;\n\t/** Callback when form submission succeeds */\n\tonSuccess?: (\n\t\tsubmission: SerializedFormSubmission & {\n\t\t\tform: { successMessage?: string; redirectUrl?: string };\n\t\t},\n\t) => void;\n\t/** Callback when form submission fails */\n\tonError?: (error: Error) => void;\n\t/** Custom field components (same as FormBuilder) */\n\tfieldComponents?: Record>;\n\t/** Override success message */\n\tsuccessMessage?: React.ReactNode;\n\t/** Override submit button text */\n\tsubmitButtonText?: string;\n\t/** Custom loading component */\n\tLoadingComponent?: ComponentType;\n\t/** Custom error component */\n\tErrorComponent?: ComponentType<{ error: Error }>;\n\t/** Class name for the form container */\n\tclassName?: string;\n}\n\nfunction DefaultLoadingComponent() {\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction DefaultErrorComponent({ error }: { error: Error }) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t

\n\t\t\t\t{localization?.FORM_BUILDER_RENDERER_LOAD_FAILED ??\n\t\t\t\t\tt(\"formBuilder.renderer.loadFailed\", \"Failed to load form\")}\n\t\t\t

\n\t\t\t

\n\t\t\t\t{error.message ||\n\t\t\t\t\t(localization?.FORM_BUILDER_RENDERER_UNEXPECTED_ERROR ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"formBuilder.renderer.unexpectedError\",\n\t\t\t\t\t\t\t\"An unexpected error occurred\",\n\t\t\t\t\t\t))}\n\t\t\t

\n\t\t
\n\t);\n}\n\nfunction DefaultSuccessComponent({ message }: { message: React.ReactNode }) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t

\n\t\t\t\t{localization?.FORM_BUILDER_RENDERER_SUBMITTED_TITLE ??\n\t\t\t\t\tt(\"formBuilder.renderer.submittedTitle\", \"Form Submitted\")}\n\t\t\t

\n\t\t\t

{message}

\n\t\t
\n\t);\n}\n\n/**\n * FormRenderer component for rendering forms on the frontend.\n *\n * Uses SteppedAutoForm which automatically handles both single-step and multi-step forms.\n *\n * @example\n * ```tsx\n * {\n * toast.success(\"Thank you!\");\n * }}\n * onError={(error) => {\n * toast.error(\"Something went wrong\");\n * }}\n * />\n * ```\n */\nexport function FormRenderer({\n\tslug,\n\tonSuccess,\n\tonError,\n\tfieldComponents: propFieldComponents,\n\tsuccessMessage: propSuccessMessage,\n\tsubmitButtonText,\n\tLoadingComponent = DefaultLoadingComponent,\n\tErrorComponent = DefaultErrorComponent,\n\tclassName,\n}: FormRendererProps) {\n\tconst t = useTranslate();\n\tconst { fieldComponents: overrideFieldComponents, localization } =\n\t\tusePluginOverrides(FORM_BUILDER_PLUGIN_ID);\n\n\tconst { form, isLoading, error } = useFormBySlug(slug);\n\tconst submitMutation = useSubmitForm(slug);\n\n\tconst [submitted, setSubmitted] = useState(false);\n\tconst [finalSuccessMessage, setFinalSuccessMessage] = useState(\n\t\tnull,\n\t);\n\n\t// Merge field components from props and overrides\n\tconst mergedFieldComponents = useMemo(\n\t\t() => ({\n\t\t\t...overrideFieldComponents,\n\t\t\t...propFieldComponents,\n\t\t}),\n\t\t[overrideFieldComponents, propFieldComponents],\n\t);\n\n\t// Parse JSON Schema and create Zod schema\n\tconst { zodSchema, fieldConfig } = useMemo(() => {\n\t\tif (!form?.schema) {\n\t\t\treturn { zodSchema: null, fieldConfig: {} };\n\t\t}\n\n\t\ttry {\n\t\t\tconst parsedSchema = JSON.parse(form.schema);\n\t\t\tconst zod = formSchemaToZod(parsedSchema);\n\t\t\tconst config = buildFieldConfigFromJsonSchema(\n\t\t\t\tparsedSchema,\n\t\t\t\tmergedFieldComponents,\n\t\t\t);\n\n\t\t\treturn { zodSchema: zod, fieldConfig: config };\n\t\t} catch {\n\t\t\treturn { zodSchema: null, fieldConfig: {} };\n\t\t}\n\t}, [form?.schema, mergedFieldComponents]);\n\n\tconst handleSubmit = async (data: Record) => {\n\t\ttry {\n\t\t\tconst result = await submitMutation.mutateAsync({ data });\n\n\t\t\t// Set success message\n\t\t\tconst message =\n\t\t\t\tpropSuccessMessage ||\n\t\t\t\tresult.form.successMessage ||\n\t\t\t\t(localization?.FORM_BUILDER_RENDERER_THANK_YOU ??\n\t\t\t\t\tt(\"formBuilder.renderer.thankYou\", \"Thank you for your submission!\"));\n\t\t\tsetFinalSuccessMessage(message as string);\n\t\t\tsetSubmitted(true);\n\n\t\t\t// Call onSuccess callback before any redirect\n\t\t\tonSuccess?.(result);\n\n\t\t\t// Handle redirect\n\t\t\tif (result.form.redirectUrl) {\n\t\t\t\twindow.location.href = result.form.redirectUrl;\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tonError?.(err as Error);\n\t\t}\n\t};\n\n\t// Loading state\n\tif (isLoading) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Error state\n\tif (error) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Form not found\n\tif (!form) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Form not active\n\tif (form.status !== \"active\") {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Schema parsing failed\n\tif (!zodSchema) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Success state\n\tif (submitted && finalSuccessMessage) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Render form using SteppedAutoForm\n\t// It automatically handles both single-step and multi-step forms\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\thandleSubmit(values as Record)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tisSubmitting={submitMutation.isPending}\n\t\t\t\t\t\tsubmitButtonText={\n\t\t\t\t\t\t\tsubmitButtonText ||\n\t\t\t\t\t\t\t(localization?.FORM_BUILDER_BUTTON_SUBMIT ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonSubmit\", \"Submit\"))\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState, useMemo, type ComponentType } from \"react\";\nimport {\n\tPermissionAccess,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { SteppedAutoForm } from \"@/components/ui/auto-form/stepped-auto-form\";\nimport { buildFieldConfigFromJsonSchema } from \"@/components/ui/auto-form/helpers\";\nimport { formSchemaToZod } from \"@/lib/schema-converter\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { AlertCircle, CheckCircle } from \"lucide-react\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\n\nimport { useFormBySlug, useSubmitForm } from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport type { SerializedFormSubmission } from \"../../../types\";\nimport { formBuilderPermissions } from \"../../../permissions\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\n\nexport interface FormRendererProps {\n\t/** Form slug to render */\n\tslug: string;\n\t/** Callback when form submission succeeds */\n\tonSuccess?: (\n\t\tsubmission: SerializedFormSubmission & {\n\t\t\tform: { successMessage?: string; redirectUrl?: string };\n\t\t},\n\t) => void;\n\t/** Callback when form submission fails */\n\tonError?: (error: Error) => void;\n\t/** Custom field components (same as FormBuilder) */\n\tfieldComponents?: Record>;\n\t/** Override success message */\n\tsuccessMessage?: React.ReactNode;\n\t/** Override submit button text */\n\tsubmitButtonText?: string;\n\t/** Custom loading component */\n\tLoadingComponent?: ComponentType;\n\t/** Custom error component */\n\tErrorComponent?: ComponentType<{ error: Error }>;\n\t/** Class name for the form container */\n\tclassName?: string;\n}\n\nfunction DefaultLoadingComponent() {\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction DefaultErrorComponent({ error }: { error: Error }) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t

\n\t\t\t\t{localization?.FORM_BUILDER_RENDERER_LOAD_FAILED ??\n\t\t\t\t\tt(\"formBuilder.renderer.loadFailed\", \"Failed to load form\")}\n\t\t\t

\n\t\t\t

\n\t\t\t\t{error.message ||\n\t\t\t\t\t(localization?.FORM_BUILDER_RENDERER_UNEXPECTED_ERROR ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"formBuilder.renderer.unexpectedError\",\n\t\t\t\t\t\t\t\"An unexpected error occurred\",\n\t\t\t\t\t\t))}\n\t\t\t

\n\t\t
\n\t);\n}\n\nfunction DefaultSuccessComponent({ message }: { message: React.ReactNode }) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t

\n\t\t\t\t{localization?.FORM_BUILDER_RENDERER_SUBMITTED_TITLE ??\n\t\t\t\t\tt(\"formBuilder.renderer.submittedTitle\", \"Form Submitted\")}\n\t\t\t

\n\t\t\t

{message}

\n\t\t
\n\t);\n}\n\n/**\n * FormRenderer component for rendering forms on the frontend.\n *\n * Uses SteppedAutoForm which automatically handles both single-step and multi-step forms.\n *\n * @example\n * ```tsx\n * {\n * toast.success(\"Thank you!\");\n * }}\n * onError={(error) => {\n * toast.error(\"Something went wrong\");\n * }}\n * />\n * ```\n */\nexport function FormRenderer({\n\tslug,\n\tonSuccess,\n\tonError,\n\tfieldComponents: propFieldComponents,\n\tsuccessMessage: propSuccessMessage,\n\tsubmitButtonText,\n\tLoadingComponent = DefaultLoadingComponent,\n\tErrorComponent = DefaultErrorComponent,\n\tclassName,\n}: FormRendererProps) {\n\tconst t = useTranslate();\n\tconst {\n\t\tfieldComponents: overrideFieldComponents,\n\t\tlocalization,\n\t\tclassNames,\n\t} = usePluginOverrides(FORM_BUILDER_PLUGIN_ID);\n\n\tconst formClassName = cn(className, classNames?.form);\n\tconst { form, isLoading, error } = useFormBySlug(slug);\n\tconst submitMutation = useSubmitForm(slug);\n\n\tconst [submitted, setSubmitted] = useState(false);\n\tconst [finalSuccessMessage, setFinalSuccessMessage] = useState(\n\t\tnull,\n\t);\n\n\t// Merge field components from props and overrides\n\tconst mergedFieldComponents = useMemo(\n\t\t() => ({\n\t\t\t...overrideFieldComponents,\n\t\t\t...propFieldComponents,\n\t\t}),\n\t\t[overrideFieldComponents, propFieldComponents],\n\t);\n\n\t// Parse JSON Schema and create Zod schema\n\tconst { zodSchema, fieldConfig } = useMemo(() => {\n\t\tif (!form?.schema) {\n\t\t\treturn { zodSchema: null, fieldConfig: {} };\n\t\t}\n\n\t\ttry {\n\t\t\tconst parsedSchema = JSON.parse(form.schema);\n\t\t\tconst zod = formSchemaToZod(parsedSchema);\n\t\t\tconst config = buildFieldConfigFromJsonSchema(\n\t\t\t\tparsedSchema,\n\t\t\t\tmergedFieldComponents,\n\t\t\t);\n\n\t\t\treturn { zodSchema: zod, fieldConfig: config };\n\t\t} catch {\n\t\t\treturn { zodSchema: null, fieldConfig: {} };\n\t\t}\n\t}, [form?.schema, mergedFieldComponents]);\n\n\tconst handleSubmit = async (data: Record) => {\n\t\ttry {\n\t\t\tconst result = await submitMutation.mutateAsync({ data });\n\n\t\t\t// Set success message\n\t\t\tconst message =\n\t\t\t\tpropSuccessMessage ||\n\t\t\t\tresult.form.successMessage ||\n\t\t\t\t(localization?.FORM_BUILDER_RENDERER_THANK_YOU ??\n\t\t\t\t\tt(\"formBuilder.renderer.thankYou\", \"Thank you for your submission!\"));\n\t\t\tsetFinalSuccessMessage(message as string);\n\t\t\tsetSubmitted(true);\n\n\t\t\t// Call onSuccess callback before any redirect\n\t\t\tonSuccess?.(result);\n\n\t\t\t// Handle redirect\n\t\t\tif (result.form.redirectUrl) {\n\t\t\t\twindow.location.href = result.form.redirectUrl;\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tonError?.(err as Error);\n\t\t}\n\t};\n\n\t// Loading state\n\tif (isLoading) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Error state\n\tif (error) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Form not found\n\tif (!form) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Form not active\n\tif (form.status !== \"active\") {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Schema parsing failed\n\tif (!zodSchema) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Success state\n\tif (submitted && finalSuccessMessage) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Render form using SteppedAutoForm\n\t// It automatically handles both single-step and multi-step forms\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\thandleSubmit(values as Record)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tisSubmitting={submitMutation.isPending}\n\t\t\t\t\t\tsubmitButtonText={\n\t\t\t\t\t\t\tsubmitButtonText ||\n\t\t\t\t\t\t\t(localization?.FORM_BUILDER_BUTTON_SUBMIT ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonSubmit\", \"Submit\"))\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/form-builder/client/components/forms/form-renderer.tsx" }, { @@ -103,7 +103,7 @@ { "path": "btst/form-builder/client/components/pages/form-list-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n\tuseNotify,\n\tPermissionAccess,\n\tusePluginOverrides,\n\tusePluginSiteNavigation,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client/hooks\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n\tMoreHorizontal,\n\tPlus,\n\tPencil,\n\tTrash2,\n\tFileText,\n\tLoader2,\n\tSearch,\n} from \"lucide-react\";\n\nimport { useForms, useSuspenseForms, useDeleteForm } from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\nimport { formBuilderPermissions } from \"../../../permissions\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\n\n// URL-synced search state: `?q=...` while typing (history: replace), clean\n// URL when the query is empty (the default is omitted from the URL).\nconst LIST_STATE_SCHEMA = {\n\tq: { type: \"string\", default: \"\", history: \"replace\" },\n} as const satisfies ListStateSchema;\n\nconst SEARCH_DEBOUNCE_MS = 300;\n\nexport function FormListPage() {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { localization } = usePluginOverrides(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\tconst { Link, navigate, resolve } = usePluginSiteNavigation(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\n\tconst [{ q: search }, setListState] = useListState(\n\t\t\"form-builder-forms\",\n\t\tLIST_STATE_SCHEMA,\n\t);\n\n\t// Local input state debounced into the URL-synced query, so the list\n\t// query (and URL) only update after the user pauses typing.\n\tconst [searchInput, setSearchInput] = useState(search);\n\n\t// External `q` changes (hydration after SSR-empty search params,\n\t// back/forward navigation) re-seed the input instead of being clobbered\n\t// by the debounced write below, which only reflects user edits.\n\tconst lastSyncedSearch = useRef(search);\n\tuseEffect(() => {\n\t\tif (search !== lastSyncedSearch.current) {\n\t\t\tlastSyncedSearch.current = search;\n\t\t\tsetSearchInput(search);\n\t\t}\n\t}, [search]);\n\n\tuseEffect(() => {\n\t\tif (searchInput === search) return;\n\t\tconst timeout = setTimeout(() => {\n\t\t\tlastSyncedSearch.current = searchInput;\n\t\t\tsetListState({ q: searchInput });\n\t\t}, SEARCH_DEBOUNCE_MS);\n\t\treturn () => clearTimeout(timeout);\n\t}, [searchInput, search, setListState]);\n\n\tconst hasSearch = search.trim().length > 0;\n\n\t// The default (unsearched) list stays on the suspense hook so SSR/SSG\n\t// hydration works; the searched list uses the non-suspense hook so\n\t// typing shows an inline loading state instead of suspending the page.\n\tconst defaultList = useSuspenseForms();\n\tconst searchedList = useForms({ search, enabled: hasSearch });\n\n\tconst activeList = hasSearch ? searchedList : defaultList;\n\tconst { forms, total, hasMore, isLoadingMore, loadMore } = activeList;\n\tconst isSearchLoading = hasSearch && searchedList.isLoading;\n\n\tconst deleteMutation = useDeleteForm();\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\tt(\"formBuilder.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.FORM_BUILDER_TOAST_DELETE_SUCCESS ??\n\t\t\t\tt(\"formBuilder.toasts.deleteSuccess\", \"Form deleted successfully\"),\n\t\t);\n\t\tsetDeleteId(null);\n\t};\n\n\tconst getStatusBadge = (status: string) => {\n\t\tconst colors: Record = {\n\t\t\tactive:\n\t\t\t\t\"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200\",\n\t\t\tinactive:\n\t\t\t\t\"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200\",\n\t\t\tarchived: \"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200\",\n\t\t};\n\t\treturn (\n\t\t\t\n\t\t\t\t{status}\n\t\t\t\n\t\t);\n\t};\n\n\tconst newFormButton = (\n\t\t\n\t\t\t\n\t\t\n\t);\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.list.title\", \"Forms\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_SUBTITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.list.subtitle\", \"Manage your forms\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t\t{newFormButton}\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t setSearchInput(e.target.value)}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tlocalization?.FORM_BUILDER_LIST_SEARCH_PLACEHOLDER ??\n\t\t\t\t\t\t\tt(\"formBuilder.list.searchPlaceholder\", \"Search forms...\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"pl-9\"\n\t\t\t\t\t/>\n\t\t\t\t\t{isSearchLoading && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{forms.length === 0 ? (\n\t\t\t\t\tisSearchLoading ? null : hasSearch ? (\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t)\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_COLUMN_NAME ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnName\", \"Name\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_COLUMN_SLUG ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnSlug\", \"Slug\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_COLUMN_STATUS ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnStatus\", \"Status\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_COLUMN_CREATED ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnCreated\", \"Created\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnActions\", \"Actions\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{forms.map((form) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{form.name}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{form.slug}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{getStatusBadge(form.status)}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{new Date(form.createdAt).toLocaleDateString()}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnavigate(\"forms\", form.id, \"edit\")\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_ACTION_EDIT ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.actionEdit\", \"Edit\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnavigate(\"forms\", form.id, \"submissions\")\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_ACTION_SUBMISSIONS ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"formBuilder.list.actionSubmissions\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Submissions\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t setDeleteId(form.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_ACTION_DELETE ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.actionDelete\", \"Delete\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t setDeleteId(null)}>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.list.deleteTitle\", \"Delete Form\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_EDITOR_DELETE_CONFIRM ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"formBuilder.editor.deleteConfirm\",\n\t\t\t\t\t\t\t\t\t\"Are you sure you want to delete this form? All submissions will also be deleted.\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_BUTTON_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonCancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteMutation.isPending\n\t\t\t\t\t\t\t\t? (localization?.FORM_BUILDER_STATUS_DELETING ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusDeleting\", \"Deleting...\"))\n\t\t\t\t\t\t\t\t: (localization?.FORM_BUILDER_BUTTON_DELETE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonDelete\", \"Delete\"))}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n\tuseNotify,\n\tPermissionAccess,\n\tusePluginOverrides,\n\tusePluginSiteNavigation,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client/hooks\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n\tMoreHorizontal,\n\tPlus,\n\tPencil,\n\tTrash2,\n\tFileText,\n\tLoader2,\n\tSearch,\n} from \"lucide-react\";\n\nimport { useForms, useSuspenseForms, useDeleteForm } from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\nimport { formBuilderPermissions } from \"../../../permissions\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\n\n// URL-synced search state: `?q=...` while typing (history: replace), clean\n// URL when the query is empty (the default is omitted from the URL).\nconst LIST_STATE_SCHEMA = {\n\tq: { type: \"string\", default: \"\", history: \"replace\" },\n} as const satisfies ListStateSchema;\n\nconst SEARCH_DEBOUNCE_MS = 300;\n\nexport function FormListPage() {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { localization, classNames } =\n\t\tusePluginOverrides(FORM_BUILDER_PLUGIN_ID);\n\tconst { Link, navigate, resolve } = usePluginSiteNavigation(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\n\tconst [{ q: search }, setListState] = useListState(\n\t\t\"form-builder-forms\",\n\t\tLIST_STATE_SCHEMA,\n\t);\n\n\t// Local input state debounced into the URL-synced query, so the list\n\t// query (and URL) only update after the user pauses typing.\n\tconst [searchInput, setSearchInput] = useState(search);\n\n\t// External `q` changes (hydration after SSR-empty search params,\n\t// back/forward navigation) re-seed the input instead of being clobbered\n\t// by the debounced write below, which only reflects user edits.\n\tconst lastSyncedSearch = useRef(search);\n\tuseEffect(() => {\n\t\tif (search !== lastSyncedSearch.current) {\n\t\t\tlastSyncedSearch.current = search;\n\t\t\tsetSearchInput(search);\n\t\t}\n\t}, [search]);\n\n\tuseEffect(() => {\n\t\tif (searchInput === search) return;\n\t\tconst timeout = setTimeout(() => {\n\t\t\tlastSyncedSearch.current = searchInput;\n\t\t\tsetListState({ q: searchInput });\n\t\t}, SEARCH_DEBOUNCE_MS);\n\t\treturn () => clearTimeout(timeout);\n\t}, [searchInput, search, setListState]);\n\n\tconst hasSearch = search.trim().length > 0;\n\n\t// The default (unsearched) list stays on the suspense hook so SSR/SSG\n\t// hydration works; the searched list uses the non-suspense hook so\n\t// typing shows an inline loading state instead of suspending the page.\n\tconst defaultList = useSuspenseForms();\n\tconst searchedList = useForms({ search, enabled: hasSearch });\n\n\tconst activeList = hasSearch ? searchedList : defaultList;\n\tconst { forms, total, hasMore, isLoadingMore, loadMore } = activeList;\n\tconst isSearchLoading = hasSearch && searchedList.isLoading;\n\n\tconst deleteMutation = useDeleteForm();\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\tt(\"formBuilder.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.FORM_BUILDER_TOAST_DELETE_SUCCESS ??\n\t\t\t\tt(\"formBuilder.toasts.deleteSuccess\", \"Form deleted successfully\"),\n\t\t);\n\t\tsetDeleteId(null);\n\t};\n\n\tconst getStatusBadge = (status: string) => {\n\t\tconst colors: Record = {\n\t\t\tactive:\n\t\t\t\t\"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200\",\n\t\t\tinactive:\n\t\t\t\t\"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200\",\n\t\t\tarchived: \"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200\",\n\t\t};\n\t\treturn (\n\t\t\t\n\t\t\t\t{status}\n\t\t\t\n\t\t);\n\t};\n\n\tconst newFormButton = (\n\t\t\n\t\t\t\n\t\t\n\t);\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.list.title\", \"Forms\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_SUBTITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.list.subtitle\", \"Manage your forms\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t\t{newFormButton}\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t setSearchInput(e.target.value)}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tlocalization?.FORM_BUILDER_LIST_SEARCH_PLACEHOLDER ??\n\t\t\t\t\t\t\tt(\"formBuilder.list.searchPlaceholder\", \"Search forms...\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"pl-9\"\n\t\t\t\t\t/>\n\t\t\t\t\t{isSearchLoading && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{forms.length === 0 ? (\n\t\t\t\t\tisSearchLoading ? null : hasSearch ? (\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t)\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_COLUMN_NAME ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnName\", \"Name\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_COLUMN_SLUG ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnSlug\", \"Slug\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_COLUMN_STATUS ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnStatus\", \"Status\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_COLUMN_CREATED ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnCreated\", \"Created\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnActions\", \"Actions\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{forms.map((form) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{form.name}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{form.slug}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{getStatusBadge(form.status)}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{new Date(form.createdAt).toLocaleDateString()}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnavigate(\"forms\", form.id, \"edit\")\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_ACTION_EDIT ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.actionEdit\", \"Edit\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnavigate(\"forms\", form.id, \"submissions\")\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_ACTION_SUBMISSIONS ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"formBuilder.list.actionSubmissions\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Submissions\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t setDeleteId(form.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_ACTION_DELETE ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.actionDelete\", \"Delete\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t setDeleteId(null)}>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.list.deleteTitle\", \"Delete Form\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_EDITOR_DELETE_CONFIRM ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"formBuilder.editor.deleteConfirm\",\n\t\t\t\t\t\t\t\t\t\"Are you sure you want to delete this form? All submissions will also be deleted.\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_BUTTON_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonCancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteMutation.isPending\n\t\t\t\t\t\t\t\t? (localization?.FORM_BUILDER_STATUS_DELETING ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusDeleting\", \"Deleting...\"))\n\t\t\t\t\t\t\t\t: (localization?.FORM_BUILDER_BUTTON_DELETE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonDelete\", \"Delete\"))}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/form-builder/client/components/pages/form-list-page.internal.tsx" }, { @@ -115,7 +115,7 @@ { "path": "btst/form-builder/client/components/pages/submissions-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport {\n\tuseNotify,\n\tPermissionAccess,\n\tusePluginOverrides,\n\tusePluginSiteNavigation,\n\tuseTranslate,\n\tuseIdentity,\n} from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport { ArrowLeft, Trash2, Eye } from \"lucide-react\";\n\nimport {\n\tuseSuspenseSubmissions,\n\tuseSubmission,\n\tuseDeleteSubmission,\n} from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\nimport { formBuilderPermissions } from \"../../../permissions\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\n\nexport interface SubmissionsPageProps {\n\tformId: string;\n}\n\nexport function SubmissionsPage({ formId }: SubmissionsPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { localization } = usePluginOverrides(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\tconst { identity } = useIdentity();\n\tconst { Link: LinkComponent, resolve } = usePluginSiteNavigation(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\n\tconst { form, submissions, total, hasMore, isLoadingMore, loadMore } =\n\t\tuseSuspenseSubmissions(formId);\n\tconst deleteMutation = useDeleteSubmission(formId);\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\tconst [viewSubmissionId, setViewSubmissionId] = useState(null);\n\tconst {\n\t\tsubmission: viewSubmission,\n\t\tisLoading: isViewLoading,\n\t\terror: viewError,\n\t} = useSubmission(formId, viewSubmissionId ?? undefined);\n\n\t// A detail response was authorized for the identity that opened it. Close\n\t// the dialog immediately when that identity snapshot changes; the hook also\n\t// partitions its sensitive cache by identity before any subsequent fetch.\n\tuseEffect(() => {\n\t\tsetViewSubmissionId(null);\n\t}, [identity]);\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\tt(\"formBuilder.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.FORM_BUILDER_TOAST_SUBMISSION_DELETED ??\n\t\t\t\tt(\n\t\t\t\t\t\"formBuilder.toasts.submissionDeleted\",\n\t\t\t\t\t\"Submission deleted successfully\",\n\t\t\t\t),\n\t\t);\n\t\tsetDeleteId(null);\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{form?.name ||\n\t\t\t\t\t\t\t\t(localization?.FORM_BUILDER_SUBMISSIONS_TITLE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.title\", \"Submissions\"))}\n\t\t\t\t\t\t

\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_SUBTITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.subtitle\", \"View form submissions\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t
\n\n\t\t\t\t{submissions.length === 0 ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_COLUMN_ID ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.columnId\", \"ID\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_COLUMN_SUBMITTED_AT ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.columnSubmittedAt\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"Submitted\",\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.columnActions\", \"Actions\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{submissions.map((sub) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{sub.id.slice(0, 8)}...\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{new Date(sub.submittedAt).toLocaleString()}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t setViewSubmissionId(sub.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_ACTION_VIEW ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.actionView\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"View\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t setDeleteId(sub.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_ACTION_DELETE ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.actionDelete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Delete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t{/* View submission dialog */}\n\t\t\t {\n\t\t\t\t\tif (!open) setViewSubmissionId(null);\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_DETAILS_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.detailsTitle\", \"Submission Details\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{t(\n\t\t\t\t\t\t\t\t\"formBuilder.submissions.detailsDescription\",\n\t\t\t\t\t\t\t\t\"Submitted form data and request metadata.\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{isViewLoading && (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_STATUS_LOADING ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusLoading\", \"Loading...\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t)}\n\t\t\t\t\t{viewError && (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"formBuilder.toasts.error\",\n\t\t\t\t\t\t\t\t\t\"An error occurred. Please try again.\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t

\n\t\t\t\t\t)}\n\t\t\t\t\t{!isViewLoading && !viewError && viewSubmission && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_ID ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldId\", \"ID:\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t

{viewSubmission.id}

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_SUBMITTED ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldSubmitted\", \"Submitted:\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{new Date(viewSubmission.submittedAt).toLocaleString()}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_IP ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldIp\", \"IP Address:\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{viewSubmission.ipAddress || \"-\"}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_USER_AGENT ??\n\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.fieldUserAgent\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"User Agent:\",\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{viewSubmission.userAgent || \"-\"}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_DATA ??\n\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldData\", \"Data:\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{JSON.stringify(viewSubmission.parsedData, null, 2)}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t setDeleteId(null)}>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.deleteTitle\", \"Delete Submission\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_DELETE_CONFIRM ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.deleteConfirm\",\n\t\t\t\t\t\t\t\t\t\"Are you sure you want to delete this submission?\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_BUTTON_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonCancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteMutation.isPending\n\t\t\t\t\t\t\t\t? (localization?.FORM_BUILDER_STATUS_DELETING ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusDeleting\", \"Deleting...\"))\n\t\t\t\t\t\t\t\t: (localization?.FORM_BUILDER_BUTTON_DELETE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonDelete\", \"Delete\"))}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport {\n\tuseNotify,\n\tPermissionAccess,\n\tusePluginOverrides,\n\tusePluginSiteNavigation,\n\tuseTranslate,\n\tuseIdentity,\n} from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport { ArrowLeft, Trash2, Eye } from \"lucide-react\";\n\nimport {\n\tuseSuspenseSubmissions,\n\tuseSubmission,\n\tuseDeleteSubmission,\n} from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\nimport { formBuilderPermissions } from \"../../../permissions\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\n\nexport interface SubmissionsPageProps {\n\tformId: string;\n}\n\nexport function SubmissionsPage({ formId }: SubmissionsPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { localization, classNames } =\n\t\tusePluginOverrides(FORM_BUILDER_PLUGIN_ID);\n\tconst { identity } = useIdentity();\n\tconst { Link: LinkComponent, resolve } = usePluginSiteNavigation(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\n\tconst { form, submissions, total, hasMore, isLoadingMore, loadMore } =\n\t\tuseSuspenseSubmissions(formId);\n\tconst deleteMutation = useDeleteSubmission(formId);\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\tconst [viewSubmissionId, setViewSubmissionId] = useState(null);\n\tconst {\n\t\tsubmission: viewSubmission,\n\t\tisLoading: isViewLoading,\n\t\terror: viewError,\n\t} = useSubmission(formId, viewSubmissionId ?? undefined);\n\n\t// A detail response was authorized for the identity that opened it. Close\n\t// the dialog immediately when that identity snapshot changes; the hook also\n\t// partitions its sensitive cache by identity before any subsequent fetch.\n\tuseEffect(() => {\n\t\tsetViewSubmissionId(null);\n\t}, [identity]);\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\tt(\"formBuilder.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.FORM_BUILDER_TOAST_SUBMISSION_DELETED ??\n\t\t\t\tt(\n\t\t\t\t\t\"formBuilder.toasts.submissionDeleted\",\n\t\t\t\t\t\"Submission deleted successfully\",\n\t\t\t\t),\n\t\t);\n\t\tsetDeleteId(null);\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{form?.name ||\n\t\t\t\t\t\t\t\t(localization?.FORM_BUILDER_SUBMISSIONS_TITLE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.title\", \"Submissions\"))}\n\t\t\t\t\t\t

\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_SUBTITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.subtitle\", \"View form submissions\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t
\n\n\t\t\t\t{submissions.length === 0 ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_COLUMN_ID ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.columnId\", \"ID\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_COLUMN_SUBMITTED_AT ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.columnSubmittedAt\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"Submitted\",\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.columnActions\", \"Actions\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{submissions.map((sub) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{sub.id.slice(0, 8)}...\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{new Date(sub.submittedAt).toLocaleString()}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t setViewSubmissionId(sub.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_ACTION_VIEW ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.actionView\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"View\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t setDeleteId(sub.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_ACTION_DELETE ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.actionDelete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Delete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\n\t\t\t{/* View submission dialog */}\n\t\t\t {\n\t\t\t\t\tif (!open) setViewSubmissionId(null);\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_DETAILS_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.detailsTitle\", \"Submission Details\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{t(\n\t\t\t\t\t\t\t\t\"formBuilder.submissions.detailsDescription\",\n\t\t\t\t\t\t\t\t\"Submitted form data and request metadata.\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{isViewLoading && (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_STATUS_LOADING ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusLoading\", \"Loading...\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t)}\n\t\t\t\t\t{viewError && (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"formBuilder.toasts.error\",\n\t\t\t\t\t\t\t\t\t\"An error occurred. Please try again.\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t

\n\t\t\t\t\t)}\n\t\t\t\t\t{!isViewLoading && !viewError && viewSubmission && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_ID ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldId\", \"ID:\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t

{viewSubmission.id}

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_SUBMITTED ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldSubmitted\", \"Submitted:\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{new Date(viewSubmission.submittedAt).toLocaleString()}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_IP ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldIp\", \"IP Address:\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{viewSubmission.ipAddress || \"-\"}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_USER_AGENT ??\n\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.fieldUserAgent\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"User Agent:\",\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{viewSubmission.userAgent || \"-\"}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_DATA ??\n\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldData\", \"Data:\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{JSON.stringify(viewSubmission.parsedData, null, 2)}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t setDeleteId(null)}>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.deleteTitle\", \"Delete Submission\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_DELETE_CONFIRM ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.deleteConfirm\",\n\t\t\t\t\t\t\t\t\t\"Are you sure you want to delete this submission?\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_BUTTON_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonCancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteMutation.isPending\n\t\t\t\t\t\t\t\t? (localization?.FORM_BUILDER_STATUS_DELETING ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusDeleting\", \"Deleting...\"))\n\t\t\t\t\t\t\t\t: (localization?.FORM_BUILDER_BUTTON_DELETE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonDelete\", \"Delete\"))}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/form-builder/client/components/pages/submissions-page.internal.tsx" }, { @@ -139,13 +139,13 @@ { "path": "btst/form-builder/client/components/shared/page-wrapper.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution } = usePluginOverrides<\n\t\tFormBuilderPluginOverrides,\n\t\tPartial\n\t>(FORM_BUILDER_PLUGIN_ID, {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution, classNames } = usePluginOverrides<\n\t\tFormBuilderPluginOverrides,\n\t\tPartial\n\t>(FORM_BUILDER_PLUGIN_ID, {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", "target": "src/components/btst/form-builder/client/components/shared/page-wrapper.tsx" }, { "path": "btst/form-builder/client/components/shared/pagination.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronRight } from \"lucide-react\";\n\ninterface PaginationProps {\n\ttotal: number;\n\tshowing: number;\n\thasMore: boolean;\n\tisLoadingMore: boolean;\n\tonLoadMore: () => void;\n\tlabels?: {\n\t\tshowing?: string;\n\t\tprevious?: string;\n\t\tnext?: string;\n\t\tloading?: string;\n\t};\n}\n\nexport function Pagination({\n\ttotal,\n\tshowing,\n\thasMore,\n\tisLoadingMore,\n\tonLoadMore,\n\tlabels = {},\n}: PaginationProps) {\n\tconst {\n\t\tshowing: showingLabel = \"Showing {count} of {total}\",\n\t\tnext = \"Load More\",\n\t\tloading = \"Loading...\",\n\t} = labels;\n\n\tconst showingText = showingLabel\n\t\t.replace(\"{count}\", String(showing))\n\t\t.replace(\"{total}\", String(total));\n\n\treturn (\n\t\t
\n\t\t\t

{showingText}

\n\t\t\t{hasMore && (\n\t\t\t\t\n\t\t\t\t\t{isLoadingMore ? loading : next}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronRight } from \"lucide-react\";\nimport { cn } from \"../../../utils\";\n\ninterface PaginationProps {\n\ttotal: number;\n\tshowing: number;\n\thasMore: boolean;\n\tisLoadingMore: boolean;\n\tonLoadMore: () => void;\n\tclassName?: string;\n\tlabels?: {\n\t\tshowing?: string;\n\t\tprevious?: string;\n\t\tnext?: string;\n\t\tloading?: string;\n\t};\n}\n\nexport function Pagination({\n\ttotal,\n\tshowing,\n\thasMore,\n\tisLoadingMore,\n\tonLoadMore,\n\tclassName,\n\tlabels = {},\n}: PaginationProps) {\n\tconst {\n\t\tshowing: showingLabel = \"Showing {count} of {total}\",\n\t\tnext = \"Load More\",\n\t\tloading = \"Loading...\",\n\t} = labels;\n\n\tconst showingText = showingLabel\n\t\t.replace(\"{count}\", String(showing))\n\t\t.replace(\"{total}\", String(total));\n\n\treturn (\n\t\t\n\t\t\t

{showingText}

\n\t\t\t{hasMore && (\n\t\t\t\t\n\t\t\t\t\t{isLoadingMore ? loading : next}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n", "target": "src/components/btst/form-builder/client/components/shared/pagination.tsx" }, { @@ -199,7 +199,7 @@ { "path": "btst/form-builder/client/overrides.ts", "type": "registry:lib", - "content": "import type { ComponentType } from \"react\";\nimport type { FormBuilderLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the Form Builder plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface FormBuilderPluginOverrides {\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your forms\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the Form Builder plugin\n\t */\n\tlocalization?: Partial;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'formList', 'formBuilder', 'submissions')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", + "content": "import type { ComponentType } from \"react\";\nimport type { FormBuilderLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the Form Builder plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface FormBuilderPluginOverrides {\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your forms\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Optional class names merged onto specific UI elements.\n\t * Use these for targeted styling without replacing whole components.\n\t */\n\tclassNames?: {\n\t\t/** Page container shared by every Form Builder page */\n\t\tcontainer?: string;\n\t\t/** Wrapper around the forms or submissions table */\n\t\ttable?: string;\n\t\t/** Each body row in the forms or submissions table */\n\t\ttableRow?: string;\n\t\t/** Pagination footer under the forms or submissions list */\n\t\tpagination?: string;\n\t\t/** Root of `FormRenderer`, including loading, error, and success states */\n\t\tform?: string;\n\t};\n\n\t/**\n\t * Localization object for the Form Builder plugin\n\t */\n\tlocalization?: Partial;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'formList', 'formBuilder', 'submissions')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", "target": "src/components/btst/form-builder/client/overrides.ts" }, { diff --git a/packages/stack/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx b/packages/stack/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx new file mode 100644 index 000000000..65aa82a89 --- /dev/null +++ b/packages/stack/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx @@ -0,0 +1,248 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +// Core primitives MUST be imported from the package entry (not relative src +// paths) so they share module identity — and React context — with the plugin +// components, which resolve `@btst/stack/*` via package self-reference. +import { StackProvider } from "@btst/stack/context"; +import type { UIMessage } from "ai"; +import { ChatInput } from "../client/components/chat-input"; +import { ChatLayout } from "../client/components/chat-layout"; +import { ChatMessage } from "../client/components/chat-message"; +import { aiChatClientPlugin } from "../client/plugin"; +import type { SerializedConversation } from "../types"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +(globalThis as any).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; +Element.prototype.scrollIntoView ??= () => {}; + +const mocks = vi.hoisted(() => ({ + useChat: vi.fn(), + useConversation: vi.fn(), + useConversations: vi.fn(), + useRenameConversationForm: vi.fn(), + useDeleteConversation: vi.fn(), + useAiChatIdentityPartition: vi.fn(), +})); + +vi.mock("@ai-sdk/react", () => ({ useChat: mocks.useChat })); +vi.mock("../client/hooks/chat-hooks", () => ({ + useAiChatIdentityPartition: mocks.useAiChatIdentityPartition, + useConversation: mocks.useConversation, + useConversations: mocks.useConversations, + useRenameConversationForm: mocks.useRenameConversationForm, + useDeleteConversation: mocks.useDeleteConversation, +})); +vi.mock("../client/context/page-ai-context", () => ({ + usePageAIContext: () => undefined, +})); +// Markdown rendering is unrelated to class wiring and drags in highlight/katex. +vi.mock("@workspace/ui/components/markdown-content", () => ({ + MarkdownContent: ({ content }: { content: string }) =>
{content}
, +})); + +const CLASS_NAMES = { + container: "bg-red-100", + sidebar: "bg-blue-100", + messageList: "bg-yellow-50", + message: "text-purple-700", + userMessage: "border-4 border-orange-500", + assistantMessage: "border-4 border-green-500", + input: "bg-pink-100", +} as const; + +const conversation: SerializedConversation = { + id: "conv-1", + userId: "owner-1", + title: "First conversation", + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-02").toISOString(), +}; + +const userMessage: UIMessage = { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Hi there" }], +}; +const assistantMessage: UIMessage = { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Hello back" }], +}; + +let container: HTMLDivElement; +let root: Root; +let queryClient: QueryClient; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + mocks.useAiChatIdentityPartition.mockReturnValue("anonymous"); + mocks.useChat.mockReturnValue({ + messages: [userMessage, assistantMessage], + sendMessage: vi.fn(), + status: "ready", + error: null, + setMessages: vi.fn(), + regenerate: vi.fn(), + addToolOutput: vi.fn(), + stop: vi.fn(), + }); + mocks.useConversation.mockReturnValue({ + conversation: null, + isLoading: false, + error: null, + refetch: vi.fn(), + }); + mocks.useConversations.mockReturnValue({ + conversations: [conversation], + isLoading: false, + error: null, + refetch: vi.fn(), + }); + mocks.useRenameConversationForm.mockReturnValue({ + submit: vi.fn(), + isSubmitting: false, + error: null, + fieldErrors: {}, + clearErrors: vi.fn(), + }); + mocks.useDeleteConversation.mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); +}); + +async function render(ui: React.ReactElement) { + await act(async () => { + root.render( + + + {ui} + + , + ); + }); +} + +function query(selector: string): HTMLElement { + const el = container.querySelector(selector); + expect(el, `expected to find ${selector}`).toBeTruthy(); + return el!; +} + +describe("ai-chat classNames overrides (issue #36)", () => { + it("applies message, userMessage and assistantMessage to a user message", async () => { + await render(); + + const row = query('[data-testid="chat-message"][data-role="user"]'); + expect(row.className).toContain(CLASS_NAMES.message); + expect(row.className).toContain("justify-end"); + + const bubble = query('[data-testid="chat-message-bubble"]'); + expect(bubble.className).toContain("border-orange-500"); + expect(bubble.className).not.toContain("border-green-500"); + // The role-specific base styling is preserved. + expect(bubble.className).toContain("bg-primary"); + }); + + it("applies assistantMessage (not userMessage) to an assistant message", async () => { + await render(); + + const row = query('[data-testid="chat-message"][data-role="assistant"]'); + expect(row.className).toContain(CLASS_NAMES.message); + expect(row.className).toContain("justify-start"); + + const bubble = query('[data-testid="chat-message-bubble"]'); + expect(bubble.className).toContain("border-green-500"); + expect(bubble.className).not.toContain("border-orange-500"); + expect(bubble.className).toContain("bg-muted"); + }); + + it("applies classNames.input to the composer form", async () => { + await render( + {}} + handleSubmit={() => {}} + isLoading={false} + />, + ); + + const form = query('[data-testid="chat-input-form"]'); + expect(form.tagName).toBe("FORM"); + expect(form.className).toContain(CLASS_NAMES.input); + expect(form.className).toContain("space-y-2"); + }); + + it("applies container, sidebar and messageList in the full layout", async () => { + await render(); + + const layout = query('[data-testid="chat-layout"]'); + expect(layout.className).toContain(CLASS_NAMES.container); + expect(layout.className).toContain("overflow-hidden"); + + const sidebar = query('[data-testid="chat-sidebar"]'); + expect(sidebar.className).toContain(CLASS_NAMES.sidebar); + expect(sidebar.className).toContain("border-r"); + + const list = query('[data-testid="chat-message-list"]'); + expect(list.className).toContain(CLASS_NAMES.messageList); + expect(list.className).toContain("flex-col"); + }); + + it("applies classNames.container to the widget root", async () => { + await render(); + + const trigger = query('[data-testid="widget-trigger"]'); + const widgetRoot = trigger.parentElement!; + expect(widgetRoot.className).toContain(CLASS_NAMES.container); + expect(widgetRoot.className).toContain("items-end"); + }); + + it("leaves base classes untouched when no classNames override is supplied", async () => { + await act(async () => { + root.render( + + + + + , + ); + }); + + const bubble = query('[data-testid="chat-message-bubble"]'); + expect(bubble.className).toContain("bg-primary"); + expect(bubble.className).not.toContain("border-orange-500"); + }); +}); diff --git a/packages/stack/src/plugins/ai-chat/client/components/chat-input.tsx b/packages/stack/src/plugins/ai-chat/client/components/chat-input.tsx index 7ca7a3022..c9ce478a9 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/chat-input.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/chat-input.tsx @@ -93,6 +93,7 @@ export function ChatInput({ uploadFile, localization: customLocalization, allowedFileTypes, + classNames, } = usePluginOverrides>( "aiChat", {}, @@ -314,7 +315,11 @@ export function ChatInput({ )} )} -
+ {/* Attached Files Preview */} {attachedFiles.length > 0 && (
diff --git a/packages/stack/src/plugins/ai-chat/client/components/chat-interface.tsx b/packages/stack/src/plugins/ai-chat/client/components/chat-interface.tsx index 11d4055fb..2a0c093ae 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/chat-interface.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/chat-interface.tsx @@ -263,6 +263,7 @@ export function ChatInterface({ localization: customLocalization, showAttribution, chatSuggestions, + classNames, } = usePluginOverrides>( "aiChat", { showAttribution: true }, @@ -1139,7 +1140,9 @@ export function ChatInterface({ className={cn( "flex flex-col p-4", isWidget ? "max-w-full" : "max-w-3xl mx-auto w-full", + classNames?.messageList, )} + data-testid="chat-message-list" > {messages.length === 0 ? (
diff --git a/packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx b/packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx index 0a9522336..9a4edb42c 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx @@ -86,7 +86,7 @@ export function ChatLayout(props: ChatLayoutProps) { onMessagesChange, onClear, } = props; - const { localization } = usePluginOverrides< + const { localization, classNames } = usePluginOverrides< AiChatPluginOverrides, Partial >("aiChat", {}); @@ -133,7 +133,13 @@ export function ChatLayout(props: ChatLayoutProps) { if (layout === "widget") { return ( -
+
{/* Chat panel — always mounted to preserve conversation state, hidden when closed */}
diff --git a/packages/stack/src/plugins/ai-chat/client/components/chat-message.tsx b/packages/stack/src/plugins/ai-chat/client/components/chat-message.tsx index f4d9bfb99..632135e37 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/chat-message.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/chat-message.tsx @@ -133,11 +133,14 @@ export function ChatMessage({ }: ChatMessageProps) { const isUser = message.role === "user"; - const { localization: customLocalization, toolRenderers } = - usePluginOverrides>( - "aiChat", - {}, - ); + const { + localization: customLocalization, + toolRenderers, + classNames, + } = usePluginOverrides>( + "aiChat", + {}, + ); const { router } = useStack(); const Link = router?.Link; const Image = router?.Image; @@ -265,7 +268,10 @@ export function ChatMessage({ "flex gap-3 w-full", isCompact ? "mb-3" : "mb-4", isUser ? "justify-end" : "justify-start", + classNames?.message, )} + data-testid="chat-message" + data-role={message.role} aria-label={ isUser ? tr("A11Y_USER_MESSAGE", "aiChat.a11y.userMessage", "Your message") @@ -295,7 +301,9 @@ export function ChatMessage({ "rounded-lg", isCompact ? "px-3 py-2" : "px-4 py-3", isUser ? "bg-primary text-primary-foreground" : "bg-muted", + isUser ? classNames?.userMessage : classNames?.assistantMessage, )} + data-testid="chat-message-bubble" > {isUser ? ( // User messages: files + images + text (or edit mode) diff --git a/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.tsx b/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.tsx index fb117ea9b..8ead00155 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.tsx @@ -67,7 +67,7 @@ export function ChatSidebar({ onNewChat, className, }: ChatSidebarProps) { - const { localization: customLocalization } = usePluginOverrides< + const { localization: customLocalization, classNames } = usePluginOverrides< AiChatPluginOverrides, Partial >("aiChat", {}); @@ -208,7 +208,11 @@ export function ChatSidebar({ return (
{/* Header */}
diff --git a/packages/stack/src/plugins/ai-chat/client/overrides.ts b/packages/stack/src/plugins/ai-chat/client/overrides.ts index 660c668ee..e45512b2b 100644 --- a/packages/stack/src/plugins/ai-chat/client/overrides.ts +++ b/packages/stack/src/plugins/ai-chat/client/overrides.ts @@ -201,6 +201,27 @@ export interface AiChatPluginOverrides { */ toolRenderers?: Record; + /** + * Optional class names merged onto specific UI elements. + * Use these for targeted styling without replacing whole components. + */ + classNames?: { + /** Root of `ChatLayout` (full-page or widget mode) */ + container?: string; + /** Conversation sidebar root */ + sidebar?: string; + /** Wrapper around the list of messages */ + messageList?: string; + /** Each message row (user and assistant) */ + message?: string; + /** The bubble of a user message */ + userMessage?: string; + /** The bubble of an assistant message */ + assistantMessage?: string; + /** The composer form at the bottom of the chat */ + input?: string; + }; + // ============== Lifecycle Hooks (optional) ============== /** diff --git a/packages/stack/src/plugins/blog/__tests__/classnames-overrides.test.tsx b/packages/stack/src/plugins/blog/__tests__/classnames-overrides.test.tsx new file mode 100644 index 000000000..ad9d981e9 --- /dev/null +++ b/packages/stack/src/plugins/blog/__tests__/classnames-overrides.test.tsx @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +// Core primitives MUST be imported from the package entry (not relative src +// paths) so they share module identity — and React context — with the blog +// components, which resolve `@btst/stack/*` via package self-reference. +import { StackProvider } from "@btst/stack/context"; +import { PageWrapper } from "../client/components/shared/page-wrapper"; +import { PostsList } from "../client/components/shared/posts-list"; +import type { SerializedPost } from "../types"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; +import { blogClientPlugin } from "../client/plugin"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// jsdom lacks these APIs used by Radix / cmdk inside SearchInput +(globalThis as any).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; +Element.prototype.scrollIntoView ??= () => {}; + +const hooks = vi.hoisted(() => ({ + useSuspensePost: vi.fn(), + useDeletePost: vi.fn(), + usePostForm: vi.fn(), + usePostSearch: vi.fn(), + useSuspensePosts: vi.fn(), + useTags: vi.fn(), +})); + +vi.mock("../client/hooks/blog-hooks", () => hooks); + +const CLASS_NAMES = { + container: "bg-red-100", + postCard: "bg-blue-100", + postTitle: "text-purple-700", + postMeta: "text-green-600", + tagsList: "bg-yellow-50", + pagination: "border-4 border-orange-500", +} as const; + +const post: SerializedPost = { + id: "p1", + title: "Hello World", + content: "# Hello", + excerpt: "An excerpt", + slug: "hello-world", + published: true, + image: "", + tags: [{ id: "t1", name: "Release", slug: "release" }], + authorId: null, + publishedAt: new Date("2024-01-01").toISOString(), + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-01").toISOString(), +} as unknown as SerializedPost; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + hooks.usePostSearch.mockReturnValue({ data: [], isLoading: false }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); +}); + +async function render(ui: React.ReactElement) { + await act(async () => { + root.render(ui); + }); +} + +function withBlogOverrides(children: React.ReactNode) { + return ( + "", + classNames: CLASS_NAMES, + }, + }} + > + {children} + + ); +} + +function query(selector: string): HTMLElement { + const el = container.querySelector(selector); + expect(el, `expected to find ${selector}`).toBeTruthy(); + return el!; +} + +describe("blog classNames overrides (issue #36)", () => { + it("applies classNames.container to the page container", async () => { + await render( + withBlogOverrides( + + content + , + ), + ); + + const el = query('[data-testid="blog-container"]'); + expect(el.className).toContain(CLASS_NAMES.container); + // The override must merge with the layout's own classes, not replace them. + expect(el.className).toContain("container"); + expect(el.className).toContain("mx-auto"); + }); + + it("applies classNames.postCard, postTitle, postMeta and tagsList inside a post card", async () => { + await render(withBlogOverrides()); + + // postCard — anchored on the Card primitive's structural slot. + const card = query('[data-slot="card"]'); + expect(card.className).toContain(CLASS_NAMES.postCard); + expect(card.className).toContain("group"); + + // postTitle — anchored on the CardTitle primitive's structural slot. + const title = query('[data-slot="card-title"]'); + expect(title.className).toContain(CLASS_NAMES.postTitle); + expect(title.className).toContain("line-clamp-3"); + expect(title.textContent).toBe(post.title); + + // postMeta — the wrapper holding the published date. + const meta = query("time").parentElement!; + expect(meta.className).toContain(CLASS_NAMES.postMeta); + // `cn` runs tailwind-merge, so the conflicting `text-muted-foreground` + // is intentionally replaced by the override; non-conflicting base + // classes must survive. + expect(meta.className).toContain("items-center"); + expect(meta.className).not.toContain("text-muted-foreground"); + + // tagsList — the wrapper immediately following the metadata block. + const tags = meta.nextElementSibling as HTMLElement; + expect(tags.className).toContain(CLASS_NAMES.tagsList); + expect(tags.className).toContain("flex-wrap"); + expect(tags.textContent).toContain("Release"); + }); + + it("applies classNames.pagination to the load-more wrapper", async () => { + await render( + withBlogOverrides( + {}} hasMore />, + ), + ); + + const button = [...container.querySelectorAll("button")].find((b) => + b.textContent?.includes("Load more posts"), + ); + expect(button, "expected a load-more button").toBeTruthy(); + + const pagination = button!.parentElement!; + expect(pagination.className).toContain("border-4"); + expect(pagination.className).toContain("border-orange-500"); + expect(pagination.className).toContain("justify-center"); + }); + + it("leaves base classes untouched when no classNames override is supplied", async () => { + await render( + "" } }} + > + {}} + hasMore + /> + , + ); + + const card = query('[data-slot="card"]'); + expect(card.className).toContain("group"); + expect(card.className).not.toContain(CLASS_NAMES.postCard); + const metadata = query("time").parentElement!; + const tags = metadata.parentElement!.querySelectorAll( + '[data-slot="badge"]', + ); + expect(tags).toHaveLength(2); + for (const tag of tags) { + expect(tag.parentElement).toBe(metadata.parentElement); + } + }); +}); diff --git a/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.tsx b/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.tsx index 0a1d649ec..cb420fb3b 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.tsx +++ b/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.tsx @@ -4,6 +4,7 @@ import { usePluginOverrides } from "@btst/stack/context"; import { PageWrapper as SharedPageWrapper } from "@workspace/ui/components/page-wrapper"; import type { BlogPluginOverrides } from "../../overrides"; import { BLOG_PLUGIN_ID } from "../../constants"; +import { cn } from "../../../utils"; export function PageWrapper({ children, @@ -14,7 +15,7 @@ export function PageWrapper({ className?: string; testId?: string; }) { - const { showAttribution } = usePluginOverrides< + const { showAttribution, classNames } = usePluginOverrides< BlogPluginOverrides, Partial >(BLOG_PLUGIN_ID, { @@ -23,7 +24,7 @@ export function PageWrapper({ return ( diff --git a/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx b/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx index 8f1cb6846..586efa999 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx +++ b/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx @@ -18,7 +18,7 @@ import { CalendarIcon } from "lucide-react"; import type { BlogPluginOverrides } from "../../overrides"; import { BLOG_PLUGIN_ID } from "../../constants"; import { DefaultLink, DefaultImage } from "./defaults"; - +import { cn } from "../../../utils"; // Beautiful gradient color combinations const GRADIENT_PALETTES = [ { @@ -222,7 +222,7 @@ function getGradientFromTitle(title: string) { export function PostCard({ post }: { post: SerializedPost }) { const t = useTranslate(); - const { localization } = usePluginOverrides< + const { localization, classNames } = usePluginOverrides< BlogPluginOverrides, Partial >(BLOG_PLUGIN_ID, {}); @@ -234,9 +234,19 @@ export function PostCard({ post }: { post: SerializedPost }) { const timestamp = post.publishedAt || post.createdAt; const postDate = formatPostDate(timestamp); const gradient = post.image ? null : getGradientFromTitle(post.title); + const tags = post.tags?.map((tag) => ( + + {tag.name} + + )); return ( - + {/* Image or Placeholder */} - + {post.title}
-
+
- {post.tags && - post.tags.length > 0 && - post.tags.map((tag) => ( - - {tag.name} - - ))} + {classNames?.tagsList ? ( +
+ {tags} +
+ ) : ( + tags + )}
diff --git a/packages/stack/src/plugins/blog/client/components/shared/posts-list.tsx b/packages/stack/src/plugins/blog/client/components/shared/posts-list.tsx index a25249b15..c250d0b94 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/posts-list.tsx +++ b/packages/stack/src/plugins/blog/client/components/shared/posts-list.tsx @@ -6,6 +6,7 @@ import { SearchInput } from "./search-input"; import type { BlogPluginOverrides } from "../../overrides"; import { BLOG_PLUGIN_ID } from "../../constants"; import { PostCard as DefaultPostCard } from "./post-card"; +import { cn } from "../../../utils"; interface PostsListProps { posts: SerializedPost[]; @@ -21,7 +22,7 @@ export function PostsList({ isLoadingMore, }: PostsListProps) { const t = useTranslate(); - const { localization, PostCard } = + const { localization, PostCard, classNames } = usePluginOverrides(BLOG_PLUGIN_ID); const PostCardComponent = PostCard || DefaultPostCard; @@ -61,7 +62,7 @@ export function PostsList({
{onLoadMore && hasMore && ( -
+
+ ), +})); + +const CLASS_NAMES = { + container: "bg-red-100", + table: "bg-yellow-50", + tableRow: "text-green-600", + pagination: "border-4 border-orange-500", + form: "bg-blue-100", +} as const; + +const form: SerializedForm = { + id: "f1", + name: "Contact Form", + slug: "contact-form", + description: null, + schema: JSON.stringify({ type: "object", properties: {} }), + successMessage: null, + redirectUrl: null, + status: "active", + createdBy: null, + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-01").toISOString(), +} as unknown as SerializedForm; + +const authorization = defineAuthorization({ + identity: z.object({ id: z.string(), role: z.string() }), + permissions: [formBuilderPermissions] as const, + rules: ({ forms }) => [ + forms.form.render.allow(), + forms.submission.create.allow(), + forms.submission.read.allow(), + ], +}); +const auth = createClientAuth({ authorization, getIdentity: () => null }); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + listHooks.useSuspenseForms.mockReturnValue({ + forms: [form], + total: 1, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + listHooks.useForms.mockReturnValue({ + forms: [], + total: 0, + isLoading: false, + error: null, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + listHooks.useDeleteForm.mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + }); + rendererHooks.useFormBySlug.mockReturnValue({ + form, + isLoading: false, + error: null, + }); + rendererHooks.useSubmitForm.mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); +}); + +function createMockRouter() { + let params = new URLSearchParams(); + return { + navigate: vi.fn(), + getSearchParams: () => new URLSearchParams(params.toString()), + setSearchParams: vi.fn((next: URLSearchParams) => { + params = new URLSearchParams(next.toString()); + }), + }; +} + +async function render(ui: React.ReactElement, withOverrides = true) { + await act(async () => { + root.render( + + {ui} + , + ); + }); +} + +function query(selector: string): HTMLElement { + const el = container.querySelector(selector); + expect(el, `expected to find ${selector}`).toBeTruthy(); + return el!; +} + +describe("form-builder classNames overrides (issue #36)", () => { + it("applies container, table, tableRow and pagination on the forms list", async () => { + await render(); + + const page = query('[data-testid="form-list-page"]'); + expect(page.className).toContain(CLASS_NAMES.container); + // The override must merge with the layout's own classes, not replace them. + expect(page.className).toContain("mx-auto"); + + const table = query('[data-testid="form-builder-list-table"]'); + expect(table.className).toContain(CLASS_NAMES.table); + expect(table.className).toContain("rounded-lg"); + + const row = table.querySelector("tbody tr")!; + expect(row, "expected a body row").toBeTruthy(); + expect(row.className).toContain(CLASS_NAMES.tableRow); + expect(row.textContent).toContain(form.name); + + const pagination = query('[data-testid="form-builder-pagination"]'); + expect(pagination.className).toContain("border-4"); + expect(pagination.className).toContain("border-orange-500"); + expect(pagination.className).toContain("justify-between"); + }); + + it("applies table, tableRow and pagination on the submissions page", async () => { + listHooks.useSuspenseSubmissions.mockReturnValue({ + form, + submissions: [ + { + id: "sub-11111111", + formId: form.id, + submittedAt: new Date("2024-01-02").toISOString(), + }, + ], + total: 1, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + listHooks.useSubmission.mockReturnValue({ + submission: null, + isLoading: false, + error: null, + refetch: vi.fn(), + }); + listHooks.useDeleteSubmission.mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + }); + + await render(); + + const table = query('[data-testid="form-builder-submissions-table"]'); + expect(table.className).toContain(CLASS_NAMES.table); + expect(table.className).toContain("rounded-lg"); + + const row = table.querySelector("tbody tr")!; + expect(row, "expected a body row").toBeTruthy(); + expect(row.className).toContain(CLASS_NAMES.tableRow); + + const pagination = query('[data-testid="form-builder-pagination"]'); + expect(pagination.className).toContain("border-orange-500"); + expect(pagination.className).toContain("justify-between"); + }); + + it("applies classNames.form to the rendered public form", async () => { + await render(); + + const root = query('[data-testid="form-renderer"]'); + expect(root.className).toContain(CLASS_NAMES.form); + // The real auto-form is stubbed, but it must still be mounted inside the slot. + expect( + root.querySelector('[data-testid="stepped-auto-form"]'), + ).toBeTruthy(); + }); + + it.each([ + ["loading", { form: null, isLoading: true, error: null }], + [ + "error", + { form: null, isLoading: false, error: new Error("Unavailable") }, + ], + ["not found", { form: null, isLoading: false, error: null }], + [ + "inactive", + { form: { ...form, status: "inactive" }, isLoading: false, error: null }, + ], + [ + "invalid schema", + { form: { ...form, schema: "invalid" }, isLoading: false, error: null }, + ], + ])("preserves form classes in the %s state", async (_state, result) => { + rendererHooks.useFormBySlug.mockReturnValue(result); + await render(); + const wrapper = container.firstElementChild!; + expect(wrapper.className).toContain(CLASS_NAMES.form); + expect(wrapper.className).toContain("p-6"); + }); + + it("preserves form classes after successful submission", async () => { + const submit = vi + .fn() + .mockResolvedValue({ form: { successMessage: "Thank you!" } }); + rendererHooks.useSubmitForm.mockReturnValue({ + mutateAsync: submit, + isPending: false, + }); + await render(); + await act(async () => { + query('[data-testid="stepped-auto-form"]').click(); + }); + expect(submit).toHaveBeenCalled(); + expect(container.textContent).toContain("Thank you!"); + const wrapper = container.firstElementChild!; + expect(wrapper.className).toContain(CLASS_NAMES.form); + expect(wrapper.className).toContain("p-6"); + }); + + it("leaves base classes untouched when no classNames override is supplied", async () => { + await render(, false); + + const table = query('[data-testid="form-builder-list-table"]'); + expect(table.className).toContain("rounded-lg"); + expect(table.className).not.toContain(CLASS_NAMES.table); + }); +}); diff --git a/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.tsx b/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.tsx index bf16178a6..7fba166a0 100644 --- a/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.tsx @@ -18,6 +18,7 @@ import type { FormBuilderPluginOverrides } from "../../overrides"; import type { SerializedFormSubmission } from "../../../types"; import { formBuilderPermissions } from "../../../permissions"; import { FORM_BUILDER_PLUGIN_ID } from "../../constants"; +import { cn } from "../../../utils"; export interface FormRendererProps { /** Form slug to render */ @@ -132,9 +133,13 @@ export function FormRenderer({ className, }: FormRendererProps) { const t = useTranslate(); - const { fieldComponents: overrideFieldComponents, localization } = - usePluginOverrides(FORM_BUILDER_PLUGIN_ID); + const { + fieldComponents: overrideFieldComponents, + localization, + classNames, + } = usePluginOverrides(FORM_BUILDER_PLUGIN_ID); + const formClassName = cn(className, classNames?.form); const { form, isLoading, error } = useFormBySlug(slug); const submitMutation = useSubmitForm(slug); @@ -201,7 +206,7 @@ export function FormRenderer({ // Loading state if (isLoading) { return ( -
+
); @@ -210,7 +215,7 @@ export function FormRenderer({ // Error state if (error) { return ( -
+
); @@ -219,7 +224,7 @@ export function FormRenderer({ // Form not found if (!form) { return ( -
+
+
+
+
); @@ -300,7 +305,7 @@ export function FormRenderer({ status: form.status, })} > -
+
( - FORM_BUILDER_PLUGIN_ID, - ); + const { localization, classNames } = + usePluginOverrides(FORM_BUILDER_PLUGIN_ID); const { Link, navigate, resolve } = usePluginSiteNavigation( FORM_BUILDER_PLUGIN_ID, ); @@ -233,7 +233,10 @@ export function FormListPage() { ) ) : ( <> -
+
@@ -261,7 +264,7 @@ export function FormListPage() { {forms.map((form) => ( - + {form.name} {form.slug} @@ -362,6 +365,7 @@ export function FormListPage() { hasMore={hasMore} isLoadingMore={isLoadingMore} onLoadMore={loadMore} + className={classNames?.pagination} labels={{ showing: localization?.FORM_BUILDER_LIST_PAGINATION_SHOWING ?? diff --git a/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx b/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx index a38c30384..0baaae896 100644 --- a/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx @@ -48,6 +48,7 @@ import { EmptyState } from "../shared/empty-state"; import { Pagination } from "../shared/pagination"; import { formBuilderPermissions } from "../../../permissions"; import { FORM_BUILDER_PLUGIN_ID } from "../../constants"; +import { cn } from "../../../utils"; export interface SubmissionsPageProps { formId: string; @@ -56,9 +57,8 @@ export interface SubmissionsPageProps { export function SubmissionsPage({ formId }: SubmissionsPageProps) { const t = useTranslate(); const notify = useNotify(); - const { localization } = usePluginOverrides( - FORM_BUILDER_PLUGIN_ID, - ); + const { localization, classNames } = + usePluginOverrides(FORM_BUILDER_PLUGIN_ID); const { identity } = useIdentity(); const { Link: LinkComponent, resolve } = usePluginSiteNavigation( FORM_BUILDER_PLUGIN_ID, @@ -143,7 +143,10 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) { /> ) : ( <> -
+
@@ -166,7 +169,7 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) { {submissions.map((sub) => ( - + {sub.id.slice(0, 8)}... @@ -249,6 +252,7 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) { hasMore={hasMore} isLoadingMore={isLoadingMore} onLoadMore={loadMore} + className={classNames?.pagination} labels={{ showing: localization?.FORM_BUILDER_LIST_PAGINATION_SHOWING ?? diff --git a/packages/stack/src/plugins/form-builder/client/components/shared/page-wrapper.tsx b/packages/stack/src/plugins/form-builder/client/components/shared/page-wrapper.tsx index ff1f50ac7..745bb507e 100644 --- a/packages/stack/src/plugins/form-builder/client/components/shared/page-wrapper.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/shared/page-wrapper.tsx @@ -4,6 +4,7 @@ import { usePluginOverrides } from "@btst/stack/context"; import { PageWrapper as SharedPageWrapper } from "@workspace/ui/components/page-wrapper"; import type { FormBuilderPluginOverrides } from "../../overrides"; import { FORM_BUILDER_PLUGIN_ID } from "../../constants"; +import { cn } from "../../../utils"; export function PageWrapper({ children, @@ -14,7 +15,7 @@ export function PageWrapper({ className?: string; testId?: string; }) { - const { showAttribution } = usePluginOverrides< + const { showAttribution, classNames } = usePluginOverrides< FormBuilderPluginOverrides, Partial >(FORM_BUILDER_PLUGIN_ID, { @@ -23,7 +24,7 @@ export function PageWrapper({ return ( diff --git a/packages/stack/src/plugins/form-builder/client/components/shared/pagination.tsx b/packages/stack/src/plugins/form-builder/client/components/shared/pagination.tsx index ffb3613f9..64733fba0 100644 --- a/packages/stack/src/plugins/form-builder/client/components/shared/pagination.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/shared/pagination.tsx @@ -2,6 +2,7 @@ import { Button } from "@workspace/ui/components/button"; import { ChevronRight } from "lucide-react"; +import { cn } from "../../../utils"; interface PaginationProps { total: number; @@ -9,6 +10,7 @@ interface PaginationProps { hasMore: boolean; isLoadingMore: boolean; onLoadMore: () => void; + className?: string; labels?: { showing?: string; previous?: string; @@ -23,6 +25,7 @@ export function Pagination({ hasMore, isLoadingMore, onLoadMore, + className, labels = {}, }: PaginationProps) { const { @@ -36,7 +39,10 @@ export function Pagination({ .replace("{total}", String(total)); return ( -
+

{showingText}

{hasMore && (