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..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();
@@ -57,7 +63,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
@@ -83,12 +89,12 @@ 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...");
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({
@@ -111,11 +117,11 @@ 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");
- await page.keyboard.press("Enter");
+ await input.press("Enter");
// Wait for response
await expect(
@@ -145,6 +151,69 @@ 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 gotoChat(page);
+ 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: 45000,
+ });
+ await waitForChatReady(page, 45000);
+ // 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 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 });
+ await expect(page.locator("body")).not.toHaveCSS("pointer-events", "none", {
+ 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,
}) => {
@@ -160,12 +229,12 @@ 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...");
await input.fill(firstMessage);
- await page.keyboard.press("Enter");
+ await input.press("Enter");
// Wait for first AI response
await expect(
@@ -179,7 +248,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(
@@ -211,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(
@@ -226,10 +295,10 @@ 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 page.keyboard.press("Enter");
+ await input.press("Enter");
// Wait for AI response + navigation to conversation URL
await expect(
@@ -260,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...");
@@ -278,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
@@ -298,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"]');
@@ -320,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();
@@ -331,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(
@@ -340,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();
@@ -372,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();
@@ -400,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();
@@ -433,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();
@@ -457,7 +526,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({
@@ -476,10 +545,11 @@ 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"]');
+ await expect(fileInput).toBeAttached({ timeout: 5000 });
const testImageBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
await fileInput.setInputFiles({
@@ -496,7 +566,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({
@@ -512,9 +582,10 @@ 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 });
const testImageBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
@@ -548,14 +619,14 @@ 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"]');
// 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(
@@ -615,14 +686,14 @@ 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"]');
// 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(
@@ -703,14 +774,14 @@ 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...");
// 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,
});
@@ -723,7 +794,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,
});
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(
diff --git a/packages/stack/package.json b/packages/stack/package.json
index fc1259a8..612d206b 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"
],
@@ -821,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/packages/stack/registry/btst-ai-chat.json b/packages/stack/registry/btst-ai-chat.json
index 18a58236..07b27f36 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);\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);\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>\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\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)}\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)}\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\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\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)}\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\n\n\t\t\t{/* Rename Dialog */}\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