--- phase: 06-web-chat plan: 02 subsystem: frontend tags: [web-chat, websocket, react-markdown, tanstack-query, portal-ui] dependency_graph: requires: - packages/gateway/gateway/channels/web.py (WebSocket endpoint /chat/ws/{conversationId}) - packages/shared/shared/api/chat.py (REST API /api/portal/chat/*) provides: - /chat page accessible to all roles - ChatSidebar, ChatWindow, ChatMessage, TypingIndicator components - useChatSocket hook with auth handshake and reconnection - useConversations, useConversationHistory, useCreateConversation, useDeleteConversation hooks - Chat nav link visible to all roles affects: - packages/portal/components/nav.tsx (Chat link added) - packages/portal/lib/api.ts (Conversation types added) - packages/portal/lib/queries.ts (chat hooks added) tech_stack: added: - react-markdown@^10.x (markdown rendering for assistant messages) - remark-gfm (GitHub Flavored Markdown support) - packages/portal/lib/use-chat-socket.ts (WebSocket lifecycle hook) - packages/portal/components/chat-sidebar.tsx - packages/portal/components/chat-window.tsx - packages/portal/components/chat-message.tsx - packages/portal/components/typing-indicator.tsx - packages/portal/app/(dashboard)/chat/page.tsx patterns: - Suspense wrapper required for useSearchParams in Next.js 16 static prerendering - Stable callback refs in useChatSocket to prevent WebSocket reconnect on re-renders - Optimistic user message append before WebSocket send completes - DialogTrigger with render prop (base-ui pattern, not asChild) - crypto.randomUUID() for local message IDs before server assignment key_files: created: - packages/portal/lib/use-chat-socket.ts - packages/portal/components/chat-sidebar.tsx - packages/portal/components/chat-window.tsx - packages/portal/components/chat-message.tsx - packages/portal/components/typing-indicator.tsx - packages/portal/app/(dashboard)/chat/page.tsx modified: - packages/portal/lib/api.ts (Conversation, ConversationMessage, CreateConversationRequest, ConversationDetail types) - packages/portal/lib/queries.ts (conversations/conversationHistory queryKeys + 4 hooks) - packages/portal/components/nav.tsx (Chat nav item added) - packages/portal/package.json (react-markdown, remark-gfm added) decisions: - "useSearchParams wrapped in Suspense boundary — Next.js 16 requires this for static prerendering of pages using URL params" - "Stable callback refs in useChatSocket — onMessage/onTyping held in refs so WebSocket effect re-runs only when conversationId or auth changes, not on every render" - "Optimistic user message appended locally before server echo — avoids waiting for WebSocket roundtrip to show the user's own message" - "ChatPageInner + ChatPage split — useSearchParams must be inside Suspense; outer page provides fallback" metrics: duration: "~6 minutes" completed_date: "2026-03-25" tasks_completed: 2 files_created: 6 files_modified: 4 --- # Phase 6 Plan 02: Web Chat Portal UI Summary **One-liner:** Full portal chat UI with WebSocket hook, markdown-rendering message bubbles, animated typing indicator, and conversation sidebar connecting to the Plan 01 gateway backend. ## What Was Built This plan delivers the user-facing chat experience on top of the backend infrastructure from Plan 01. ### useChatSocket Hook (`lib/use-chat-socket.ts`) WebSocket lifecycle management for browser clients: - Connects to `${NEXT_PUBLIC_WS_URL}/chat/ws/{conversationId}` - Sends JSON auth message immediately on open (browser WebSocket cannot send custom HTTP headers — established in Plan 01) - Parses `{"type": "typing"}` and `{"type": "response", "text": "..."}` server messages - Reconnects up to 3 times with 3-second delay after unexpected close - Uses `useRef` for the WebSocket instance and callback refs for stable event handlers - Intentional cleanup (unmount/conversationId change) sets `onclose = null` before closing to prevent spurious reconnect ### Chat Types and Query Hooks Four new types in `api.ts`: `Conversation`, `ConversationMessage`, `CreateConversationRequest`, `ConversationDetail`. Four new hooks in `queries.ts`: - `useConversations(tenantId)` — lists all conversations for a tenant - `useConversationHistory(conversationId)` — fetches last 50 messages - `useCreateConversation()` — POST to create/get-or-create, invalidates conversations list - `useDeleteConversation()` — DELETE with conversation + history invalidation ### Components **TypingIndicator** — Three CSS `animate-bounce` dots with staggered `animationDelay` values (0ms, 150ms, 300ms) wrapped in a left-aligned muted bubble matching the assistant message style. **ChatMessage** — Role-based bubble rendering: - User: right-aligned, `bg-primary text-primary-foreground`, plain text - Assistant: left-aligned, `bg-muted`, `Bot` icon avatar, full `react-markdown` with `remark-gfm` for code blocks, lists, links, tables - Relative timestamp visible on hover via `opacity-0 group-hover:opacity-100` **ChatSidebar** — Scrollable conversation list showing agent name, last message preview (truncated), and relative time. Active conversation highlighted with `bg-accent`. "New Conversation" button (Plus icon) triggers agent picker. **ChatWindow** — Full-height conversation panel: - Loads history via `useConversationHistory` on mount - WebSocket via `useChatSocket` for real-time exchange - Optimistically appends user message before server acknowledgement - Auto-scrolls with `scrollIntoView({ behavior: "smooth" })` on new messages or typing changes - Auto-growing textarea (capped at 96px / ~4 lines), Enter to send, Shift+Enter for newline - Amber "Connecting..." banner when WebSocket disconnected **ChatPage (`/chat`)** — Two-column layout (w-72 sidebar + flex-1 main): - Reads `?id=` from URL via `useSearchParams` for bookmark/refresh support - Agent picker dialog (base-ui `Dialog` with `render` prop on `DialogTrigger`) lists agents and calls `useCreateConversation` - Session-derived auth headers passed to `ChatWindow` → `useChatSocket` - Wrapped in `Suspense` (required for `useSearchParams` in Next.js 16) ### Nav Update `MessageSquare` icon added to `nav.tsx` with `{ href: "/chat", label: "Chat" }` — no `allowedRoles` restriction, visible to operator, customer_admin, and platform_admin. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] Suspense boundary required for useSearchParams** - **Found during:** Task 2 build verification - **Issue:** Next.js 16 static prerendering throws at build time when `useSearchParams()` is called outside a Suspense boundary: "useSearchParams() should be wrapped in a suspense boundary at page /chat" - **Fix:** Extracted all page logic into `ChatPageInner` and wrapped it with `` in the `ChatPage` default export - **Files modified:** `packages/portal/app/(dashboard)/chat/page.tsx` - **Commit:** f9e67f9 ## Self-Check: PASSED All key artifacts verified: - `packages/portal/app/(dashboard)/chat/page.tsx` — FOUND (235 lines, >50 min_lines) - `packages/portal/components/chat-sidebar.tsx` — FOUND (contains ChatSidebar) - `packages/portal/components/chat-window.tsx` — FOUND (contains ChatWindow) - `packages/portal/components/chat-message.tsx` — FOUND (contains ChatMessage) - `packages/portal/components/typing-indicator.tsx` — FOUND (contains TypingIndicator) - `packages/portal/lib/use-chat-socket.ts` — FOUND (contains useChatSocket) - WebSocket `new WebSocket` in use-chat-socket.ts — FOUND - Nav href="/chat" in nav.tsx — FOUND - useConversations/useConversationHistory in chat/page.tsx — FOUND - Commits `7e21420` and `f9e67f9` — FOUND in git log - Portal build: passes with `/chat` route listed