---
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 `<Suspense fallback={...}>` 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
