--- phase: 21-chat-foundation plan: 03 type: execute wave: 2 depends_on: ["21-01", "21-02"] files_modified: - ui/src/api/chat.ts - ui/src/context/ChatPanelContext.tsx - ui/src/hooks/useChatConversations.ts - ui/src/hooks/useChatMessages.ts - ui/src/components/ChatPanel.tsx - ui/src/components/ChatConversationList.tsx - ui/src/components/ChatMessageList.tsx - ui/src/components/Layout.tsx - ui/src/main.tsx autonomous: true requirements: - CHAT-04 - CHAT-05 - CHAT-06 - HIST-02 - HIST-03 must_haves: truths: - "User can see a chat icon in the layout that toggles a right-side panel" - "User can create a new conversation and see it in the sidebar list" - "User can type a message, send it, and see it appear in the message list" - "Conversation list is sorted by most recent, loads more via infinite scroll" - "Opening chat panel closes the PropertiesPanel" - "Chat panel open state persists in localStorage across page loads" artifacts: - path: "ui/src/api/chat.ts" provides: "chatApi fetch wrappers for all endpoints" exports: ["chatApi"] - path: "ui/src/context/ChatPanelContext.tsx" provides: "ChatPanelProvider with open/close state and active conversation" exports: ["ChatPanelProvider", "useChatPanel"] - path: "ui/src/hooks/useChatConversations.ts" provides: "TanStack Query useInfiniteQuery wrapper for conversations" exports: ["useChatConversations"] - path: "ui/src/hooks/useChatMessages.ts" provides: "TanStack Query wrapper for messages" exports: ["useChatMessages"] - path: "ui/src/components/ChatPanel.tsx" provides: "Right-side drawer shell with conversation list and message area" contains: "role=\"complementary\"" - path: "ui/src/components/ChatConversationList.tsx" provides: "Sidebar conversation list with infinite scroll, pin/archive/delete actions" contains: "IntersectionObserver" - path: "ui/src/components/ChatMessageList.tsx" provides: "Message thread rendering user and assistant messages" contains: "role=\"log\"" key_links: - from: "ui/src/components/ChatPanel.tsx" to: "ui/src/api/chat.ts" via: "useChatConversations and useChatMessages hooks" pattern: "useChatConversations|useChatMessages" - from: "ui/src/components/Layout.tsx" to: "ui/src/components/ChatPanel.tsx" via: "ChatPanel rendered in flex row, toggle via ChatPanelContext" pattern: " Wire the chat UI together: API client, panel context, TanStack Query hooks, conversation list with infinite scroll, message list, and the chat panel drawer integrated into the Layout. Purpose: This plan connects the backend (Plan 01) and presentational components (Plan 02) into a working end-to-end chat experience where users can create conversations, send messages, and browse history. Output: A fully functional chat panel accessible from the Layout, with conversation CRUD and message display. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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-01-SUMMARY.md @.planning/phases/21-chat-foundation/21-02-SUMMARY.md From packages/shared/src/types/chat.ts: ```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 ChatMessage { id: string; conversationId: string; role: "user" | "assistant" | "system"; content: string; agentId: string | null; createdAt: string; } export interface ChatConversationListResponse { items: ChatConversation[]; hasMore: boolean; } ``` API endpoints (from Plan 01): - GET /api/companies/:companyId/conversations?cursor=&limit= - POST /api/companies/:companyId/conversations - GET /api/conversations/:id - PATCH /api/conversations/:id - DELETE /api/conversations/:id - POST /api/conversations/:id/archive - POST /api/conversations/:id/unarchive - POST /api/conversations/:id/pin - POST /api/conversations/:id/unpin - GET /api/conversations/:id/messages?cursor=&limit= - POST /api/conversations/:id/messages From ui/src/components/ChatMarkdownMessage.tsx: ```typescript export function ChatMarkdownMessage({ content, className }: { content: string; className?: string }) ``` From ui/src/components/ChatInput.tsx: ```typescript export function ChatInput({ onSend, onClose, isSubmitting, className }: { onSend: (content: string) => void; onClose?: () => void; isSubmitting?: boolean; className?: string; }) ``` From ui/src/api/client.ts: ```typescript // api is an axios-like client or fetch wrapper — used as api.get("/path"), api.post("/path", body) ``` From ui/src/context/PanelContext.tsx: ```typescript export function usePanel(): { panelVisible: boolean; setPanelVisible: (visible: boolean) => void; togglePanelVisible: () => void; // ... } ``` From ui/src/context/CompanyContext.tsx: ```typescript export function useCompany(): { selectedCompanyId: string | null; // ... } ``` From ui/src/components/Layout.tsx (line 416): ```tsx
```
Task 1: Chat API client, context provider, and TanStack Query hooks ui/src/api/chat.ts, ui/src/context/ChatPanelContext.tsx, ui/src/hooks/useChatConversations.ts, ui/src/hooks/useChatMessages.ts, ui/src/main.tsx ui/src/api/activity.ts, ui/src/api/client.ts, ui/src/context/PanelContext.tsx, ui/src/context/CompanyContext.tsx, ui/src/hooks/useKeyboardShortcuts.ts, ui/src/main.tsx 1. Create `ui/src/api/chat.ts` following the pattern from `ui/src/api/activity.ts`: ```typescript import type { ChatConversation, ChatConversationListResponse, ChatMessage } from "@paperclipai/shared"; import { api } from "./client"; 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(`/api/companies/${companyId}/conversations${qs ? `?${qs}` : ""}`); }, createConversation: (companyId: string, data?: { title?: string }) => api.post(`/api/companies/${companyId}/conversations`, data ?? {}), getConversation: (id: string) => api.get(`/api/conversations/${id}`), updateConversation: (id: string, data: { title?: string }) => api.patch(`/api/conversations/${id}`, data), deleteConversation: (id: string) => api.delete(`/api/conversations/${id}`), archiveConversation: (id: string) => api.post(`/api/conversations/${id}/archive`), unarchiveConversation: (id: string) => api.post(`/api/conversations/${id}/unarchive`), pinConversation: (id: string) => api.post(`/api/conversations/${id}/pin`), unpinConversation: (id: string) => api.post(`/api/conversations/${id}/unpin`), 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<{ items: ChatMessage[]; hasMore: boolean }>(`/api/conversations/${conversationId}/messages${qs ? `?${qs}` : ""}`); }, sendMessage: (conversationId: string, data: { role: string; content: string; agentId?: string | null }) => api.post(`/api/conversations/${conversationId}/messages`, data), }; ``` 2. Create `ui/src/context/ChatPanelContext.tsx` following the `PanelContext.tsx` localStorage pattern: ```typescript import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; const STORAGE_KEY = "nexus:chat-panel-open"; interface ChatPanelContextValue { chatOpen: boolean; setChatOpen: (open: boolean) => void; toggleChat: () => void; activeConversationId: string | null; setActiveConversationId: (id: string | null) => void; } const ChatPanelContext = createContext(null); function readPreference(): boolean { try { const raw = localStorage.getItem(STORAGE_KEY); return raw === "true"; } catch { return false; } } function writePreference(open: boolean) { try { localStorage.setItem(STORAGE_KEY, String(open)); } catch { /* ignore */ } } export function ChatPanelProvider({ children }: { children: ReactNode }) { const [chatOpen, setChatOpenState] = useState(readPreference); const [activeConversationId, setActiveConversationId] = useState(null); const setChatOpen = useCallback((open: boolean) => { setChatOpenState(open); writePreference(open); }, []); const toggleChat = useCallback(() => { setChatOpenState((prev) => { const next = !prev; writePreference(next); return next; }); }, []); return ( {children} ); } export function useChatPanel() { const ctx = useContext(ChatPanelContext); if (!ctx) throw new Error("useChatPanel must be used within ChatPanelProvider"); return ctx; } ``` 3. Create `ui/src/hooks/useChatConversations.ts`: ```typescript import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { chatApi } from "../api/chat"; export function useChatConversations(companyId: string | null) { return useInfiniteQuery({ queryKey: ["chat", "conversations", companyId], queryFn: ({ pageParam }) => chatApi.listConversations(companyId!, { cursor: pageParam as string | undefined }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.items.at(-1)?.updatedAt : undefined, enabled: !!companyId, }); } export function useCreateConversation(companyId: string | null) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data?: { title?: string }) => chatApi.createConversation(companyId!, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] }); }, }); } export function useConversationActions() { const queryClient = useQueryClient(); return { pin: useMutation({ mutationFn: chatApi.pinConversation, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }), }), unpin: useMutation({ mutationFn: chatApi.unpinConversation, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }), }), archive: useMutation({ mutationFn: chatApi.archiveConversation, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }), }), unarchive: useMutation({ mutationFn: chatApi.unarchiveConversation, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }), }), remove: useMutation({ mutationFn: chatApi.deleteConversation, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }), }), rename: useMutation({ mutationFn: ({ id, title }: { id: string; title: string }) => chatApi.updateConversation(id, { title }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }), }), }; } ``` 4. Create `ui/src/hooks/useChatMessages.ts`: ```typescript import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { chatApi } from "../api/chat"; export function useChatMessages(conversationId: string | null) { return useInfiniteQuery({ queryKey: ["chat", "messages", conversationId], queryFn: ({ pageParam }) => chatApi.listMessages(conversationId!, { cursor: pageParam as string | undefined }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.items.at(-1)?.createdAt : undefined, enabled: !!conversationId, }); } export function useSendMessage(conversationId: string | null) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (content: string) => chatApi.sendMessage(conversationId!, { role: "user", content }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] }); queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }); }, }); } ``` 5. Add `ChatPanelProvider` to `ui/src/main.tsx`: wrap the app tree with `` as a sibling of the existing providers. Import from `"./context/ChatPanelContext"`. Place it INSIDE the existing `` but outside `` (or at the same level as other context providers). cd /Volumes/UsbNvme/repos/nexus && grep -c "chatApi" ui/src/api/chat.ts && grep -c "useChatPanel" ui/src/context/ChatPanelContext.tsx && grep -c "ChatPanelProvider" ui/src/main.tsx - ui/src/api/chat.ts exports `chatApi` with methods: listConversations, createConversation, getConversation, updateConversation, deleteConversation, archiveConversation, unarchiveConversation, pinConversation, unpinConversation, listMessages, sendMessage - ui/src/context/ChatPanelContext.tsx contains `localStorage.getItem(STORAGE_KEY)` with `STORAGE_KEY = "nexus:chat-panel-open"` - ui/src/context/ChatPanelContext.tsx exports `ChatPanelProvider` and `useChatPanel` - ui/src/hooks/useChatConversations.ts contains `useInfiniteQuery` and `getNextPageParam` - ui/src/hooks/useChatMessages.ts contains `useInfiniteQuery` - ui/src/main.tsx contains `ChatPanelProvider` Chat API client, context, and hooks are wired. ChatPanelProvider is in the app tree. Task 2: ChatPanel, ChatConversationList, ChatMessageList, and Layout integration ui/src/components/ChatPanel.tsx, ui/src/components/ChatConversationList.tsx, ui/src/components/ChatMessageList.tsx, ui/src/components/Layout.tsx ui/src/components/Layout.tsx, ui/src/components/PropertiesPanel.tsx, ui/src/context/PanelContext.tsx, ui/src/context/ChatPanelContext.tsx, ui/src/context/CompanyContext.tsx, ui/src/hooks/useChatConversations.ts, ui/src/hooks/useChatMessages.ts, ui/src/components/ChatMarkdownMessage.tsx, ui/src/components/ChatInput.tsx, ui/src/api/chat.ts, ui/src/components/ui/skeleton.tsx, ui/src/components/ui/dropdown-menu.tsx, ui/src/components/ui/scroll-area.tsx, ui/src/components/ui/tooltip.tsx 1. Create `ui/src/components/ChatConversationList.tsx`: A sidebar list of conversations with infinite scroll, inline actions, and inline rename. Props: `{ companyId: string; activeId: string | null; onSelect: (id: string) => void; onNew: () => void }` Implementation details: - Uses `useChatConversations(companyId)` for paginated data - Uses `useConversationActions()` for pin/archive/delete/rename mutations - Renders `