From 26215d906067e487573dc4a605d926d9d6778751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:03:43 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Story=203.1=20=E2=80=94=20c?= =?UTF-8?q?hat=20panel=20UI=20with=20streaming=20AI=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AI copilot chat panel to the diagram editor with streaming responses, chat history persistence, and markdown rendering. Includes copilot API route, diagram-aware system prompt, and schema with 15 passing tests. Co-Authored-By: Claude Opus 4.6 --- ...at-panel-ui-with-streaming-ai-responses.md | 297 +++ .../sprint-status.yaml | 12 +- .../copilot/components/CopilotPanel.tsx | 299 +++ .../components/editor/DiagramEditor.tsx | 6 +- .../diagram/components/editor/RightPanel.tsx | 28 +- .../modules/pdf/components/recent-chats.tsx | 2 +- .../web/src/modules/pdf/history/list/item.tsx | 2 +- packages/ai/package.json | 3 +- packages/ai/src/modules/copilot/api.ts | 159 ++ .../ai/src/modules/copilot/schema.test.ts | 87 + packages/ai/src/modules/copilot/schema.ts | 20 + .../src/modules/copilot/system-prompt.test.ts | 55 + .../ai/src/modules/copilot/system-prompt.ts | 33 + packages/ai/src/modules/copilot/types.ts | 5 + packages/api/src/modules/ai/copilot/router.ts | 47 + packages/api/src/modules/ai/router.ts | 2 + .../db/migrations/0002_numerous_siren.sql | 2 + .../db/migrations/meta/0002_snapshot.json | 2158 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema/chat.ts | 36 +- 20 files changed, 3223 insertions(+), 37 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/3-1-chat-panel-ui-with-streaming-ai-responses.md create mode 100644 apps/web/src/modules/copilot/components/CopilotPanel.tsx create mode 100644 packages/ai/src/modules/copilot/api.ts create mode 100644 packages/ai/src/modules/copilot/schema.test.ts create mode 100644 packages/ai/src/modules/copilot/schema.ts create mode 100644 packages/ai/src/modules/copilot/system-prompt.test.ts create mode 100644 packages/ai/src/modules/copilot/system-prompt.ts create mode 100644 packages/ai/src/modules/copilot/types.ts create mode 100644 packages/api/src/modules/ai/copilot/router.ts create mode 100644 packages/db/migrations/0002_numerous_siren.sql create mode 100644 packages/db/migrations/meta/0002_snapshot.json diff --git a/_bmad-output/implementation-artifacts/3-1-chat-panel-ui-with-streaming-ai-responses.md b/_bmad-output/implementation-artifacts/3-1-chat-panel-ui-with-streaming-ai-responses.md new file mode 100644 index 0000000..ccf36cd --- /dev/null +++ b/_bmad-output/implementation-artifacts/3-1-chat-panel-ui-with-streaming-ai-responses.md @@ -0,0 +1,297 @@ +# Story 3.1: Chat Panel UI with Streaming AI Responses + +Status: done + +## Story + +As a user, +I want a chat panel where I can converse with an AI about my diagram, +So that I can describe what I want and see the AI's responses stream in real-time. + +## Acceptance Criteria + +1. **Given** I am in the diagram editor, **When** I look at the right panel, **Then** I see a Chat tab (active by default) with a message input area at the bottom, a scrollable message history above, and a mic button placeholder (disabled, for Epic 5). + +2. **Given** I type a message and press Enter (or click Send), **When** the message is sent to the AI backend, **Then** my message appears in the chat history immediately **And** the AI response streams token-by-token into a new message bubble **And** first token appears in < 1 second. + +3. **Given** the AI is streaming a response, **When** I view the chat panel, **Then** I see a typing indicator while tokens stream **And** the chat auto-scrolls to show the latest content **And** the message progressively renders with markdown formatting. + +4. **Given** the AI service is unavailable, **When** I send a message, **Then** I see a user-friendly error message within 5 seconds **And** the chat remains functional for viewing history. + +## Tasks / Subtasks + +- [x] Task 1: Create diagram copilot API route (AC: 2, 4) + - [x] 1.1 Create `packages/api/src/modules/ai/copilot/router.ts` with POST `/` endpoint + - [x] 1.2 Wire `enforceAuth` → `validate("json", copilotMessageSchema)` → handler + - [x] 1.3 Create `copilotMessageSchema` in same file or adjacent schema file — extends chat message with `diagramId: z.string()`, `diagramType: z.string()`, optional `graphContext: z.string()` + - [x] 1.4 Handler calls `streamText()` with diagram-specific system prompt, returns `result.toUIMessageStreamResponse()` + - [x] 1.5 Register route in `packages/api/src/modules/ai/router.ts` as `.route("/copilot", copilotRouter)` + - [x] 1.6 Create diagram system prompt in `packages/ai/src/modules/copilot/system-prompt.ts` — instructs AI about diagram types, graph model, and conversation scope + +- [x] Task 2: Create ChatPanel component (AC: 1, 2, 3) + - [x] 2.1 Create `apps/web/src/modules/chat/components/ChatPanel.tsx` — main copilot panel + - [x] 2.2 Use AI SDK `useChat` hook with `api` pointing to `/api/ai/copilot` endpoint + - [x] 2.3 Pass `body: { diagramId, diagramType }` to `useChat` for diagram context + - [x] 2.4 Message list: scrollable container, auto-scroll on new content via `useRef` + `scrollIntoView` + - [x] 2.5 Input area: text input with Enter-to-send, Send button, disabled mic button placeholder + - [x] 2.6 Render user messages and assistant messages with parts-based rendering (`message.parts.map(...)`) + +- [x] Task 3: Implement streaming UX (AC: 2, 3) + - [x] 3.1 Typing indicator: show animated dots while `status === "submitted"` (waiting for first token) + - [x] 3.2 Streaming indicator: progressive text rendering while `status === "streaming"` + - [x] 3.3 Auto-scroll: scroll to bottom on each streaming update, respect user scroll-up (pause auto-scroll if user scrolled up) + - [x] 3.4 Markdown rendering: render assistant text parts with basic markdown (bold, italic, code blocks, lists) — use a lightweight markdown renderer or dangerouslySetInnerHTML with sanitization + +- [x] Task 4: Wire ChatPanel into RightPanel (AC: 1) + - [x] 4.1 Replace the placeholder content in `RightPanel.tsx` Chat tab with `` + - [x] 4.2 Pass `diagramId` and `diagramType` as props from DiagramEditor → RightPanel → ChatPanel + - [x] 4.3 Ensure Chat tab is active by default (already implemented in RightPanel) + +- [x] Task 5: Error handling (AC: 4) + - [x] 5.1 Use `useChat`'s `onError` callback to show toast via sonner + - [x] 5.2 Display inline error message in chat when `error` is set — "Something went wrong. Please try again." + - [x] 5.3 Keep message history visible and input functional during error state + - [x] 5.4 Add Stop button visible during streaming (`status === "submitted" || status === "streaming"`) that calls `stop()` + +- [x] Task 6: Persist chat per diagram (AC: 1, 2) + - [x] 6.1 Add `diagramId` column to existing `chat.chat` table (nullable text, indexed) — or create a new `copilot_chat` table in a `copilot` pgSchema + - [x] 6.2 Generate and apply Drizzle migration + - [x] 6.3 On ChatPanel mount: query for existing chat by diagramId, load history if exists + - [x] 6.4 On first message: create chat record linked to diagramId, then stream + - [x] 6.5 Register any new table exports in `packages/db/src/schema/index.ts` + +- [x] Task 7: Tests (all ACs) + - [x] 7.1 Unit test copilot message schema validation (valid/invalid payloads) + - [x] 7.2 Unit test system prompt generation (correct diagram type context) + - [ ] 7.3 Unit test ChatPanel message rendering logic — deferred: React component rendering tests go to E2E per project testing standards + - [ ] 7.4 Unit test auto-scroll behavior — deferred: DOM interaction tests go to E2E per project testing standards + - [ ] 7.5 Unit test status-based UI states — deferred: React component rendering tests go to E2E per project testing standards + +## Dev Notes + +### Existing Infrastructure — DO NOT Reinvent + +The TurboStarter template already has a complete generic chat system. **Reuse patterns, do NOT duplicate:** + +| What Exists | Location | How to Reuse | +|-------------|----------|-------------| +| Chat DB schema (chat, message, part tables) | `packages/db/src/schema/chat.ts` | Extend with `diagramId` column or create parallel copilot schema | +| Generic chat API (CRUD + streaming) | `packages/api/src/modules/ai/chat.ts` | Reference patterns; copilot gets its OWN route | +| `streamChat()` with AI SDK | `packages/ai/src/modules/chat/api.ts` | Study `createUIMessageStream` + `streamText` + `smoothStream` patterns | +| Chat composer hook | `apps/web/src/modules/chat/composer/hooks/use-composer.tsx` | Study `getChatInstance` + `DefaultChatTransport` pattern | +| Assistant message renderer | `apps/web/src/modules/chat/thread/message/assistant/index.tsx` | Study parts-based rendering with status-driven streaming UX | +| Model definitions (12 models, 5 providers) | `packages/ai/src/modules/chat/constants.ts` | Reuse model registry; copilot picks a default model | +| React Query chat hooks | `apps/web/src/modules/chat/lib/api.ts` | Reference patterns for chat CRUD queries | +| Hono API client setup | `apps/web/src/lib/api/client.tsx` + `server.ts` | Use existing type-safe Hono RPC client | + +### AI SDK Latest API (February 2026) + +The AI SDK has evolved from its earlier API. Key differences: + +| Old API | Current API | Notes | +|---------|-------------|-------| +| `handleSubmit()` | `sendMessage()` | No longer form-bound | +| `input` / `handleInputChange` | Manage your own input state | Use `useState` for input | +| `isLoading` | `status: 'submitted' \| 'streaming' \| 'ready' \| 'error'` | Granular status | +| `message.content` (string) | `message.parts: Part[]` | Parts-based rendering | +| `append()` | `sendMessage({ text })` | Message submission | + +**Critical useChat pattern:** + +```tsx +const { messages, sendMessage, status, error, stop } = useChat({ + api: '/api/ai/copilot', // Custom endpoint + body: { diagramId, diagramType }, // Extra body params sent with every request + onError: (err) => toast.error('Failed to get AI response'), + onFinish: () => { /* invalidate queries if needed */ }, +}); +``` + +**Critical streamText server pattern:** + +```ts +import { streamText, convertToModelMessages } from 'ai'; + +const result = streamText({ + model: registry.languageModel('openai:gpt-4o'), + system: diagramSystemPrompt, + messages: convertToModelMessages(messages), // Convert UIMessage[] to ModelMessage[] +}); +return result.toUIMessageStreamResponse(); // SSE format for useChat +``` + +### Diagram System Prompt + +Create a diagram-aware system prompt that tells the AI: +- It is a diagram design copilot for domaingraph +- The current diagram type (BPMN, E-R, Org Chart, Architecture, Sequence, Flowchart) +- It should discuss diagram design, explain concepts, suggest improvements +- Story 3.1 is **chat-only** — the AI does NOT modify the diagram yet (that's Story 3.2) +- Keep responses concise and diagram-focused + +### Component Architecture + +``` +DiagramEditor + ├── DiagramCanvas (left, existing) + └── RightPanel (right, existing) + ├── Tab: Chat (active) → + ├── Tab: Inspector (placeholder) + └── Tab: Annotations (placeholder) + +ChatPanel + ├── MessageList (scrollable) + │ ├── UserMessage (text bubble) + │ └── AssistantMessage (parts-based, streaming) + ├── TypingIndicator (shown when status === "submitted") + ├── ErrorMessage (shown when error !== undefined) + └── InputArea + ├── TextInput (Enter to send) + ├── SendButton (disabled when status !== "ready") + ├── StopButton (shown during streaming) + └── MicButton (placeholder, disabled) +``` + +### Prop Threading + +The `diagramId` and `diagramType` must flow from the page down to ChatPanel: + +1. `page.tsx` fetches diagram data (already does this) +2. `DiagramEditor` receives `diagram` prop (already does this) +3. Pass `diagram.id` and `diagram.diagramType` to `RightPanel` +4. `RightPanel` passes to `ChatPanel` + +Check the current `DiagramEditor` component to see how `diagram` prop is structured and what fields are available. + +### Styling + +- Use Tailwind CSS classes (no CSS modules) +- Use shadcn/ui components from `@turbostarter/ui-web/` for Button, Input, ScrollArea +- Follow existing diagram accent pattern: consider using `--diagram-chat` or a neutral accent for the copilot +- Support light/dark mode via CSS variables (already system-wide via `colorMode="system"`) +- Use `color-mix()` for computed tints following Epic 2 patterns + +### Project Structure Notes + +**New files to create:** + +``` +packages/api/src/modules/ai/copilot/ + └── router.ts # Copilot streaming endpoint + +packages/ai/src/modules/copilot/ + └── system-prompt.ts # Diagram system prompt generator + +apps/web/src/modules/chat/components/ + └── ChatPanel.tsx # Main copilot chat panel + └── ChatPanel.test.ts # Unit tests + └── MessageList.tsx # Scrollable message container (optional split) + └── ChatInput.tsx # Input area with send/mic (optional split) +``` + +**Existing files to modify:** + +``` +apps/web/src/modules/diagram/components/editor/RightPanel.tsx # Replace placeholder +apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx # Thread diagramId/type props +packages/api/src/modules/ai/router.ts # Register copilot route +packages/db/src/schema/chat.ts # Add diagramId column (or new copilot schema) +packages/db/src/schema/index.ts # Register new exports if schema changes +``` + +**Naming conventions:** PascalCase component files in kebab-case dirs. Co-located tests (`ChatPanel.test.ts` beside `ChatPanel.tsx`). + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 3.1] +- [Source: _bmad-output/project-context.md#Critical Implementation Rules] +- [Source: packages/db/src/schema/chat.ts — existing chat DB schema] +- [Source: packages/api/src/modules/ai/chat.ts — existing chat API router] +- [Source: packages/ai/src/modules/chat/api.ts — streamChat() implementation] +- [Source: packages/ai/src/modules/chat/constants.ts — model definitions] +- [Source: apps/web/src/modules/chat/composer/hooks/use-composer.tsx — useChat transport pattern] +- [Source: apps/web/src/modules/chat/thread/message/assistant/index.tsx — streaming message rendering] +- [Source: apps/web/src/modules/diagram/components/editor/RightPanel.tsx — current placeholder] +- [Source: apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx — existing canvas] +- [Source: AI SDK docs — https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat] +- [Source: AI SDK docs — https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text] + +### Previous Story Intelligence (Story 2.9) + +- **Pattern**: `useGraphStore.getState()` for accessing store state in callbacks (avoids stale closures) +- **Map over Set+find**: Use `Map` for O(1) lookups when you need both membership test and value retrieval +- **Review themes**: Dead code removal, CSS variable discipline, performance-aware selectors +- **Web test count at end of 2.9**: 186 tests in apps/web + +### Testing Standards + +- Vitest with `describe`/`it`/`expect` — explicit imports from `vitest` +- Co-located test files: `ChatPanel.test.ts` next to `ChatPanel.tsx` +- Factory functions for test data (e.g., `createTestMessage(overrides)`) +- Test schema validation, prompt generation, UI state logic +- Do NOT test React component rendering (deferred to E2E) — test logic/hooks/utilities +- Run: `pnpm --filter @turbostarter/web test` or `pnpm test` + +### Critical Don't-Miss Rules + +- **ESM-only**: No `require()`. All `import` statements. +- **Path alias**: `~/` for app-internal imports in apps/web +- **IDs**: `generateId()` from `@turbostarter/shared/utils` — 32-char text columns +- **Validation**: `validate("json", schema)` middleware on Hono — never inline `.parse()` +- **Error handling**: `HttpException(HttpStatusCode.XXX, {...})` — never raw status numbers +- **No business logic in routers**: Handler delegates to domain function +- **Workspace imports**: Must match `exports` field exactly (e.g., `@turbostarter/ai/credits/server`) +- **pgSchema**: If creating new schema, wrap with `prefix()` in `schema/index.ts` +- **Conventional commits**: `feat: implement chat panel UI with streaming AI responses` +- **Soft-delete guards**: `isNull(deletedAt)` on every query touching soft-deleted tables + +## Dev Agent Record + +### Agent Model Used +Claude Opus 4.6 + +### Debug Log References +- Fixed `useChat` `body` option — not a top-level option in latest AI SDK; used `prepareSendMessagesRequest` in `DefaultChatTransport` instead +- Fixed `toChatMessage` type error — DB `text()` column typed as `string` but `UIMessagePart` needs literal `"text"` type; used `"text" as const` + +### Code Review Fixes Applied +- **H1**: Added GET `/messages` endpoint to copilot router + `getCopilotHistory` in api.ts; CopilotPanel fetches history on mount via `useQuery` and seeds via `setMessages` +- **H2**: Tasks 7.3-7.5 marked as deferred (not [x]) with explanation notes +- **M1**: Removed unused `generateId` import from CopilotPanel +- **M2**: Wrapped `DefaultChatTransport` in `useMemo` to prevent recreation on every render +- **M3**: Added `graphContext: z.string().optional()` to copilot message schema +- **M4**: Rewrote `types.ts` to derive `DiagramType` from DB `diagramTypeEnum` (single source of truth) +- **Cascading fix**: Changed PDF module `Chat` imports from `@turbostarter/ai/chat/types` to `SelectPdfChat` from `@turbostarter/db/schema/pdf` (type broke when `diagramId` was added to `chat.chat`) + +### Completion Notes List +- Copilot API route created at `/api/ai/copilot` with own system prompt, schema, and streaming +- Reuses existing `chat.chat`, `chat.message`, `chat.part` DB tables (no new schema) +- Added `diagramId` column (nullable, indexed) to `chat.chat` table with migration +- CopilotPanel uses `useChat` directly (simpler than `useComposer` hook) +- Chat history persists and loads on page refresh via GET `/messages` endpoint +- Auto-scroll with user-scroll-pause detection +- Typing indicator, error display, stop button, markdown rendering all functional +- 15 copilot tests (9 schema + 6 system prompt) passing +- 186 web tests passing (unchanged) +- Task 7.3-7.5 (component rendering tests) deferred per testing standards: "Do NOT test React component rendering — deferred to E2E" + +### File List +**Created:** +- `packages/ai/src/modules/copilot/types.ts` +- `packages/ai/src/modules/copilot/schema.ts` +- `packages/ai/src/modules/copilot/schema.test.ts` +- `packages/ai/src/modules/copilot/system-prompt.ts` +- `packages/ai/src/modules/copilot/system-prompt.test.ts` +- `packages/ai/src/modules/copilot/api.ts` +- `packages/api/src/modules/ai/copilot/router.ts` +- `apps/web/src/modules/copilot/components/CopilotPanel.tsx` +- `packages/db/migrations/0002_numerous_siren.sql` + +**Modified:** +- `packages/ai/package.json` — added `./copilot/*` export +- `packages/api/src/modules/ai/router.ts` — registered copilot route +- `packages/db/src/schema/chat.ts` — added `diagramId` column + index +- `apps/web/src/modules/diagram/components/editor/RightPanel.tsx` — wired CopilotPanel +- `apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx` — passed diagramId/diagramType props +- `apps/web/src/modules/pdf/components/recent-chats.tsx` — fixed Chat type import (cascading fix) +- `apps/web/src/modules/pdf/history/list/item.tsx` — fixed Chat type import (cascading fix) diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index afe7bfd..9404123 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -41,15 +41,15 @@ story_location: "{project-root}/_bmad-output/implementation-artifacts" development_status: # ── Epic 1: Workspace & Diagram Management (Phase 1 - Foundation) ── - epic-1: in-progress + epic-1: done 1-1-create-and-view-diagrams: done 1-2-organize-diagrams-into-projects: done 1-3-diagram-access-control-and-management: done 1-4-recent-view-and-drag-and-drop-organization: done - epic-1-retrospective: optional + epic-1-retrospective: done # ── Epic 2: Interactive Canvas & Diagram Types (Phase 2) ── - epic-2: in-progress + epic-2: done 2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model: done 2-2-elk-js-auto-layout-engine-in-web-worker: done 2-3-bpmn-diagram-type-renderer: done @@ -59,11 +59,11 @@ development_status: 2-7-sequence-diagram-type-renderer: done 2-8-flowchart-diagram-type-renderer: done 2-9-node-selection-and-manual-repositioning: done - epic-2-retrospective: optional + epic-2-retrospective: done # ── Epic 3: AI Copilot & Chat (Phase 2) ── - epic-3: backlog - 3-1-chat-panel-ui-with-streaming-ai-responses: backlog + epic-3: in-progress + 3-1-chat-panel-ui-with-streaming-ai-responses: done 3-2-ai-diagram-generation-from-natural-language: backlog 3-3-badge-based-element-referencing-for-targeted-modifications: backlog 3-4-ai-semantic-suggestions-and-accept-reject-workflow: backlog diff --git a/apps/web/src/modules/copilot/components/CopilotPanel.tsx b/apps/web/src/modules/copilot/components/CopilotPanel.tsx new file mode 100644 index 0000000..ebe6d5c --- /dev/null +++ b/apps/web/src/modules/copilot/components/CopilotPanel.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { cn } from "@turbostarter/ui"; +import { Button } from "@turbostarter/ui-web/button"; +import { Icons } from "@turbostarter/ui-web/icons"; +import { ScrollArea } from "@turbostarter/ui-web/scroll-area"; + +import { api } from "~/lib/api/client"; +import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown"; +import { Prose } from "~/modules/common/prose"; + +import type { DiagramType } from "~/modules/diagram/types/graph"; + +interface CopilotPanelProps { + diagramId: string; + diagramType: DiagramType; +} + +export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) { + const chatId = useMemo(() => `copilot-${diagramId}`, [diagramId]); + const [input, setInput] = useState(""); + const scrollRef = useRef(null); + const inputRef = useRef(null); + const userScrolledRef = useRef(false); + + // Fetch existing chat history on mount (H1 fix) + const { data: initialMessages } = useQuery({ + queryKey: ["copilot", "messages", chatId], + queryFn: async () => { + const res = await api.ai.copilot.messages.$get({ + query: { chatId }, + }); + if (!res.ok) return []; + return res.json(); + }, + staleTime: Infinity, + }); + + // Memoize transport to avoid recreation on every render (M2 fix) + const transport = useMemo( + () => + new DefaultChatTransport({ + api: api.ai.copilot.$url().toString(), + prepareSendMessagesRequest: ({ messages, id }) => { + const lastMessage = messages.at(-1); + return { + body: { + ...lastMessage, + chatId: id, + diagramId, + diagramType, + }, + }; + }, + }), + [diagramId, diagramType], + ); + + const { messages, sendMessage, status, error, stop, setMessages } = useChat({ + id: chatId, + transport, + onError: (err) => { + console.error("[copilot]", err); + toast.error("Failed to get AI response"); + }, + }); + + // Seed chat with persisted history once loaded + useEffect(() => { + if (initialMessages && initialMessages.length > 0 && messages.length === 0) { + setMessages( + initialMessages.map((m) => ({ + ...m, + createdAt: new Date(), + })), + ); + } + }, [initialMessages, messages.length, setMessages]); + + const isSubmitting = status === "submitted" || status === "streaming"; + + // Auto-scroll on new content, but pause if user scrolled up + useEffect(() => { + if (userScrolledRef.current) return; + const viewport = scrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ); + if (viewport) { + viewport.scrollTop = viewport.scrollHeight; + } + }, [messages]); + + // Detect user scroll + useEffect(() => { + const viewport = scrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ); + if (!viewport) return; + + let timeoutId: NodeJS.Timeout; + const handleScroll = () => { + const isAtBottom = + Math.abs( + viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight, + ) < 80; + userScrolledRef.current = !isAtBottom; + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + userScrolledRef.current = false; + }, 2000); + }; + + viewport.addEventListener("scroll", handleScroll); + return () => { + viewport.removeEventListener("scroll", handleScroll); + clearTimeout(timeoutId); + }; + }, []); + + const handleSend = useCallback(() => { + const text = input.trim(); + if (!text || isSubmitting) return; + + void sendMessage({ + text, + metadata: {}, + }); + setInput(""); + userScrolledRef.current = false; + }, [input, isSubmitting, sendMessage]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + return ( +
+ {/* Messages area */} + +
+ {messages.length === 0 && ( + + )} + + {messages.map((message) => ( +
+ {message.role === "user" ? ( + + ) : ( + + )} +
+ ))} + + {/* Typing indicator */} + {status === "submitted" && ( +
+
+ + + +
+
+ )} + + {/* Error display */} + {error && ( +
+ Something went wrong. Please try again. +
+ )} +
+
+ + {/* Input area */} +
+
+