nexus/.planning/milestones/v1.3-phases/21-chat-foundation/21-05-PLAN.md
Nexus Dev ffc7b130e4 chore: archive v1.3 phase directories to milestones/
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:55:48 +00:00

544 lines
26 KiB
Markdown

---
phase: 21-chat-foundation
plan: 05
type: execute
wave: 3
depends_on: ["21-03", "21-04"]
files_modified:
- ui/src/api/chat.ts
- ui/src/hooks/useChatConversations.ts
- ui/src/hooks/useChatMessages.ts
- ui/src/components/ChatConversationList.tsx
- ui/src/components/ChatConversationItem.tsx
- ui/src/components/ChatMessageList.tsx
- ui/src/components/ChatPanel.tsx
autonomous: false
requirements: [HIST-02, HIST-03]
must_haves:
truths:
- "User can create a new conversation via the + button"
- "Conversation list shows all conversations sorted by most recent, with pinned at top"
- "Clicking a conversation loads its messages into the thread pane"
- "Sending a message posts to API, appends optimistically, and auto-scrolls"
- "User can rename, pin, archive, and delete conversations from a dropdown menu"
- "Scrolling to bottom of conversation list loads more conversations (infinite scroll)"
- "Data survives page reload (read from server)"
artifacts:
- path: "ui/src/api/chat.ts"
provides: "Chat API client functions"
exports: ["chatApi"]
- path: "ui/src/hooks/useChatConversations.ts"
provides: "TanStack Query hook for conversation list with infinite scroll"
exports: ["useChatConversations"]
- path: "ui/src/hooks/useChatMessages.ts"
provides: "TanStack Query hook for message list"
exports: ["useChatMessages"]
- path: "ui/src/components/ChatConversationList.tsx"
provides: "Sidebar conversation list with infinite scroll"
exports: ["ChatConversationList"]
- path: "ui/src/components/ChatConversationItem.tsx"
provides: "Single conversation row with action dropdown"
exports: ["ChatConversationItem"]
- path: "ui/src/components/ChatMessageList.tsx"
provides: "Message thread with auto-scroll"
exports: ["ChatMessageList"]
key_links:
- from: "ui/src/api/chat.ts"
to: "server/src/routes/chat.ts"
via: "fetch calls to /companies/:companyId/conversations and /conversations/:id/messages"
pattern: "api\\.(get|post|patch|delete)"
- from: "ui/src/hooks/useChatConversations.ts"
to: "ui/src/api/chat.ts"
via: "useInfiniteQuery calling chatApi.listConversations"
pattern: "useInfiniteQuery"
- from: "ui/src/components/ChatPanel.tsx"
to: "ui/src/components/ChatConversationList.tsx"
via: "renders ChatConversationList in left column"
pattern: "<ChatConversationList"
- from: "ui/src/components/ChatPanel.tsx"
to: "ui/src/components/ChatMessageList.tsx"
via: "renders ChatMessageList in right column"
pattern: "<ChatMessageList"
---
<objective>
Wire the full chat UI: API client, TanStack Query hooks, conversation list with infinite scroll, message thread, and ChatPanel integration.
Purpose: Connect the UI shell (Plan 04) to the server API (Plan 03), enabling users to create conversations, send messages, and manage their conversation list. This is the integration plan that brings the chat feature to life.
Output: Fully functional chat experience -- create, read, update, delete conversations; send and view messages.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/21-chat-foundation/21-RESEARCH.md
@.planning/phases/21-chat-foundation/21-UI-SPEC.md
@.planning/phases/21-chat-foundation/21-03-SUMMARY.md
@.planning/phases/21-chat-foundation/21-04-SUMMARY.md
<interfaces>
From ui/src/api/client.ts:
```typescript
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) => request<T>(path, { method: "POST", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};
```
From packages/shared/src/types/chat.ts (created in Plan 01):
```typescript
export interface ChatConversation { id: string; companyId: string; title: string | null; agentId: string | null; pinnedAt: string | null; archivedAt: string | null; deletedAt: string | null; createdAt: string; updatedAt: string; }
export interface ChatConversationListItem { id: string; companyId: string; title: string | null; agentId: string | null; pinnedAt: string | null; archivedAt: string | null; updatedAt: string; lastMessagePreview: string | null; }
export interface ChatMessage { id: string; conversationId: string; role: "user" | "assistant" | "system"; content: string; agentId: string | null; createdAt: string; }
export interface ChatConversationListResponse { items: ChatConversationListItem[]; hasMore: boolean; }
export interface ChatMessageListResponse { items: ChatMessage[]; hasMore: boolean; }
```
From ui/src/context/ChatPanelContext.tsx (created in Plan 04):
```typescript
export function useChatPanel(): { chatOpen: boolean; activeConversationId: string | null; setChatOpen: (open: boolean) => void; toggleChat: () => void; setActiveConversationId: (id: string | null) => void; };
```
From ui/src/context/CompanyContext.tsx:
```typescript
export function useCompany(): { selectedCompanyId: string | null; selectedCompany: Company | null; ... };
```
From ui/src/components/ChatPanel.tsx (created in Plan 04):
- Currently has placeholder conversation list and message thread
- Has ChatInput wired with a console.log onSend
From ui/src/components/ChatMessage.tsx (created in Plan 04):
```typescript
export function ChatMessage({ role, content }: { role: "user" | "assistant" | "system"; content: string }): JSX.Element;
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create chat API client and TanStack Query hooks</name>
<files>ui/src/api/chat.ts, ui/src/hooks/useChatConversations.ts, ui/src/hooks/useChatMessages.ts</files>
<read_first>
- ui/src/api/client.ts (api.get, api.post, api.patch, api.delete patterns)
- ui/src/api/activity.ts (reference for a simple API module pattern)
- ui/src/hooks/useKeyboardShortcuts.ts (hook file pattern)
- ui/src/lib/queryKeys.ts (if exists -- check for existing query key patterns)
</read_first>
<action>
**chat.ts API client:**
Create `ui/src/api/chat.ts`:
```typescript
import { api } from "./client";
import type {
ChatConversation,
ChatConversationListResponse,
ChatMessage,
ChatMessageListResponse,
} from "@paperclipai/shared";
export const chatApi = {
listConversations(companyId: string, opts?: { cursor?: string; limit?: number }) {
const params = new URLSearchParams();
if (opts?.cursor) params.set("cursor", opts.cursor);
if (opts?.limit) params.set("limit", String(opts.limit));
const qs = params.toString();
return api.get<ChatConversationListResponse>(
`/companies/${companyId}/conversations${qs ? `?${qs}` : ""}`,
);
},
createConversation(companyId: string, data?: { title?: string; agentId?: string }) {
return api.post<ChatConversation>(`/companies/${companyId}/conversations`, data ?? {});
},
getConversation(id: string) {
return api.get<ChatConversation>(`/conversations/${id}`);
},
updateConversation(id: string, data: { title?: string; pinnedAt?: string | null; archivedAt?: string | null }) {
return api.patch<ChatConversation>(`/conversations/${id}`, data);
},
deleteConversation(id: string) {
return api.delete<void>(`/conversations/${id}`);
},
listMessages(conversationId: string, opts?: { cursor?: string; limit?: number }) {
const params = new URLSearchParams();
if (opts?.cursor) params.set("cursor", opts.cursor);
if (opts?.limit) params.set("limit", String(opts.limit));
const qs = params.toString();
return api.get<ChatMessageListResponse>(
`/conversations/${conversationId}/messages${qs ? `?${qs}` : ""}`,
);
},
postMessage(conversationId: string, data: { role: string; content: string; agentId?: string }) {
return api.post<ChatMessage>(`/conversations/${conversationId}/messages`, data);
},
};
```
**useChatConversations.ts:**
Create `ui/src/hooks/useChatConversations.ts`:
```typescript
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { chatApi } from "../api/chat";
import type { ChatConversationListResponse } from "@paperclipai/shared";
export function useChatConversations(companyId: string | null) {
const queryClient = useQueryClient();
const query = useInfiniteQuery({
queryKey: ["chat", "conversations", companyId],
queryFn: ({ pageParam }) =>
chatApi.listConversations(companyId!, { cursor: pageParam as string | undefined }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage: ChatConversationListResponse) =>
lastPage.hasMore ? lastPage.items.at(-1)?.updatedAt : undefined,
enabled: !!companyId,
placeholderData: (prev) => prev, // keepPreviousData equivalent -- prevents flicker (Pitfall 6)
});
const createMutation = useMutation({
mutationFn: (data?: { title?: string }) => chatApi.createConversation(companyId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] });
},
});
const updateMutation = useMutation({
mutationFn: ({ id, ...data }: { id: string; title?: string; pinnedAt?: string | null; archivedAt?: string | null }) =>
chatApi.updateConversation(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => chatApi.deleteConversation(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] });
},
});
return { ...query, createMutation, updateMutation, deleteMutation };
}
```
**useChatMessages.ts:**
Create `ui/src/hooks/useChatMessages.ts`:
```typescript
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { chatApi } from "../api/chat";
import type { ChatMessage, ChatMessageListResponse } from "@paperclipai/shared";
export function useChatMessages(conversationId: string | null) {
const queryClient = useQueryClient();
const query = useInfiniteQuery({
queryKey: ["chat", "messages", conversationId],
queryFn: ({ pageParam }) =>
chatApi.listMessages(conversationId!, { cursor: pageParam as string | undefined }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage: ChatMessageListResponse) =>
lastPage.hasMore ? lastPage.items.at(-1)?.createdAt : undefined,
enabled: !!conversationId,
});
const sendMutation = useMutation({
mutationFn: (data: { content: string }) =>
chatApi.postMessage(conversationId!, { role: "user", content: data.content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
// Also invalidate conversations to update lastMessagePreview and sort order
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
},
});
// Flatten pages into a single sorted array (oldest first for display)
const messages: ChatMessage[] = query.data?.pages.flatMap((p) => p.items).reverse() ?? [];
return { ...query, messages, sendMutation };
}
```
Note: Messages come from API in `desc(createdAt)` order (most recent first). Reversing gives chronological order for display.
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | tail -3</automated>
</verify>
<acceptance_criteria>
- ui/src/api/chat.ts exports `chatApi` object with methods: listConversations, createConversation, getConversation, updateConversation, deleteConversation, listMessages, postMessage
- ui/src/hooks/useChatConversations.ts exports `useChatConversations` using `useInfiniteQuery`
- ui/src/hooks/useChatConversations.ts contains `placeholderData` to prevent flicker
- ui/src/hooks/useChatConversations.ts exports createMutation, updateMutation, deleteMutation
- ui/src/hooks/useChatMessages.ts exports `useChatMessages` using `useInfiniteQuery`
- ui/src/hooks/useChatMessages.ts exports `sendMutation` and `messages` (flattened+reversed)
- Both hooks have `enabled: !!conversationId` or `enabled: !!companyId` guards
- TypeScript compilation passes
</acceptance_criteria>
<done>Chat API client provides 7 fetch methods. useChatConversations provides infinite scroll + CRUD mutations. useChatMessages provides paginated messages + send mutation with optimistic invalidation.</done>
</task>
<task type="auto">
<name>Task 2: Create ChatConversationList, ChatConversationItem, ChatMessageList, and wire ChatPanel</name>
<files>ui/src/components/ChatConversationList.tsx, ui/src/components/ChatConversationItem.tsx, ui/src/components/ChatMessageList.tsx, ui/src/components/ChatPanel.tsx</files>
<read_first>
- ui/src/components/ChatPanel.tsx (current placeholder state from Plan 04 -- will be updated)
- ui/src/components/ChatMessage.tsx (message rendering component from Plan 04)
- ui/src/context/ChatPanelContext.tsx (useChatPanel hook -- activeConversationId, setActiveConversationId)
- ui/src/context/CompanyContext.tsx (useCompany for selectedCompanyId)
- ui/src/components/ui/dropdown-menu.tsx (shadcn dropdown component for action menu)
- ui/src/components/ui/skeleton.tsx (shadcn skeleton for loading state)
- ui/src/components/ui/scroll-area.tsx (shadcn scroll area)
- ui/src/components/ui/dialog.tsx (shadcn dialog for delete confirmation)
</read_first>
<action>
**ChatConversationItem.tsx:**
Create `ui/src/components/ChatConversationItem.tsx`:
```typescript
import type { ChatConversationListItem } from "@paperclipai/shared";
```
Props:
```typescript
interface ChatConversationItemProps {
conversation: ChatConversationListItem;
isActive: boolean;
onSelect: (id: string) => void;
onRename: (id: string, title: string) => void;
onPin: (id: string, pinned: boolean) => void;
onArchive: (id: string) => void;
onDelete: (id: string) => void;
}
```
Renders a row with:
- Title text (truncated with `truncate` class), or "New Conversation" if title is null
- Preview text below title: `lastMessagePreview` truncated, `text-xs text-muted-foreground truncate`
- Active state: `bg-accent/60` when `isActive`, otherwise `hover:bg-accent`
- On hover: reveal a `MoreHorizontal` icon button (lucide-react) that opens a `DropdownMenu` with items:
- "Rename" -- triggers inline rename (for simplicity in Phase 21, use `window.prompt("Rename conversation", currentTitle)` and call `onRename` -- a proper inline editor can be added later)
- "Pin" / "Unpin" -- calls `onPin(id, !isPinned)` where `isPinned = !!conversation.pinnedAt`
- "Archive" -- calls `onArchive(id)`
- "Delete" -- calls `onDelete(id)` (the parent handles the confirmation dialog)
- Pin indicator: if `conversation.pinnedAt`, show a small `Pin` icon (lucide-react, `h-3 w-3 text-muted-foreground`) before the title
- Click on the row (outside dropdown) calls `onSelect(conversation.id)`
**ChatConversationList.tsx:**
Create `ui/src/components/ChatConversationList.tsx`:
Props:
```typescript
interface ChatConversationListProps {
companyId: string;
}
```
Implementation:
- Uses `useChatConversations(companyId)` hook
- Renders a `ScrollArea` container
- At the top: a "New conversation" button with `Plus` icon, `text-xs`, full width -- calls `createMutation.mutateAsync()` then `setActiveConversationId(newConvo.id)`
- Separate pinned conversations from unpinned: render pinned first (sorted by `pinnedAt`), then unpinned (sorted by `updatedAt`)
- Map conversations to `<ChatConversationItem />` entries
- Loading state: 5 `Skeleton` elements (`h-10 w-full rounded`)
- Empty state: centered text "No conversations yet" / "Start a conversation to get help from your agents."
- Infinite scroll: use an IntersectionObserver on a sentinel `<div>` at the bottom of the list. When it enters the viewport and `hasNextPage` is true, call `fetchNextPage()`
- Delete confirmation: maintain a `deletingId` state. When set, render a shadcn `Dialog` with title "Delete conversation?", body "This conversation and all its messages will be permanently deleted.", and "Delete" (destructive) + "Keep conversation" (outline) buttons
- Rename handler: `updateMutation.mutate({ id, title: newTitle })`
- Pin handler: `updateMutation.mutate({ id, pinnedAt: pinned ? new Date().toISOString() : null })`
- Archive handler: `updateMutation.mutate({ id, archivedAt: new Date().toISOString() })`
- Delete handler: `deleteMutation.mutate(id)` then clear `deletingId` and if the deleted conversation was active, set `activeConversationId` to null
**ChatMessageList.tsx:**
Create `ui/src/components/ChatMessageList.tsx`:
Props:
```typescript
interface ChatMessageListProps {
conversationId: string;
}
```
Implementation:
- Uses `useChatMessages(conversationId)` hook
- Renders messages in a container with `space-y-4`
- Maps `messages` array (already chronological from the hook) to `<ChatMessage role={m.role} content={m.content} key={m.id} />`
- Auto-scroll: use a `useRef` on a bottom sentinel div and `useEffect` that scrolls it into view when `messages.length` changes
- Empty state: "Send a message to start this conversation." centered
- Wrap in a `ScrollArea` or use a plain `div` with `overflow-auto flex-1`
- The parent (`ChatPanel`) wraps this in the scroll region
**ChatPanel.tsx update:**
Replace the placeholder content in `ChatPanel.tsx` (from Plan 04) with the real components:
- Import `ChatConversationList`, `ChatMessageList`, `useCompany`, `useChatMessages`, `chatApi`, `useQueryClient`
- Get `selectedCompanyId` from `useCompany()`
- Get `activeConversationId`, `setActiveConversationId` from `useChatPanel()`
- Left column: `<ChatConversationList companyId={selectedCompanyId!} />` (guard: only render if `selectedCompanyId`)
- Right column:
- If `activeConversationId`: render `<ChatMessageList conversationId={activeConversationId} />`
- If no `activeConversationId`: show empty state "Send a message to start this conversation."
**Message send flow -- two distinct paths in handleSend:**
The `handleSend` function in ChatPanel handles two cases:
1. **No active conversation (activeConversationId is null):** Call `chatApi.createConversation(selectedCompanyId!, {})` directly to create a new conversation, then set it as active via `setActiveConversationId(newConvo.id)`, then call `chatApi.postMessage(newConvo.id, { role: "user", content })`. This path uses `chatApi` directly (NOT `useChatMessages.sendMutation`) because `sendMutation` requires a non-null `conversationId` which does not exist yet when the mutation is configured.
2. **Active conversation exists (activeConversationId is set):** Call `useChatMessages(activeConversationId).sendMutation.mutateAsync({ content })`. This path uses the hook's mutation which handles query invalidation automatically.
Both paths invalidate the conversation list query after completion to update sort order and lastMessagePreview.
```typescript
const { sendMutation } = useChatMessages(activeConversationId);
const queryClient = useQueryClient();
const handleSend = async (content: string) => {
if (!selectedCompanyId) return;
if (!activeConversationId) {
// Path 1: No active conversation -- create one first via direct API call
const newConvo = await chatApi.createConversation(selectedCompanyId, {});
setActiveConversationId(newConvo.id);
await chatApi.postMessage(newConvo.id, { role: "user", content });
queryClient.invalidateQueries({ queryKey: ["chat"] });
} else {
// Path 2: Active conversation -- use hook mutation for automatic invalidation
await sendMutation.mutateAsync({ content });
}
};
```
Pass `isSubmitting` to ChatInput: derive from `sendMutation.isPending` for path 2, or manage a local `isSending` state that covers both paths.
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | tail -3</automated>
</verify>
<acceptance_criteria>
- ui/src/components/ChatConversationList.tsx uses `useChatConversations` hook
- ui/src/components/ChatConversationList.tsx renders `Plus` icon button for new conversation
- ui/src/components/ChatConversationList.tsx has IntersectionObserver or sentinel div for infinite scroll
- ui/src/components/ChatConversationList.tsx shows 5 Skeleton elements during loading
- ui/src/components/ChatConversationList.tsx has delete confirmation Dialog with "Delete conversation?" title
- ui/src/components/ChatConversationItem.tsx renders `DropdownMenu` with Rename, Pin/Unpin, Archive, Delete items
- ui/src/components/ChatConversationItem.tsx applies `bg-accent/60` when `isActive`
- ui/src/components/ChatMessageList.tsx uses `useChatMessages` hook
- ui/src/components/ChatMessageList.tsx renders `ChatMessage` components
- ui/src/components/ChatMessageList.tsx auto-scrolls to bottom on new messages
- ui/src/components/ChatPanel.tsx renders `ChatConversationList` in the left column
- ui/src/components/ChatPanel.tsx renders `ChatMessageList` when `activeConversationId` is set
- ui/src/components/ChatPanel.tsx handleSend creates conversation on first send (path 1: direct chatApi)
- ui/src/components/ChatPanel.tsx handleSend uses sendMutation for existing conversation (path 2: hook mutation)
- ui/src/components/ChatPanel.tsx invalidates queries after sending a message
</acceptance_criteria>
<done>Full chat UI wired: conversation list with infinite scroll, CRUD actions (rename, pin, archive, delete with confirmation), message thread with auto-scroll, and send flow with two documented paths (direct API for new conversations, hook mutation for existing ones).</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Verify complete chat flow</name>
<files>none</files>
<action>
Human verification checkpoint. No automated work -- all implementation was completed in Tasks 1 and 2. The user follows the verification steps below to confirm the complete Phase 21 chat feature works end-to-end.
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit && pnpm --filter @paperclipai/server exec -- tsc --noEmit && echo "TYPE CHECK OK"</automated>
</verify>
<read_first>
- ui/src/components/ChatPanel.tsx
- server/src/routes/chat.ts
</read_first>
<acceptance_criteria>
- TypeScript compilation passes for both ui and server packages
- User confirms: chat panel opens/closes from Layout toggle button
- User confirms: conversations can be created, renamed, pinned, archived, deleted
- User confirms: messages persist across page reload
- User confirms: code blocks show syntax highlighting and copy button
- User confirms: theme switch changes code block colors
</acceptance_criteria>
<what-built>
Complete Phase 21 Chat Foundation: database persistence, server API, and full chat UI with conversation management, markdown rendering, syntax highlighting, and theme integration.
</what-built>
<how-to-verify>
1. Start the server: `cd /opt/nexus && pnpm dev`
2. Open the app in a browser
3. Click the MessageSquare (chat) icon in the top-right area -- the chat panel should slide open from the right
4. Click the "+" button to create a new conversation
5. Type a message and press Enter -- the message should appear as a right-aligned bubble
6. Type a message with a code block:
````
Here is some code:
```typescript
const x: number = 42;
console.log(x);
```
````
Send it. The assistant message area will not auto-reply (no streaming in Phase 21), but you can manually POST an assistant message via curl to verify rendering:
```bash
curl -X POST http://localhost:3100/api/conversations/CONVERSATION_ID/messages \
-H 'Content-Type: application/json' \
-d '{"role":"assistant","content":"Here is code:\n```typescript\nconst x: number = 42;\nconsole.log(x);\n```"}'
```
7. Verify the code block has:
- Syntax highlighting (colors matching the active theme)
- Language label ("typescript")
- Copy button (hover over the code block)
8. Switch themes (cycle button in top-right) -- verify code block colors change
9. Test conversation management:
- Hover a conversation row, click "...", try Rename, Pin, Archive, Delete
- Pin a conversation -- verify it moves to the top
- Delete a conversation -- verify confirmation dialog appears
10. Reload the page -- verify conversations and messages persist
11. Press Shift+Enter in the input -- verify newline is inserted
12. Press Escape in the input -- verify content is cleared
</how-to-verify>
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
<done>User has verified the complete Phase 21 chat flow: panel toggle, conversation CRUD, message persistence, markdown rendering, syntax highlighting, theme integration, and keyboard shortcuts.</done>
</task>
</tasks>
<verification>
- All API endpoints respond correctly (conversation CRUD + message CRUD)
- Conversation list uses infinite scroll (TanStack Query useInfiniteQuery)
- Messages render with markdown + syntax highlighting
- Theme switch updates code block colors
- Data persists across page reload
- Keyboard shortcuts work (Enter, Shift+Enter, Escape)
</verification>
<success_criteria>
- User can create, rename, pin, archive, and delete conversations
- User can send messages and see them in the thread
- Code blocks in messages have syntax highlighting, language label, and copy button
- Conversation list supports infinite scroll
- All data persists in PostgreSQL across server restarts
- Chat panel respects all three themes
</success_criteria>
<output>
After completion, create `.planning/phases/21-chat-foundation/21-05-SUMMARY.md`
</output>