25 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 21-chat-foundation | 05 | execute | 3 |
|
|
false |
|
|
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 && grep -q "export const chatApi" ui/src/api/chat.ts && grep -q "useInfiniteQuery" ui/src/hooks/useChatConversations.ts && grep -q "useInfiniteQuery" ui/src/hooks/useChatMessages.ts && grep -q "sendMutation" ui/src/hooks/useChatMessages.ts && echo "OK"
<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
</acceptance_criteria>
Chat API client provides 7 fetch methods. useChatConversations provides infinite scroll + CRUD mutations. useChatMessages provides paginated messages + send mutation with optimistic invalidation.
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
truncateclass), or "New Conversation" if title is null - Preview text below title:
lastMessagePreviewtruncated,text-xs text-muted-foreground truncate - Active state:
bg-accent/60whenisActive, otherwisehover:bg-accent - On hover: reveal a
MoreHorizontalicon button (lucide-react) that opens aDropdownMenuwith items:- "Rename" — triggers inline rename (for simplicity in Phase 21, use
window.prompt("Rename conversation", currentTitle)and callonRename— a proper inline editor can be added later) - "Pin" / "Unpin" — calls
onPin(id, !isPinned)whereisPinned = !!conversation.pinnedAt - "Archive" — calls
onArchive(id) - "Delete" — calls
onDelete(id)(the parent handles the confirmation dialog)
- "Rename" — triggers inline rename (for simplicity in Phase 21, use
- Pin indicator: if
conversation.pinnedAt, show a smallPinicon (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
ScrollAreacontainer - At the top: a "New conversation" button with
Plusicon,text-xs, full width — callscreateMutation.mutateAsync()thensetActiveConversationId(newConvo.id) - Separate pinned conversations from unpinned: render pinned first (sorted by
pinnedAt), then unpinned (sorted byupdatedAt) - Map conversations to
<ChatConversationItem />entries - Loading state: 5
Skeletonelements (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 andhasNextPageis true, callfetchNextPage() - Delete confirmation: maintain a
deletingIdstate. When set, render a shadcnDialogwith 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 cleardeletingIdand if the deleted conversation was active, setactiveConversationIdto 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
messagesarray (already chronological from the hook) to<ChatMessage role={m.role} content={m.content} key={m.id} /> - Auto-scroll: use a
useRefon a bottom sentinel div anduseEffectthat scrolls it into view whenmessages.lengthchanges - Empty state: "Send a message to start this conversation." centered
- Wrap in a
ScrollAreaor use a plaindivwithoverflow-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 - Get
selectedCompanyIdfromuseCompany() - Get
activeConversationId,setActiveConversationIdfromuseChatPanel() - Wire
useChatMessages(activeConversationId)for the send handler - Left column:
<ChatConversationList companyId={selectedCompanyId!} />(guard: only render ifselectedCompanyId) - Right column:
- If
activeConversationId: render<ChatMessageList conversationId={activeConversationId} /> - If no
activeConversationId: show empty state "Send a message to start this conversation."
- If
- Wire ChatInput's
onSendto: if no activeConversationId, first create a conversation, then send message. If activeConversationId exists, just send message:
Useconst handleSend = async (content: string) => { let convId = activeConversationId; if (!convId) { const newConvo = await chatApi.createConversation(selectedCompanyId!, {}); convId = newConvo.id; setActiveConversationId(convId); } await chatApi.postMessage(convId, { role: "user", content }); // Invalidate queries queryClient.invalidateQueries({ queryKey: ["chat", "messages", convId] }); queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }); };useMutationor direct api calls withuseQueryClientfor invalidation. - Pass
isSubmittingto ChatInput from the mutation state cd /opt/nexus && grep -q "ChatConversationList" ui/src/components/ChatConversationList.tsx && grep -q "ChatConversationItem" ui/src/components/ChatConversationItem.tsx && grep -q "ChatMessageList" ui/src/components/ChatMessageList.tsx && grep -q "ChatConversationList" ui/src/components/ChatPanel.tsx && grep -q "ChatMessageList" ui/src/components/ChatPanel.tsx && grep -q "postMessage" ui/src/components/ChatPanel.tsx && echo "OK" <acceptance_criteria>- ui/src/components/ChatConversationList.tsx uses
useChatConversationshook - ui/src/components/ChatConversationList.tsx renders
Plusicon 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
DropdownMenuwith Rename, Pin/Unpin, Archive, Delete items - ui/src/components/ChatConversationItem.tsx applies
bg-accent/60whenisActive - ui/src/components/ChatMessageList.tsx uses
useChatMessageshook - ui/src/components/ChatMessageList.tsx renders
ChatMessagecomponents - ui/src/components/ChatMessageList.tsx auto-scrolls to bottom on new messages
- ui/src/components/ChatPanel.tsx renders
ChatConversationListin the left column - ui/src/components/ChatPanel.tsx renders
ChatMessageListwhenactiveConversationIdis set - ui/src/components/ChatPanel.tsx creates a conversation on first send if none active
- 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 that creates conversations on first message.
- ui/src/components/ChatConversationList.tsx uses
<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>