From d494bfaaf99aec12b231dd309e2bbdf108d138ec Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:18:13 +0000 Subject: [PATCH 1/7] feat(ai-chat): complete phase 2 client sweep --- docs/content/docs/i18n.mdx | 73 ++++ docs/content/docs/plugins/ai-chat.mdx | 92 ++++- e2e/tests/smoke.chat.spec.ts | 53 +++ packages/stack/package.json | 13 + packages/stack/registry/btst-ai-chat.json | 30 +- .../src/__tests__/ai-chat-query-keys.test.ts | 103 +++++ .../client-plugin-ssr-loaders.test.ts | 74 ++++ .../src/__tests__/resource-factory.test.tsx | 50 ++- .../ai-chat/__tests__/client-sweep.test.tsx | 310 +++++++++++++++ .../plugins/ai-chat/__tests__/hooks.test.tsx | 128 +++++++ .../stack/src/plugins/ai-chat/api/index.ts | 8 +- .../ai-chat/client/components/chat-input.tsx | 62 ++- .../client/components/chat-interface.tsx | 102 +++-- .../ai-chat/client/components/chat-layout.tsx | 66 +++- .../client/components/chat-message.tsx | 111 +++++- .../client/components/chat-sidebar.tsx | 269 +++++++++---- .../client/components/pages/404-page.tsx | 20 +- .../components/pages/chat-page.internal.tsx | 32 +- .../client/components/pages/chat-page.tsx | 11 +- .../components/shared/default-error.tsx | 27 +- .../client/components/tool-call-display.tsx | 77 ++-- .../ai-chat/client/hooks/ai-chat-resource.ts | 10 + .../ai-chat/client/hooks/chat-hooks.tsx | 359 +++++------------- .../ai-chat/client/localization/index.ts | 110 ++++++ .../src/plugins/ai-chat/client/plugin.tsx | 95 +++-- .../stack/src/plugins/ai-chat/query-keys.ts | 151 ++++---- packages/stack/src/plugins/ai-chat/schemas.ts | 2 +- .../src/plugins/client/resource/internal.ts | 33 +- .../src/plugins/client/resource/queries.ts | 15 + 29 files changed, 1906 insertions(+), 580 deletions(-) create mode 100644 packages/stack/src/__tests__/ai-chat-query-keys.test.ts create mode 100644 packages/stack/src/plugins/ai-chat/__tests__/client-sweep.test.tsx create mode 100644 packages/stack/src/plugins/ai-chat/__tests__/hooks.test.tsx create mode 100644 packages/stack/src/plugins/ai-chat/client/hooks/ai-chat-resource.ts diff --git a/docs/content/docs/i18n.mdx b/docs/content/docs/i18n.mdx index 44295b8f..0fa9df37 100644 --- a/docs/content/docs/i18n.mdx +++ b/docs/content/docs/i18n.mdx @@ -113,4 +113,77 @@ An app-provided `localization` override wins byte-for-byte; otherwise the string | `blog.search.placeholder` | Type to search... | | `blog.search.searching` | Searching... | +## AI Chat + +Legacy `AiChatLocalization` values override these catalog entries when both are configured. + +| Key | Default | +| --- | --- | +| `aiChat.a11y.assistantMessage` | AI response | +| `aiChat.a11y.clearChat` | Clear chat | +| `aiChat.a11y.closeChat` | Close chat | +| `aiChat.a11y.closeSidebar` | Close sidebar | +| `aiChat.a11y.conversationActions` | Conversation actions | +| `aiChat.a11y.openChat` | Open chat | +| `aiChat.a11y.openMenu` | Open menu | +| `aiChat.a11y.openSidebar` | Open sidebar | +| `aiChat.a11y.title` | AI Chat | +| `aiChat.a11y.userMessage` | Your message | +| `aiChat.chat.emptyState` | Start a conversation... | +| `aiChat.chat.error` | Something went wrong. Please try again. | +| `aiChat.chat.loading` | Thinking... | +| `aiChat.chat.placeholder` | Type a message... | +| `aiChat.chat.send` | Send | +| `aiChat.conversation.delete` | Delete | +| `aiChat.conversation.deleteCancel` | Cancel | +| `aiChat.conversation.deleteConfirmButton` | Delete | +| `aiChat.conversation.deleteConfirmDescription` | Are you sure you want to delete this conversation? This action cannot be undone. | +| `aiChat.conversation.deleteConfirmTitle` | Delete conversation | +| `aiChat.conversation.rename` | Rename | +| `aiChat.conversation.renameCancel` | Cancel | +| `aiChat.conversation.renameDescription` | Enter a new title for this conversation. | +| `aiChat.conversation.renamePlaceholder` | Enter conversation name | +| `aiChat.conversation.renameSave` | Save | +| `aiChat.conversation.titleRequired` | Title is required | +| `aiChat.errors.genericMessage` | An error occurred while loading the chat. Please try again. | +| `aiChat.errors.genericTitle` | Something went wrong | +| `aiChat.errors.missingConversation` | Conversation is required | +| `aiChat.errors.notFoundDescription` | The conversation you're looking for doesn't exist or has been deleted. | +| `aiChat.errors.notFoundTitle` | Chat not found | +| `aiChat.files.attach` | Attach file | +| `aiChat.files.fallbackName` | File | +| `aiChat.files.remove` | Remove file | +| `aiChat.files.tooLarge` | File must be less than 10MB | +| `aiChat.files.uploadFailure` | Failed to attach file | +| `aiChat.files.uploadSuccess` | File attached | +| `aiChat.images.attachedAlt` | Attached image \{\{count\}\} | +| `aiChat.images.generatedAlt` | Image \{\{count\}\} | +| `aiChat.messages.cancel` | Cancel | +| `aiChat.messages.copied` | Copied! | +| `aiChat.messages.copy` | Copy message | +| `aiChat.messages.edit` | Edit message | +| `aiChat.messages.retry` | Retry | +| `aiChat.messages.save` | Save | +| `aiChat.sidebar.empty` | No conversations yet | +| `aiChat.sidebar.newChat` | New chat | +| `aiChat.time.daysAgo` | \{\{count\}\} days ago | +| `aiChat.time.hoursAgo` | \{\{count\}\} hours ago | +| `aiChat.time.justNow` | Just now | +| `aiChat.time.minutesAgo` | \{\{count\}\} minutes ago | +| `aiChat.time.yesterday` | Yesterday | +| `aiChat.toasts.deleteFailure` | Failed to delete conversation | +| `aiChat.toasts.deleteSuccess` | Conversation deleted | +| `aiChat.toasts.renameFailure` | Failed to rename conversation | +| `aiChat.toasts.renameSuccess` | Conversation renamed | +| `aiChat.tools.executionFailed` | Tool execution failed | +| `aiChat.tools.handlerMissing` | No client-side handler registered for tool "\{\{toolName\}\}". The page context may have changed while the response was streaming. | +| `aiChat.tools.id` | ID: \{\{id\}\} | +| `aiChat.tools.input` | Input | +| `aiChat.tools.output` | Output | +| `aiChat.tools.status.complete` | Complete | +| `aiChat.tools.status.error` | Error | +| `aiChat.tools.status.executing` | Executing... | +| `aiChat.tools.status.pending` | Pending | +| `aiChat.tools.status.running` | Running... | + Other plugins adopt the same convention as their phase-2 sweeps land. diff --git a/docs/content/docs/plugins/ai-chat.mdx b/docs/content/docs/plugins/ai-chat.mdx index 1fef17ca..978d7f4b 100644 --- a/docs/content/docs/plugins/ai-chat.mdx +++ b/docs/content/docs/plugins/ai-chat.mdx @@ -347,11 +347,11 @@ The AI Chat plugin supports two distinct modes: The AI Chat plugin provides the following API endpoints (mounted at your configured `apiBasePath`): - **POST** `/chat` - Send a message and receive streaming response -- **GET** `/conversations` - List all conversations (authenticated mode only) -- **GET** `/conversations/:id` - Get a conversation with messages -- **POST** `/conversations` - Create a new conversation -- **PUT** `/conversations/:id` - Update (rename) a conversation -- **DELETE** `/conversations/:id` - Delete a conversation +- **GET** `/chat/conversations` - List all conversations (authenticated mode only) +- **GET** `/chat/conversations/:id` - Get a conversation with messages +- **POST** `/chat/conversations` - Create a new conversation +- **PUT** `/chat/conversations/:id` - Rename a conversation; the title is trimmed and must not be empty +- **DELETE** `/chat/conversations/:id` - Delete a conversation ### Page Routes @@ -397,7 +397,30 @@ aiChatClientPlugin({ ### Adding Authorization -To add authorization rules and customize behavior, you can use the lifecycle hooks defined in the API Reference section below. These hooks allow you to control access to API endpoints, add logging, and customize the plugin's behavior to fit your application's needs. +Use the `auth` provider on `StackProvider` for client-side route and control visibility, and backend lifecycle hooks for authoritative API authorization. Authenticated mode uses this permission map: + +| Resource | Action | UI covered | +| --- | --- | --- | +| `ai-chat:conversation` | `read` | `/chat`, `/chat/:id`, and conversation history | +| `ai-chat:conversation` | `create` | New chat and the first persisted send | +| `ai-chat:conversation` | `update` | Continue, retry, edit, and rename; receives `{ id }` when available | +| `ai-chat:conversation` | `delete` | Delete; receives `{ id }` | + +```tsx + session?.user ?? null, + can: ({ resource, action, params }) => + authorizeConversation(resource, action, params?.id), + loginPath: "/sign-in", + }} + // ... +> + {children} + +``` + +Without an auth provider, permission checks remain permissive for backward compatibility. Public mode intentionally bypasses conversation permission gates because it is stateless; protect the public streaming endpoint with backend rate limits or `onBeforeChat` as needed. ## API Reference @@ -717,6 +740,7 @@ import { useSuspenseConversation, useCreateConversation, useRenameConversation, + useRenameConversationForm, useDeleteConversation, } from "@btst/stack/plugins/ai-chat/client/hooks" ``` @@ -737,6 +761,12 @@ import { +### UseRenameConversationFormOptions + + + +`useRenameConversationForm()` trims the submitted title, maps server validation issues to `fieldErrors.title`, sends success and non-field failures through the `StackProvider` `notify` provider, and preserves the conversation detail cache while refreshing the list. + **Example usage:** ```tsx @@ -745,6 +775,7 @@ import { useConversation, useCreateConversation, useRenameConversation, + useRenameConversationForm, useDeleteConversation, } from "@btst/stack/plugins/ai-chat/client/hooks" @@ -777,6 +808,35 @@ function ConversationsList() { } ``` +For a custom rename dialog, prefer the form lifecycle over calling the raw mutation directly: + +```tsx +const renameForm = useRenameConversationForm({ + conversation, + onSuccess: () => setOpen(false), +}) + +await renameForm.submit({ title }) + +return renameForm.fieldErrors.title ? ( +

{renameForm.fieldErrors.title}

