26 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 && 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.
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,chatApi,useQueryClient - Get
selectedCompanyIdfromuseCompany() - Get
activeConversationId,setActiveConversationIdfromuseChatPanel() - 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
Message send flow -- two distinct paths in handleSend:
The handleSend function in ChatPanel handles two cases:
-
No active conversation (activeConversationId is null): Call
chatApi.createConversation(selectedCompanyId!, {})directly to create a new conversation, then set it as active viasetActiveConversationId(newConvo.id), then callchatApi.postMessage(newConvo.id, { role: "user", content }). This path useschatApidirectly (NOTuseChatMessages.sendMutation) becausesendMutationrequires a non-nullconversationIdwhich does not exist yet when the mutation is configured. -
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).
<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>