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

26 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
21-chat-foundation 05 execute 3
21-03
21-04
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
false
HIST-02
HIST-03
truths artifacts key_links
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)
path provides exports
ui/src/api/chat.ts Chat API client functions
chatApi
path provides exports
ui/src/hooks/useChatConversations.ts TanStack Query hook for conversation list with infinite scroll
useChatConversations
path provides exports
ui/src/hooks/useChatMessages.ts TanStack Query hook for message list
useChatMessages
path provides exports
ui/src/components/ChatConversationList.tsx Sidebar conversation list with infinite scroll
ChatConversationList
path provides exports
ui/src/components/ChatConversationItem.tsx Single conversation row with action dropdown
ChatConversationItem
path provides exports
ui/src/components/ChatMessageList.tsx Message thread with auto-scroll
ChatMessageList
from to via pattern
ui/src/api/chat.ts server/src/routes/chat.ts fetch calls to /companies/:companyId/conversations and /conversations/:id/messages api.(get|post|patch|delete)
from to via pattern
ui/src/hooks/useChatConversations.ts ui/src/api/chat.ts useInfiniteQuery calling chatApi.listConversations useInfiniteQuery
from to via pattern
ui/src/components/ChatPanel.tsx ui/src/components/ChatConversationList.tsx renders ChatConversationList in left column <ChatConversationList
from to via pattern
ui/src/components/ChatPanel.tsx ui/src/components/ChatMessageList.tsx renders ChatMessageList in right column <ChatMessageList
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.

<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-03-SUMMARY.md @.planning/phases/21-chat-foundation/21-04-SUMMARY.md From ui/src/api/client.ts: ```typescript export const api = { get: (path: string) => request(path), post: (path: string, body: unknown) => request(path, { method: "POST", body: JSON.stringify(body) }), patch: (path: string, body: unknown) => request(path, { method: "PATCH", body: JSON.stringify(body) }), delete: (path: string) => request(path, { method: "DELETE" }), }; ```

From packages/shared/src/types/chat.ts (created in Plan 01):

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):

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:

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):

export function ChatMessage({ role, content }: { role: "user" | "assistant" | "system"; content: string }): JSX.Element;
Task 1: Create chat API client and TanStack Query hooks ui/src/api/chat.ts, ui/src/hooks/useChatConversations.ts, ui/src/hooks/useChatMessages.ts - 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) **chat.ts API client:**

Create ui/src/api/chat.ts:

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:

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:

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. cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | tail -3 <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> Chat API client provides 7 fetch methods. useChatConversations provides infinite scroll + CRUD mutations. useChatMessages provides paginated messages + send mutation with optimistic invalidation.

Task 2: Create ChatConversationList, ChatConversationItem, ChatMessageList, and wire ChatPanel ui/src/components/ChatConversationList.tsx, ui/src/components/ChatConversationItem.tsx, ui/src/components/ChatMessageList.tsx, ui/src/components/ChatPanel.tsx - 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) **ChatConversationItem.tsx:**

Create ui/src/components/ChatConversationItem.tsx:

import type { ChatConversationListItem } from "@paperclipai/shared";

Props:

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:

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:

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.

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. cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | tail -3 <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> 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).

Task 3: Verify complete chat flow none 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. cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit && pnpm --filter @paperclipai/server exec -- tsc --noEmit && echo "TYPE CHECK OK" - ui/src/components/ChatPanel.tsx - server/src/routes/chat.ts - 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 Complete Phase 21 Chat Foundation: database persistence, server API, and full chat UI with conversation management, markdown rendering, syntax highlighting, and theme integration. 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 Type "approved" or describe issues to fix User has verified the complete Phase 21 chat flow: panel toggle, conversation CRUD, message persistence, markdown rendering, syntax highlighting, theme integration, and keyboard shortcuts. - 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)

<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>
After completion, create `.planning/phases/21-chat-foundation/21-05-SUMMARY.md`