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

553 lines
25 KiB
Markdown

---
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: "<ChatPanel"
- from: "ui/src/components/Layout.tsx"
to: "ui/src/context/ChatPanelContext.tsx"
via: "useChatPanel to close PropertiesPanel when chat opens"
pattern: "useChatPanel"
- from: "ui/src/components/ChatConversationList.tsx"
to: "ui/src/hooks/useChatConversations.ts"
via: "useInfiniteQuery for paginated conversation list"
pattern: "useChatConversations"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- From Plan 01 outputs -->
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:
```typescript
export function ChatInput({ onSend, onClose, isSubmitting, className }: {
onSend: (content: string) => void;
onClose?: () => void;
isSubmitting?: boolean;
className?: string;
})
```
<!-- Existing codebase interfaces -->
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
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main id="main-content" ...>
<Outlet />
</main>
<PropertiesPanel />
</div>
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Chat API client, context provider, and TanStack Query hooks</name>
<files>
ui/src/api/chat.ts,
ui/src/context/ChatPanelContext.tsx,
ui/src/hooks/useChatConversations.ts,
ui/src/hooks/useChatMessages.ts,
ui/src/main.tsx
</files>
<read_first>
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
</read_first>
<action>
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).
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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`
</acceptance_criteria>
<done>Chat API client, context, and hooks are wired. ChatPanelProvider is in the app tree.</done>
</task>
<task type="auto">
<name>Task 2: ChatPanel, ChatConversationList, ChatMessageList, and Layout integration</name>
<files>
ui/src/components/ChatPanel.tsx,
ui/src/components/ChatConversationList.tsx,
ui/src/components/ChatMessageList.tsx,
ui/src/components/Layout.tsx
</files>
<read_first>
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
</read_first>
<action>
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>`
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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 `<nav aria-label="Conversations"`
- ui/src/components/ChatConversationList.tsx contains `IntersectionObserver`
- ui/src/components/ChatConversationList.tsx contains `border-l-2 border-primary` for active state
- ui/src/components/ChatConversationList.tsx contains `"Delete this conversation?"` confirmation text
- ui/src/components/ChatConversationList.tsx contains `"No conversations yet"` empty state
- ui/src/components/ChatMessageList.tsx contains `role="log"` and `aria-live="polite"`
- ui/src/components/ChatMessageList.tsx contains `ChatMarkdownMessage`
- ui/src/components/Layout.tsx contains `import { ChatPanel }` and `<ChatPanel />`
- 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
</acceptance_criteria>
<done>Chat panel is visible in Layout, conversation list shows with infinite scroll, messages render with markdown, input sends messages. Opening chat closes PropertiesPanel.</done>
</task>
</tasks>
<verification>
- 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`
</verification>
<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>
<output>
After completion, create `.planning/phases/21-chat-foundation/21-03-SUMMARY.md`
</output>