nexus/.planning/phases/21-chat-foundation/21-03-PLAN.md

25 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
21-chat-foundation 03 execute 2
21-01
21-02
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
true
CHAT-04
CHAT-05
CHAT-06
HIST-02
HIST-03
truths artifacts key_links
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
path provides exports
ui/src/api/chat.ts chatApi fetch wrappers for all endpoints
chatApi
path provides exports
ui/src/context/ChatPanelContext.tsx ChatPanelProvider with open/close state and active conversation
ChatPanelProvider
useChatPanel
path provides exports
ui/src/hooks/useChatConversations.ts TanStack Query useInfiniteQuery wrapper for conversations
useChatConversations
path provides exports
ui/src/hooks/useChatMessages.ts TanStack Query wrapper for messages
useChatMessages
path provides contains
ui/src/components/ChatPanel.tsx Right-side drawer shell with conversation list and message area role="complementary"
path provides contains
ui/src/components/ChatConversationList.tsx Sidebar conversation list with infinite scroll, pin/archive/delete actions IntersectionObserver
path provides contains
ui/src/components/ChatMessageList.tsx Message thread rendering user and assistant messages role="log"
from to via pattern
ui/src/components/ChatPanel.tsx ui/src/api/chat.ts useChatConversations and useChatMessages hooks useChatConversations|useChatMessages
from to via pattern
ui/src/components/Layout.tsx ui/src/components/ChatPanel.tsx ChatPanel rendered in flex row, toggle via ChatPanelContext <ChatPanel
from to via pattern
ui/src/components/Layout.tsx ui/src/context/ChatPanelContext.tsx useChatPanel to close PropertiesPanel when chat opens useChatPanel
from to via pattern
ui/src/components/ChatConversationList.tsx ui/src/hooks/useChatConversations.ts useInfiniteQuery for paginated conversation list useChatConversations
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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-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 Plan 02 outputs -->
From ui/src/components/ChatMarkdownMessage.tsx:
```typescript
export function ChatMarkdownMessage({ content, className }: { content: string; className?: string })

From ui/src/components/ChatInput.tsx:

export function ChatInput({ onSend, onClose, isSubmitting, className }: {
  onSend: (content: string) => void;
  onClose?: () => void;
  isSubmitting?: boolean;
  className?: string;
})

From ui/src/api/client.ts:

// api is an axios-like client or fetch wrapper — used as api.get("/path"), api.post("/path", body)

From ui/src/context/PanelContext.tsx:

export function usePanel(): {
  panelVisible: boolean;
  setPanelVisible: (visible: boolean) => void;
  togglePanelVisible: () => void;
  // ...
}

From ui/src/context/CompanyContext.tsx:

export function useCompany(): {
  selectedCompanyId: string | null;
  // ...
}

From ui/src/components/Layout.tsx (line 416):

<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
  <main id="main-content" ...>
    <Outlet />
  </main>
  <PropertiesPanel />
</div>
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<ChatConversationListResponse>(`/api/companies/${companyId}/conversations${qs ? `?${qs}` : ""}`);
     },
     createConversation: (companyId: string, data?: { title?: string }) =>
       api.post<ChatConversation>(`/api/companies/${companyId}/conversations`, data ?? {}),
     getConversation: (id: string) =>
       api.get<ChatConversation>(`/api/conversations/${id}`),
     updateConversation: (id: string, data: { title?: string }) =>
       api.patch<ChatConversation>(`/api/conversations/${id}`, data),
     deleteConversation: (id: string) =>
       api.delete(`/api/conversations/${id}`),
     archiveConversation: (id: string) =>
       api.post<ChatConversation>(`/api/conversations/${id}/archive`),
     unarchiveConversation: (id: string) =>
       api.post<ChatConversation>(`/api/conversations/${id}/unarchive`),
     pinConversation: (id: string) =>
       api.post<ChatConversation>(`/api/conversations/${id}/pin`),
     unpinConversation: (id: string) =>
       api.post<ChatConversation>(`/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<ChatMessage>(`/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<ChatPanelContextValue | null>(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<string | null>(null);

     const setChatOpen = useCallback((open: boolean) => {
       setChatOpenState(open);
       writePreference(open);
     }, []);

     const toggleChat = useCallback(() => {
       setChatOpenState((prev) => {
         const next = !prev;
         writePreference(next);
         return next;
       });
     }, []);

     return (
       <ChatPanelContext.Provider
         value={{ chatOpen, setChatOpen, toggleChat, activeConversationId, setActiveConversationId }}
       >
         {children}
       </ChatPanelContext.Provider>
     );
   }

   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 `<ChatPanelProvider>` as a sibling of the existing providers. Import from `"./context/ChatPanelContext"`. Place it INSIDE the existing `<QueryClientProvider>` but outside `<RouterProvider>` (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 `<nav aria-label="Conversations">` containing a scrollable `<ScrollArea>` with list items
   - Each item: 48px height, `py-3 px-3` padding, `text-[13px]` title (truncated), `text-xs text-muted-foreground` timestamp right-aligned
   - Active item: `border-l-2 border-primary bg-sidebar-accent`
   - Hover: `bg-sidebar-accent/50` with a `<DropdownMenu>` trigger (MoreHorizontal icon) appearing on hover
   - DropdownMenu items: "Rename conversation", "Pin/Unpin conversation", "Archive/Unarchive conversation", "Delete conversation" (text-destructive)
   - Delete uses inline confirmation: when delete is clicked, replace the dropdown with a small popover showing "Delete this conversation?" with "Delete conversation" (variant="destructive") and "Keep conversation" (variant="ghost") buttons
   - Inline rename: double-click title or "Rename" from dropdown swaps title text with an `<input>` at 13px font, Enter/blur confirms, Escape cancels
   - Pinned conversations show filled Pin icon (14px, text-primary)
   - Infinite scroll: sentinel `<div ref={sentinelRef}>` at bottom, `IntersectionObserver` triggers `fetchNextPage()` when visible. While loading next page, show 2 `<Skeleton className="h-12 mx-3 my-1">` items
   - Loading state (initial): show 3 `<Skeleton>` items with `aria-busy="true"` on list container
   - Empty state: centered text "No conversations yet" (text-sm text-muted-foreground), "Start a conversation to get help with your work." body, "New conversation" button
   - Header: "Chat" title (text-base font-semibold), Plus icon button (tooltip "New conversation"), X icon button (close)

2. Create `ui/src/components/ChatMessageList.tsx`:
   Message thread for a single conversation.

   Props: `{ conversationId: string }`

   Implementation details:
   - Uses `useChatMessages(conversationId)` for data
   - Container: `<div role="log" aria-live="polite">` with `p-4 gap-4 flex flex-col`
   - User messages: right-aligned (`ml-auto`), `bg-secondary text-secondary-foreground`, `max-w-[75%]`, `px-4 py-2`, plain text (no markdown)
   - Assistant messages: left-aligned, no background, `max-w-[85%]`, rendered via `<ChatMarkdownMessage content={msg.content} />`
   - Timestamps: `text-xs text-muted-foreground`, visible on hover (`opacity-0 group-hover:opacity-100 transition-opacity`)
   - Auto-scroll to bottom when new messages arrive: `useEffect` with `scrollIntoView({ behavior: "smooth" })` on a bottom sentinel ref
   - Loading: single `<Skeleton>` block
   - Empty (no messages yet): light prompt text "Send a message to start the conversation."

3. Create `ui/src/components/ChatPanel.tsx`:
   The main right-side drawer shell that composes ChatConversationList, ChatMessageList, and ChatInput.

   Implementation details:
   - Uses `useChatPanel()` for open/close state and activeConversationId
   - Uses `useCompany()` for selectedCompanyId
   - Uses `useCreateConversation(companyId)` for creating new conversations
   - Uses `useSendMessage(activeConversationId)` for sending messages
   - Outer div: `role="complementary" aria-label="Chat"`, width transition `transition-[width] duration-100 ease-out`, width: `chatOpen ? 380 : 0`, `overflow-hidden`, `border-l border-border`, `flex-shrink-0`
   - Internal layout when open: flex column, full height
     - Top: header bar (48px, `border-b border-border`, "Chat" heading, plus button, close button)
     - Middle: split horizontally — left side is `ChatConversationList` (240px wide, `bg-sidebar`, `border-r border-border`), right side is `ChatMessageList` (flex-1)
     - When no activeConversationId: show conversation list full-width and empty state
     - When activeConversationId set: show conversation list (240px) + message area
     - Bottom: `ChatInput` with `onSend` that calls `sendMessage.mutateAsync(content)`, `onClose` that calls `setChatOpen(false)`, `isSubmitting` bound to `sendMessage.isPending`
   - On "New conversation": call `createConversation.mutateAsync()`, set activeConversationId to the returned id, focus the ChatInput textarea
   - Focus management: when panel opens, focus ChatInput. When new conversation created, focus ChatInput.

4. Modify `ui/src/components/Layout.tsx`:
   - Add import: `import { ChatPanel } from "./ChatPanel";`
   - Add import: `import { useChatPanel } from "../context/ChatPanelContext";`
   - Add import: `import { MessageSquare } from "lucide-react";`
   - In the `Layout()` function body, add: `const { chatOpen, toggleChat, setChatOpen } = useChatPanel();`
   - Add effect: when `chatOpen` becomes true, call `setPanelVisible(false)` to close PropertiesPanel. This prevents both panels from competing for space.
   - In the flex row at line 416 (`<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>`), AFTER `<PropertiesPanel />` (line 434), add `<ChatPanel />`.
   - Add a chat toggle button in the top-right area of the layout (near the theme toggle button, around line 290-310). Use: `<Tooltip><TooltipTrigger asChild><Button variant="ghost" size="icon" onClick={toggleChat} aria-label={chatOpen ? "Close chat" : "Open chat"}><MessageSquare className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{chatOpen ? "Close chat" : "Open chat"}</TooltipContent></Tooltip>`
cd /Volumes/UsbNvme/repos/nexus && grep -c "ChatPanel" ui/src/components/Layout.tsx && grep -c "role=\"complementary\"" ui/src/components/ChatPanel.tsx && grep -c "IntersectionObserver" ui/src/components/ChatConversationList.tsx && grep -c "role=\"log\"" ui/src/components/ChatMessageList.tsx - ui/src/components/ChatPanel.tsx contains `role="complementary"` and `aria-label="Chat"` - ui/src/components/ChatPanel.tsx contains `transition-[width] duration-100 ease-out` - ui/src/components/ChatPanel.tsx contains `chatOpen ? 380 : 0` - ui/src/components/ChatConversationList.tsx contains `` - ui/src/components/Layout.tsx contains `useChatPanel` - ui/src/components/Layout.tsx contains `MessageSquare` - ui/src/components/Layout.tsx contains `setPanelVisible(false)` when chat opens Chat panel is visible in Layout, conversation list shows with infinite scroll, messages render with markdown, input sends messages. Opening chat closes PropertiesPanel. - App compiles without errors: `cd /Volumes/UsbNvme/repos/nexus && pnpm --filter @paperclipai/ui build` succeeds - ChatPanel renders in Layout with width transition - ChatConversationList uses IntersectionObserver for infinite scroll - ChatMessageList renders messages with ChatMarkdownMessage - Full test suite still passes: `pnpm test:run`

<success_criteria>

  • User can open/close chat panel via MessageSquare button in Layout
  • User can create a new conversation via the plus button
  • User can send a message and see it in the message list
  • Conversation list shows sorted by most recent with infinite scroll
  • Pin/archive/delete/rename work from dropdown menu
  • Opening chat closes PropertiesPanel
  • Panel state persists in localStorage </success_criteria>
After completion, create `.planning/phases/21-chat-foundation/21-03-SUMMARY.md`