+) : null +``` + +### Query keys and resource declaration + +The server-safe query-key entry point exposes both the factory and the underlying declaration: + +```ts +import { + aiChatResources, + createAiChatQueryKeys, + type AiChatQueryKeys, +} from "@btst/stack/plugins/ai-chat/query-keys" +``` + +The stable keys remain `['conversations', 'list', 'all']` and `['conversations', 'detail', id]`, so existing dehydrated caches and manual invalidations continue to match. + ## Model & Tools Configuration ### Using Different Models @@ -988,7 +1048,7 @@ overrides={{ ``` -In public mode, the sidebar is hidden, conversation history is not saved to the database, and only the `/chat` route is available. +In public mode, the sidebar is hidden, conversation history is not saved to the database, only the `/chat` route is available, and client conversation permission gates are bypassed. ### Local Storage Persistence @@ -1036,9 +1096,9 @@ This pattern enables: - **IndexedDB** - Larger storage for long conversations - **External state management** - Redux, Zustand, etc. -## Localization +## Localization and notifications -Customize UI strings by providing a `localization` override: +All rendered AI Chat copy is routed through the `StackProvider` `i18n` provider with `aiChat..` keys. The legacy `localization` override remains supported and takes precedence when both are configured: ```tsx overrides={{ @@ -1059,6 +1119,20 @@ overrides={{ +Rename, delete, and file-upload feedback uses the shared `notify` provider. Field validation and streaming errors remain inline: + +```tsx + myToast.success(message), + error: (message) => myToast.error(message), + }} + // ... +> + {children} + +``` + ## Server-side Data Access The AI Chat plugin exposes standalone getter functions for server-side use cases, giving you direct access to conversation history without going through HTTP. diff --git a/e2e/tests/smoke.chat.spec.ts b/e2e/tests/smoke.chat.spec.ts index f43f6f11..29cedb08 100644 --- a/e2e/tests/smoke.chat.spec.ts +++ b/e2e/tests/smoke.chat.spec.ts @@ -145,6 +145,59 @@ test.describe("AI Chat Plugin", () => { }); }); + test("should rename and delete a conversation from the sidebar", async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + const initialTitle = `Manage conversation ${runId}`; + const renamedTitle = `Renamed conversation ${runId}`; + + await page.goto("/pages/chat"); + await page.getByPlaceholder("Type a message...").fill(initialTitle); + await page.keyboard.press("Enter"); + await waitForChatReady(page, 45000); + await page.waitForURL(/\/pages\/chat\/[a-zA-Z0-9-]+/, { + timeout: 10000, + }); + // Reload so the route-level conversationId and sidebar selection are in sync. + await page.reload(); + + const sidebar = page.locator('[data-testid="chat-sidebar"]'); + const initialRow = sidebar + .locator(".group") + .filter({ hasText: initialTitle }); + await expect(initialRow).toBeVisible({ timeout: 10000 }); + await initialRow.hover(); + await initialRow + .getByRole("button", { name: "Conversation actions" }) + .click(); + await page.getByRole("menuitem", { name: "Rename" }).click(); + + const renameInput = page.getByPlaceholder("Enter conversation name"); + await renameInput.fill(renamedTitle); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(sidebar.getByText(renamedTitle, { exact: true })).toBeVisible({ + timeout: 10000, + }); + + const renamedRow = sidebar + .locator(".group") + .filter({ hasText: renamedTitle }); + await renamedRow.hover(); + await renamedRow + .getByRole("button", { name: "Conversation actions" }) + .click(); + await page.getByRole("menuitem", { name: "Delete" }).click(); + const alert = page.getByRole("alertdialog"); + await alert.getByRole("button", { name: "Delete", exact: true }).click(); + + await page.waitForURL("/pages/chat", { timeout: 10000 }); + await expect(sidebar.getByText(renamedTitle, { exact: true })).toHaveCount( + 0, + ); + }); + test("should keep all messages in the same conversation", async ({ page, }) => { diff --git a/packages/stack/package.json b/packages/stack/package.json index fc1259a8..ce53cd19 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -237,6 +237,16 @@ "default": "./dist/plugins/ai-chat/client/context/page-ai-context.cjs" } }, + "./plugins/ai-chat/query-keys": { + "import": { + "types": "./dist/plugins/ai-chat/query-keys.d.ts", + "default": "./dist/plugins/ai-chat/query-keys.mjs" + }, + "require": { + "types": "./dist/plugins/ai-chat/query-keys.d.cts", + "default": "./dist/plugins/ai-chat/query-keys.cjs" + } + }, "./plugins/ai-chat/css": "./dist/plugins/ai-chat/style.css", "./plugins/blog/css": "./dist/plugins/blog/style.css", "./plugins/cms/api": { @@ -700,6 +710,9 @@ "plugins/ai-chat/client/context": [ "./dist/plugins/ai-chat/client/context/page-ai-context.d.ts" ], + "plugins/ai-chat/query-keys": [ + "./dist/plugins/ai-chat/query-keys.d.ts" + ], "plugins/cms/api": [ "./dist/plugins/cms/api/index.d.ts" ], diff --git a/packages/stack/registry/btst-ai-chat.json b/packages/stack/registry/btst-ai-chat.json index 18a58236..32fad5d4 100644 --- a/packages/stack/registry/btst-ai-chat.json +++ b/packages/stack/registry/btst-ai-chat.json @@ -38,37 +38,37 @@ { "path": "btst/ai-chat/schemas.ts", "type": "registry:lib", - "content": "import { z } from \"zod\";\n\nexport const createConversationSchema = z.object({\n\tid: z.string().optional(),\n\ttitle: z.string().optional(),\n});\n\nexport const updateConversationSchema = z.object({\n\ttitle: z.string().optional(),\n});\n\nexport const chatRequestSchema = z.object({\n\tmessages: z.array(\n\t\tz.union([\n\t\t\t// Format with content string\n\t\t\tz.object({\n\t\t\t\trole: z.enum([\"system\", \"user\", \"assistant\", \"data\"]),\n\t\t\t\tcontent: z.string(),\n\t\t\t\tid: z.string().optional(),\n\t\t\t}),\n\t\t\t// Format with parts array (from UIMessage)\n\t\t\tz.object({\n\t\t\t\trole: z.enum([\"system\", \"user\", \"assistant\", \"data\"]),\n\t\t\t\tparts: z.array(\n\t\t\t\t\tz\n\t\t\t\t\t\t.object({\n\t\t\t\t\t\t\ttype: z.string(),\n\t\t\t\t\t\t\ttext: z.string().optional(),\n\t\t\t\t\t\t\t// Allow other properties that might be present\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.loose(),\n\t\t\t\t),\n\t\t\t\tid: z.string().optional(),\n\t\t\t\tmetadata: z.any().optional(),\n\t\t\t}),\n\t\t]),\n\t),\n\tconversationId: z.string().optional(),\n\tmodel: z.string().optional(),\n\t/**\n\t * Description of the current page context, injected into the AI system prompt.\n\t * Sent by ChatInterface when a page has registered context via useRegisterPageAIContext.\n\t */\n\tpageContext: z.string().max(16000).optional(),\n\t/**\n\t * Names of client-side tools currently available on the page.\n\t * The server includes matching tool schemas in the streamText call.\n\t */\n\tavailableTools: z.array(z.string()).optional(),\n\t/**\n\t * The routeName registered by the page via useRegisterPageAIContext.\n\t * Cross-validated server-side against each built-in tool's route allowlist\n\t * to prevent a page from claiming tools intended for a different route.\n\t */\n\trouteName: z.string().optional(),\n});\n", + "content": "import { z } from \"zod\";\n\nexport const createConversationSchema = z.object({\n\tid: z.string().optional(),\n\ttitle: z.string().optional(),\n});\n\nexport const updateConversationSchema = z.object({\n\ttitle: z.string().trim().min(1, \"Title is required\"),\n});\n\nexport const chatRequestSchema = z.object({\n\tmessages: z.array(\n\t\tz.union([\n\t\t\t// Format with content string\n\t\t\tz.object({\n\t\t\t\trole: z.enum([\"system\", \"user\", \"assistant\", \"data\"]),\n\t\t\t\tcontent: z.string(),\n\t\t\t\tid: z.string().optional(),\n\t\t\t}),\n\t\t\t// Format with parts array (from UIMessage)\n\t\t\tz.object({\n\t\t\t\trole: z.enum([\"system\", \"user\", \"assistant\", \"data\"]),\n\t\t\t\tparts: z.array(\n\t\t\t\t\tz\n\t\t\t\t\t\t.object({\n\t\t\t\t\t\t\ttype: z.string(),\n\t\t\t\t\t\t\ttext: z.string().optional(),\n\t\t\t\t\t\t\t// Allow other properties that might be present\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.loose(),\n\t\t\t\t),\n\t\t\t\tid: z.string().optional(),\n\t\t\t\tmetadata: z.any().optional(),\n\t\t\t}),\n\t\t]),\n\t),\n\tconversationId: z.string().optional(),\n\tmodel: z.string().optional(),\n\t/**\n\t * Description of the current page context, injected into the AI system prompt.\n\t * Sent by ChatInterface when a page has registered context via useRegisterPageAIContext.\n\t */\n\tpageContext: z.string().max(16000).optional(),\n\t/**\n\t * Names of client-side tools currently available on the page.\n\t * The server includes matching tool schemas in the streamText call.\n\t */\n\tavailableTools: z.array(z.string()).optional(),\n\t/**\n\t * The routeName registered by the page via useRegisterPageAIContext.\n\t * Cross-validated server-side against each built-in tool's route allowlist\n\t * to prevent a page from claiming tools intended for a different route.\n\t */\n\trouteName: z.string().optional(),\n});\n", "target": "src/components/btst/ai-chat/schemas.ts" }, { "path": "btst/ai-chat/client/components/chat-input.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { 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 { toast } from \"sonner\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../overrides\";\nimport { DEFAULT_ALLOWED_FILE_TYPES, FILE_TYPE_MIME_MAP } from \"../overrides\";\nimport { AI_CHAT_LOCALIZATION } from \"../localization\";\nimport type { FormEvent } from \"react\";\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}\n\nexport function ChatInput({\n\tinput = \"\",\n\thandleInputChange,\n\thandleSubmit,\n\tisLoading,\n\tplaceholder,\n\tvariant = \"default\",\n\tonFilesAttached,\n\tattachedFiles: controlledFiles,\n}: ChatInputProps) {\n\tconst {\n\t\tuploadFile,\n\t\tlocalization: customLocalization,\n\t\tmode,\n\t\tallowedFileTypes,\n\t} = usePluginOverrides>(\n\t\t\"ai-chat\",\n\t\t{},\n\t);\n\n\tconst localization = { ...AI_CHAT_LOCALIZATION, ...customLocalization };\n\n\tconst fileInputRef = useRef(null);\n\tconst [internalFiles, setInternalFiles] = useState([]);\n\tconst [isUploading, setIsUploading] = useState(false);\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\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 handleFileUpload = async (\n\t\tevent: React.ChangeEvent,\n\t) => {\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\ttoast.error(localization.FILE_UPLOAD_ERROR_TOO_LARGE);\n\t\t\treturn;\n\t\t}\n\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\taddFile({ url, mediaType: file.type, filename: file.name });\n\t\t\t\ttoast.success(localization.FILE_UPLOAD_SUCCESS);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Failed to upload file:\", error);\n\t\t\t\ttoast.error(localization.FILE_UPLOAD_FAILURE);\n\t\t\t} finally {\n\t\t\t\tsetIsUploading(false);\n\t\t\t}\n\t\t} else {\n\t\t\t// Fallback: create data URL\n\t\t\tsetIsUploading(true);\n\t\t\tconst reader = new FileReader();\n\t\t\treader.onload = (e) => {\n\t\t\t\tconst dataUrl = e.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\ttoast.error(localization.FILE_UPLOAD_FAILURE);\n\t\t\t\tsetIsUploading(false);\n\t\t\t};\n\t\t\treader.readAsDataURL(file);\n\t\t}\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{/* Attached Files Preview */}\n\t\t\t{attachedFiles.length > 0 && (\n\t\t\t\t
\n\t\t\t\t\t{attachedFiles.map((file, index) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{isImageFile(file.mediaType) ? (\n\t\t\t\t\t\t\t\t// Image preview\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// File preview with icon\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\n\t\t\t\t\t\t\t\t\t\t{file.filename}\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 removeFile(index)}\n\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>\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{/* Input Area */}\n\t\t\t
\n\t\t\t\t{/* File Upload Button - hidden in public mode */}\n\t\t\t\t{canUploadFiles && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t\n\t\t\t\t\t\t fileInputRef.current?.click()}\n\t\t\t\t\t\t\tdisabled={isLoading || isUploading}\n\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\taria-label={localization.FILE_UPLOAD_BUTTON}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{isUploading ? (\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\t\t\t\t)}\n\n\t\t\t\t{/* Text Input */}\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\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { 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 { useNotify, usePluginOverrides } from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../overrides\";\nimport { DEFAULT_ALLOWED_FILE_TYPES, FILE_TYPE_MIME_MAP } from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\nimport type { FormEvent } from \"react\";\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}\n\nexport function ChatInput({\n\tinput = \"\",\n\thandleInputChange,\n\thandleSubmit,\n\tisLoading,\n\tplaceholder,\n\tvariant = \"default\",\n\tonFilesAttached,\n\tattachedFiles: controlledFiles,\n}: ChatInputProps) {\n\tconst {\n\t\tuploadFile,\n\t\tlocalization: customLocalization,\n\t\tmode,\n\t\tallowedFileTypes,\n\t} = usePluginOverrides>(\n\t\t\"ai-chat\",\n\t\t{},\n\t);\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\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\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 handleFileUpload = async (\n\t\tevent: React.ChangeEvent,\n\t) => {\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\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\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\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\tsetIsUploading(false);\n\t\t\t}\n\t\t} else {\n\t\t\t// Fallback: create data URL\n\t\t\tsetIsUploading(true);\n\t\t\tconst reader = new FileReader();\n\t\t\treader.onload = (e) => {\n\t\t\t\tconst dataUrl = e.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\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\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{/* Attached Files Preview */}\n\t\t\t{attachedFiles.length > 0 && (\n\t\t\t\t
\n\t\t\t\t\t{attachedFiles.map((file, index) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{isImageFile(file.mediaType) ? (\n\t\t\t\t\t\t\t\t// Image preview\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// File preview with icon\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\n\t\t\t\t\t\t\t\t\t\t{file.filename}\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 removeFile(index)}\n\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\taria-label={tr(\n\t\t\t\t\t\t\t\t\t\"FILE_REMOVE\",\n\t\t\t\t\t\t\t\t\t\"aiChat.files.remove\",\n\t\t\t\t\t\t\t\t\t\"Remove 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\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{/* Input Area */}\n\t\t\t
\n\t\t\t\t{/* File Upload Button - hidden in public mode */}\n\t\t\t\t{canUploadFiles && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t\n\t\t\t\t\t\t fileInputRef.current?.click()}\n\t\t\t\t\t\t\tdisabled={isLoading || isUploading}\n\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\taria-label={tr(\n\t\t\t\t\t\t\t\t\"FILE_UPLOAD_BUTTON\",\n\t\t\t\t\t\t\t\t\"aiChat.files.attach\",\n\t\t\t\t\t\t\t\t\"Attach file\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{isUploading ? (\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\t\t\t\t)}\n\n\t\t\t\t{/* Text Input */}\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\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 { useEffect, useRef, useState, useMemo, useCallback } from \"react\";\nimport { 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 { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../overrides\";\nimport { AI_CHAT_LOCALIZATION } from \"../localization\";\nimport { createApiClient } from \"@btst/stack/plugins/client\";\nimport type { AiChatApiRouter } from \"@btst/stack/plugins/ai-chat/api\";\nimport { createAiChatQueryKeys } from \"@btst/stack/plugins/ai-chat/api\";\nimport {\n\tuseConversation,\n\tuseConversations,\n\ttype SerializedConversation,\n} from \"@btst/stack/plugins/ai-chat/client/hooks\";\nimport { usePageAIContext } from \"../context/page-ai-context\";\n\ninterface ChatInterfaceProps {\n\tapiPath?: string;\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\nexport function ChatInterface({\n\tapiPath = \"/api/chat\",\n\tinitialMessages,\n\tid,\n\tvariant = \"full\",\n\tclassName,\n\tonMessagesChange,\n}: ChatInterfaceProps) {\n\tconst {\n\t\tnavigate,\n\t\tlocalization: customLocalization,\n\t\tapiBaseURL,\n\t\tapiBasePath,\n\t\theaders,\n\t\tmode,\n\t\tshowAttribution,\n\t\tchatSuggestions,\n\t} = usePluginOverrides>(\n\t\t\"ai-chat\",\n\t\t{ showAttribution: true },\n\t);\n\tconst basePath = useBasePath();\n\tconst isPublicMode = mode === \"public\";\n\n\t// Read page AI context registered by the current page\n\tconst pageAIContext = usePageAIContext();\n\n\tconst localization = { ...AI_CHAT_LOCALIZATION, ...customLocalization };\n\tconst queryClient = useQueryClient();\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});\n\t\tconst queries = createAiChatQueryKeys(client, headers);\n\t\treturn queries.conversations.list().queryKey;\n\t}, [apiBaseURL, apiBasePath, headers, isPublicMode]);\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\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\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\t() =>\n\t\t\tnew DefaultChatTransport({\n\t\t\t\tapi: apiPath,\n\t\t\t\t// In public mode, don't send conversationId\n\t\t\t\tbody: isPublicMode\n\t\t\t\t\t? undefined\n\t\t\t\t\t: () => ({ conversationId: conversationIdRef.current }),\n\t\t\t\t// Handle edit operations and inject page context\n\t\t\t\tprepareSendMessagesRequest: ({ messages: hookMessages }) => {\n\t\t\t\t\tconst currentPageContext = pageAIContextRef.current;\n\n\t\t\t\t\t// Build page context fields to include in every request\n\t\t\t\t\tconst pageContextBody = currentPageContext?.pageDescription\n\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\tpageContext: currentPageContext.pageDescription,\n\t\t\t\t\t\t\t\tavailableTools: Object.keys(\n\t\t\t\t\t\t\t\t\tcurrentPageContext.clientTools ?? {},\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\trouteName: currentPageContext.routeName,\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t: {};\n\n\t\t\t\t\t// If we're in an edit operation, use the truncated messages + new user message\n\t\t\t\t\tif (editMessagesRef.current !== null) {\n\t\t\t\t\t\tconst newUserMessage = hookMessages[hookMessages.length - 1];\n\t\t\t\t\t\tconst messagesToSend = [...editMessagesRef.current];\n\t\t\t\t\t\tif (newUserMessage) {\n\t\t\t\t\t\t\tmessagesToSend.push(newUserMessage);\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Clear the ref after use\n\t\t\t\t\t\teditMessagesRef.current = null;\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tbody: {\n\t\t\t\t\t\t\t\tmessages: messagesToSend,\n\t\t\t\t\t\t\t\tconversationId: conversationIdRef.current,\n\t\t\t\t\t\t\t\t...pageContextBody,\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// Normal case - use the messages as-is\n\t\t\t\t\treturn {\n\t\t\t\t\t\tbody: {\n\t\t\t\t\t\t\tmessages: hookMessages,\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}),\n\t\t[apiPath, isPublicMode],\n\t);\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} = useChat({\n\t\ttransport,\n\t\t// Automatically resubmit after all client-side tool results are provided\n\t\tsendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,\n\t\tonToolCall: async ({ toolCall }) => {\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\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\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 ? err.message : \"Tool execution failed\",\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: `No client-side handler registered for tool \"${toolName}\". The page context may have changed while the response was streaming.`,\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\t// Reset first-message tracking if the send failed before a conversation was created.\n\t\t\t// Without this, isFirstMessageSentRef stays true and the next successful send\n\t\t\t// skips the \"first message\" navigation logic, corrupting the conversation flow.\n\t\t\tif (!id && !hasNavigatedRef.current) {\n\t\t\t\tisFirstMessageSentRef.current = false;\n\t\t\t}\n\t\t},\n\t\tonFinish: async () => {\n\t\t\t// In public mode, skip all persistence-related operations\n\t\t\tif (isPublicMode) return;\n\n\t\t\t// Invalidate conversation list to show new/updated conversations\n\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\tqueryKey: conversationsListQueryKey,\n\t\t\t});\n\n\t\t\t// If this was the first message on a new chat, update the URL without full navigation\n\t\t\t// This avoids losing the in-memory messages during component remount\n\t\t\tif (isFirstMessageSentRef.current && !id && !hasNavigatedRef.current) {\n\t\t\t\thasNavigatedRef.current = true;\n\t\t\t\t// Wait for the invalidation to complete and refetch conversations\n\t\t\t\tawait queryClient.refetchQueries({\n\t\t\t\t\tqueryKey: conversationsListQueryKey,\n\t\t\t\t});\n\t\t\t\t// Get the updated conversations from cache\n\t\t\t\tconst cachedConversations = queryClient.getQueryData<\n\t\t\t\t\tSerializedConversation[]\n\t\t\t\t>(conversationsListQueryKey);\n\t\t\t\tif (cachedConversations && cachedConversations.length > 0) {\n\t\t\t\t\t// The most recently updated conversation should be the one we just created\n\t\t\t\t\tconst newConversation = cachedConversations[0];\n\t\t\t\t\tif (newConversation) {\n\t\t\t\t\t\t// Update our local state\n\t\t\t\t\t\tsetCurrentConversationId(newConversation.id);\n\t\t\t\t\t\tconversationIdRef.current = newConversation.id;\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 newUrl = `${basePath}/chat/${newConversation.id}`;\n\t\t\t\t\t\t\tif (typeof window !== \"undefined\") {\n\t\t\t\t\t\t\t\twindow.history.replaceState(\n\t\t\t\t\t\t\t\t\t{ ...window.history.state },\n\t\t\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\t\t\tnewUrl,\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\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\t// Filter out \"data\" role messages as UIMessage only accepts \"user\" | \"assistant\" | \"system\"\n\t\t\tconst uiMessages: UIMessage[] = conversation.messages\n\t\t\t\t.filter((m) => m.role !== \"data\")\n\t\t\t\t.map((m) => {\n\t\t\t\t\t// Try to parse content as JSON parts (new format with images)\n\t\t\t\t\tlet parts: UIMessage[\"parts\"];\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst parsed = JSON.parse(m.content);\n\t\t\t\t\t\tif (Array.isArray(parsed)) {\n\t\t\t\t\t\t\tparts = parsed;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Fallback: wrap as text\n\t\t\t\t\t\t\tparts = [{ type: \"text\" as const, text: m.content }];\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Not JSON - legacy format, wrap as text\n\t\t\t\t\t\tparts = [{ type: \"text\" as const, text: m.content }];\n\t\t\t\t\t}\n\t\t\t\t\treturn {\n\t\t\t\t\t\tid: m.id,\n\t\t\t\t\t\trole: m.role as \"user\" | \"assistant\" | \"system\",\n\t\t\t\t\t\tparts,\n\t\t\t\t\t};\n\t\t\t\t});\n\t\t\tsetMessages(uiMessages);\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\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, setMessages, messages.length]);\n\n\tconst [input, setInput] = useState(\"\");\n\tconst [attachedFiles, setAttachedFiles] = useState([]);\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\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\n\t\ttry {\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\tawait 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} else {\n\t\t\t\tawait sendMessage({ text });\n\t\t\t}\n\t\t} catch (error) {\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\tregenerate();\n\t}, [regenerate]);\n\n\t// Pending edit state - stores the text to send after messages are truncated\n\tconst [pendingEdit, setPendingEdit] = useState<{\n\t\ttext: string;\n\t\texpectedLength: number;\n\t} | null>(null);\n\n\t// Effect to send the edited message after React has processed the truncation\n\tuseEffect(() => {\n\t\tif (pendingEdit && messages.length === pendingEdit.expectedLength) {\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\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\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({ text: newText, expectedLength: messageIndex });\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

{localization.CHAT_EMPTY_STATE}

\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 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{localization.CHAT_LOADING}\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 && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{localization.CHAT_ERROR}\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
\n\t\t\t\t\t\n\t\t\t\t\t{showAttribution && }\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 { useEffect, useRef, useState, useMemo, useCallback } from \"react\";\nimport { 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 { useCan, usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\nimport { createApiClient } from \"@btst/stack/plugins/client\";\nimport type { AiChatApiRouter } from \"@btst/stack/plugins/ai-chat/api\";\nimport { createAiChatQueryKeys } from \"@btst/stack/plugins/ai-chat/api\";\nimport {\n\tuseConversation,\n\tuseConversations,\n\ttype SerializedConversation,\n} from \"@btst/stack/plugins/ai-chat/client/hooks\";\nimport { usePageAIContext } from \"../context/page-ai-context\";\n\ninterface ChatInterfaceProps {\n\tapiPath?: string;\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\nexport function ChatInterface({\n\tapiPath = \"/api/chat\",\n\tinitialMessages,\n\tid,\n\tvariant = \"full\",\n\tclassName,\n\tonMessagesChange,\n}: ChatInterfaceProps) {\n\tconst {\n\t\tnavigate,\n\t\tlocalization: customLocalization,\n\t\tapiBaseURL,\n\t\tapiBasePath,\n\t\theaders,\n\t\tmode,\n\t\tshowAttribution,\n\t\tchatSuggestions,\n\t} = usePluginOverrides>(\n\t\t\"ai-chat\",\n\t\t{ showAttribution: true },\n\t);\n\tconst basePath = useBasePath();\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();\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});\n\t\tconst queries = createAiChatQueryKeys(client, headers);\n\t\treturn queries.conversations.list().queryKey;\n\t}, [apiBaseURL, apiBasePath, headers, isPublicMode]);\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\tconst { can: canPersistConversation, isPending: isPermissionPending } =\n\t\tuseCan({\n\t\t\tresource: \"ai-chat:conversation\",\n\t\t\taction: currentConversationId ? \"update\" : \"create\",\n\t\t\tparams: currentConversationId ? { id: currentConversationId } : undefined,\n\t\t});\n\tconst canWrite =\n\t\tisPublicMode || (!isPermissionPending && canPersistConversation);\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\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\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\t() =>\n\t\t\tnew DefaultChatTransport({\n\t\t\t\tapi: apiPath,\n\t\t\t\t// In public mode, don't send conversationId\n\t\t\t\tbody: isPublicMode\n\t\t\t\t\t? undefined\n\t\t\t\t\t: () => ({ conversationId: conversationIdRef.current }),\n\t\t\t\t// Handle edit operations and inject page context\n\t\t\t\tprepareSendMessagesRequest: ({ messages: hookMessages }) => {\n\t\t\t\t\tconst currentPageContext = pageAIContextRef.current;\n\n\t\t\t\t\t// Build page context fields to include in every request\n\t\t\t\t\tconst pageContextBody = currentPageContext?.pageDescription\n\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\tpageContext: currentPageContext.pageDescription,\n\t\t\t\t\t\t\t\tavailableTools: Object.keys(\n\t\t\t\t\t\t\t\t\tcurrentPageContext.clientTools ?? {},\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\trouteName: currentPageContext.routeName,\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t: {};\n\n\t\t\t\t\t// If we're in an edit operation, use the truncated messages + new user message\n\t\t\t\t\tif (editMessagesRef.current !== null) {\n\t\t\t\t\t\tconst newUserMessage = hookMessages[hookMessages.length - 1];\n\t\t\t\t\t\tconst messagesToSend = [...editMessagesRef.current];\n\t\t\t\t\t\tif (newUserMessage) {\n\t\t\t\t\t\t\tmessagesToSend.push(newUserMessage);\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Clear the ref after use\n\t\t\t\t\t\teditMessagesRef.current = null;\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tbody: {\n\t\t\t\t\t\t\t\tmessages: messagesToSend,\n\t\t\t\t\t\t\t\tconversationId: conversationIdRef.current,\n\t\t\t\t\t\t\t\t...pageContextBody,\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// Normal case - use the messages as-is\n\t\t\t\t\treturn {\n\t\t\t\t\t\tbody: {\n\t\t\t\t\t\t\tmessages: hookMessages,\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}),\n\t\t[apiPath, isPublicMode],\n\t);\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} = useChat({\n\t\ttransport,\n\t\t// Automatically resubmit after all client-side tool results are provided\n\t\tsendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,\n\t\tonToolCall: async ({ toolCall }) => {\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\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\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\t// Reset first-message tracking if the send failed before a conversation was created.\n\t\t\t// Without this, isFirstMessageSentRef stays true and the next successful send\n\t\t\t// skips the \"first message\" navigation logic, corrupting the conversation flow.\n\t\t\tif (!id && !hasNavigatedRef.current) {\n\t\t\t\tisFirstMessageSentRef.current = false;\n\t\t\t}\n\t\t},\n\t\tonFinish: async () => {\n\t\t\t// In public mode, skip all persistence-related operations\n\t\t\tif (isPublicMode) return;\n\n\t\t\t// Invalidate conversation list to show new/updated conversations\n\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\tqueryKey: conversationsListQueryKey,\n\t\t\t});\n\n\t\t\t// If this was the first message on a new chat, update the URL without full navigation\n\t\t\t// This avoids losing the in-memory messages during component remount\n\t\t\tif (isFirstMessageSentRef.current && !id && !hasNavigatedRef.current) {\n\t\t\t\thasNavigatedRef.current = true;\n\t\t\t\t// Wait for the invalidation to complete and refetch conversations\n\t\t\t\tawait queryClient.refetchQueries({\n\t\t\t\t\tqueryKey: conversationsListQueryKey,\n\t\t\t\t});\n\t\t\t\t// Get the updated conversations from cache\n\t\t\t\tconst cachedConversations = queryClient.getQueryData<\n\t\t\t\t\tSerializedConversation[]\n\t\t\t\t>(conversationsListQueryKey);\n\t\t\t\tif (cachedConversations && cachedConversations.length > 0) {\n\t\t\t\t\t// The most recently updated conversation should be the one we just created\n\t\t\t\t\tconst newConversation = cachedConversations[0];\n\t\t\t\t\tif (newConversation) {\n\t\t\t\t\t\t// Update our local state\n\t\t\t\t\t\tsetCurrentConversationId(newConversation.id);\n\t\t\t\t\t\tconversationIdRef.current = newConversation.id;\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 newUrl = `${basePath}/chat/${newConversation.id}`;\n\t\t\t\t\t\t\tif (typeof window !== \"undefined\") {\n\t\t\t\t\t\t\t\twindow.history.replaceState(\n\t\t\t\t\t\t\t\t\t{ ...window.history.state },\n\t\t\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\t\t\tnewUrl,\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\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\t// Filter out \"data\" role messages as UIMessage only accepts \"user\" | \"assistant\" | \"system\"\n\t\t\tconst uiMessages: UIMessage[] = conversation.messages\n\t\t\t\t.filter((m) => m.role !== \"data\")\n\t\t\t\t.map((m) => {\n\t\t\t\t\t// Try to parse content as JSON parts (new format with images)\n\t\t\t\t\tlet parts: UIMessage[\"parts\"];\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst parsed = JSON.parse(m.content);\n\t\t\t\t\t\tif (Array.isArray(parsed)) {\n\t\t\t\t\t\t\tparts = parsed;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Fallback: wrap as text\n\t\t\t\t\t\t\tparts = [{ type: \"text\" as const, text: m.content }];\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Not JSON - legacy format, wrap as text\n\t\t\t\t\t\tparts = [{ type: \"text\" as const, text: m.content }];\n\t\t\t\t\t}\n\t\t\t\t\treturn {\n\t\t\t\t\t\tid: m.id,\n\t\t\t\t\t\trole: m.role as \"user\" | \"assistant\" | \"system\",\n\t\t\t\t\t\tparts,\n\t\t\t\t\t};\n\t\t\t\t});\n\t\t\tsetMessages(uiMessages);\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\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, setMessages, messages.length]);\n\n\tconst [input, setInput] = useState(\"\");\n\tconst [attachedFiles, setAttachedFiles] = useState([]);\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\tif (!canWrite) return;\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\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\n\t\ttry {\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\tawait 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} else {\n\t\t\t\tawait sendMessage({ text });\n\t\t\t}\n\t\t} catch (error) {\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\tregenerate();\n\t}, [regenerate]);\n\n\t// Pending edit state - stores the text to send after messages are truncated\n\tconst [pendingEdit, setPendingEdit] = useState<{\n\t\ttext: string;\n\t\texpectedLength: number;\n\t} | null>(null);\n\n\t// Effect to send the edited message after React has processed the truncation\n\tuseEffect(() => {\n\t\tif (pendingEdit && messages.length === pendingEdit.expectedLength) {\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\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\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({ text: newText, expectedLength: messageIndex });\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 canWrite && 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 handleEditMessage(m.id, newText)\n\t\t\t\t\t\t\t\t\t\t\t\t\t: undefined\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 && (\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
\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{canWrite && (\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{showAttribution && }\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-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\";\n\ninterface ChatLayoutBaseProps {\n\t/** API base URL */\n\tapiBaseURL: string;\n\t/** API base path */\n\tapiBasePath: string;\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\tapiBaseURL,\n\t\tapiBasePath,\n\t\tconversationId,\n\t\tlayout = \"full\",\n\t\tclassName,\n\t\tshowSidebar = true,\n\t\tinitialMessages,\n\t\tonMessagesChange,\n\t\tonClear,\n\t} = props;\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\tconst apiPath = `${apiBaseURL}${apiBasePath}/chat`;\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\tAI 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=\"Clear chat\"\n\t\t\t\t\t\t\ttitle=\"Clear chat\"\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=\"Close chat\"\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={widgetOpen ? \"Close chat\" : \"Open chat\"}\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={sidebarOpen ? \"Close sidebar\" : \"Open sidebar\"}\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=\"Clear chat\"\n\t\t\t\t\t\t\ttitle=\"Clear chat\"\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 } from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\n\ninterface ChatLayoutBaseProps {\n\t/** API base URL */\n\tapiBaseURL: string;\n\t/** API base path */\n\tapiBasePath: string;\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\tapiBaseURL,\n\t\tapiBasePath,\n\t\tconversationId,\n\t\tlayout = \"full\",\n\t\tclassName,\n\t\tshowSidebar = 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>(\"ai-chat\", {});\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\tconst apiPath = `${apiBaseURL}${apiBasePath}/chat`;\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 } from \"@btst/stack/context\";\nimport type {\n\tAiChatPluginOverrides,\n\tToolCallProps,\n\tToolCallState,\n} from \"../overrides\";\nimport { AI_CHAT_LOCALIZATION } 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\tLink,\n\t\tImage,\n\t\tlocalization: customLocalization,\n\t\ttoolRenderers,\n\t} = usePluginOverrides>(\n\t\t\"ai-chat\",\n\t\t{},\n\t);\n\n\tconst localization = { ...AI_CHAT_LOCALIZATION, ...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 || \"File\"}\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 } 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\tLink,\n\t\tImage,\n\t\tlocalization: customLocalization,\n\t\ttoolRenderers,\n\t} = usePluginOverrides>(\n\t\t\"ai-chat\",\n\t\t{},\n\t);\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\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 { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../overrides\";\nimport type { SerializedConversation } from \"../../types\";\nimport {\n\tuseConversations,\n\tuseRenameConversation,\n\tuseDeleteConversation,\n} from \"@btst/stack/plugins/ai-chat/client/hooks\";\nimport { AI_CHAT_LOCALIZATION } from \"../localization\";\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 { navigate, localization: customLocalization } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"ai-chat\", {});\n\tconst basePath = useBasePath();\n\n\tconst localization = { ...AI_CHAT_LOCALIZATION, ...customLocalization };\n\n\tconst { conversations, isLoading } = useConversations();\n\tconst renameMutation = useRenameConversation();\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\n\tconst handleNewChat = () => {\n\t\t// Always use overrides navigation when available, so consumers control routing.\n\t\t// Also run onNewChat to support \"reset chat\" behavior when already on /chat.\n\t\tif (navigate) {\n\t\t\tnavigate(`${basePath}/chat`);\n\t\t}\n\t\tif (onNewChat) {\n\t\t\tonNewChat();\n\t\t}\n\t};\n\n\tconst handleConversationClick = (conversation: SerializedConversation) => {\n\t\tif (navigate) {\n\t\t\tnavigate(`${basePath}/chat/${conversation.id}`);\n\t\t}\n\t};\n\n\tconst handleRenameClick = (conversation: SerializedConversation) => {\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 && newTitle.trim()) {\n\t\t\tawait renameMutation.mutateAsync({\n\t\t\t\tid: selectedConversation.id,\n\t\t\t\ttitle: newTitle.trim(),\n\t\t\t});\n\t\t\tsetRenameDialogOpen(false);\n\t\t\tsetSelectedConversation(null);\n\t\t\tsetNewTitle(\"\");\n\t\t}\n\t};\n\n\tconst handleDeleteConfirm = async () => {\n\t\tif (selectedConversation) {\n\t\t\tawait deleteMutation.mutateAsync({ id: selectedConversation.id });\n\t\t\tsetDeleteDialogOpen(false);\n\t\t\tsetSelectedConversation(null);\n\t\t\t// Navigate away if deleted current conversation\n\t\t\tif (selectedConversation.id === currentConversationId && navigate) {\n\t\t\t\tnavigate(`${basePath}/chat`);\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) return localization.TIME_JUST_NOW;\n\t\tif (diffMins < 60)\n\t\t\treturn localization.TIME_MINUTES_AGO.replace(\"{count}\", String(diffMins));\n\t\tif (diffHours < 24)\n\t\t\treturn localization.TIME_HOURS_AGO.replace(\"{count}\", String(diffHours));\n\t\tif (diffDays === 1) return localization.TIME_YESTERDAY;\n\t\tif (diffDays < 7)\n\t\t\treturn localization.TIME_DAYS_AGO.replace(\"{count}\", String(diffDays));\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{localization.SIDEBAR_NEW_CHAT}\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{localization.CHAT_LOADING}\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{localization.SIDEBAR_NO_CONVERSATIONS}\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\te.stopPropagation();\n\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}}\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{localization.CONVERSATION_RENAME}\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\te.stopPropagation();\n\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}}\n\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>\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{localization.CONVERSATION_DELETE}\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\n\t\t\t\t\t\n\t\t\t\t\t\t{localization.CONVERSATION_RENAME}\n\t\t\t\t\t\n\t\t\t\t\t setNewTitle(e.target.value)}\n\t\t\t\t\t\tplaceholder={localization.CONVERSATION_RENAME_PLACEHOLDER}\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\n\t\t\t\t\t\t setRenameDialogOpen(false)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{localization.CONVERSATION_RENAME_CANCEL}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.CONVERSATION_RENAME_SAVE}\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{localization.CONVERSATION_DELETE_CONFIRM_TITLE}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.CONVERSATION_DELETE_CONFIRM_DESCRIPTION}\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.CONVERSATION_DELETE_CANCEL}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.CONVERSATION_DELETE_CONFIRM_BUTTON}\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\tCanAccess,\n\tuseBasePath,\n\tuseCan,\n\tuseNotify,\n\tusePluginOverrides,\n} from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } 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\";\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 { navigate, localization: customLocalization } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"ai-chat\", {});\n\tconst basePath = useBasePath();\n\tconst notify = useNotify();\n\tconst tr = useAiChatTranslation(customLocalization);\n\tconst { can: canRead, isPending: isReadPermissionPending } = useCan({\n\t\tresource: \"ai-chat:conversation\",\n\t\taction: \"read\",\n\t});\n\tconst { conversations, isLoading } = useConversations({\n\t\tenabled: !isReadPermissionPending && canRead,\n\t});\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\n\tconst handleNewChat = () => {\n\t\t// Always use overrides navigation when available, so consumers control routing.\n\t\t// Also run onNewChat to support \"reset chat\" behavior when already on /chat.\n\t\tif (navigate) {\n\t\t\tnavigate(`${basePath}/chat`);\n\t\t}\n\t\tif (onNewChat) {\n\t\t\tonNewChat();\n\t\t}\n\t};\n\n\tconst handleConversationClick = (conversation: SerializedConversation) => {\n\t\tif (navigate) {\n\t\t\tnavigate(`${basePath}/chat/${conversation.id}`);\n\t\t}\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\tawait deleteMutation.mutateAsync({ id: selectedConversation.id });\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\t// Navigate away if deleted current conversation\n\t\t\t\tif (selectedConversation.id === currentConversationId && navigate) {\n\t\t\t\t\tawait navigate(`${basePath}/chat`);\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tnotify.error(\n\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t: tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_FAILURE\",\n\t\t\t\t\t\t\t\t\"aiChat.toasts.deleteFailure\",\n\t\t\t\t\t\t\t\t\"Failed to delete conversation\",\n\t\t\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" }, { @@ -86,25 +86,25 @@ { "path": "btst/ai-chat/client/components/pages/404-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { ErrorPlaceholder } from \"../shared/error-placeholder\";\nimport { AI_CHAT_LOCALIZATION } from \"../../localization\";\nimport type { AiChatPluginOverrides } from \"../../overrides\";\n\nexport function NotFoundPage({ message }: { message?: string }) {\n\tconst { localization } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"ai-chat\", {\n\t\tlocalization: AI_CHAT_LOCALIZATION,\n\t});\n\tconst title =\n\t\tlocalization?.CHAT_PAGE_NOT_FOUND_TITLE ??\n\t\tAI_CHAT_LOCALIZATION.CHAT_PAGE_NOT_FOUND_TITLE;\n\tconst desc =\n\t\tmessage ||\n\t\t(localization?.CHAT_PAGE_NOT_FOUND_DESCRIPTION ??\n\t\t\tAI_CHAT_LOCALIZATION.CHAT_PAGE_NOT_FOUND_DESCRIPTION);\n\treturn (\n\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { ErrorPlaceholder } from \"../shared/error-placeholder\";\nimport { useAiChatTranslation } from \"../../localization\";\nimport type { AiChatPluginOverrides } from \"../../overrides\";\n\nexport function NotFoundPage({ message }: { message?: string }) {\n\tconst { localization } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"ai-chat\", {\n\t\tlocalization: {},\n\t});\n\tconst tr = useAiChatTranslation(localization);\n\tconst title = tr(\n\t\t\"CHAT_PAGE_NOT_FOUND_TITLE\",\n\t\t\"aiChat.errors.notFoundTitle\",\n\t\t\"Chat not found\",\n\t);\n\tconst desc =\n\t\tmessage ||\n\t\ttr(\n\t\t\t\"CHAT_PAGE_NOT_FOUND_DESCRIPTION\",\n\t\t\t\"aiChat.errors.notFoundDescription\",\n\t\t\t\"The conversation you're looking for doesn't exist or has been deleted.\",\n\t\t);\n\treturn (\n\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/ai-chat/client/components/pages/404-page.tsx" }, { "path": "btst/ai-chat/client/components/pages/chat-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { ChatLayout } from \"../chat-layout\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../../overrides\";\n\nexport interface ChatPageProps {\n\tconversationId?: string;\n}\n\n/**\n * Internal chat page component - loaded lazily by ChatPageComponent\n */\nexport function ChatPage({ conversationId }: ChatPageProps) {\n\tconst { apiBaseURL, apiBasePath, mode } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"ai-chat\", {});\n\n\t// In public mode, don't show sidebar\n\tconst showSidebar = mode !== \"public\";\n\n\treturn (\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { ChatLayout } from \"../chat-layout\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../../overrides\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\nexport interface ChatPageProps {\n\tconversationId?: string;\n}\n\n/**\n * Internal chat page component - loaded lazily by ChatPageComponent\n */\nexport function ChatPage({ conversationId }: ChatPageProps) {\n\tconst overrides = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"ai-chat\", {});\n\tconst { apiBaseURL, apiBasePath, mode } = overrides;\n\tconst routeName = conversationId ? \"chatConversation\" : \"chat\";\n\tconst context = useMemo(\n\t\t() => ({\n\t\t\tpath: conversationId ? `/chat/${conversationId}` : \"/chat\",\n\t\t\tparams: conversationId ? { id: conversationId } : undefined,\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t}),\n\t\t[conversationId],\n\t);\n\n\tuseRouteLifecycle({\n\t\trouteName,\n\t\tcontext,\n\t\toverrides,\n\t\tbeforeRenderHook: (currentOverrides, routeContext) => {\n\t\t\tif (conversationId && currentOverrides.onBeforeConversationPageRendered) {\n\t\t\t\treturn currentOverrides.onBeforeConversationPageRendered(\n\t\t\t\t\tconversationId,\n\t\t\t\t\trouteContext,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (!conversationId && currentOverrides.onBeforeChatPageRendered) {\n\t\t\t\treturn currentOverrides.onBeforeChatPageRendered(routeContext);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\t// In public mode, don't show sidebar\n\tconst showSidebar = mode !== \"public\";\n\n\treturn (\n\t\t\n\t);\n}\n", "target": "src/components/btst/ai-chat/client/components/pages/chat-page.internal.tsx" }, { "path": "btst/ai-chat/client/components/pages/chat-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { ChatLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\n\n// Lazy load the internal component with actual page content\nconst ChatPage = lazy(() =>\n\timport(\"./chat-page.internal\").then((m) => ({ default: m.ChatPage })),\n);\n\nexport interface ChatPageComponentProps {\n\tconversationId?: string;\n}\n\n// Exported wrapped component with error and loading boundaries\nexport function ChatPageComponent({ conversationId }: ChatPageComponentProps) {\n\tconst { onRouteError } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"ai-chat\", {});\n\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(conversationId ? \"chatConversation\" : \"chat\", error, {\n\t\t\t\t\t\tpath: conversationId ? `/chat/${conversationId}` : \"/chat\",\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t\tparams: conversationId ? { id: conversationId } : undefined,\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 { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { ChatLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\n\n// Lazy load the internal component with actual page content\nconst ChatPage = lazy(() =>\n\timport(\"./chat-page.internal\").then((m) => ({ default: m.ChatPage })),\n);\n\nexport interface ChatPageComponentProps {\n\tconversationId?: string;\n}\n\n// Exported wrapped component with error and loading boundaries\nexport function ChatPageComponent({ conversationId }: ChatPageComponentProps) {\n\tconst { mode, onRouteError } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"ai-chat\", {});\n\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(conversationId ? \"chatConversation\" : \"chat\", error, {\n\t\t\t\t\t\tpath: conversationId ? `/chat/${conversationId}` : \"/chat\",\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t\tparams: conversationId ? { id: conversationId } : undefined,\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/pages/chat-page.tsx" }, { "path": "btst/ai-chat/client/components/shared/default-error.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport type { FallbackProps } from \"react-error-boundary\";\nimport { ErrorPlaceholder } from \"./error-placeholder\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { AI_CHAT_LOCALIZATION } from \"../../localization\";\nimport type { AiChatPluginOverrides } from \"../../overrides\";\n\n// Default error component for AI chat plugin routes\nexport function DefaultError({ error }: FallbackProps) {\n\tconst { localization } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"ai-chat\", {\n\t\tlocalization: AI_CHAT_LOCALIZATION,\n\t});\n\tconst title =\n\t\tlocalization?.CHAT_GENERIC_ERROR_TITLE ??\n\t\tAI_CHAT_LOCALIZATION.CHAT_GENERIC_ERROR_TITLE;\n\tconst message =\n\t\tprocess.env.NODE_ENV === \"production\"\n\t\t\t? (localization?.CHAT_GENERIC_ERROR_MESSAGE ??\n\t\t\t\tAI_CHAT_LOCALIZATION.CHAT_GENERIC_ERROR_MESSAGE)\n\t\t\t: ((error instanceof Error ? error.message : undefined) ??\n\t\t\t\tlocalization?.CHAT_GENERIC_ERROR_MESSAGE ??\n\t\t\t\tAI_CHAT_LOCALIZATION.CHAT_GENERIC_ERROR_MESSAGE);\n\treturn ;\n}\n", + "content": "\"use client\";\n\nimport type { FallbackProps } from \"react-error-boundary\";\nimport { ErrorPlaceholder } from \"./error-placeholder\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { useAiChatTranslation } from \"../../localization\";\nimport type { AiChatPluginOverrides } from \"../../overrides\";\n\n// Default error component for AI chat plugin routes\nexport function DefaultError({ error }: FallbackProps) {\n\tconst { localization } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"ai-chat\", {\n\t\tlocalization: {},\n\t});\n\tconst tr = useAiChatTranslation(localization);\n\tconst title = tr(\n\t\t\"CHAT_GENERIC_ERROR_TITLE\",\n\t\t\"aiChat.errors.genericTitle\",\n\t\t\"Something went wrong\",\n\t);\n\tconst message =\n\t\tprocess.env.NODE_ENV === \"production\"\n\t\t\t? tr(\n\t\t\t\t\t\"CHAT_GENERIC_ERROR_MESSAGE\",\n\t\t\t\t\t\"aiChat.errors.genericMessage\",\n\t\t\t\t\t\"An error occurred while loading the chat. Please try again.\",\n\t\t\t\t)\n\t\t\t: ((error instanceof Error ? error.message : undefined) ??\n\t\t\t\ttr(\n\t\t\t\t\t\"CHAT_GENERIC_ERROR_MESSAGE\",\n\t\t\t\t\t\"aiChat.errors.genericMessage\",\n\t\t\t\t\t\"An error occurred while loading the chat. Please try again.\",\n\t\t\t\t));\n\treturn ;\n}\n", "target": "src/components/btst/ai-chat/client/components/shared/default-error.tsx" }, { @@ -116,7 +116,7 @@ { "path": "btst/ai-chat/client/components/tool-call-display.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport {\n\tAccordion,\n\tAccordionContent,\n\tAccordionItem,\n\tAccordionTrigger,\n} from \"@/components/ui/accordion\";\nimport { MarkdownContent } from \"@/components/ui/markdown-content\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { cn } from \"@/lib/utils\";\nimport { Wrench, Check, AlertCircle, Loader2 } from \"lucide-react\";\nimport type { ToolCallProps, ToolCallState } from \"../overrides\";\n\n/**\n * Formats a tool name for display (converts camelCase/snake_case to Title Case)\n */\nfunction formatToolName(name: string): string {\n\treturn (\n\t\tname\n\t\t\t// Insert space before uppercase letters (camelCase)\n\t\t\t.replace(/([A-Z])/g, \" $1\")\n\t\t\t// Replace underscores and hyphens with spaces\n\t\t\t.replace(/[_-]/g, \" \")\n\t\t\t// Capitalize first letter of each word\n\t\t\t.replace(/\\b\\w/g, (char) => char.toUpperCase())\n\t\t\t.trim()\n\t);\n}\n\n/**\n * Returns the appropriate status icon based on tool call state\n */\nfunction getStatusIcon(state: ToolCallState, isLoading: boolean) {\n\tif (isLoading || state === \"input-streaming\" || state === \"input-available\") {\n\t\treturn (\n\t\t\t\n\t\t);\n\t}\n\tif (state === \"output-error\") {\n\t\treturn ;\n\t}\n\tif (state === \"output-available\") {\n\t\treturn ;\n\t}\n\treturn ;\n}\n\n/**\n * Returns a human-readable status label based on tool call state\n */\nfunction getStatusLabel(state: ToolCallState, isLoading: boolean): string {\n\tif (isLoading || state === \"input-streaming\") {\n\t\treturn \"Running...\";\n\t}\n\tif (state === \"input-available\") {\n\t\treturn \"Executing...\";\n\t}\n\tif (state === \"output-error\") {\n\t\treturn \"Error\";\n\t}\n\tif (state === \"output-available\") {\n\t\treturn \"Complete\";\n\t}\n\treturn \"Pending\";\n}\n\ninterface JsonDisplayProps {\n\tdata: unknown;\n\tlabel: string;\n}\n\n/**\n * Renders JSON data using MarkdownContent with syntax highlighting\n */\nfunction JsonDisplay({ data, label }: JsonDisplayProps) {\n\tif (data === undefined || data === null) {\n\t\treturn null;\n\t}\n\n\t// Format JSON with proper indentation\n\tlet jsonString: string;\n\ttry {\n\t\tjsonString = JSON.stringify(data, null, 2);\n\t} catch {\n\t\tjsonString = String(data);\n\t}\n\n\t// Wrap in markdown code fence for syntax highlighting\n\tconst markdown = `\\`\\`\\`json\\n${jsonString}\\n\\`\\`\\``;\n\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{label}\n\t\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t
\n\t);\n}\n\n/**\n * Default tool call display component.\n * Shows an accordion with tool name, status, inputs, and outputs.\n */\nexport function ToolCallDisplay({\n\ttoolCallId,\n\ttoolName,\n\tstate,\n\tinput,\n\toutput,\n\terrorText,\n\tisLoading,\n}: ToolCallProps) {\n\tconst displayName = formatToolName(toolName);\n\tconst statusLabel = getStatusLabel(state, isLoading);\n\tconst statusIcon = getStatusIcon(state, isLoading);\n\n\tconst isComplete = state === \"output-available\" || state === \"output-error\";\n\tconst hasError = state === \"output-error\";\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\n\t\t\t\t\t\t\t{displayName}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{statusIcon}\n\t\t\t\t\t\t\t{statusLabel}\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\t{/* Loading skeleton when input is streaming */}\n\t\t\t\t\t\t{state === \"input-streaming\" && !input && (\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\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{/* Input section */}\n\t\t\t\t\t\t{input !== undefined && }\n\n\t\t\t\t\t\t{/* Output section */}\n\t\t\t\t\t\t{state === \"output-available\" && output !== undefined && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{/* Error section */}\n\t\t\t\t\t\t{state === \"output-error\" && errorText && (\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\tError\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{errorText}\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{/* Loading skeleton for output */}\n\t\t\t\t\t\t{(state === \"input-available\" || state === \"input-streaming\") && (\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\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{/* Tool call ID for debugging (collapsed by default) */}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\tID: {toolCallId}\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 {\n\tAccordion,\n\tAccordionContent,\n\tAccordionItem,\n\tAccordionTrigger,\n} from \"@/components/ui/accordion\";\nimport { MarkdownContent } from \"@/components/ui/markdown-content\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { cn } from \"@/lib/utils\";\nimport { Wrench, Check, AlertCircle, Loader2 } from \"lucide-react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type {\n\tAiChatPluginOverrides,\n\tToolCallProps,\n\tToolCallState,\n} from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\n\n/**\n * Formats a tool name for display (converts camelCase/snake_case to Title Case)\n */\nfunction formatToolName(name: string): string {\n\treturn (\n\t\tname\n\t\t\t// Insert space before uppercase letters (camelCase)\n\t\t\t.replace(/([A-Z])/g, \" $1\")\n\t\t\t// Replace underscores and hyphens with spaces\n\t\t\t.replace(/[_-]/g, \" \")\n\t\t\t// Capitalize first letter of each word\n\t\t\t.replace(/\\b\\w/g, (char) => char.toUpperCase())\n\t\t\t.trim()\n\t);\n}\n\n/**\n * Returns the appropriate status icon based on tool call state\n */\nfunction getStatusIcon(state: ToolCallState, isLoading: boolean) {\n\tif (isLoading || state === \"input-streaming\" || state === \"input-available\") {\n\t\treturn (\n\t\t\t\n\t\t);\n\t}\n\tif (state === \"output-error\") {\n\t\treturn ;\n\t}\n\tif (state === \"output-available\") {\n\t\treturn ;\n\t}\n\treturn ;\n}\n\ninterface JsonDisplayProps {\n\tdata: unknown;\n\tlabel: string;\n}\n\n/**\n * Renders JSON data using MarkdownContent with syntax highlighting\n */\nfunction JsonDisplay({ data, label }: JsonDisplayProps) {\n\tif (data === undefined || data === null) {\n\t\treturn null;\n\t}\n\n\t// Format JSON with proper indentation\n\tlet jsonString: string;\n\ttry {\n\t\tjsonString = JSON.stringify(data, null, 2);\n\t} catch {\n\t\tjsonString = String(data);\n\t}\n\n\t// Wrap in markdown code fence for syntax highlighting\n\tconst markdown = `\\`\\`\\`json\\n${jsonString}\\n\\`\\`\\``;\n\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{label}\n\t\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t
\n\t);\n}\n\n/**\n * Default tool call display component.\n * Shows an accordion with tool name, status, inputs, and outputs.\n */\nexport function ToolCallDisplay({\n\ttoolCallId,\n\ttoolName,\n\tstate,\n\tinput,\n\toutput,\n\terrorText,\n\tisLoading,\n}: ToolCallProps) {\n\tconst { localization } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"ai-chat\", {});\n\tconst tr = useAiChatTranslation(localization);\n\tconst displayName = formatToolName(toolName);\n\tconst statusLabel =\n\t\tisLoading || state === \"input-streaming\"\n\t\t\t? tr(\"TOOL_STATUS_RUNNING\", \"aiChat.tools.status.running\", \"Running...\")\n\t\t\t: state === \"input-available\"\n\t\t\t\t? tr(\n\t\t\t\t\t\t\"TOOL_STATUS_EXECUTING\",\n\t\t\t\t\t\t\"aiChat.tools.status.executing\",\n\t\t\t\t\t\t\"Executing...\",\n\t\t\t\t\t)\n\t\t\t\t: state === \"output-error\"\n\t\t\t\t\t? tr(\"TOOL_STATUS_ERROR\", \"aiChat.tools.status.error\", \"Error\")\n\t\t\t\t\t: state === \"output-available\"\n\t\t\t\t\t\t? tr(\n\t\t\t\t\t\t\t\t\"TOOL_STATUS_COMPLETE\",\n\t\t\t\t\t\t\t\t\"aiChat.tools.status.complete\",\n\t\t\t\t\t\t\t\t\"Complete\",\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t: tr(\n\t\t\t\t\t\t\t\t\"TOOL_STATUS_PENDING\",\n\t\t\t\t\t\t\t\t\"aiChat.tools.status.pending\",\n\t\t\t\t\t\t\t\t\"Pending\",\n\t\t\t\t\t\t\t);\n\tconst statusIcon = getStatusIcon(state, isLoading);\n\n\tconst isComplete = state === \"output-available\" || state === \"output-error\";\n\tconst hasError = state === \"output-error\";\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\n\t\t\t\t\t\t\t{displayName}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{statusIcon}\n\t\t\t\t\t\t\t{statusLabel}\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\t{/* Loading skeleton when input is streaming */}\n\t\t\t\t\t\t{state === \"input-streaming\" && !input && (\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\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{/* Input section */}\n\t\t\t\t\t\t{input !== undefined && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{/* Output section */}\n\t\t\t\t\t\t{state === \"output-available\" && output !== undefined && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{/* Error section */}\n\t\t\t\t\t\t{state === \"output-error\" && errorText && (\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{tr(\n\t\t\t\t\t\t\t\t\t\t\"TOOL_STATUS_ERROR\",\n\t\t\t\t\t\t\t\t\t\t\"aiChat.tools.status.error\",\n\t\t\t\t\t\t\t\t\t\t\"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\t
\n\t\t\t\t\t\t\t\t\t{errorText}\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{/* Loading skeleton for output */}\n\t\t\t\t\t\t{(state === \"input-available\" || state === \"input-streaming\") && (\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\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{/* Tool call ID for debugging (collapsed by default) */}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{tr(\"TOOL_ID\", \"aiChat.tools.id\", \"ID: {{id}}\", {\n\t\t\t\t\t\t\t\tid: toolCallId,\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/tool-call-display.tsx" }, { @@ -128,7 +128,7 @@ { "path": "btst/ai-chat/client/localization/index.ts", "type": "registry:lib", - "content": "/**\n * AI Chat plugin localization strings\n */\nexport interface AiChatLocalization {\n\t// Chat interface\n\tCHAT_PLACEHOLDER: string;\n\tCHAT_SEND_BUTTON: string;\n\tCHAT_EMPTY_STATE: string;\n\tCHAT_LOADING: string;\n\tCHAT_ERROR: string;\n\tCHAT_RETRY: string;\n\n\t// Page errors\n\tCHAT_GENERIC_ERROR_TITLE: string;\n\tCHAT_GENERIC_ERROR_MESSAGE: string;\n\tCHAT_PAGE_NOT_FOUND_TITLE: string;\n\tCHAT_PAGE_NOT_FOUND_DESCRIPTION: string;\n\n\t// Sidebar\n\tSIDEBAR_TITLE: string;\n\tSIDEBAR_NEW_CHAT: string;\n\tSIDEBAR_NO_CONVERSATIONS: string;\n\tSIDEBAR_SEARCH_PLACEHOLDER: string;\n\n\t// Conversation actions\n\tCONVERSATION_RENAME: string;\n\tCONVERSATION_RENAME_PLACEHOLDER: string;\n\tCONVERSATION_RENAME_SAVE: string;\n\tCONVERSATION_RENAME_CANCEL: string;\n\tCONVERSATION_DELETE: string;\n\tCONVERSATION_DELETE_CONFIRM_TITLE: string;\n\tCONVERSATION_DELETE_CONFIRM_DESCRIPTION: string;\n\tCONVERSATION_DELETE_CONFIRM_BUTTON: string;\n\tCONVERSATION_DELETE_CANCEL: string;\n\n\t// Image upload (legacy)\n\tIMAGE_UPLOAD_BUTTON: string;\n\tIMAGE_UPLOAD_UPLOADING: string;\n\tIMAGE_UPLOAD_ERROR_NOT_IMAGE: string;\n\tIMAGE_UPLOAD_ERROR_TOO_LARGE: string;\n\tIMAGE_UPLOAD_SUCCESS: string;\n\tIMAGE_UPLOAD_FAILURE: string;\n\n\t// File upload\n\tFILE_UPLOAD_BUTTON: string;\n\tFILE_UPLOAD_ERROR_TOO_LARGE: string;\n\tFILE_UPLOAD_SUCCESS: string;\n\tFILE_UPLOAD_FAILURE: string;\n\n\t// Timestamps\n\tTIME_JUST_NOW: string;\n\tTIME_MINUTES_AGO: string;\n\tTIME_HOURS_AGO: string;\n\tTIME_YESTERDAY: string;\n\tTIME_DAYS_AGO: string;\n\n\t// Message actions\n\tMESSAGE_COPY: string;\n\tMESSAGE_COPIED: string;\n\tMESSAGE_RETRY: string;\n\tMESSAGE_EDIT: string;\n\tMESSAGE_SAVE: string;\n\tMESSAGE_CANCEL: string;\n\n\t// Accessibility\n\tA11Y_USER_MESSAGE: string;\n\tA11Y_ASSISTANT_MESSAGE: string;\n\tA11Y_COPY_CODE: string;\n\tA11Y_CODE_COPIED: string;\n}\n\n/**\n * Default English localization strings\n */\nexport const AI_CHAT_LOCALIZATION: AiChatLocalization = {\n\t// Chat interface\n\tCHAT_PLACEHOLDER: \"Type a message...\",\n\tCHAT_SEND_BUTTON: \"Send\",\n\tCHAT_EMPTY_STATE: \"Start a conversation...\",\n\tCHAT_LOADING: \"Thinking...\",\n\tCHAT_ERROR: \"Something went wrong. Please try again.\",\n\tCHAT_RETRY: \"Retry\",\n\n\t// Page errors\n\tCHAT_GENERIC_ERROR_TITLE: \"Something went wrong\",\n\tCHAT_GENERIC_ERROR_MESSAGE:\n\t\t\"An error occurred while loading the chat. Please try again.\",\n\tCHAT_PAGE_NOT_FOUND_TITLE: \"Chat not found\",\n\tCHAT_PAGE_NOT_FOUND_DESCRIPTION:\n\t\t\"The conversation you're looking for doesn't exist or has been deleted.\",\n\n\t// Sidebar\n\tSIDEBAR_TITLE: \"Conversations\",\n\tSIDEBAR_NEW_CHAT: \"New chat\",\n\tSIDEBAR_NO_CONVERSATIONS: \"No conversations yet\",\n\tSIDEBAR_SEARCH_PLACEHOLDER: \"Search conversations...\",\n\n\t// Conversation actions\n\tCONVERSATION_RENAME: \"Rename\",\n\tCONVERSATION_RENAME_PLACEHOLDER: \"Enter conversation name\",\n\tCONVERSATION_RENAME_SAVE: \"Save\",\n\tCONVERSATION_RENAME_CANCEL: \"Cancel\",\n\tCONVERSATION_DELETE: \"Delete\",\n\tCONVERSATION_DELETE_CONFIRM_TITLE: \"Delete conversation\",\n\tCONVERSATION_DELETE_CONFIRM_DESCRIPTION:\n\t\t\"Are you sure you want to delete this conversation? This action cannot be undone.\",\n\tCONVERSATION_DELETE_CONFIRM_BUTTON: \"Delete\",\n\tCONVERSATION_DELETE_CANCEL: \"Cancel\",\n\n\t// Image upload (legacy)\n\tIMAGE_UPLOAD_BUTTON: \"Attach image\",\n\tIMAGE_UPLOAD_UPLOADING: \"Uploading...\",\n\tIMAGE_UPLOAD_ERROR_NOT_IMAGE: \"Please select an image file\",\n\tIMAGE_UPLOAD_ERROR_TOO_LARGE: \"Image must be less than 4MB\",\n\tIMAGE_UPLOAD_SUCCESS: \"Image uploaded\",\n\tIMAGE_UPLOAD_FAILURE: \"Failed to upload image\",\n\n\t// File upload\n\tFILE_UPLOAD_BUTTON: \"Attach file\",\n\tFILE_UPLOAD_ERROR_TOO_LARGE: \"File must be less than 10MB\",\n\tFILE_UPLOAD_SUCCESS: \"File attached\",\n\tFILE_UPLOAD_FAILURE: \"Failed to attach file\",\n\n\t// Timestamps\n\tTIME_JUST_NOW: \"Just now\",\n\tTIME_MINUTES_AGO: \"{count} minutes ago\",\n\tTIME_HOURS_AGO: \"{count} hours ago\",\n\tTIME_YESTERDAY: \"Yesterday\",\n\tTIME_DAYS_AGO: \"{count} days ago\",\n\n\t// Message actions\n\tMESSAGE_COPY: \"Copy message\",\n\tMESSAGE_COPIED: \"Copied!\",\n\tMESSAGE_RETRY: \"Retry\",\n\tMESSAGE_EDIT: \"Edit message\",\n\tMESSAGE_SAVE: \"Save\",\n\tMESSAGE_CANCEL: \"Cancel\",\n\n\t// Accessibility\n\tA11Y_USER_MESSAGE: \"Your message\",\n\tA11Y_ASSISTANT_MESSAGE: \"AI response\",\n\tA11Y_COPY_CODE: \"Copy code\",\n\tA11Y_CODE_COPIED: \"Code copied\",\n};\n\n/**\n * Helper function to format localized strings with placeholders\n */\nexport function formatLocalized(\n\ttemplate: string,\n\tvalues: Record,\n): string {\n\treturn template.replace(/\\{(\\w+)\\}/g, (match, key) => {\n\t\treturn values[key]?.toString() ?? match;\n\t});\n}\n", + "content": "\"use client\";\n\nimport { useCallback } from \"react\";\nimport { useTranslate, type TranslateFn } from \"@btst/stack/context\";\n\n/**\n * AI Chat plugin localization strings\n */\nexport interface AiChatLocalization {\n\t// Chat interface\n\tCHAT_PLACEHOLDER: string;\n\tCHAT_SEND_BUTTON: string;\n\tCHAT_EMPTY_STATE: string;\n\tCHAT_LOADING: string;\n\tCHAT_ERROR: string;\n\tCHAT_RETRY: string;\n\n\t// Page errors\n\tCHAT_GENERIC_ERROR_TITLE: string;\n\tCHAT_GENERIC_ERROR_MESSAGE: string;\n\tCHAT_PAGE_NOT_FOUND_TITLE: string;\n\tCHAT_PAGE_NOT_FOUND_DESCRIPTION: string;\n\n\t// Sidebar\n\tSIDEBAR_TITLE: string;\n\tSIDEBAR_NEW_CHAT: string;\n\tSIDEBAR_NO_CONVERSATIONS: string;\n\tSIDEBAR_SEARCH_PLACEHOLDER: string;\n\n\t// Conversation actions\n\tCONVERSATION_RENAME: string;\n\tCONVERSATION_RENAME_PLACEHOLDER: string;\n\tCONVERSATION_RENAME_DESCRIPTION: string;\n\tCONVERSATION_RENAME_SAVE: string;\n\tCONVERSATION_RENAME_CANCEL: string;\n\tCONVERSATION_DELETE: string;\n\tCONVERSATION_DELETE_CONFIRM_TITLE: string;\n\tCONVERSATION_DELETE_CONFIRM_DESCRIPTION: string;\n\tCONVERSATION_DELETE_CONFIRM_BUTTON: string;\n\tCONVERSATION_DELETE_CANCEL: string;\n\tCONVERSATION_RENAME_SUCCESS: string;\n\tCONVERSATION_RENAME_FAILURE: string;\n\tCONVERSATION_DELETE_SUCCESS: string;\n\tCONVERSATION_DELETE_FAILURE: string;\n\tCONVERSATION_TITLE_REQUIRED: string;\n\n\t// Image upload (legacy)\n\tIMAGE_UPLOAD_BUTTON: string;\n\tIMAGE_UPLOAD_UPLOADING: string;\n\tIMAGE_UPLOAD_ERROR_NOT_IMAGE: string;\n\tIMAGE_UPLOAD_ERROR_TOO_LARGE: string;\n\tIMAGE_UPLOAD_SUCCESS: string;\n\tIMAGE_UPLOAD_FAILURE: string;\n\n\t// File upload\n\tFILE_UPLOAD_BUTTON: string;\n\tFILE_UPLOAD_ERROR_TOO_LARGE: string;\n\tFILE_UPLOAD_SUCCESS: string;\n\tFILE_UPLOAD_FAILURE: string;\n\tFILE_REMOVE: string;\n\tFILE_FALLBACK_NAME: string;\n\tIMAGE_ATTACHED_ALT: string;\n\tIMAGE_GENERATED_ALT: string;\n\n\t// Timestamps\n\tTIME_JUST_NOW: string;\n\tTIME_MINUTES_AGO: string;\n\tTIME_HOURS_AGO: string;\n\tTIME_YESTERDAY: string;\n\tTIME_DAYS_AGO: string;\n\n\t// Message actions\n\tMESSAGE_COPY: string;\n\tMESSAGE_COPIED: string;\n\tMESSAGE_RETRY: string;\n\tMESSAGE_EDIT: string;\n\tMESSAGE_SAVE: string;\n\tMESSAGE_CANCEL: string;\n\n\t// Accessibility\n\tA11Y_USER_MESSAGE: string;\n\tA11Y_ASSISTANT_MESSAGE: string;\n\tA11Y_COPY_CODE: string;\n\tA11Y_CODE_COPIED: string;\n\tA11Y_CONVERSATION_ACTIONS: string;\n\tA11Y_CHAT_TITLE: string;\n\tA11Y_CLEAR_CHAT: string;\n\tA11Y_CLOSE_CHAT: string;\n\tA11Y_OPEN_CHAT: string;\n\tA11Y_OPEN_MENU: string;\n\tA11Y_CLOSE_SIDEBAR: string;\n\tA11Y_OPEN_SIDEBAR: string;\n\n\t// Tool calls\n\tTOOL_STATUS_RUNNING: string;\n\tTOOL_STATUS_EXECUTING: string;\n\tTOOL_STATUS_ERROR: string;\n\tTOOL_STATUS_COMPLETE: string;\n\tTOOL_STATUS_PENDING: string;\n\tTOOL_INPUT: string;\n\tTOOL_OUTPUT: string;\n\tTOOL_ID: string;\n\tTOOL_EXECUTION_FAILED: string;\n\tTOOL_HANDLER_MISSING: string;\n}\n\n/**\n * Default English localization strings\n */\nexport const AI_CHAT_LOCALIZATION: AiChatLocalization = {\n\t// Chat interface\n\tCHAT_PLACEHOLDER: \"Type a message...\",\n\tCHAT_SEND_BUTTON: \"Send\",\n\tCHAT_EMPTY_STATE: \"Start a conversation...\",\n\tCHAT_LOADING: \"Thinking...\",\n\tCHAT_ERROR: \"Something went wrong. Please try again.\",\n\tCHAT_RETRY: \"Retry\",\n\n\t// Page errors\n\tCHAT_GENERIC_ERROR_TITLE: \"Something went wrong\",\n\tCHAT_GENERIC_ERROR_MESSAGE:\n\t\t\"An error occurred while loading the chat. Please try again.\",\n\tCHAT_PAGE_NOT_FOUND_TITLE: \"Chat not found\",\n\tCHAT_PAGE_NOT_FOUND_DESCRIPTION:\n\t\t\"The conversation you're looking for doesn't exist or has been deleted.\",\n\n\t// Sidebar\n\tSIDEBAR_TITLE: \"Conversations\",\n\tSIDEBAR_NEW_CHAT: \"New chat\",\n\tSIDEBAR_NO_CONVERSATIONS: \"No conversations yet\",\n\tSIDEBAR_SEARCH_PLACEHOLDER: \"Search conversations...\",\n\n\t// Conversation actions\n\tCONVERSATION_RENAME: \"Rename\",\n\tCONVERSATION_RENAME_PLACEHOLDER: \"Enter conversation name\",\n\tCONVERSATION_RENAME_DESCRIPTION: \"Enter a new title for this conversation.\",\n\tCONVERSATION_RENAME_SAVE: \"Save\",\n\tCONVERSATION_RENAME_CANCEL: \"Cancel\",\n\tCONVERSATION_DELETE: \"Delete\",\n\tCONVERSATION_DELETE_CONFIRM_TITLE: \"Delete conversation\",\n\tCONVERSATION_DELETE_CONFIRM_DESCRIPTION:\n\t\t\"Are you sure you want to delete this conversation? This action cannot be undone.\",\n\tCONVERSATION_DELETE_CONFIRM_BUTTON: \"Delete\",\n\tCONVERSATION_DELETE_CANCEL: \"Cancel\",\n\tCONVERSATION_RENAME_SUCCESS: \"Conversation renamed\",\n\tCONVERSATION_RENAME_FAILURE: \"Failed to rename conversation\",\n\tCONVERSATION_DELETE_SUCCESS: \"Conversation deleted\",\n\tCONVERSATION_DELETE_FAILURE: \"Failed to delete conversation\",\n\tCONVERSATION_TITLE_REQUIRED: \"Title is required\",\n\n\t// Image upload (legacy)\n\tIMAGE_UPLOAD_BUTTON: \"Attach image\",\n\tIMAGE_UPLOAD_UPLOADING: \"Uploading...\",\n\tIMAGE_UPLOAD_ERROR_NOT_IMAGE: \"Please select an image file\",\n\tIMAGE_UPLOAD_ERROR_TOO_LARGE: \"Image must be less than 4MB\",\n\tIMAGE_UPLOAD_SUCCESS: \"Image uploaded\",\n\tIMAGE_UPLOAD_FAILURE: \"Failed to upload image\",\n\n\t// File upload\n\tFILE_UPLOAD_BUTTON: \"Attach file\",\n\tFILE_UPLOAD_ERROR_TOO_LARGE: \"File must be less than 10MB\",\n\tFILE_UPLOAD_SUCCESS: \"File attached\",\n\tFILE_UPLOAD_FAILURE: \"Failed to attach file\",\n\tFILE_REMOVE: \"Remove file\",\n\tFILE_FALLBACK_NAME: \"File\",\n\tIMAGE_ATTACHED_ALT: \"Attached image {count}\",\n\tIMAGE_GENERATED_ALT: \"Image {count}\",\n\n\t// Timestamps\n\tTIME_JUST_NOW: \"Just now\",\n\tTIME_MINUTES_AGO: \"{count} minutes ago\",\n\tTIME_HOURS_AGO: \"{count} hours ago\",\n\tTIME_YESTERDAY: \"Yesterday\",\n\tTIME_DAYS_AGO: \"{count} days ago\",\n\n\t// Message actions\n\tMESSAGE_COPY: \"Copy message\",\n\tMESSAGE_COPIED: \"Copied!\",\n\tMESSAGE_RETRY: \"Retry\",\n\tMESSAGE_EDIT: \"Edit message\",\n\tMESSAGE_SAVE: \"Save\",\n\tMESSAGE_CANCEL: \"Cancel\",\n\n\t// Accessibility\n\tA11Y_USER_MESSAGE: \"Your message\",\n\tA11Y_ASSISTANT_MESSAGE: \"AI response\",\n\tA11Y_COPY_CODE: \"Copy code\",\n\tA11Y_CODE_COPIED: \"Code copied\",\n\tA11Y_CONVERSATION_ACTIONS: \"Conversation actions\",\n\tA11Y_CHAT_TITLE: \"AI Chat\",\n\tA11Y_CLEAR_CHAT: \"Clear chat\",\n\tA11Y_CLOSE_CHAT: \"Close chat\",\n\tA11Y_OPEN_CHAT: \"Open chat\",\n\tA11Y_OPEN_MENU: \"Open menu\",\n\tA11Y_CLOSE_SIDEBAR: \"Close sidebar\",\n\tA11Y_OPEN_SIDEBAR: \"Open sidebar\",\n\n\t// Tool calls\n\tTOOL_STATUS_RUNNING: \"Running...\",\n\tTOOL_STATUS_EXECUTING: \"Executing...\",\n\tTOOL_STATUS_ERROR: \"Error\",\n\tTOOL_STATUS_COMPLETE: \"Complete\",\n\tTOOL_STATUS_PENDING: \"Pending\",\n\tTOOL_INPUT: \"Input\",\n\tTOOL_OUTPUT: \"Output\",\n\tTOOL_ID: \"ID: {id}\",\n\tTOOL_EXECUTION_FAILED: \"Tool execution failed\",\n\tTOOL_HANDLER_MISSING:\n\t\t'No client-side handler registered for tool \"{toolName}\". The page context may have changed while the response was streaming.',\n};\n\n/**\n * Helper function to format localized strings with placeholders\n */\nexport function formatLocalized(\n\ttemplate: string,\n\tvalues: Record,\n): string {\n\treturn template.replace(/\\{(\\w+)\\}/g, (match, key) => {\n\t\treturn values[key]?.toString() ?? match;\n\t});\n}\n\n/**\n * Resolves AI Chat strings with the legacy override taking precedence over\n * the StackProvider i18n catalog. Legacy `{name}` placeholders remain\n * supported while catalog defaults use `{{name}}` interpolation.\n */\nexport function resolveAiChatString(\n\tt: TranslateFn,\n\tlocalization: Partial | undefined,\n\tlegacyKey: keyof AiChatLocalization,\n\tkey: string,\n\tdefaultValue: string,\n\tparams?: Record,\n): string {\n\tconst legacyValue = localization?.[legacyKey];\n\tif (legacyValue !== undefined) {\n\t\treturn params ? formatLocalized(legacyValue, params) : legacyValue;\n\t}\n\treturn t(key, defaultValue, params);\n}\n\n/** Client hook for resolving AI Chat catalog strings. */\nexport function useAiChatTranslation(\n\tlocalization?: Partial,\n) {\n\tconst t = useTranslate();\n\treturn useCallback(\n\t\t(\n\t\t\tlegacyKey: keyof AiChatLocalization,\n\t\t\tkey: string,\n\t\t\tdefaultValue: string,\n\t\t\tparams?: Record,\n\t\t) =>\n\t\t\tresolveAiChatString(\n\t\t\t\tt,\n\t\t\t\tlocalization,\n\t\t\t\tlegacyKey,\n\t\t\t\tkey,\n\t\t\t\tdefaultValue,\n\t\t\t\tparams,\n\t\t\t),\n\t\t[t, localization],\n\t);\n}\n", "target": "src/components/btst/ai-chat/client/localization/index.ts" }, { @@ -149,6 +149,12 @@ "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n\tcreateElement,\n\tisValidElement,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n\tuseEffect,\n} from \"react\";\nimport type {\n\tComponentPropsWithoutRef,\n\tComponentType,\n\tMouseEventHandler,\n\tReactNode,\n} from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport type { Components } from \"react-markdown\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeRaw from \"rehype-raw\";\nimport remarkGfm from \"remark-gfm\";\nimport { CopyIcon, CheckIcon } from \"lucide-react\";\n\n// Default Link component\nconst DefaultLink = (props: React.ComponentProps<\"a\">) => {\n\treturn ;\n};\n\n// Default Image component\nconst DefaultImage = (props: React.ImgHTMLAttributes) => {\n\treturn ;\n};\n\n// Utility to detect if markdown contains math syntax\nfunction containsMath(markdown: string): boolean {\n\treturn (\n\t\t/\\$\\$[\\s\\S]+?\\$\\$/.test(markdown) || // Display math\n\t\t/(?;\n\t\treturn getNodeText(props.children as ReactNode);\n\t}\n\treturn \"\";\n}\n\n// Deterministic hash function for generating consistent IDs\nfunction simpleHash(str: string): string {\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;\n\t}\n\treturn Math.abs(hash).toString(36).slice(0, 6);\n}\n\nfunction isCheckboxElement(\n\tnode: ReactNode,\n): node is ReturnType {\n\treturn (\n\t\tisValidElement(node) &&\n\t\t(node.type as unknown) === \"input\" &&\n\t\t(node.props as { type?: string }).type === \"checkbox\"\n\t);\n}\n\nfunction createTaskListItemRenderer() {\n\treturn function LiRenderer(\n\t\tprops: React.LiHTMLAttributes & { children?: ReactNode },\n\t) {\n\t\tconst { className, children, ...rest } = props;\n\t\tconst isTaskItem = (className ?? \"\").split(\" \").includes(\"task-list-item\");\n\t\tif (!isTaskItem) {\n\t\t\treturn (\n\t\t\t\t
  • \n\t\t\t\t\t{children}\n\t\t\t\t
  • \n\t\t\t);\n\t\t}\n\n\t\tconst childArray = Array.isArray(children) ? children : [children];\n\t\tconst checkboxNode = childArray.find(isCheckboxElement);\n\t\tconst nonCheckboxChildren = childArray.filter((c) => !isCheckboxElement(c));\n\n\t\tconst labelText = getNodeText(nonCheckboxChildren as unknown as ReactNode);\n\t\tconst baseId = slugify(labelText || \"task-item\");\n\t\tconst hashSuffix = simpleHash(labelText || \"task-item\");\n\t\tconst uniqueId = `${baseId}-${hashSuffix}`;\n\n\t\treturn (\n\t\t\t
  • \n\t\t\t\t{checkboxNode\n\t\t\t\t\t? createElement(checkboxNode.type, {\n\t\t\t\t\t\t\t...checkboxNode.props,\n\t\t\t\t\t\t\tid: uniqueId,\n\t\t\t\t\t\t\t\"aria-label\": labelText || \"Task item\",\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\ntype HeadingTag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\";\nfunction createHeadingRenderer(tag: T) {\n\treturn function HeadingRenderer(props: ComponentPropsWithoutRef) {\n\t\tconst { children, ...rest } = props as { children: ReactNode };\n\t\tconst text = getNodeText(children);\n\t\tconst id = slugify(text);\n\t\treturn createElement(\n\t\t\ttag,\n\t\t\t{ id, ...(rest as object) },\n\t\t\tchildren,\n\t\t\ttext ? (\n\t\t\t\t\n\t\t\t\t\t#\n\t\t\t\t
    \n\t\t\t) : null,\n\t\t);\n\t};\n}\n\ntype CodeProps = React.HTMLAttributes & {\n\tinline?: boolean;\n\tnode?: unknown;\n};\n\nfunction CodeRenderer({ inline, className, children, ...rest }: CodeProps) {\n\tconst hasLanguage = /language-([a-z0-9-]+)/i.test(className ?? \"\");\n\tconst isInline = inline ?? !hasLanguage;\n\tif (isInline) {\n\t\treturn (\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\n\t\t);\n\t}\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n\nfunction PreRenderer(props: React.HTMLAttributes) {\n\tconst { children, ...rest } = props;\n\tconst child = Array.isArray(children) ? children[0] : children;\n\tlet language: string | undefined;\n\tif (isValidElement(child)) {\n\t\tconst className = (child.props as { className?: string }).className;\n\t\tconst match = /language-([a-z0-9-]+)/i.exec(className ?? \"\");\n\t\tlanguage = match?.[1];\n\t}\n\tconst label = (language ?? \"text\").toUpperCase();\n\n\tconst preRef = useRef(null);\n\tconst [copied, setCopied] = useState(false);\n\tconst resetTimerRef = useRef(null);\n\n\tlet codeText = \"\";\n\tif (isValidElement(child)) {\n\t\tconst childProps = child.props as { children?: ReactNode };\n\t\tcodeText = getNodeText(childProps.children as ReactNode);\n\t}\n\tconst normalized = codeText.endsWith(\"\\n\") ? codeText.slice(0, -1) : codeText;\n\tconst lineCount = Math.max(1, normalized.split(\"\\n\").length);\n\tconst digitCount = String(lineCount).length;\n\t\n\tconst onCopy: MouseEventHandler = async (e) => {\n\t\te.preventDefault();\n\t\te.stopPropagation();\n\t\ttry {\n\t\t\tconst text = preRef.current?.textContent ?? \"\";\n\t\t\tif (\n\t\t\t\ttext &&\n\t\t\t\ttypeof navigator !== \"undefined\" &&\n\t\t\t\tnavigator.clipboard?.writeText\n\t\t\t) {\n\t\t\t\tawait navigator.clipboard.writeText(text);\n\t\t\t\tsetCopied(true);\n\t\t\t\tif (resetTimerRef.current) window.clearTimeout(resetTimerRef.current);\n\t\t\t\tresetTimerRef.current = window.setTimeout(() => {\n\t\t\t\t\tsetCopied(false);\n\t\t\t\t\tresetTimerRef.current = null;\n\t\t\t\t}, 2000);\n\t\t\t}\n\t\t} catch {}\n\t};\n\t\n\treturn (\n\t\t\n\t\t\t
    \n\t\t\t\t{label}\n\t\t\t\t\n\t\t\t\t\t{copied ? : }\n\t\t\t\t\n\t\t\t
    \n\t\t\t
    \n\t\t\t\t
      \n\t\t\t\t\t{Array.from({ length: lineCount }).map((_, idx) => (\n\t\t\t\t\t\t
    1. {idx + 1}
    2. \n\t\t\t\t\t))}\n\t\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t{children}\n\t\t\t\t
    \n\t\t\t
    \n\t\t\n\t);\n}\n\nexport type MarkdownContentProps = {\n\t/** The markdown string to render */\n\tmarkdown: string;\n\t/** Additional CSS class name */\n\tclassName?: string;\n\t/** Variant for different use cases - 'default' for full blog posts, 'chat' for compact chat messages */\n\tvariant?: \"default\" | \"chat\";\n\t/** Custom Link component for navigation */\n\tLinkComponent?: ComponentType & Record>;\n\t/** Custom Image component for images */\n\tImageComponent?: ComponentType & Record>;\n};\n\nexport function MarkdownContent({\n\tmarkdown,\n\tclassName,\n\tvariant = \"default\",\n\tLinkComponent = DefaultLink,\n\tImageComponent = DefaultImage,\n}: MarkdownContentProps) {\n\tconst [mathPlugins, setMathPlugins] = useState<{\n\t\tremarkMath: unknown;\n\t\trehypeKatex: unknown;\n\t} | null>(null);\n\tconst [isLoadingMath, setIsLoadingMath] = useState(false);\n\n\tconst hasMath = useMemo(() => containsMath(markdown), [markdown]);\n\n\t// Dynamically load math plugins and CSS only if needed\n\tuseEffect(() => {\n\t\tif (!hasMath || mathPlugins || isLoadingMath) return;\n\n\t\tsetIsLoadingMath(true);\n\n\t\t// Dynamically inject KaTeX CSS into the document\n\t\tconst katexCSSId = \"katex-css\";\n\t\tif (typeof document !== \"undefined\" && !document.getElementById(katexCSSId)) {\n\t\t\tconst link = document.createElement(\"link\");\n\t\t\tlink.id = katexCSSId;\n\t\t\tlink.rel = \"stylesheet\";\n\t\t\tlink.href =\n\t\t\t\t\"https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css\";\n\t\t\tlink.integrity =\n\t\t\t\t\"sha384-nB0miv6/jRmo5UMMR1wu3Gz6NLsoTkbqJghGIsx//Rlm+ZU03BU6SQNC66uf4l5+\";\n\t\t\tlink.crossOrigin = \"anonymous\";\n\t\t\tdocument.head.appendChild(link);\n\n\t\t\t// Add font-display override for KaTeX fonts\n\t\t\tconst fontDisplayStyleId = \"katex-font-display-override\";\n\t\t\tif (!document.getElementById(fontDisplayStyleId)) {\n\t\t\t\tconst style = document.createElement(\"style\");\n\t\t\t\tstyle.id = fontDisplayStyleId;\n\t\t\t\tstyle.textContent = `\n\t\t\t\t\t@font-face {\n\t\t\t\t\t\tfont-family: 'KaTeX_Main';\n\t\t\t\t\t\tfont-style: normal;\n\t\t\t\t\t\tfont-weight: normal;\n\t\t\t\t\t\tfont-display: swap;\n\t\t\t\t\t\tsrc: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Main-Regular.woff2') format('woff2');\n\t\t\t\t\t}\n\t\t\t\t\t@font-face {\n\t\t\t\t\t\tfont-family: 'KaTeX_Math';\n\t\t\t\t\t\tfont-style: italic;\n\t\t\t\t\t\tfont-weight: normal;\n\t\t\t\t\t\tfont-display: swap;\n\t\t\t\t\t\tsrc: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Math-Italic.woff2') format('woff2');\n\t\t\t\t\t}\n\t\t\t\t`;\n\t\t\t\tdocument.head.appendChild(style);\n\t\t\t}\n\t\t}\n\n\t\tPromise.all([import(\"remark-math\"), import(\"rehype-katex\")])\n\t\t\t.then(([remarkMathModule, rehypeKatexModule]) => {\n\t\t\t\tsetMathPlugins({\n\t\t\t\t\tremarkMath: remarkMathModule.default,\n\t\t\t\t\trehypeKatex: rehypeKatexModule.default,\n\t\t\t\t});\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tconsole.error(\"Failed to load math plugins:\", error);\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tsetIsLoadingMath(false);\n\t\t\t});\n\t}, [hasMath, mathPlugins, isLoadingMath]);\n\n\t// Create anchor renderer with custom Link component\n\tconst AnchorRenderer = useMemo(() => {\n\t\treturn function AnchorRendererComponent(\n\t\t\tprops: React.AnchorHTMLAttributes,\n\t\t) {\n\t\t\tconst { href = \"\", children, className: anchorClassName, ...rest } = props;\n\t\t\treturn (\n\t\t\t\t\n\t\t\t\t\t{children}\n\t\t\t\t\n\t\t\t);\n\t\t};\n\t}, [LinkComponent]);\n\n\t// Create image renderer with custom Image component\n\tconst ImgRenderer = useMemo(() => {\n\t\treturn function ImgRendererComponent(\n\t\t\tprops: React.ImgHTMLAttributes,\n\t\t) {\n\t\t\tconst {\n\t\t\t\tsrc = \"\",\n\t\t\t\talt = \"\",\n\t\t\t\tclassName: imgClassName,\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tstyle,\n\t\t\t\t...rest\n\t\t\t} = props;\n\n\t\t\tconst imageProps: React.ComponentProps = {\n\t\t\t\tsrc,\n\t\t\t\talt,\n\t\t\t\tclassName: imgClassName as string,\n\t\t\t\tstyle,\n\t\t\t\t...rest,\n\t\t\t};\n\n\t\t\tif (width != null && height != null) {\n\t\t\t\timageProps.width = width as number;\n\t\t\t\timageProps.height = height as number;\n\t\t\t} else {\n\t\t\t\treturn (\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}\n\n\t\t\treturn ;\n\t\t};\n\t}, [ImageComponent]);\n\n\tconst components = useMemo(() => {\n\t\treturn {\n\t\t\ta: AnchorRenderer,\n\t\t\timg: ImgRenderer,\n\t\t\tcode: CodeRenderer,\n\t\t\tpre: PreRenderer,\n\t\t\th1: createHeadingRenderer(\"h1\"),\n\t\t\th2: createHeadingRenderer(\"h2\"),\n\t\t\th3: createHeadingRenderer(\"h3\"),\n\t\t\th4: createHeadingRenderer(\"h4\"),\n\t\t\th5: createHeadingRenderer(\"h5\"),\n\t\t\th6: createHeadingRenderer(\"h6\"),\n\t\t\tli: createTaskListItemRenderer(),\n\t\t};\n\t}, [AnchorRenderer, ImgRenderer]);\n\n\tconst remarkPlugins = useMemo(() => {\n\t\tconst plugins: unknown[] = [remarkGfm];\n\t\tif (hasMath && mathPlugins?.remarkMath) {\n\t\t\tplugins.push(mathPlugins.remarkMath);\n\t\t}\n\t\treturn plugins as never;\n\t}, [hasMath, mathPlugins]);\n\n\tconst rehypePlugins = useMemo(() => {\n\t\tconst plugins: unknown[] = [rehypeRaw, rehypeHighlight];\n\t\tif (hasMath && mathPlugins?.rehypeKatex) {\n\t\t\tplugins.push(mathPlugins.rehypeKatex);\n\t\t}\n\t\treturn plugins as never;\n\t}, [hasMath, mathPlugins]);\n\n\tconst variantClass = variant === \"chat\" ? \"markdown-content-chat\" : \"\";\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{markdown}\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/ui/markdown-content.tsx" }, + { + "path": "ui/hooks/use-route-lifecycle.ts", + "type": "registry:hook", + "content": "\"use client\";\n\nimport { useEffect } from \"react\";\n\n/**\n * Base route context interface that plugins can extend\n */\nexport interface BaseRouteContext {\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]: unknown;\n}\n\n/**\n * Minimum interface required for route lifecycle hooks\n * Plugin overrides should implement these optional hooks\n */\nexport interface RouteLifecycleOverrides {\n\t/** Called when a route is rendered */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: TContext,\n\t) => void | Promise;\n\t/** Called when a route encounters an error */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: TContext,\n\t) => void | Promise;\n}\n\n/**\n * Hook to handle route lifecycle events\n * - Calls authorization check before render\n * - Calls onRouteRender on mount\n * - Handles errors with onRouteError\n *\n * @example\n * ```tsx\n * const overrides = usePluginOverrides(\"myPlugin\");\n *\n * useRouteLifecycle({\n * routeName: \"dashboard\",\n * context: { path: \"/dashboard\", isSSR: typeof window === \"undefined\" },\n * overrides,\n * beforeRenderHook: (overrides, context) => {\n * if (overrides.onBeforeDashboardRendered) {\n * return overrides.onBeforeDashboardRendered(context);\n * }\n * return true;\n * },\n * });\n * ```\n */\nexport function useRouteLifecycle<\n\tTContext extends BaseRouteContext,\n\tTOverrides extends RouteLifecycleOverrides,\n>({\n\trouteName,\n\tcontext,\n\toverrides,\n\tbeforeRenderHook,\n}: {\n\trouteName: string;\n\tcontext: TContext;\n\toverrides: TOverrides;\n\tbeforeRenderHook?: (overrides: TOverrides, context: TContext) => boolean;\n}) {\n\t// Authorization check - runs synchronously before render\n\tif (beforeRenderHook) {\n\t\tconst canRender = beforeRenderHook(overrides, context);\n\t\tif (!canRender) {\n\t\t\tconst error = new Error(`Unauthorized: Cannot render ${routeName}`);\n\t\t\t// Call error hook synchronously\n\t\t\tif (overrides.onRouteError) {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = overrides.onRouteError(routeName, error, context);\n\t\t\t\t\tif (result instanceof Promise) {\n\t\t\t\t\t\tresult.catch(() => {}); // Ignore promise rejection\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Ignore errors in error hook\n\t\t\t\t}\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t// Lifecycle hook - runs on mount\n\tuseEffect(() => {\n\t\tif (overrides.onRouteRender) {\n\t\t\ttry {\n\t\t\t\tconst result = overrides.onRouteRender(routeName, context);\n\t\t\t\tif (result instanceof Promise) {\n\t\t\t\t\tresult.catch((error) => {\n\t\t\t\t\t\t// If onRouteRender throws, call onRouteError\n\t\t\t\t\t\tif (overrides.onRouteError) {\n\t\t\t\t\t\t\toverrides.onRouteError(routeName, error, context);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\t// If onRouteRender throws, call onRouteError\n\t\t\t\tif (overrides.onRouteError) {\n\t\t\t\t\toverrides.onRouteError(routeName, error as Error, context);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}, [routeName, overrides, context]);\n}\n", + "target": "src/hooks/use-route-lifecycle.ts" + }, { "path": "ui/styles/markdown-content.css", "type": "registry:file", diff --git a/packages/stack/src/__tests__/ai-chat-query-keys.test.ts b/packages/stack/src/__tests__/ai-chat-query-keys.test.ts new file mode 100644 index 00000000..b643641c --- /dev/null +++ b/packages/stack/src/__tests__/ai-chat-query-keys.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import { runResourceMutation } from "../plugins/client"; +import { + aiChatResources, + createAiChatQueryKeys, +} from "../plugins/ai-chat/query-keys"; +import { updateConversationSchema } from "../plugins/ai-chat/schemas"; + +describe("AI Chat resource declaration", () => { + it("preserves the v3 conversation query keys", () => { + const client = vi.fn() as any; + const queries = createAiChatQueryKeys(client); + + expect([...queries.conversations._def]).toEqual(["conversations"]); + expect([...queries.conversations.list().queryKey]).toEqual([ + "conversations", + "list", + "all", + ]); + expect([...queries.conversations.detail("conv-1").queryKey]).toEqual([ + "conversations", + "detail", + "conv-1", + ]); + }); + + it("forwards headers through list and detail requests", async () => { + const client = vi + .fn() + .mockResolvedValueOnce({ data: [] }) + .mockResolvedValueOnce({ data: null }) as any; + const headers = { authorization: "Bearer test" }; + const queries = createAiChatQueryKeys(client, headers); + + await queries.conversations.list().queryFn(); + expect(client).toHaveBeenLastCalledWith("/chat/conversations", { + method: "GET", + headers, + }); + + await queries.conversations.detail("conv-1").queryFn(); + expect(client).toHaveBeenLastCalledWith("/chat/conversations/:id", { + method: "GET", + params: { id: "conv-1" }, + headers, + }); + }); + + it("maps create, rename, and delete mutations and forwards headers", async () => { + const client = vi + .fn() + .mockResolvedValueOnce({ data: { id: "conv-1", title: "New" } }) + .mockResolvedValueOnce({ data: { id: "conv-1", title: "Renamed" } }) + .mockResolvedValueOnce({ data: { success: true } }) as any; + const headers = { authorization: "Bearer test" }; + + await runResourceMutation( + client, + aiChatResources.conversations.mutations.create, + { id: "conv-1", title: "New" }, + headers, + ); + expect(client).toHaveBeenLastCalledWith("@post/chat/conversations", { + method: "POST", + body: { id: "conv-1", title: "New" }, + headers, + }); + + await runResourceMutation( + client, + aiChatResources.conversations.mutations.rename, + { id: "conv-1", title: "Renamed" }, + headers, + ); + expect(client).toHaveBeenLastCalledWith("@put/chat/conversations/:id", { + method: "PUT", + params: { id: "conv-1" }, + body: { title: "Renamed" }, + headers, + }); + + await runResourceMutation( + client, + aiChatResources.conversations.mutations.delete, + { id: "conv-1" }, + headers, + ); + expect(client).toHaveBeenLastCalledWith("@delete/chat/conversations/:id", { + method: "DELETE", + params: { id: "conv-1" }, + headers, + }); + }); + + it("trims rename titles and rejects empty values", () => { + expect(updateConversationSchema.parse({ title: " Renamed " })).toEqual({ + title: "Renamed", + }); + expect(updateConversationSchema.safeParse({ title: " " }).success).toBe( + false, + ); + }); +}); diff --git a/packages/stack/src/__tests__/client-plugin-ssr-loaders.test.ts b/packages/stack/src/__tests__/client-plugin-ssr-loaders.test.ts index 26900505..dbbd270d 100644 --- a/packages/stack/src/__tests__/client-plugin-ssr-loaders.test.ts +++ b/packages/stack/src/__tests__/client-plugin-ssr-loaders.test.ts @@ -15,6 +15,9 @@ import { createUIBuilderQueryKeys } from "../plugins/ui-builder/query-keys"; import { commentsClientPlugin } from "../plugins/comments/client"; import type { CommentsApiRouter } from "../plugins/comments/api"; import { createCommentsQueryKeys } from "../plugins/comments/query-keys"; +import { aiChatClientPlugin } from "../plugins/ai-chat/client"; +import type { AiChatApiRouter } from "../plugins/ai-chat/api"; +import { createAiChatQueryKeys } from "../plugins/ai-chat/query-keys"; const API_BASE_URL = "http://localhost:3000"; const API_BASE_PATH = "/api/data"; @@ -321,4 +324,75 @@ describe("client plugin SSR loaders", () => { expect((errorArg as Error).message).toBe(apiErrorMessage); expect(contextArg).toMatchObject({ currentUserId }); }); + + it("AI Chat list loader seeds a sanitized query error when a hook throws", async () => { + const queryClient = new QueryClient(); + const plugin = aiChatClientPlugin({ + apiBaseURL: API_BASE_URL, + apiBasePath: API_BASE_PATH, + siteBaseURL: SITE_BASE_URL, + siteBasePath: SITE_BASE_PATH, + queryClient, + headers: TEST_HEADERS, + hooks: { + beforeLoadConversations: () => { + throw new Error("private loader detail"); + }, + }, + }); + + await plugin.routes().chat().loader?.(); + + const client = createApiClient({ + baseURL: API_BASE_URL, + basePath: API_BASE_PATH, + }); + const query = createAiChatQueryKeys( + client, + TEST_HEADERS, + ).conversations.list(); + expect(getErrorMessage(queryClient, query.queryKey)).toBe( + SSR_LOADER_ERROR_MESSAGE, + ); + }); + + it("AI Chat detail loader seeds sanitized detail and list errors", async () => { + const queryClient = new QueryClient(); + const id = "conv-1"; + const plugin = aiChatClientPlugin({ + apiBaseURL: API_BASE_URL, + apiBasePath: API_BASE_PATH, + siteBaseURL: SITE_BASE_URL, + siteBasePath: SITE_BASE_PATH, + queryClient, + headers: TEST_HEADERS, + hooks: { + beforeLoadConversation: () => { + throw new Error("private conversation detail"); + }, + }, + }); + + const routes = plugin.routes(); + const chatConversation = ( + routes as unknown as { + chatConversation: (args: { params: { id: string } }) => { + loader?: () => Promise; + }; + } + ).chatConversation; + await chatConversation({ params: { id } }).loader?.(); + + const client = createApiClient({ + baseURL: API_BASE_URL, + basePath: API_BASE_PATH, + }); + const queries = createAiChatQueryKeys(client, TEST_HEADERS); + expect( + getErrorMessage(queryClient, queries.conversations.detail(id).queryKey), + ).toBe(SSR_LOADER_ERROR_MESSAGE); + expect( + getErrorMessage(queryClient, queries.conversations.list().queryKey), + ).toBe(SSR_LOADER_ERROR_MESSAGE); + }); }); diff --git a/packages/stack/src/__tests__/resource-factory.test.tsx b/packages/stack/src/__tests__/resource-factory.test.tsx index e943f0fb..cbf26fad 100644 --- a/packages/stack/src/__tests__/resource-factory.test.tsx +++ b/packages/stack/src/__tests__/resource-factory.test.tsx @@ -104,12 +104,31 @@ const resources = { refetchType: "all" as const, refresh: false, }, + rename: { + path: "@put/items/:id", + method: "PUT" as const, + input: (vars: { id: string; name: string }) => ({ + params: { id: vars.id }, + body: { name: vars.name }, + }), + select: (data: any) => data as Item, + setData: { + args: (result: Item) => [result.id], + updater: (previous: unknown, result: Item) => ({ + ...(previous as Record), + ...result, + }), + }, + }, remove: { path: "@delete/items/:id", method: "DELETE" as const, input: (vars: { id: string }) => ({ params: { id: vars.id } }), select: (data: any) => data as { success: boolean }, invalidates: ["items"], + removeData: { + args: (_result, variables: { id: string }) => [variables.id], + }, }, // Public-style mutation: success UI is client state, so it must not // trigger the router refresh (a full reload on public pages) @@ -513,7 +532,7 @@ describe("createResource hooks", () => { expect(refresh).toHaveBeenCalledTimes(1); }); - it("resource-level invalidation targets every query of the resource", async () => { + it("resource invalidation and removal clear the matching detail query", async () => { fetchMock.mockResolvedValue(jsonResponse({ success: true })); const listKey = ["items", "list", { q: undefined, limit: 10 }]; @@ -533,7 +552,34 @@ describe("createResource hooks", () => { }); expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(detailKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(detailKey)).toBeUndefined(); + }); + + it("setData updater preserves fields missing from the mutation result", async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: "9", name: "renamed" })); + const detailKey = ["items", "detail", "9"]; + queryClient.setQueryData(detailKey, { + id: "9", + name: "old", + messages: ["preserved"], + }); + + let captured: any; + function Probe() { + captured = items.items.rename.use(); + return null; + } + await render(); + + await act(async () => { + await captured.mutateAsync({ id: "9", name: "renamed" }); + }); + + expect(queryClient.getQueryData(detailKey)).toEqual({ + id: "9", + name: "renamed", + messages: ["preserved"], + }); }); it("can refetch inactive queries during invalidation", async () => { diff --git a/packages/stack/src/plugins/ai-chat/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/ai-chat/__tests__/client-sweep.test.tsx new file mode 100644 index 00000000..ff94d12b --- /dev/null +++ b/packages/stack/src/plugins/ai-chat/__tests__/client-sweep.test.tsx @@ -0,0 +1,310 @@ +// @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"; +import { + StackProvider, + type StackAuthProvider, + type StackI18nProvider, +} from "@btst/stack/context"; +import { ChatInterface } from "../client/components/chat-interface"; +import { ChatSidebar } from "../client/components/chat-sidebar"; +import { ChatPage } from "../client/components/pages/chat-page.internal"; +import type { SerializedConversation } from "../types"; + +(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(), + chatLayout: vi.fn(), +})); + +vi.mock("@ai-sdk/react", () => ({ useChat: mocks.useChat })); +vi.mock("../client/hooks/chat-hooks", () => ({ + useConversation: mocks.useConversation, + useConversations: mocks.useConversations, + useRenameConversationForm: mocks.useRenameConversationForm, + useDeleteConversation: mocks.useDeleteConversation, +})); +vi.mock("../client/components/chat-input", () => ({ + ChatInput: ({ placeholder }: { placeholder?: string }) => ( +
    {placeholder}
    + ), +})); +vi.mock("../client/components/chat-message", () => ({ + ChatMessage: () =>
    , +})); +vi.mock("../client/components/chat-layout", () => ({ + ChatLayout: (props: unknown) => { + mocks.chatLayout(props); + return
    ; + }, +})); + +const conversation: SerializedConversation = { + id: "conv-1", + title: "First conversation", + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-02").toISOString(), +}; + +let container: HTMLDivElement; +let root: Root; +let queryClient: QueryClient; +let deleteConversation: ReturnType; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + deleteConversation = vi.fn().mockResolvedValue({ success: true }); + + mocks.useChat.mockReturnValue({ + messages: [], + sendMessage: vi.fn(), + status: "ready", + error: null, + setMessages: vi.fn(), + regenerate: vi.fn(), + addToolOutput: 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().mockResolvedValue(conversation), + isSubmitting: false, + error: null, + fieldErrors: {}, + clearErrors: vi.fn(), + }); + mocks.useDeleteConversation.mockReturnValue({ + mutateAsync: deleteConversation, + isPending: false, + }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + document.body.innerHTML = ""; + queryClient.clear(); + vi.clearAllMocks(); +}); + +const router = () => ({ + navigate: vi.fn(), + getSearchParams: () => new URLSearchParams(), + setSearchParams: vi.fn(), +}); + +function overrides( + mode: "authenticated" | "public" = "authenticated", + localization?: Record, +) { + return { + mode, + apiBaseURL: "http://test.local", + apiBasePath: "/api/data", + navigate: vi.fn(), + localization, + }; +} + +async function render( + ui: React.ReactElement, + options: { + auth?: StackAuthProvider; + i18n?: StackI18nProvider; + notify?: { + success: ReturnType; + error: ReturnType; + }; + mode?: "authenticated" | "public"; + localization?: Record; + } = {}, +) { + await act(async () => { + root.render( + + + {ui} + + , + ); + await Promise.resolve(); + }); +} + +async function openConversationMenu() { + const trigger = container.querySelector( + 'button[aria-label="Conversation actions"]', + )!; + await act(async () => { + trigger.dispatchEvent( + new MouseEvent("pointerdown", { bubbles: true, button: 0 }), + ); + }); +} + +function menuItem(text: string) { + return Array.from( + document.querySelectorAll("[role=menuitem]"), + ).find((item) => item.textContent?.includes(text)); +} + +describe("AI Chat permissions", () => { + it("keeps public chat writable when an auth provider denies every action", async () => { + const can = vi.fn(() => false); + await render(, { + mode: "public", + auth: { getIdentity: () => null, can }, + }); + + expect(container.querySelector('[data-testid="chat-input"]')).toBeTruthy(); + }); + + it("gates authenticated create and per-conversation controls independently", async () => { + const can = vi.fn( + ({ action }: { action: string }) => + action === "read" || action === "delete", + ); + const auth: StackAuthProvider = { + getIdentity: () => ({ id: "viewer" }), + can, + }; + + await render(, { auth }); + expect(document.body.textContent).not.toContain("New chat"); + + await openConversationMenu(); + expect(menuItem("Rename")).toBeUndefined(); + expect(menuItem("Delete")).toBeTruthy(); + expect(can).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "ai-chat:conversation", + action: "delete", + params: { id: conversation.id }, + }), + ); + }); +}); + +describe("AI Chat forms, notifications, and i18n", () => { + it("renders rename field errors inline through the i18n catalog", async () => { + mocks.useRenameConversationForm.mockReturnValue({ + submit: vi.fn(), + isSubmitting: false, + error: new Error("Validation failed"), + fieldErrors: { title: "Title is required" }, + clearErrors: vi.fn(), + }); + await render(, { + i18n: { + translate: (key, fallback) => + key === "aiChat.conversation.titleRequired" + ? "Titel erforderlich" + : fallback, + }, + }); + + await openConversationMenu(); + await act(async () => menuItem("Rename")?.click()); + expect(document.body.textContent).toContain("Titel erforderlich"); + }); + + it("lets legacy localization overrides win over the i18n catalog", async () => { + await render(, { + i18n: { translate: () => "Catalog new chat" }, + localization: { SIDEBAR_NEW_CHAT: "Legacy new chat" }, + }); + + expect(document.body.textContent).toContain("Legacy new chat"); + expect(document.body.textContent).not.toContain("Catalog new chat"); + }); + + it("routes delete success through the notify provider", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + await render(, { notify }); + + await openConversationMenu(); + await act(async () => menuItem("Delete")?.click()); + const confirm = Array.from( + document.querySelectorAll("button"), + ).find((button) => button.textContent === "Delete"); + await act(async () => confirm?.click()); + + expect(deleteConversation).toHaveBeenCalledWith({ id: conversation.id }); + expect(notify.success).toHaveBeenCalledWith("Conversation deleted"); + expect(notify.error).not.toHaveBeenCalled(); + }); +}); + +describe("AI Chat route lifecycle compatibility", () => { + it("runs the conversation render and legacy before-render hooks", async () => { + const onRouteRender = vi.fn(); + const onBeforeConversationPageRendered = vi.fn(() => true); + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + }); + + expect(onBeforeConversationPageRendered).toHaveBeenCalledWith( + "conv-1", + expect.objectContaining({ + path: "/chat/conv-1", + params: { id: "conv-1" }, + }), + ); + expect(onRouteRender).toHaveBeenCalledWith( + "chatConversation", + expect.objectContaining({ path: "/chat/conv-1" }), + ); + }); +}); diff --git a/packages/stack/src/plugins/ai-chat/__tests__/hooks.test.tsx b/packages/stack/src/plugins/ai-chat/__tests__/hooks.test.tsx new file mode 100644 index 00000000..b04b8c2b --- /dev/null +++ b/packages/stack/src/plugins/ai-chat/__tests__/hooks.test.tsx @@ -0,0 +1,128 @@ +// @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"; +import { StackProvider } from "@btst/stack/context"; +import { useRenameConversationForm } from "../client/hooks/chat-hooks"; +import type { SerializedConversation } from "../types"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const conversation: SerializedConversation = { + id: "conv-1", + title: "Original", + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-02").toISOString(), +}; + +let container: HTMLDivElement; +let root: Root; +let queryClient: QueryClient; +let fetchMock: ReturnType; +let refresh: ReturnType; +let notify: { + success: ReturnType; + error: ReturnType; +}; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + fetchMock = vi.spyOn(globalThis, "fetch" as any); + refresh = vi.fn(); + notify = { success: vi.fn(), error: vi.fn() }; +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + queryClient.clear(); + vi.restoreAllMocks(); +}); + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +async function renderProbe() { + let captured: ReturnType; + function Probe() { + captured = useRenameConversationForm({ conversation }); + return null; + } + + await act(async () => { + root.render( + + + + + , + ); + }); + + return () => captured!; +} + +describe("useRenameConversationForm", () => { + it("trims the title, forwards headers, refreshes, and notifies on success", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ ...conversation, title: "Renamed" }), + ); + const getForm = await renderProbe(); + + await act(async () => { + await getForm().submit({ title: " Renamed " }); + }); + + const [url, init] = fetchMock.mock.calls[0] as [unknown, RequestInit]; + expect(String(url)).toContain("/api/data/chat/conversations/conv-1"); + expect(init.method).toBe("PUT"); + expect(JSON.parse(String(init.body))).toEqual({ title: "Renamed" }); + expect(new Headers(init.headers).get("x-chat-test")).toBe("forwarded"); + expect(notify.success).toHaveBeenCalledWith("Conversation renamed"); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("keeps server validation errors on the title field without an error toast", async () => { + fetchMock.mockResolvedValue( + jsonResponse( + { + message: "[body.title] Title is required", + code: "VALIDATION_ERROR", + issues: [{ path: ["title"], message: "Title is required" }], + }, + 400, + ), + ); + const getForm = await renderProbe(); + + await act(async () => { + await getForm().submit({ title: " " }); + }); + + expect(getForm().fieldErrors).toEqual({ title: "Title is required" }); + expect(notify.error).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/stack/src/plugins/ai-chat/api/index.ts b/packages/stack/src/plugins/ai-chat/api/index.ts index c41d081e..24fa089f 100644 --- a/packages/stack/src/plugins/ai-chat/api/index.ts +++ b/packages/stack/src/plugins/ai-chat/api/index.ts @@ -1,3 +1,9 @@ export * from "./plugin"; export { getAllConversations, getConversationById } from "./getters"; -export { createAiChatQueryKeys } from "../query-keys"; +export { aiChatResources, createAiChatQueryKeys } from "../query-keys"; +export type { + AiChatQueryKeys, + ConversationWithMessages, + CreateConversationInput, + RenameConversationInput, +} from "../query-keys"; 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 0a94d10c..45d3fe3e 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 @@ -5,11 +5,10 @@ import { Button } from "@workspace/ui/components/button"; import { Textarea } from "@workspace/ui/components/textarea"; import { Send, Paperclip, X, Loader2, FileText } from "lucide-react"; import { cn } from "@workspace/ui/lib/utils"; -import { toast } from "sonner"; -import { usePluginOverrides } from "@btst/stack/context"; +import { useNotify, usePluginOverrides } from "@btst/stack/context"; import type { AiChatPluginOverrides } from "../overrides"; import { DEFAULT_ALLOWED_FILE_TYPES, FILE_TYPE_MIME_MAP } from "../overrides"; -import { AI_CHAT_LOCALIZATION } from "../localization"; +import { useAiChatTranslation } from "../localization"; import type { FormEvent } from "react"; /** Represents an attached file with metadata */ @@ -58,7 +57,8 @@ export function ChatInput({ {}, ); - const localization = { ...AI_CHAT_LOCALIZATION, ...customLocalization }; + const notify = useNotify(); + const tr = useAiChatTranslation(customLocalization); const fileInputRef = useRef(null); const [internalFiles, setInternalFiles] = useState([]); @@ -136,7 +136,13 @@ export function ChatInput({ if (!file) return; if (file.size > MAX_FILE_SIZE) { - toast.error(localization.FILE_UPLOAD_ERROR_TOO_LARGE); + notify.error( + tr( + "FILE_UPLOAD_ERROR_TOO_LARGE", + "aiChat.files.tooLarge", + "File must be less than 10MB", + ), + ); return; } @@ -146,10 +152,22 @@ export function ChatInput({ setIsUploading(true); const url = await uploadFile(file); addFile({ url, mediaType: file.type, filename: file.name }); - toast.success(localization.FILE_UPLOAD_SUCCESS); + notify.success( + tr( + "FILE_UPLOAD_SUCCESS", + "aiChat.files.uploadSuccess", + "File attached", + ), + ); } catch (error) { console.error("Failed to upload file:", error); - toast.error(localization.FILE_UPLOAD_FAILURE); + notify.error( + tr( + "FILE_UPLOAD_FAILURE", + "aiChat.files.uploadFailure", + "Failed to attach file", + ), + ); } finally { setIsUploading(false); } @@ -163,7 +181,13 @@ export function ChatInput({ setIsUploading(false); }; reader.onerror = () => { - toast.error(localization.FILE_UPLOAD_FAILURE); + notify.error( + tr( + "FILE_UPLOAD_FAILURE", + "aiChat.files.uploadFailure", + "Failed to attach file", + ), + ); setIsUploading(false); }; reader.readAsDataURL(file); @@ -221,6 +245,11 @@ export function ChatInput({ type="button" onClick={() => removeFile(index)} className="absolute top-0.5 right-0.5 p-0.5 rounded-full bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity" + aria-label={tr( + "FILE_REMOVE", + "aiChat.files.remove", + "Remove file", + )} > @@ -248,7 +277,11 @@ export function ChatInput({ onClick={() => fileInputRef.current?.click()} disabled={isLoading || isUploading} className={cn("shrink-0", isCompact ? "h-8 w-8" : "h-9 w-9")} - aria-label={localization.FILE_UPLOAD_BUTTON} + aria-label={tr( + "FILE_UPLOAD_BUTTON", + "aiChat.files.attach", + "Attach file", + )} > {isUploading ? ( @@ -265,7 +298,14 @@ export function ChatInput({ value={input} onChange={handleInputChange} onKeyDown={handleKeyDown} - placeholder={placeholder || localization.CHAT_PLACEHOLDER} + placeholder={ + placeholder || + tr( + "CHAT_PLACEHOLDER", + "aiChat.chat.placeholder", + "Type a message...", + ) + } className={cn( "resize-none pr-12 max-w-full", isCompact @@ -284,7 +324,7 @@ export function ChatInput({ "absolute right-2 bottom-2", isCompact ? "h-7 w-7" : "h-8 w-8", )} - aria-label={localization.CHAT_SEND_BUTTON} + aria-label={tr("CHAT_SEND_BUTTON", "aiChat.chat.send", "Send")} > 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 a637e541..9e03fe81 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 @@ -13,9 +13,9 @@ import { type UIMessage, } from "ai"; import { cn } from "@workspace/ui/lib/utils"; -import { usePluginOverrides, useBasePath } from "@btst/stack/context"; +import { useCan, usePluginOverrides, useBasePath } from "@btst/stack/context"; import type { AiChatPluginOverrides } from "../overrides"; -import { AI_CHAT_LOCALIZATION } from "../localization"; +import { useAiChatTranslation } from "../localization"; import { createApiClient } from "@btst/stack/plugins/client"; import type { AiChatApiRouter } from "../../api/plugin"; import { createAiChatQueryKeys } from "../../query-keys"; @@ -64,7 +64,7 @@ export function ChatInterface({ // Read page AI context registered by the current page const pageAIContext = usePageAIContext(); - const localization = { ...AI_CHAT_LOCALIZATION, ...customLocalization }; + const tr = useAiChatTranslation(customLocalization); const queryClient = useQueryClient(); const conversationsListQueryKey = useMemo(() => { @@ -83,6 +83,14 @@ export function ChatInterface({ const [currentConversationId, setCurrentConversationId] = useState< string | undefined >(isPublicMode ? undefined : id); + const { can: canPersistConversation, isPending: isPermissionPending } = + useCan({ + resource: "ai-chat:conversation", + action: currentConversationId ? "update" : "create", + params: currentConversationId ? { id: currentConversationId } : undefined, + }); + const canWrite = + isPublicMode || (!isPermissionPending && canPersistConversation); // Track if we've sent the first message on a new chat (to trigger navigation) const isFirstMessageSentRef = useRef(false); const hasNavigatedRef = useRef(false); @@ -232,7 +240,13 @@ export function ChatInterface({ toolCallId: toolCall.toolCallId, state: "output-error", errorText: - err instanceof Error ? err.message : "Tool execution failed", + err instanceof Error + ? err.message + : tr( + "TOOL_EXECUTION_FAILED", + "aiChat.tools.executionFailed", + "Tool execution failed", + ), }); } } else { @@ -244,7 +258,12 @@ export function ChatInterface({ tool: toolName, toolCallId: toolCall.toolCallId, state: "output-error", - errorText: `No client-side handler registered for tool "${toolName}". The page context may have changed while the response was streaming.`, + errorText: tr( + "TOOL_HANDLER_MISSING", + "aiChat.tools.handlerMissing", + 'No client-side handler registered for tool "{{toolName}}". The page context may have changed while the response was streaming.', + { toolName }, + ), }); } }, @@ -428,6 +447,7 @@ export function ChatInterface({ files?: AttachedFile[], ) => { e.preventDefault(); + if (!canWrite) return; const text = input.trim(); // Allow submit if there's text OR files if (!text && (!files || files.length === 0)) return; @@ -572,7 +592,13 @@ export function ChatInterface({ {messages.length === 0 ? (
    -

    {localization.CHAT_EMPTY_STATE}

    +

    + {tr( + "CHAT_EMPTY_STATE", + "aiChat.chat.emptyState", + "Start a conversation...", + )} +

    {(() => { // Merge static suggestions from overrides with dynamic ones from page context. @@ -582,7 +608,7 @@ export function ChatInterface({ ...pageSuggestions, ...(chatSuggestions ?? []), ]; - return allSuggestions.length > 0 ? ( + return canWrite && allSuggestions.length > 0 ? (
    {allSuggestions.map((suggestion, index) => (
    @@ -643,26 +679,32 @@ export function ChatInterface({
    {/* Input Area */} -
    -
    - - {showAttribution && } + {canWrite && ( +
    +
    + + {showAttribution && } +
    -
    + )} ); } 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 ddf6022f..17b6e6fe 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 @@ -21,6 +21,9 @@ import { ChatSidebar } from "./chat-sidebar"; import { ChatInterface } from "./chat-interface"; import type { UIMessage } from "ai"; import { usePageAIContext } from "../context/page-ai-context"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { AiChatPluginOverrides } from "../overrides"; +import { useAiChatTranslation } from "../localization"; interface ChatLayoutBaseProps { /** API base URL */ @@ -88,6 +91,11 @@ export function ChatLayout(props: ChatLayoutProps) { onMessagesChange, onClear, } = props; + const { localization } = usePluginOverrides< + AiChatPluginOverrides, + Partial + >("ai-chat", {}); + const tr = useAiChatTranslation(localization); // Widget-specific props — TypeScript narrows props to ChatLayoutWidgetProps here const widgetHeight = @@ -151,7 +159,7 @@ export function ChatLayout(props: ChatLayoutProps) { ) : ( - AI Chat + {tr("A11Y_CHAT_TITLE", "aiChat.a11y.title", "AI Chat")} )}
    @@ -163,8 +171,16 @@ export function ChatLayout(props: ChatLayoutProps) { onClear?.(); setWidgetResetKey((prev) => prev + 1); }} - aria-label="Clear chat" - title="Clear chat" + aria-label={tr( + "A11Y_CLEAR_CHAT", + "aiChat.a11y.clearChat", + "Clear chat", + )} + title={tr( + "A11Y_CLEAR_CHAT", + "aiChat.a11y.clearChat", + "Clear chat", + )} > @@ -173,7 +189,11 @@ export function ChatLayout(props: ChatLayoutProps) { size="icon" className="h-5 w-5" onClick={() => setWidgetOpen(false)} - aria-label="Close chat" + aria-label={tr( + "A11Y_CLOSE_CHAT", + "aiChat.a11y.closeChat", + "Close chat", + )} > @@ -199,7 +219,11 @@ export function ChatLayout(props: ChatLayoutProps) { setWidgetOpen((prev) => !prev); setWidgetEverOpened(true); }} - aria-label={widgetOpen ? "Close chat" : "Open chat"} + aria-label={ + widgetOpen + ? tr("A11Y_CLOSE_CHAT", "aiChat.a11y.closeChat", "Close chat") + : tr("A11Y_OPEN_CHAT", "aiChat.a11y.openChat", "Open chat") + } data-testid="widget-trigger" > {widgetOpen ? ( @@ -252,7 +276,11 @@ export function ChatLayout(props: ChatLayoutProps) { variant="ghost" size="icon" className="md:hidden" - aria-label="Open menu" + aria-label={tr( + "A11Y_OPEN_MENU", + "aiChat.a11y.openMenu", + "Open menu", + )} > @@ -276,7 +304,19 @@ export function ChatLayout(props: ChatLayoutProps) { size="icon" className="hidden md:flex" onClick={() => setSidebarOpen(!sidebarOpen)} - aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"} + aria-label={ + sidebarOpen + ? tr( + "A11Y_CLOSE_SIDEBAR", + "aiChat.a11y.closeSidebar", + "Close sidebar", + ) + : tr( + "A11Y_OPEN_SIDEBAR", + "aiChat.a11y.openSidebar", + "Open sidebar", + ) + } > {sidebarOpen ? ( @@ -308,8 +348,16 @@ export function ChatLayout(props: ChatLayoutProps) { onClear(); setChatResetKey((prev) => prev + 1); }} - aria-label="Clear chat" - title="Clear chat" + aria-label={tr( + "A11Y_CLEAR_CHAT", + "aiChat.a11y.clearChat", + "Clear chat", + )} + title={tr( + "A11Y_CLEAR_CHAT", + "aiChat.a11y.clearChat", + "Clear chat", + )} > 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 53fe96ac..2b711c4f 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 @@ -34,7 +34,7 @@ import type { ToolCallProps, ToolCallState, } from "../overrides"; -import { AI_CHAT_LOCALIZATION } from "../localization"; +import { useAiChatTranslation } from "../localization"; import { ToolCallDisplay } from "./tool-call-display"; // Import shared markdown + syntax highlighting styles (same pattern as blog plugin) @@ -143,7 +143,7 @@ export function ChatMessage({ {}, ); - const localization = { ...AI_CHAT_LOCALIZATION, ...customLocalization }; + const tr = useAiChatTranslation(customLocalization); // Use provided Image component or fallback to default const ImageComponent = Image ?? DefaultImage; @@ -269,8 +269,12 @@ export function ChatMessage({ )} aria-label={ isUser - ? localization.A11Y_USER_MESSAGE - : localization.A11Y_ASSISTANT_MESSAGE + ? tr("A11Y_USER_MESSAGE", "aiChat.a11y.userMessage", "Your message") + : tr( + "A11Y_ASSISTANT_MESSAGE", + "aiChat.a11y.assistantMessage", + "AI response", + ) } > {/* Assistant Avatar */} @@ -310,7 +314,12 @@ export function ChatMessage({ > - {part.filename || "File"} + {part.filename || + tr( + "FILE_FALLBACK_NAME", + "aiChat.files.fallbackName", + "File", + )} ))} @@ -323,7 +332,15 @@ export function ChatMessage({ @@ -365,7 +391,12 @@ export function ChatMessage({ onClick={handleSaveEdit} disabled={!editText.trim()} className="h-7 w-7 text-primary-foreground hover:bg-primary-foreground/20 disabled:opacity-50" - title={localization.MESSAGE_SAVE} + title={tr("MESSAGE_SAVE", "aiChat.messages.save", "Save")} + aria-label={tr( + "MESSAGE_SAVE", + "aiChat.messages.save", + "Save", + )} > @@ -400,7 +431,12 @@ export function ChatMessage({ > - {part.filename || "File"} + {part.filename || + tr( + "FILE_FALLBACK_NAME", + "aiChat.files.fallbackName", + "File", + )} ))} @@ -413,7 +449,15 @@ export function ChatMessage({ @@ -511,8 +564,29 @@ export function ChatMessage({ className="h-7 w-7 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50" title={ copied - ? localization.MESSAGE_COPIED - : localization.MESSAGE_COPY + ? tr( + "MESSAGE_COPIED", + "aiChat.messages.copied", + "Copied!", + ) + : tr( + "MESSAGE_COPY", + "aiChat.messages.copy", + "Copy message", + ) + } + aria-label={ + copied + ? tr( + "MESSAGE_COPIED", + "aiChat.messages.copied", + "Copied!", + ) + : tr( + "MESSAGE_COPY", + "aiChat.messages.copy", + "Copy message", + ) } > {copied ? ( @@ -529,7 +603,16 @@ export function ChatMessage({ onClick={onRetry} disabled={isRetrying} className="h-7 w-7 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50 disabled:opacity-50" - title={localization.MESSAGE_RETRY} + title={tr( + "MESSAGE_RETRY", + "aiChat.messages.retry", + "Retry", + )} + aria-label={tr( + "MESSAGE_RETRY", + "aiChat.messages.retry", + "Retry", + )} > >("ai-chat", {}); const basePath = useBasePath(); - - const localization = { ...AI_CHAT_LOCALIZATION, ...customLocalization }; - - const { conversations, isLoading } = useConversations(); - const renameMutation = useRenameConversation(); + const notify = useNotify(); + const tr = useAiChatTranslation(customLocalization); + const { can: canRead, isPending: isReadPermissionPending } = useCan({ + resource: "ai-chat:conversation", + action: "read", + }); + const { conversations, isLoading } = useConversations({ + enabled: !isReadPermissionPending && canRead, + }); const deleteMutation = useDeleteConversation(); const [renameDialogOpen, setRenameDialogOpen] = useState(false); @@ -73,6 +84,14 @@ export function ChatSidebar({ const [selectedConversation, setSelectedConversation] = useState(null); const [newTitle, setNewTitle] = useState(""); + const renameForm = useRenameConversationForm({ + conversation: selectedConversation, + onSuccess: () => { + setRenameDialogOpen(false); + setSelectedConversation(null); + setNewTitle(""); + }, + }); const handleNewChat = () => { // Always use overrides navigation when available, so consumers control routing. @@ -92,6 +111,7 @@ export function ChatSidebar({ }; const handleRenameClick = (conversation: SerializedConversation) => { + renameForm.clearErrors(); setSelectedConversation(conversation); setNewTitle(conversation.title); setRenameDialogOpen(true); @@ -103,25 +123,38 @@ export function ChatSidebar({ }; const handleRenameConfirm = async () => { - if (selectedConversation && newTitle.trim()) { - await renameMutation.mutateAsync({ - id: selectedConversation.id, - title: newTitle.trim(), - }); - setRenameDialogOpen(false); - setSelectedConversation(null); - setNewTitle(""); + if (selectedConversation) { + await renameForm.submit({ title: newTitle }); } }; const handleDeleteConfirm = async () => { if (selectedConversation) { - await deleteMutation.mutateAsync({ id: selectedConversation.id }); - setDeleteDialogOpen(false); - setSelectedConversation(null); - // Navigate away if deleted current conversation - if (selectedConversation.id === currentConversationId && navigate) { - navigate(`${basePath}/chat`); + try { + await deleteMutation.mutateAsync({ id: selectedConversation.id }); + notify.success( + tr( + "CONVERSATION_DELETE_SUCCESS", + "aiChat.toasts.deleteSuccess", + "Conversation deleted", + ), + ); + setDeleteDialogOpen(false); + setSelectedConversation(null); + // Navigate away if deleted current conversation + if (selectedConversation.id === currentConversationId && navigate) { + await navigate(`${basePath}/chat`); + } + } catch (error) { + notify.error( + error instanceof Error + ? error.message + : tr( + "CONVERSATION_DELETE_FAILURE", + "aiChat.toasts.deleteFailure", + "Failed to delete conversation", + ), + ); } } }; @@ -134,14 +167,28 @@ export function ChatSidebar({ const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); - if (diffMins < 1) return localization.TIME_JUST_NOW; + if (diffMins < 1) + return tr("TIME_JUST_NOW", "aiChat.time.justNow", "Just now"); if (diffMins < 60) - return localization.TIME_MINUTES_AGO.replace("{count}", String(diffMins)); + return tr( + "TIME_MINUTES_AGO", + "aiChat.time.minutesAgo", + "{{count}} minutes ago", + { count: diffMins }, + ); if (diffHours < 24) - return localization.TIME_HOURS_AGO.replace("{count}", String(diffHours)); - if (diffDays === 1) return localization.TIME_YESTERDAY; + return tr( + "TIME_HOURS_AGO", + "aiChat.time.hoursAgo", + "{{count}} hours ago", + { count: diffHours }, + ); + if (diffDays === 1) + return tr("TIME_YESTERDAY", "aiChat.time.yesterday", "Yesterday"); if (diffDays < 7) - return localization.TIME_DAYS_AGO.replace("{count}", String(diffDays)); + return tr("TIME_DAYS_AGO", "aiChat.time.daysAgo", "{{count}} days ago", { + count: diffDays, + }); return date.toLocaleDateString(); }; @@ -152,14 +199,16 @@ export function ChatSidebar({ > {/* Header */}
    - + + +
    {/* Conversations List */} @@ -167,11 +216,15 @@ export function ChatSidebar({
    {isLoading ? (
    - {localization.CHAT_LOADING} + {tr("CHAT_LOADING", "aiChat.chat.loading", "Thinking...")}
    ) : conversations.length === 0 ? (
    - {localization.SIDEBAR_NO_CONVERSATIONS} + {tr( + "SIDEBAR_NO_CONVERSATIONS", + "aiChat.sidebar.empty", + "No conversations yet", + )}
    ) : (
    @@ -204,30 +257,55 @@ export function ChatSidebar({ variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity" + aria-label={tr( + "A11Y_CONVERSATION_ACTIONS", + "aiChat.a11y.conversationActions", + "Conversation actions", + )} > - { - e.stopPropagation(); - handleRenameClick(conversation); - }} + - - {localization.CONVERSATION_RENAME} - - { - e.stopPropagation(); - handleDeleteClick(conversation); - }} - className="text-destructive focus:text-destructive" + { + e.stopPropagation(); + handleRenameClick(conversation); + }} + > + + {tr( + "CONVERSATION_RENAME", + "aiChat.conversation.rename", + "Rename", + )} + + + - - {localization.CONVERSATION_DELETE} - + { + e.stopPropagation(); + handleDeleteClick(conversation); + }} + className="text-destructive focus:text-destructive" + > + + {tr( + "CONVERSATION_DELETE", + "aiChat.conversation.delete", + "Delete", + )} + +
    @@ -238,33 +316,74 @@ export function ChatSidebar({ {/* Rename Dialog */} - + { + setRenameDialogOpen(open); + if (!open) renameForm.clearErrors(); + }} + > - {localization.CONVERSATION_RENAME} + + {tr( + "CONVERSATION_RENAME", + "aiChat.conversation.rename", + "Rename", + )} + + + {tr( + "CONVERSATION_RENAME_DESCRIPTION", + "aiChat.conversation.renameDescription", + "Enter a new title for this conversation.", + )} + setNewTitle(e.target.value)} - placeholder={localization.CONVERSATION_RENAME_PLACEHOLDER} + placeholder={tr( + "CONVERSATION_RENAME_PLACEHOLDER", + "aiChat.conversation.renamePlaceholder", + "Enter conversation name", + )} + aria-invalid={!!renameForm.fieldErrors.title} onKeyDown={(e) => { if (e.key === "Enter") { void handleRenameConfirm(); } }} /> + {renameForm.fieldErrors.title && ( +

    + {tr( + "CONVERSATION_TITLE_REQUIRED", + "aiChat.conversation.titleRequired", + "Title is required", + )} +

    + )}
    @@ -275,21 +394,41 @@ export function ChatSidebar({ - {localization.CONVERSATION_DELETE_CONFIRM_TITLE} + {tr( + "CONVERSATION_DELETE_CONFIRM_TITLE", + "aiChat.conversation.deleteConfirmTitle", + "Delete conversation", + )} - {localization.CONVERSATION_DELETE_CONFIRM_DESCRIPTION} + {tr( + "CONVERSATION_DELETE_CONFIRM_DESCRIPTION", + "aiChat.conversation.deleteConfirmDescription", + "Are you sure you want to delete this conversation? This action cannot be undone.", + )} - {localization.CONVERSATION_DELETE_CANCEL} + {tr( + "CONVERSATION_DELETE_CANCEL", + "aiChat.conversation.deleteCancel", + "Cancel", + )} { + event.preventDefault(); + void handleDeleteConfirm(); + }} + disabled={deleteMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {localization.CONVERSATION_DELETE_CONFIRM_BUTTON} + {tr( + "CONVERSATION_DELETE_CONFIRM_BUTTON", + "aiChat.conversation.deleteConfirmButton", + "Delete", + )} diff --git a/packages/stack/src/plugins/ai-chat/client/components/pages/404-page.tsx b/packages/stack/src/plugins/ai-chat/client/components/pages/404-page.tsx index 5d01397a..655e2e6c 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/pages/404-page.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/pages/404-page.tsx @@ -2,7 +2,7 @@ import { usePluginOverrides } from "@btst/stack/context"; import { ErrorPlaceholder } from "../shared/error-placeholder"; -import { AI_CHAT_LOCALIZATION } from "../../localization"; +import { useAiChatTranslation } from "../../localization"; import type { AiChatPluginOverrides } from "../../overrides"; export function NotFoundPage({ message }: { message?: string }) { @@ -10,15 +10,21 @@ export function NotFoundPage({ message }: { message?: string }) { AiChatPluginOverrides, Partial >("ai-chat", { - localization: AI_CHAT_LOCALIZATION, + localization: {}, }); - const title = - localization?.CHAT_PAGE_NOT_FOUND_TITLE ?? - AI_CHAT_LOCALIZATION.CHAT_PAGE_NOT_FOUND_TITLE; + const tr = useAiChatTranslation(localization); + const title = tr( + "CHAT_PAGE_NOT_FOUND_TITLE", + "aiChat.errors.notFoundTitle", + "Chat not found", + ); const desc = message || - (localization?.CHAT_PAGE_NOT_FOUND_DESCRIPTION ?? - AI_CHAT_LOCALIZATION.CHAT_PAGE_NOT_FOUND_DESCRIPTION); + tr( + "CHAT_PAGE_NOT_FOUND_DESCRIPTION", + "aiChat.errors.notFoundDescription", + "The conversation you're looking for doesn't exist or has been deleted.", + ); return (
    diff --git a/packages/stack/src/plugins/ai-chat/client/components/pages/chat-page.internal.tsx b/packages/stack/src/plugins/ai-chat/client/components/pages/chat-page.internal.tsx index 3e378b1c..a91f79b2 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/pages/chat-page.internal.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/pages/chat-page.internal.tsx @@ -1,8 +1,10 @@ "use client"; +import { useMemo } from "react"; import { ChatLayout } from "../chat-layout"; import { usePluginOverrides } from "@btst/stack/context"; import type { AiChatPluginOverrides } from "../../overrides"; +import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; export interface ChatPageProps { conversationId?: string; @@ -12,10 +14,38 @@ export interface ChatPageProps { * Internal chat page component - loaded lazily by ChatPageComponent */ export function ChatPage({ conversationId }: ChatPageProps) { - const { apiBaseURL, apiBasePath, mode } = usePluginOverrides< + const overrides = usePluginOverrides< AiChatPluginOverrides, Partial >("ai-chat", {}); + const { apiBaseURL, apiBasePath, mode } = overrides; + const routeName = conversationId ? "chatConversation" : "chat"; + const context = useMemo( + () => ({ + path: conversationId ? `/chat/${conversationId}` : "/chat", + params: conversationId ? { id: conversationId } : undefined, + isSSR: typeof window === "undefined", + }), + [conversationId], + ); + + useRouteLifecycle({ + routeName, + context, + overrides, + beforeRenderHook: (currentOverrides, routeContext) => { + if (conversationId && currentOverrides.onBeforeConversationPageRendered) { + return currentOverrides.onBeforeConversationPageRendered( + conversationId, + routeContext, + ); + } + if (!conversationId && currentOverrides.onBeforeChatPageRendered) { + return currentOverrides.onBeforeChatPageRendered(routeContext); + } + return true; + }, + }); // In public mode, don't show sidebar const showSidebar = mode !== "public"; diff --git a/packages/stack/src/plugins/ai-chat/client/components/pages/chat-page.tsx b/packages/stack/src/plugins/ai-chat/client/components/pages/chat-page.tsx index 5dfc7ed9..155eb568 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/pages/chat-page.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/pages/chat-page.tsx @@ -19,7 +19,7 @@ export interface ChatPageComponentProps { // Exported wrapped component with error and loading boundaries export function ChatPageComponent({ conversationId }: ChatPageComponentProps) { - const { onRouteError } = usePluginOverrides< + const { mode, onRouteError } = usePluginOverrides< AiChatPluginOverrides, Partial >("ai-chat", {}); @@ -27,6 +27,15 @@ export function ChatPageComponent({ conversationId }: ChatPageComponentProps) { return ( >("ai-chat", { - localization: AI_CHAT_LOCALIZATION, + localization: {}, }); - const title = - localization?.CHAT_GENERIC_ERROR_TITLE ?? - AI_CHAT_LOCALIZATION.CHAT_GENERIC_ERROR_TITLE; + const tr = useAiChatTranslation(localization); + const title = tr( + "CHAT_GENERIC_ERROR_TITLE", + "aiChat.errors.genericTitle", + "Something went wrong", + ); const message = process.env.NODE_ENV === "production" - ? (localization?.CHAT_GENERIC_ERROR_MESSAGE ?? - AI_CHAT_LOCALIZATION.CHAT_GENERIC_ERROR_MESSAGE) + ? tr( + "CHAT_GENERIC_ERROR_MESSAGE", + "aiChat.errors.genericMessage", + "An error occurred while loading the chat. Please try again.", + ) : ((error instanceof Error ? error.message : undefined) ?? - localization?.CHAT_GENERIC_ERROR_MESSAGE ?? - AI_CHAT_LOCALIZATION.CHAT_GENERIC_ERROR_MESSAGE); + tr( + "CHAT_GENERIC_ERROR_MESSAGE", + "aiChat.errors.genericMessage", + "An error occurred while loading the chat. Please try again.", + )); return ; } diff --git a/packages/stack/src/plugins/ai-chat/client/components/tool-call-display.tsx b/packages/stack/src/plugins/ai-chat/client/components/tool-call-display.tsx index 2316e85d..38d9869c 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/tool-call-display.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/tool-call-display.tsx @@ -10,7 +10,13 @@ import { MarkdownContent } from "@workspace/ui/components/markdown-content"; import { Skeleton } from "@workspace/ui/components/skeleton"; import { cn } from "@workspace/ui/lib/utils"; import { Wrench, Check, AlertCircle, Loader2 } from "lucide-react"; -import type { ToolCallProps, ToolCallState } from "../overrides"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { + AiChatPluginOverrides, + ToolCallProps, + ToolCallState, +} from "../overrides"; +import { useAiChatTranslation } from "../localization"; /** * Formats a tool name for display (converts camelCase/snake_case to Title Case) @@ -46,25 +52,6 @@ function getStatusIcon(state: ToolCallState, isLoading: boolean) { return ; } -/** - * Returns a human-readable status label based on tool call state - */ -function getStatusLabel(state: ToolCallState, isLoading: boolean): string { - if (isLoading || state === "input-streaming") { - return "Running..."; - } - if (state === "input-available") { - return "Executing..."; - } - if (state === "output-error") { - return "Error"; - } - if (state === "output-available") { - return "Complete"; - } - return "Pending"; -} - interface JsonDisplayProps { data: unknown; label: string; @@ -114,8 +101,34 @@ export function ToolCallDisplay({ errorText, isLoading, }: ToolCallProps) { + const { localization } = usePluginOverrides< + AiChatPluginOverrides, + Partial + >("ai-chat", {}); + const tr = useAiChatTranslation(localization); const displayName = formatToolName(toolName); - const statusLabel = getStatusLabel(state, isLoading); + const statusLabel = + isLoading || state === "input-streaming" + ? tr("TOOL_STATUS_RUNNING", "aiChat.tools.status.running", "Running...") + : state === "input-available" + ? tr( + "TOOL_STATUS_EXECUTING", + "aiChat.tools.status.executing", + "Executing...", + ) + : state === "output-error" + ? tr("TOOL_STATUS_ERROR", "aiChat.tools.status.error", "Error") + : state === "output-available" + ? tr( + "TOOL_STATUS_COMPLETE", + "aiChat.tools.status.complete", + "Complete", + ) + : tr( + "TOOL_STATUS_PENDING", + "aiChat.tools.status.pending", + "Pending", + ); const statusIcon = getStatusIcon(state, isLoading); const isComplete = state === "output-available" || state === "output-error"; @@ -158,18 +171,30 @@ export function ToolCallDisplay({ )} {/* Input section */} - {input !== undefined && } + {input !== undefined && ( + + )} {/* Output section */} {state === "output-available" && output !== undefined && ( - + )} {/* Error section */} {state === "output-error" && errorText && (
    - Error + {tr( + "TOOL_STATUS_ERROR", + "aiChat.tools.status.error", + "Error", + )}
    {errorText} @@ -187,7 +212,9 @@ export function ToolCallDisplay({ {/* Tool call ID for debugging (collapsed by default) */}
    - ID: {toolCallId} + {tr("TOOL_ID", "aiChat.tools.id", "ID: {{id}}", { + id: toolCallId, + })}
    diff --git a/packages/stack/src/plugins/ai-chat/client/hooks/ai-chat-resource.ts b/packages/stack/src/plugins/ai-chat/client/hooks/ai-chat-resource.ts new file mode 100644 index 00000000..c4adbf90 --- /dev/null +++ b/packages/stack/src/plugins/ai-chat/client/hooks/ai-chat-resource.ts @@ -0,0 +1,10 @@ +"use client"; + +import { createResource } from "@btst/stack/plugins/client/hooks"; +import { aiChatResources } from "../../query-keys"; + +/** Factory-generated hooks shared by the public AI Chat hook wrappers. */ +export const aiChat = createResource({ + plugin: "ai-chat", + resources: aiChatResources, +}); diff --git a/packages/stack/src/plugins/ai-chat/client/hooks/chat-hooks.tsx b/packages/stack/src/plugins/ai-chat/client/hooks/chat-hooks.tsx index d31747b6..2dc6203a 100644 --- a/packages/stack/src/plugins/ai-chat/client/hooks/chat-hooks.tsx +++ b/packages/stack/src/plugins/ai-chat/client/hooks/chat-hooks.tsx @@ -1,349 +1,170 @@ "use client"; -import { createApiClient } from "@btst/stack/plugins/client"; -import { - useMutation, - useQuery, - useQueryClient, - useSuspenseQuery, -} from "@tanstack/react-query"; -import type { AiChatApiRouter } from "../../api/plugin"; -import { - createAiChatQueryKeys, - type ConversationWithMessages, +import type { ResourceFormResult } from "@btst/stack/plugins/client/hooks"; +import { usePluginOverrides, useTranslate } from "@btst/stack/context"; +import type { + ConversationWithMessages, + CreateConversationInput, + RenameConversationInput, } from "../../query-keys"; import type { SerializedConversation, SerializedMessage } from "../../types"; -import { usePluginOverrides } from "@btst/stack/context"; import type { AiChatPluginOverrides } from "../overrides"; +import { aiChat } from "./ai-chat-resource"; -/** - * Shared React Query configuration for all chat queries - * Prevents automatic refetching to avoid hydration mismatches in SSR - */ -const SHARED_QUERY_CONFIG = { - retry: false, - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - staleTime: 1000 * 60 * 5, // 5 minutes - gcTime: 1000 * 60 * 10, // 10 minutes -} as const; - -/** - * Options for the useConversations hook - */ +/** Options for the useConversations hook. */ export interface UseConversationsOptions { - /** Whether to enable the query (default: true) */ + /** Whether to enable the query (default: true). */ enabled?: boolean; } -/** - * Result from the useConversations hook - */ +/** Result from the useConversations hook. */ export interface UseConversationsResult { - /** Array of conversations */ conversations: SerializedConversation[]; - /** Whether the initial load is in progress */ isLoading: boolean; - /** Error if the query failed */ error: Error | null; - /** Function to refetch the conversations */ refetch: () => void; } -/** - * Hook for fetching all conversations - */ +/** Fetch all conversations while preserving the legacy result shape. */ export function useConversations( options: UseConversationsOptions = {}, ): UseConversationsResult { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ai-chat"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const { enabled = true } = options; - const queries = createAiChatQueryKeys(client, headers); - - const listQuery = queries.conversations.list(); - - const { data, isLoading, error, refetch } = useQuery< - SerializedConversation[], - Error, - SerializedConversation[], - typeof listQuery.queryKey - >({ - ...listQuery, - ...SHARED_QUERY_CONFIG, - enabled: enabled && !!client, + const query = aiChat.conversations.list.use([], { + enabled: options.enabled ?? true, }); return { - conversations: data ?? [], - isLoading, - error, - refetch, + conversations: query.data ?? [], + isLoading: query.isLoading, + error: query.error, + refetch: query.refetch, }; } -/** - * Suspense variant of useConversations - */ +/** Suspense variant of useConversations. */ export function useSuspenseConversations(): { conversations: SerializedConversation[]; refetch: () => Promise; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ai-chat"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createAiChatQueryKeys(client, headers); - const listQuery = queries.conversations.list(); - - const { data, refetch, error, isFetching } = useSuspenseQuery< - SerializedConversation[], - Error, - SerializedConversation[], - typeof listQuery.queryKey - >({ - ...listQuery, - ...SHARED_QUERY_CONFIG, - }); - - if (error && !isFetching) { - throw error; - } - + const query = aiChat.conversations.list.useSuspense([]); return { - conversations: data ?? [], - refetch, + conversations: query.data ?? [], + refetch: query.refetch, }; } -/** - * Options for the useConversation hook - */ +/** Options for the useConversation hook. */ export interface UseConversationOptions { - /** Whether to enable the query (default: true) */ + /** Whether to enable the query (default: true). */ enabled?: boolean; } -/** - * Result from the useConversation hook - */ +/** Result from the useConversation hook. */ export interface UseConversationResult { - /** The conversation with messages, or null if not found */ conversation: ConversationWithMessages | null; - /** Whether the conversation is being loaded */ isLoading: boolean; - /** Error if the query failed */ error: Error | null; - /** Function to refetch the conversation */ refetch: () => void; } -/** - * Hook for fetching a single conversation with messages - */ +/** Fetch a conversation and its messages while preserving the legacy shape. */ export function useConversation( id?: string, options: UseConversationOptions = {}, ): UseConversationResult { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ai-chat"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const { enabled = true } = options; - const queries = createAiChatQueryKeys(client, headers); - - const detailQuery = queries.conversations.detail(id ?? ""); - - const { data, isLoading, error, refetch } = useQuery< - ConversationWithMessages | null, - Error, - ConversationWithMessages | null, - typeof detailQuery.queryKey - >({ - ...detailQuery, - ...SHARED_QUERY_CONFIG, - enabled: enabled && !!client && !!id, + const query = aiChat.conversations.detail.use([id ?? ""], { + enabled: (options.enabled ?? true) && !!id, }); return { - conversation: data || null, - isLoading, - error, - refetch, + conversation: query.data ?? null, + isLoading: query.isLoading, + error: query.error, + refetch: query.refetch, }; } -/** - * Suspense variant of useConversation - */ +/** Suspense variant of useConversation. */ export function useSuspenseConversation(id: string): { conversation: ConversationWithMessages | null; refetch: () => Promise; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ai-chat"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createAiChatQueryKeys(client, headers); - const detailQuery = queries.conversations.detail(id); - - const { data, refetch, error, isFetching } = useSuspenseQuery< - ConversationWithMessages | null, - Error, - ConversationWithMessages | null, - typeof detailQuery.queryKey - >({ - ...detailQuery, - ...SHARED_QUERY_CONFIG, - }); - - if (error && !isFetching) { - throw error; - } - + const query = aiChat.conversations.detail.useSuspense([id]); return { - conversation: data || null, - refetch, + conversation: query.data ?? null, + refetch: query.refetch, }; } -/** - * Hook for creating a new conversation - */ +/** Create a persisted conversation. */ export function useCreateConversation() { - const { refresh, apiBaseURL, apiBasePath } = - usePluginOverrides("ai-chat"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queryClient = useQueryClient(); - const queries = createAiChatQueryKeys(client); - - return useMutation< - SerializedConversation | null, - Error, - { id?: string; title?: string } - >({ - mutationKey: [...queries.conversations._def, "create"], - mutationFn: async (data) => { - const response = await client("@post/chat/conversations", { - method: "POST", - body: data, - }); - return response.data as SerializedConversation | null; - }, - onSuccess: async (created) => { - // Update list cache - await queryClient.invalidateQueries({ - queryKey: queries.conversations.list().queryKey, - }); - // Refresh server-side cache if available - if (refresh) { - await refresh(); - } - }, - }); + return aiChat.conversations.create.use(); } -/** - * Hook for renaming a conversation - */ +/** Rename a persisted conversation. */ export function useRenameConversation() { - const { refresh, apiBaseURL, apiBasePath } = - usePluginOverrides("ai-chat"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queryClient = useQueryClient(); - const queries = createAiChatQueryKeys(client); - - return useMutation< - SerializedConversation | null, - Error, - { id: string; title: string } - >({ - mutationKey: [...queries.conversations._def, "rename"], - mutationFn: async ({ id, title }) => { - const response = await client("@put/chat/conversations/:id", { - method: "PUT", - params: { id }, - body: { title }, - }); - return response.data as SerializedConversation | null; - }, - onSuccess: async (updated) => { - // Update detail cache if available - if (updated?.id) { - queryClient.setQueryData( - queries.conversations.detail(updated.id).queryKey, - (old: ConversationWithMessages | null | undefined) => - old ? { ...old, ...updated } : null, - ); - } - // Invalidate list - await queryClient.invalidateQueries({ - queryKey: queries.conversations.list().queryKey, - }); - if (refresh) { - await refresh(); - } - }, - }); + return aiChat.conversations.rename.use(); } -/** - * Hook for deleting a conversation - */ +/** Delete a persisted conversation. */ export function useDeleteConversation() { - const { refresh, apiBaseURL, apiBasePath } = - usePluginOverrides("ai-chat"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queryClient = useQueryClient(); - const queries = createAiChatQueryKeys(client); + return aiChat.conversations.delete.use(); +} - return useMutation<{ success: boolean }, Error, { id: string }>({ - mutationKey: [...queries.conversations._def, "delete"], - mutationFn: async ({ id }) => { - const response = await client("@delete/chat/conversations/:id", { - method: "DELETE", - params: { id }, - }); - return response.data as { success: boolean }; - }, - onSuccess: async (_, { id }) => { - // Remove from detail cache - queryClient.removeQueries({ - queryKey: queries.conversations.detail(id).queryKey, - }); - // Invalidate list - await queryClient.invalidateQueries({ - queryKey: queries.conversations.list().queryKey, - }); - if (refresh) { - await refresh(); +export interface RenameConversationFormValues { + title: string; +} + +export interface UseRenameConversationFormOptions { + conversation: SerializedConversation | null; + onSuccess?: ( + conversation: SerializedConversation | null, + ) => void | Promise; +} + +/** Form lifecycle for renaming a conversation, including field errors and notifications. */ +export function useRenameConversationForm( + options: UseRenameConversationFormOptions, +): ResourceFormResult< + RenameConversationFormValues, + SerializedConversation, + SerializedConversation | null +> { + const t = useTranslate(); + const { localization } = usePluginOverrides("ai-chat"); + + return aiChat.conversations.useForm< + RenameConversationFormValues, + SerializedConversation | null, + SerializedConversation + >({ + action: "edit", + updateMutation: "rename", + record: options.conversation, + defaults: (conversation) => ({ title: conversation?.title ?? "" }), + toUpdateVars: (values, conversation): RenameConversationInput => { + if (!conversation) { + throw new Error( + t("aiChat.errors.missingConversation", "Conversation is required"), + ); } + return { id: conversation.id, title: values.title.trim() }; }, + successMessage: + localization?.CONVERSATION_RENAME_SUCCESS ?? + t("aiChat.toasts.renameSuccess", "Conversation renamed"), + errorMessage: (error) => + error.message || + localization?.CONVERSATION_RENAME_FAILURE || + t("aiChat.toasts.renameFailure", "Failed to rename conversation"), + onSuccess: options.onSuccess, }); } export type { + ConversationWithMessages, + CreateConversationInput, + RenameConversationInput, SerializedConversation, SerializedMessage, - ConversationWithMessages, }; diff --git a/packages/stack/src/plugins/ai-chat/client/localization/index.ts b/packages/stack/src/plugins/ai-chat/client/localization/index.ts index 1fb96283..b2b79447 100644 --- a/packages/stack/src/plugins/ai-chat/client/localization/index.ts +++ b/packages/stack/src/plugins/ai-chat/client/localization/index.ts @@ -1,3 +1,8 @@ +"use client"; + +import { useCallback } from "react"; +import { useTranslate, type TranslateFn } from "@btst/stack/context"; + /** * AI Chat plugin localization strings */ @@ -25,6 +30,7 @@ export interface AiChatLocalization { // Conversation actions CONVERSATION_RENAME: string; CONVERSATION_RENAME_PLACEHOLDER: string; + CONVERSATION_RENAME_DESCRIPTION: string; CONVERSATION_RENAME_SAVE: string; CONVERSATION_RENAME_CANCEL: string; CONVERSATION_DELETE: string; @@ -32,6 +38,11 @@ export interface AiChatLocalization { CONVERSATION_DELETE_CONFIRM_DESCRIPTION: string; CONVERSATION_DELETE_CONFIRM_BUTTON: string; CONVERSATION_DELETE_CANCEL: string; + CONVERSATION_RENAME_SUCCESS: string; + CONVERSATION_RENAME_FAILURE: string; + CONVERSATION_DELETE_SUCCESS: string; + CONVERSATION_DELETE_FAILURE: string; + CONVERSATION_TITLE_REQUIRED: string; // Image upload (legacy) IMAGE_UPLOAD_BUTTON: string; @@ -46,6 +57,10 @@ export interface AiChatLocalization { FILE_UPLOAD_ERROR_TOO_LARGE: string; FILE_UPLOAD_SUCCESS: string; FILE_UPLOAD_FAILURE: string; + FILE_REMOVE: string; + FILE_FALLBACK_NAME: string; + IMAGE_ATTACHED_ALT: string; + IMAGE_GENERATED_ALT: string; // Timestamps TIME_JUST_NOW: string; @@ -67,6 +82,26 @@ export interface AiChatLocalization { A11Y_ASSISTANT_MESSAGE: string; A11Y_COPY_CODE: string; A11Y_CODE_COPIED: string; + A11Y_CONVERSATION_ACTIONS: string; + A11Y_CHAT_TITLE: string; + A11Y_CLEAR_CHAT: string; + A11Y_CLOSE_CHAT: string; + A11Y_OPEN_CHAT: string; + A11Y_OPEN_MENU: string; + A11Y_CLOSE_SIDEBAR: string; + A11Y_OPEN_SIDEBAR: string; + + // Tool calls + TOOL_STATUS_RUNNING: string; + TOOL_STATUS_EXECUTING: string; + TOOL_STATUS_ERROR: string; + TOOL_STATUS_COMPLETE: string; + TOOL_STATUS_PENDING: string; + TOOL_INPUT: string; + TOOL_OUTPUT: string; + TOOL_ID: string; + TOOL_EXECUTION_FAILED: string; + TOOL_HANDLER_MISSING: string; } /** @@ -98,6 +133,7 @@ export const AI_CHAT_LOCALIZATION: AiChatLocalization = { // Conversation actions CONVERSATION_RENAME: "Rename", CONVERSATION_RENAME_PLACEHOLDER: "Enter conversation name", + CONVERSATION_RENAME_DESCRIPTION: "Enter a new title for this conversation.", CONVERSATION_RENAME_SAVE: "Save", CONVERSATION_RENAME_CANCEL: "Cancel", CONVERSATION_DELETE: "Delete", @@ -106,6 +142,11 @@ export const AI_CHAT_LOCALIZATION: AiChatLocalization = { "Are you sure you want to delete this conversation? This action cannot be undone.", CONVERSATION_DELETE_CONFIRM_BUTTON: "Delete", CONVERSATION_DELETE_CANCEL: "Cancel", + CONVERSATION_RENAME_SUCCESS: "Conversation renamed", + CONVERSATION_RENAME_FAILURE: "Failed to rename conversation", + CONVERSATION_DELETE_SUCCESS: "Conversation deleted", + CONVERSATION_DELETE_FAILURE: "Failed to delete conversation", + CONVERSATION_TITLE_REQUIRED: "Title is required", // Image upload (legacy) IMAGE_UPLOAD_BUTTON: "Attach image", @@ -120,6 +161,10 @@ export const AI_CHAT_LOCALIZATION: AiChatLocalization = { FILE_UPLOAD_ERROR_TOO_LARGE: "File must be less than 10MB", FILE_UPLOAD_SUCCESS: "File attached", FILE_UPLOAD_FAILURE: "Failed to attach file", + FILE_REMOVE: "Remove file", + FILE_FALLBACK_NAME: "File", + IMAGE_ATTACHED_ALT: "Attached image {count}", + IMAGE_GENERATED_ALT: "Image {count}", // Timestamps TIME_JUST_NOW: "Just now", @@ -141,6 +186,27 @@ export const AI_CHAT_LOCALIZATION: AiChatLocalization = { A11Y_ASSISTANT_MESSAGE: "AI response", A11Y_COPY_CODE: "Copy code", A11Y_CODE_COPIED: "Code copied", + A11Y_CONVERSATION_ACTIONS: "Conversation actions", + A11Y_CHAT_TITLE: "AI Chat", + A11Y_CLEAR_CHAT: "Clear chat", + A11Y_CLOSE_CHAT: "Close chat", + A11Y_OPEN_CHAT: "Open chat", + A11Y_OPEN_MENU: "Open menu", + A11Y_CLOSE_SIDEBAR: "Close sidebar", + A11Y_OPEN_SIDEBAR: "Open sidebar", + + // Tool calls + TOOL_STATUS_RUNNING: "Running...", + TOOL_STATUS_EXECUTING: "Executing...", + TOOL_STATUS_ERROR: "Error", + TOOL_STATUS_COMPLETE: "Complete", + TOOL_STATUS_PENDING: "Pending", + TOOL_INPUT: "Input", + TOOL_OUTPUT: "Output", + TOOL_ID: "ID: {id}", + TOOL_EXECUTION_FAILED: "Tool execution failed", + TOOL_HANDLER_MISSING: + 'No client-side handler registered for tool "{toolName}". The page context may have changed while the response was streaming.', }; /** @@ -154,3 +220,47 @@ export function formatLocalized( return values[key]?.toString() ?? match; }); } + +/** + * Resolves AI Chat strings with the legacy override taking precedence over + * the StackProvider i18n catalog. Legacy `{name}` placeholders remain + * supported while catalog defaults use `{{name}}` interpolation. + */ +export function resolveAiChatString( + t: TranslateFn, + localization: Partial | undefined, + legacyKey: keyof AiChatLocalization, + key: string, + defaultValue: string, + params?: Record, +): string { + const legacyValue = localization?.[legacyKey]; + if (legacyValue !== undefined) { + return params ? formatLocalized(legacyValue, params) : legacyValue; + } + return t(key, defaultValue, params); +} + +/** Client hook for resolving AI Chat catalog strings. */ +export function useAiChatTranslation( + localization?: Partial, +) { + const t = useTranslate(); + return useCallback( + ( + legacyKey: keyof AiChatLocalization, + key: string, + defaultValue: string, + params?: Record, + ) => + resolveAiChatString( + t, + localization, + legacyKey, + key, + defaultValue, + params, + ), + [t, localization], + ); +} diff --git a/packages/stack/src/plugins/ai-chat/client/plugin.tsx b/packages/stack/src/plugins/ai-chat/client/plugin.tsx index d92fa904..c7c02ac6 100644 --- a/packages/stack/src/plugins/ai-chat/client/plugin.tsx +++ b/packages/stack/src/plugins/ai-chat/client/plugin.tsx @@ -1,6 +1,8 @@ import { defineClientPlugin, createApiClient, + createSanitizedSSRLoaderError, + isConnectionError, runClientHookWithShim, } from "@btst/stack/plugins/client"; import { defineRoute, defineRoutes } from "@btst/yar"; @@ -9,7 +11,7 @@ import type { QueryClient } from "@tanstack/react-query"; import type { AiChatApiRouter } from "../api"; import { createAiChatQueryKeys } from "../query-keys"; import type { SerializedConversation, SerializedMessage } from "../types"; -import { ChatLayout } from "./components/chat-layout"; +import { ChatPageComponent } from "./components/pages/chat-page"; import type { AiChatMode } from "./overrides"; /** @@ -171,6 +173,12 @@ function createConversationsLoader(config: AiChatClientConfig) { apiBasePath, headers, }; + const client = createApiClient({ + baseURL: apiBaseURL, + basePath: apiBasePath, + }); + const queries = createAiChatQueryKeys(client, headers); + const listQuery = queries.conversations.list(); try { // Before hook @@ -181,14 +189,6 @@ function createConversationsLoader(config: AiChatClientConfig) { ); } - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - - const queries = createAiChatQueryKeys(client, headers); - const listQuery = queries.conversations.list(); - await queryClient.prefetchQuery(listQuery); // After hook @@ -213,6 +213,21 @@ function createConversationsLoader(config: AiChatClientConfig) { await hooks.onLoadError(error, context); } } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/ai-chat] route.loader() failed — no server running at build time. " + + "AI Chat conversation history does not support SSG.", + ); + } else { + const errToStore = createSanitizedSSRLoaderError(); + await queryClient.prefetchQuery({ + queryKey: listQuery.queryKey, + queryFn: () => { + throw errToStore; + }, + retry: false, + }); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } @@ -235,6 +250,13 @@ function createConversationLoader(id: string, config: AiChatClientConfig) { apiBasePath, headers, }; + const client = createApiClient({ + baseURL: apiBaseURL, + basePath: apiBasePath, + }); + const queries = createAiChatQueryKeys(client, headers); + const conversationQuery = queries.conversations.detail(id); + const listQuery = queries.conversations.list(); try { // Before hook @@ -245,17 +267,7 @@ function createConversationLoader(id: string, config: AiChatClientConfig) { ); } - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - - const queries = createAiChatQueryKeys(client, headers); - // Prefetch both the conversation and the conversations list - const conversationQuery = queries.conversations.detail(id); - const listQuery = queries.conversations.list(); - await Promise.all([ queryClient.prefetchQuery(conversationQuery), queryClient.prefetchQuery(listQuery), @@ -285,6 +297,30 @@ function createConversationLoader(id: string, config: AiChatClientConfig) { await hooks.onLoadError(error, context); } } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/ai-chat] route.loader() failed — no server running at build time. " + + "AI Chat conversations do not support SSG.", + ); + } else { + const errToStore = createSanitizedSSRLoaderError(); + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: conversationQuery.queryKey, + queryFn: () => { + throw errToStore; + }, + retry: false, + }), + queryClient.prefetchQuery({ + queryKey: listQuery.queryKey, + queryFn: () => { + throw errToStore; + }, + retry: false, + }), + ]); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } @@ -397,13 +433,7 @@ export const aiChatClientPlugin = (config: AiChatClientConfig) => { { // Chat home - simple chat interface without history chat: defineRoute("/chat", { - page: () => ( - - ), + page: () => , loader: createConversationsLoader(config), meta: createChatHomeMeta(config), }), @@ -424,12 +454,7 @@ export const aiChatClientPlugin = (config: AiChatClientConfig) => { { // Chat home - new conversation or list chat: defineRoute("/chat", { - page: () => ( - - ), + page: () => , loader: createConversationsLoader(config), meta: createChatHomeMeta(config), }), @@ -437,11 +462,7 @@ export const aiChatClientPlugin = (config: AiChatClientConfig) => { // Existing conversation chatConversation: defineRoute("/chat/:id", { page: ({ params }) => ( - + ), loader: ({ params }) => createConversationLoader(params.id, config)(), diff --git a/packages/stack/src/plugins/ai-chat/query-keys.ts b/packages/stack/src/plugins/ai-chat/query-keys.ts index 876864ad..15570888 100644 --- a/packages/stack/src/plugins/ai-chat/query-keys.ts +++ b/packages/stack/src/plugins/ai-chat/query-keys.ts @@ -1,87 +1,92 @@ import { - mergeQueryKeys, - createQueryKeys, -} from "@lukemorales/query-key-factory"; -import type { AiChatApiRouter } from "./api"; -import { createApiClient } from "@btst/stack/plugins/client"; + createApiClient, + createResourceQueryKeys, + type ResourcesDeclaration, +} from "@btst/stack/plugins/client"; +import type { AiChatApiRouter } from "./api/plugin"; import type { SerializedConversation, SerializedMessage } from "./types"; -// Helper to check and handle error responses -function handleResponse(response: unknown): T { - if ( - typeof response === "object" && - response !== null && - "error" in response - ) { - const errorResponse = response as { error?: unknown }; - if (errorResponse.error !== null && errorResponse.error !== undefined) { - const error = errorResponse.error; - if (error instanceof Error) { - throw error; - } - if (typeof error === "object" && error !== null) { - const errorObj = error as Record; - const message = - (typeof errorObj.message === "string" ? errorObj.message : null) || - (typeof errorObj.error === "string" ? errorObj.error : null) || - JSON.stringify(error); - throw new Error(message); - } - throw new Error(String(error)); - } - } - return (response as { data?: T }).data as T; -} - export type ConversationWithMessages = SerializedConversation & { messages: SerializedMessage[]; }; -export function createAiChatQueryKeys( - client: ReturnType>, - headers?: HeadersInit, -) { - const conversations = createConversationsQueries(client, headers); +export interface CreateConversationInput { + id?: string; + title?: string; +} - return mergeQueryKeys(conversations); +export interface RenameConversationInput { + id: string; + title: string; } -function createConversationsQueries( +/** Single source of truth for conversation HTTP mappings and cache behavior. */ +export const aiChatResources = { + conversations: { + queries: { + list: { + path: "/chat/conversations", + key: () => ["all"], + select: (data: any): SerializedConversation[] => data ?? [], + }, + detail: { + path: "/chat/conversations/:id", + params: (id: string) => ({ id }), + key: (id: string) => [id], + select: (data: any): ConversationWithMessages | null => data, + skip: (id: string) => !id, + }, + }, + mutations: { + create: { + path: "@post/chat/conversations", + method: "POST" as const, + input: (input: CreateConversationInput) => ({ body: input }), + select: (data: any): SerializedConversation | null => data, + invalidates: ["conversations.list"], + }, + rename: { + path: "@put/chat/conversations/:id", + method: "PUT" as const, + input: (input: RenameConversationInput) => ({ + params: { id: input.id }, + body: { title: input.title }, + }), + select: (data: any): SerializedConversation | null => data, + invalidates: ["conversations.list"], + setData: { + args: (result: SerializedConversation | null) => + result?.id ? [result.id] : null, + updater: ( + previous: unknown, + result: SerializedConversation | null, + ) => + previous && result + ? { ...(previous as ConversationWithMessages), ...result } + : null, + }, + }, + delete: { + path: "@delete/chat/conversations/:id", + method: "DELETE" as const, + input: (input: { id: string }) => ({ params: { id: input.id } }), + select: (data: any): { success: boolean } => data, + invalidates: ["conversations.list"], + removeData: { + args: (_result: { success: boolean }, input: { id: string }) => [ + input.id, + ], + }, + }, + }, + }, +} satisfies ResourcesDeclaration; + +export function createAiChatQueryKeys( client: ReturnType>, headers?: HeadersInit, ) { - return createQueryKeys("conversations", { - // List all conversations - list: () => ({ - // NOTE: query-key-factory already namespaces as: - // ["conversations", "list", ...queryKey] - // so we use a stable marker (not "list") to avoid ["...","list","list"]. - queryKey: ["all"], - queryFn: async () => { - const response = await client("/chat/conversations", { - method: "GET", - headers, - }); - - const data = handleResponse(response); - return data ?? []; - }, - }), - - // Get single conversation with messages - detail: (id: string) => ({ - queryKey: [id], - queryFn: async () => { - if (!id) return null; - - const response = await client("/chat/conversations/:id", { - method: "GET", - params: { id }, - headers, - }); - - return handleResponse(response); - }, - }), - }); + return createResourceQueryKeys(client, aiChatResources, headers); } + +export type AiChatQueryKeys = ReturnType; diff --git a/packages/stack/src/plugins/ai-chat/schemas.ts b/packages/stack/src/plugins/ai-chat/schemas.ts index 577270bc..40cf69db 100644 --- a/packages/stack/src/plugins/ai-chat/schemas.ts +++ b/packages/stack/src/plugins/ai-chat/schemas.ts @@ -6,7 +6,7 @@ export const createConversationSchema = z.object({ }); export const updateConversationSchema = z.object({ - title: z.string().optional(), + title: z.string().trim().min(1, "Title is required"), }); export const chatRequestSchema = z.object({ diff --git a/packages/stack/src/plugins/client/resource/internal.ts b/packages/stack/src/plugins/client/resource/internal.ts index 60c8fe15..094be5ec 100644 --- a/packages/stack/src/plugins/client/resource/internal.ts +++ b/packages/stack/src/plugins/client/resource/internal.ts @@ -78,7 +78,7 @@ export function useResourceMutationForDef( } return runResourceMutation(client, def, vars, headers); }, - onSuccess: async (result) => { + onSuccess: async (result, variables) => { if (!def) return; // Seed a query cache entry (e.g. detail) from the mutation result @@ -87,10 +87,35 @@ export function useResourceMutationForDef( const targetName = def.setData.query ?? "detail"; const targetDef = resource.queries[targetName]; if (keyArgs && targetDef) { - queryClient.setQueryData( - buildQueryKey(resourceName, targetName, targetDef, keyArgs), - result, + const key = buildQueryKey( + resourceName, + targetName, + targetDef, + keyArgs, ); + queryClient.setQueryData(key, (previous) => + def.setData?.updater + ? def.setData.updater(previous, result) + : result, + ); + } + } + + // Remove a cache entry tied to the successful mutation variables + if (def.removeData) { + const keyArgs = def.removeData.args(result, variables); + const targetName = def.removeData.query ?? "detail"; + const targetDef = resource.queries[targetName]; + if (keyArgs && targetDef) { + queryClient.removeQueries({ + queryKey: buildQueryKey( + resourceName, + targetName, + targetDef, + keyArgs, + ), + exact: true, + }); } } diff --git a/packages/stack/src/plugins/client/resource/queries.ts b/packages/stack/src/plugins/client/resource/queries.ts index 5dbb5eda..8c7407fe 100644 --- a/packages/stack/src/plugins/client/resource/queries.ts +++ b/packages/stack/src/plugins/client/resource/queries.ts @@ -109,6 +109,21 @@ export interface ResourceMutationDef { /** Target query name on the same resource (default `"detail"`) */ query?: string; args: (result: TResult) => readonly unknown[] | null; + /** + * Optionally merge the mutation result with the existing cache value. + * Defaults to replacing the cached value with `result`. + */ + updater?: (previous: unknown, result: TResult) => unknown; + }; + /** + * Remove a query cache entry after success (e.g. the deleted record's + * detail query). `args` may derive the target key from both the result and + * the submitted variables, or return `null` to skip removal. + */ + removeData?: { + /** Target query name on the same resource (default `"detail"`) */ + query?: string; + args: (result: TResult, variables: TVars) => readonly unknown[] | null; }; /** * Whether to call the router `refresh` override after success (default From fce2b2f550340bb8fe89043a1d8532a63a84d508 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:23:57 +0000 Subject: [PATCH 2/7] chore(ai-chat): remove unused query key dependency --- packages/stack/package.json | 1 - pnpm-lock.yaml | 15 --------------- 2 files changed, 16 deletions(-) diff --git a/packages/stack/package.json b/packages/stack/package.json index ce53cd19..612d206b 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -834,7 +834,6 @@ }, "dependencies": { "@btst/db": "2.2.2", - "@lukemorales/query-key-factory": "^1.3.4", "@milkdown/crepe": "^7.17.1", "@milkdown/kit": "^7.17.1", "remend": "^1.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a6d7d82..b6275bb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,9 +344,6 @@ importers: '@hookform/resolvers': specifier: '>=5.0.0' version: 5.2.2(react-hook-form@7.66.1(react@19.2.7)) - '@lukemorales/query-key-factory': - specifier: ^1.3.4 - version: 1.3.4(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@19.2.7)) '@milkdown/crepe': specifier: ^7.17.1 version: 7.17.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.3)(typescript@5.9.3) @@ -2552,13 +2549,6 @@ packages: '@lezer/yaml@1.0.3': resolution: {integrity: sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==} - '@lukemorales/query-key-factory@1.3.4': - resolution: {integrity: sha512-A3frRDdkmaNNQi6mxIshsDk4chRXWoXa05US8fBo4kci/H+lVmujS6QrwQLLGIkNIRFGjMqp2uKjC4XsLdydRw==} - engines: {node: '>=14'} - peerDependencies: - '@tanstack/query-core': '>= 4.0.0' - '@tanstack/react-query': ^5.90.2 - '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -14362,11 +14352,6 @@ snapshots: '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.3 - '@lukemorales/query-key-factory@1.3.4(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@19.2.7))': - dependencies: - '@tanstack/query-core': 5.90.10 - '@tanstack/react-query': 5.90.10(react@19.2.7) - '@marijn/find-cluster-break@1.0.2': {} '@mdx-js/mdx@3.1.1': From 5e2c03abd29b7975bea9e4d0c82198c411720740 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:46:49 +0000 Subject: [PATCH 3/7] test(ai-chat): stabilize codegen chat interactions --- e2e/tests/smoke.chat.spec.ts | 41 ++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/e2e/tests/smoke.chat.spec.ts b/e2e/tests/smoke.chat.spec.ts index 29cedb08..093a1fc8 100644 --- a/e2e/tests/smoke.chat.spec.ts +++ b/e2e/tests/smoke.chat.spec.ts @@ -57,7 +57,7 @@ test.describe("AI Chat Plugin", () => { const input = page.getByPlaceholder("Type a message..."); await input.fill("Hello, world!"); // Use Enter key or find the submit button - await page.keyboard.press("Enter"); + await input.press("Enter"); // 4. Verify user message appears - increase timeout to account for slower state updates // Scope to the chat interface: once the assistant responds, the sidebar also @@ -88,7 +88,7 @@ test.describe("AI Chat Plugin", () => { // Send a message to create a conversation const input = page.getByPlaceholder("Type a message..."); await input.fill("Test message for sidebar"); - await page.keyboard.press("Enter"); + await input.press("Enter"); // Wait for the message to be sent and processed await expect(page.getByText("Test message for sidebar")).toBeVisible({ @@ -115,7 +115,7 @@ test.describe("AI Chat Plugin", () => { const input = page.getByPlaceholder("Type a message..."); await input.fill("Navigation test message"); - await page.keyboard.press("Enter"); + await input.press("Enter"); // Wait for response await expect( @@ -154,12 +154,17 @@ test.describe("AI Chat Plugin", () => { const renamedTitle = `Renamed conversation ${runId}`; await page.goto("/pages/chat"); - await page.getByPlaceholder("Type a message...").fill(initialTitle); - await page.keyboard.press("Enter"); - await waitForChatReady(page, 45000); + const input = page.getByPlaceholder("Type a message..."); + const chatInterface = page.locator('[data-testid="chat-interface"]'); + await input.fill(initialTitle); + await input.press("Enter"); + await expect( + chatInterface.getByText(initialTitle, { exact: true }), + ).toBeVisible({ timeout: 30000 }); await page.waitForURL(/\/pages\/chat\/[a-zA-Z0-9-]+/, { - timeout: 10000, + timeout: 45000, }); + await waitForChatReady(page, 45000); // Reload so the route-level conversationId and sidebar selection are in sync. await page.reload(); @@ -174,12 +179,14 @@ test.describe("AI Chat Plugin", () => { .click(); await page.getByRole("menuitem", { name: "Rename" }).click(); + const renameDialog = page.getByRole("dialog"); const renameInput = page.getByPlaceholder("Enter conversation name"); await renameInput.fill(renamedTitle); await page.getByRole("button", { name: "Save", exact: true }).click(); await expect(sidebar.getByText(renamedTitle, { exact: true })).toBeVisible({ timeout: 10000, }); + await expect(renameDialog).toBeHidden({ timeout: 10000 }); const renamedRow = sidebar .locator(".group") @@ -218,7 +225,7 @@ test.describe("AI Chat Plugin", () => { // Send first message const input = page.getByPlaceholder("Type a message..."); await input.fill(firstMessage); - await page.keyboard.press("Enter"); + await input.press("Enter"); // Wait for first AI response await expect( @@ -232,7 +239,7 @@ test.describe("AI Chat Plugin", () => { // Send second message await input.fill("Second message in same conversation"); - await page.keyboard.press("Enter"); + await input.press("Enter"); // Wait for second AI response (should be 2 prose elements now) await expect( @@ -282,7 +289,7 @@ test.describe("AI Chat Plugin", () => { await page.goto("/pages/chat"); const input = page.getByPlaceholder("Type a message..."); await input.fill("New chat navigation test"); - await page.keyboard.press("Enter"); + await input.press("Enter"); // Wait for AI response + navigation to conversation URL await expect( @@ -510,7 +517,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { // Type a message and send const input = page.getByPlaceholder("Type a message..."); await input.fill("Here is a file for you"); - await page.keyboard.press("Enter"); + await input.press("Enter"); // Verify user message appears await expect(page.getByText("Here is a file for you")).toBeVisible({ @@ -533,6 +540,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { // Upload a test image file const fileInput = page.locator('input[type="file"]'); + await expect(fileInput).toBeAttached({ timeout: 5000 }); const testImageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; await fileInput.setInputFiles({ @@ -549,7 +557,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { // Type a message and send const input = page.getByPlaceholder("Type a message..."); await input.fill("Please analyze this image"); - await page.keyboard.press("Enter"); + await input.press("Enter"); // Wait for the user message to appear (confirming send worked) await expect(page.getByText("Please analyze this image")).toBeVisible({ @@ -568,6 +576,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { await page.goto("/pages/chat"); const fileInput = page.locator('input[type="file"]'); + await expect(fileInput).toBeAttached({ timeout: 5000 }); const testImageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; @@ -608,7 +617,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { // Send a message const input = page.getByPlaceholder("Type a message..."); await input.fill("Say exactly: FIRST RESPONSE"); - await page.keyboard.press("Enter"); + await input.press("Enter"); // Wait for user message and AI response within chat interface await expect( @@ -675,7 +684,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { // Send first message const input = page.getByPlaceholder("Type a message..."); await input.fill("Original message content"); - await page.keyboard.press("Enter"); + await input.press("Enter"); // Wait for user message and AI response within chat interface await expect( @@ -763,7 +772,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { // Send first message await input.fill("First question"); - await page.keyboard.press("Enter"); + await input.press("Enter"); await expect(chatInterface.getByText("First question")).toBeVisible({ timeout: 30000, }); @@ -776,7 +785,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { // Send second message await input.fill("Second question"); - await page.keyboard.press("Enter"); + await input.press("Enter"); await expect(chatInterface.getByText("Second question")).toBeVisible({ timeout: 30000, }); From e2ad1183e08e69b083bcd3ab5c62ecb957d8c6c5 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:48:36 +0000 Subject: [PATCH 4/7] fix(ai-chat): address review findings --- .../src/__tests__/ai-chat-query-keys.test.ts | 23 +++++++++++++++++++ .../ai-chat/__tests__/client-sweep.test.tsx | 18 +++++++++++++++ .../client/components/chat-sidebar.tsx | 14 +++++------ .../ai-chat/client/hooks/chat-hooks.tsx | 5 ++-- .../stack/src/plugins/ai-chat/query-keys.ts | 2 +- 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/packages/stack/src/__tests__/ai-chat-query-keys.test.ts b/packages/stack/src/__tests__/ai-chat-query-keys.test.ts index b643641c..7cee3549 100644 --- a/packages/stack/src/__tests__/ai-chat-query-keys.test.ts +++ b/packages/stack/src/__tests__/ai-chat-query-keys.test.ts @@ -100,4 +100,27 @@ describe("AI Chat resource declaration", () => { false, ); }); + + it("only merges rename results into an existing detail cache entry", () => { + const updater = + aiChatResources.conversations.mutations.rename.setData.updater; + const renamed = { + id: "conv-1", + title: "Renamed", + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-03").toISOString(), + }; + + expect(updater(undefined, renamed)).toBeUndefined(); + expect( + updater( + { + ...renamed, + title: "Original", + messages: [{ id: "message-1" }], + }, + renamed, + ), + ).toEqual({ ...renamed, messages: [{ id: "message-1" }] }); + }); }); diff --git a/packages/stack/src/plugins/ai-chat/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/ai-chat/__tests__/client-sweep.test.tsx index ff94d12b..d78669ed 100644 --- a/packages/stack/src/plugins/ai-chat/__tests__/client-sweep.test.tsx +++ b/packages/stack/src/plugins/ai-chat/__tests__/client-sweep.test.tsx @@ -270,6 +270,24 @@ describe("AI Chat forms, notifications, and i18n", () => { expect(notify.success).toHaveBeenCalledWith("Conversation deleted"); expect(notify.error).not.toHaveBeenCalled(); }); + + it("does not expose raw delete errors through notifications", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + deleteConversation.mockRejectedValueOnce( + new Error("internal database detail"), + ); + await render(, { notify }); + + await openConversationMenu(); + await act(async () => menuItem("Delete")?.click()); + const confirm = Array.from( + document.querySelectorAll("button"), + ).find((button) => button.textContent === "Delete"); + await act(async () => confirm?.click()); + + expect(notify.error).toHaveBeenCalledWith("Failed to delete conversation"); + expect(notify.error).not.toHaveBeenCalledWith("internal database detail"); + }); }); describe("AI Chat route lifecycle compatibility", () => { 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 9f2733b8..d18cf69b 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 @@ -145,15 +145,13 @@ export function ChatSidebar({ if (selectedConversation.id === currentConversationId && navigate) { await navigate(`${basePath}/chat`); } - } catch (error) { + } catch { notify.error( - error instanceof Error - ? error.message - : tr( - "CONVERSATION_DELETE_FAILURE", - "aiChat.toasts.deleteFailure", - "Failed to delete conversation", - ), + tr( + "CONVERSATION_DELETE_FAILURE", + "aiChat.toasts.deleteFailure", + "Failed to delete conversation", + ), ); } } diff --git a/packages/stack/src/plugins/ai-chat/client/hooks/chat-hooks.tsx b/packages/stack/src/plugins/ai-chat/client/hooks/chat-hooks.tsx index 2dc6203a..63fa1f93 100644 --- a/packages/stack/src/plugins/ai-chat/client/hooks/chat-hooks.tsx +++ b/packages/stack/src/plugins/ai-chat/client/hooks/chat-hooks.tsx @@ -153,9 +153,8 @@ export function useRenameConversationForm( successMessage: localization?.CONVERSATION_RENAME_SUCCESS ?? t("aiChat.toasts.renameSuccess", "Conversation renamed"), - errorMessage: (error) => - error.message || - localization?.CONVERSATION_RENAME_FAILURE || + errorMessage: () => + localization?.CONVERSATION_RENAME_FAILURE ?? t("aiChat.toasts.renameFailure", "Failed to rename conversation"), onSuccess: options.onSuccess, }); diff --git a/packages/stack/src/plugins/ai-chat/query-keys.ts b/packages/stack/src/plugins/ai-chat/query-keys.ts index 15570888..99ad1d82 100644 --- a/packages/stack/src/plugins/ai-chat/query-keys.ts +++ b/packages/stack/src/plugins/ai-chat/query-keys.ts @@ -63,7 +63,7 @@ export const aiChatResources = { ) => previous && result ? { ...(previous as ConversationWithMessages), ...result } - : null, + : previous, }, }, delete: { From b84d391243632bbf0ab0b773b4513e1f88dc9f88 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:07:33 +0000 Subject: [PATCH 5/7] test(ai-chat): wait for chat hydration --- e2e/tests/smoke.chat.spec.ts | 53 ++++++++++++++++------------ e2e/tests/smoke.wealthreview.spec.ts | 1 + 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/e2e/tests/smoke.chat.spec.ts b/e2e/tests/smoke.chat.spec.ts index 093a1fc8..49fafb5b 100644 --- a/e2e/tests/smoke.chat.spec.ts +++ b/e2e/tests/smoke.chat.spec.ts @@ -10,6 +10,12 @@ async function waitForChatReady(page: Page, timeout = 30000) { ).toBeVisible({ timeout }); } +async function gotoChat(page: Page) { + await page.goto("/pages/chat"); + await page.waitForLoadState("networkidle"); + await expect(page.getByPlaceholder("Type a message...")).toBeVisible(); +} + const hasOpenAiKey = typeof process.env.OPENAI_API_KEY === "string" && process.env.OPENAI_API_KEY.trim().length > 0; @@ -28,7 +34,7 @@ test.skip( test.describe("AI Chat Plugin", () => { test("should render chat page with sidebar", async ({ page }) => { - await page.goto("/pages/chat"); + await gotoChat(page); // Verify chat layout is visible await expect(page.locator('[data-testid="chat-layout"]')).toBeVisible(); @@ -47,7 +53,7 @@ test.describe("AI Chat Plugin", () => { page, }) => { // 1. Navigate to the chat page - await page.goto("/pages/chat"); + await gotoChat(page); // 2. Verify initial state await expect(page.getByText("Start a conversation...")).toBeVisible(); @@ -83,7 +89,7 @@ test.describe("AI Chat Plugin", () => { page, }) => { // Navigate to the chat page - await page.goto("/pages/chat"); + await gotoChat(page); // Send a message to create a conversation const input = page.getByPlaceholder("Type a message..."); @@ -111,7 +117,7 @@ test.describe("AI Chat Plugin", () => { test("should navigate to existing conversation", async ({ page }) => { // First create a conversation - await page.goto("/pages/chat"); + await gotoChat(page); const input = page.getByPlaceholder("Type a message..."); await input.fill("Navigation test message"); @@ -153,7 +159,7 @@ test.describe("AI Chat Plugin", () => { const initialTitle = `Manage conversation ${runId}`; const renamedTitle = `Renamed conversation ${runId}`; - await page.goto("/pages/chat"); + await gotoChat(page); const input = page.getByPlaceholder("Type a message..."); const chatInterface = page.locator('[data-testid="chat-interface"]'); await input.fill(initialTitle); @@ -187,6 +193,9 @@ test.describe("AI Chat Plugin", () => { timeout: 10000, }); await expect(renameDialog).toBeHidden({ timeout: 10000 }); + await expect(page.locator("body")).not.toHaveCSS("pointer-events", "none", { + timeout: 10000, + }); const renamedRow = sidebar .locator(".group") @@ -220,7 +229,7 @@ test.describe("AI Chat Plugin", () => { const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; const firstMessage = `First msg in conv ${runId}`; - await page.goto("/pages/chat"); + await gotoChat(page); // Send first message const input = page.getByPlaceholder("Type a message..."); @@ -271,7 +280,7 @@ test.describe("AI Chat Plugin", () => { test("should have new chat button in sidebar", async ({ page }) => { // Navigate to the chat page - await page.goto("/pages/chat"); + await gotoChat(page); // Verify the "New chat" button exists in the sidebar await expect( @@ -286,7 +295,7 @@ test.describe("AI Chat Plugin", () => { await page.setViewportSize({ width: 1280, height: 800 }); // Create a conversation - await page.goto("/pages/chat"); + await gotoChat(page); const input = page.getByPlaceholder("Type a message..."); await input.fill("New chat navigation test"); await input.press("Enter"); @@ -320,7 +329,7 @@ test.describe("AI Chat Plugin", () => { // Ensure desktop layout so sidebar is visible await page.setViewportSize({ width: 1280, height: 800 }); - await page.goto("/pages/chat"); + await gotoChat(page); // Type a draft message but do not send it const input = page.getByPlaceholder("Type a message..."); @@ -338,7 +347,7 @@ test.describe("AI Chat Plugin", () => { // Set desktop viewport await page.setViewportSize({ width: 1280, height: 800 }); - await page.goto("/pages/chat"); + await gotoChat(page); // Find the sidebar toggle button (desktop only) const toggleButton = page @@ -358,7 +367,7 @@ test.describe("AI Chat Plugin", () => { // Set mobile viewport await page.setViewportSize({ width: 375, height: 667 }); - await page.goto("/pages/chat"); + await gotoChat(page); // Find and click the mobile menu button const menuButton = page.locator('[aria-label="Open menu"]'); @@ -380,7 +389,7 @@ test.describe("AI Chat Plugin - Widget Mode", () => { }) => { // This test assumes the chat interface adapts based on container/props // In a real widget scenario, you'd navigate to a widget-specific route - await page.goto("/pages/chat"); + await gotoChat(page); // Verify the chat interface is functional await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); @@ -391,7 +400,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { test("should show file upload button in authenticated mode", async ({ page, }) => { - await page.goto("/pages/chat"); + await gotoChat(page); // Verify the file upload button is visible (paperclip icon) await expect( @@ -400,7 +409,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { }); test("should upload and attach an image file", async ({ page }) => { - await page.goto("/pages/chat"); + await gotoChat(page); // Wait for chat interface to be fully rendered await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); @@ -432,7 +441,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { }); test("should upload and attach a text file", async ({ page }) => { - await page.goto("/pages/chat"); + await gotoChat(page); // Wait for chat interface to be fully rendered await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); @@ -460,7 +469,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { test("should remove attached file when clicking remove button", async ({ page, }) => { - await page.goto("/pages/chat"); + await gotoChat(page); // Wait for chat interface to be fully rendered await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); @@ -493,7 +502,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { }); test("should send message with attached file", async ({ page }) => { - await page.goto("/pages/chat"); + await gotoChat(page); // Wait for chat interface to be fully rendered await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); @@ -536,7 +545,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { }); test("should clear attachments after sending", async ({ page }) => { - await page.goto("/pages/chat"); + await gotoChat(page); // Upload a test image file const fileInput = page.locator('input[type="file"]'); @@ -573,7 +582,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { }); test("should allow multiple image uploads", async ({ page }) => { - await page.goto("/pages/chat"); + await gotoChat(page); const fileInput = page.locator('input[type="file"]'); await expect(fileInput).toBeAttached({ timeout: 5000 }); @@ -610,7 +619,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { test("should retry AI response and maintain correct message order", async ({ page, }) => { - await page.goto("/pages/chat"); + await gotoChat(page); const chatInterface = page.locator('[data-testid="chat-interface"]'); @@ -677,7 +686,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { }); test("should edit user message and persist correctly", async ({ page }) => { - await page.goto("/pages/chat"); + await gotoChat(page); const chatInterface = page.locator('[data-testid="chat-interface"]'); @@ -765,7 +774,7 @@ test.describe("AI Chat Plugin - File Uploads", () => { test("should handle edit in middle of conversation and persist correctly", async ({ page, }) => { - await page.goto("/pages/chat"); + await gotoChat(page); const chatInterface = page.locator('[data-testid="chat-interface"]'); const input = page.getByPlaceholder("Type a message..."); diff --git a/e2e/tests/smoke.wealthreview.spec.ts b/e2e/tests/smoke.wealthreview.spec.ts index e8f845a6..73051659 100644 --- a/e2e/tests/smoke.wealthreview.spec.ts +++ b/e2e/tests/smoke.wealthreview.spec.ts @@ -48,6 +48,7 @@ async function waitForChatReady(page: Page, timeout = 60000) { * that React effects (including the transport setup) have time to flush. */ async function waitForChatInitialized(page: Page) { + await page.waitForLoadState("networkidle"); // Prefer the semantic status attribute; fall back gracefully after 3 s. const chatLocator = page.locator('[data-testid="chat-interface"]'); const ready = chatLocator.and( From 18167b84ac8f217a25be653e91fc1e57c0bb3eaa Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:20:01 +0000 Subject: [PATCH 6/7] fix(ai-chat): release menu pointer lock --- .../src/plugins/ai-chat/client/components/chat-sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d18cf69b..8c966fc2 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 @@ -249,7 +249,7 @@ export function ChatSidebar({

    - + \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\tCanAccess,\n\tuseBasePath,\n\tuseCan,\n\tuseNotify,\n\tusePluginOverrides,\n} from \"@btst/stack/context\";\nimport type { AiChatPluginOverrides } 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\";\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 { navigate, localization: customLocalization } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"ai-chat\", {});\n\tconst basePath = useBasePath();\n\tconst notify = useNotify();\n\tconst tr = useAiChatTranslation(customLocalization);\n\tconst { can: canRead, isPending: isReadPermissionPending } = useCan({\n\t\tresource: \"ai-chat:conversation\",\n\t\taction: \"read\",\n\t});\n\tconst { conversations, isLoading } = useConversations({\n\t\tenabled: !isReadPermissionPending && canRead,\n\t});\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\n\tconst handleNewChat = () => {\n\t\t// Always use overrides navigation when available, so consumers control routing.\n\t\t// Also run onNewChat to support \"reset chat\" behavior when already on /chat.\n\t\tif (navigate) {\n\t\t\tnavigate(`${basePath}/chat`);\n\t\t}\n\t\tif (onNewChat) {\n\t\t\tonNewChat();\n\t\t}\n\t};\n\n\tconst handleConversationClick = (conversation: SerializedConversation) => {\n\t\tif (navigate) {\n\t\t\tnavigate(`${basePath}/chat/${conversation.id}`);\n\t\t}\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\tawait deleteMutation.mutateAsync({ id: selectedConversation.id });\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\t// Navigate away if deleted current conversation\n\t\t\t\tif (selectedConversation.id === currentConversationId && navigate) {\n\t\t\t\t\tawait navigate(`${basePath}/chat`);\n\t\t\t\t}\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" }, {