4 plans across 3 waves for Chat Foundation phase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
550 lines
25 KiB
Markdown
550 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:
|
|
- 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>
|