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

497 lines
18 KiB
Markdown

---
phase: 21-chat-foundation
plan: 04
type: execute
wave: 2
depends_on: ["21-00", "21-02"]
files_modified:
- ui/src/context/ChatPanelContext.tsx
- ui/src/components/ChatPanel.tsx
- ui/src/components/ChatInput.tsx
- ui/src/components/ChatMessage.tsx
- ui/src/main.tsx
- ui/src/components/Layout.tsx
autonomous: true
requirements: [INPUT-01, INPUT-07, THEME-01]
must_haves:
truths:
- "A chat icon button in the Layout toggles the chat drawer open/closed"
- "Chat panel open state persists to localStorage under nexus:chat-panel-open"
- "Opening chat panel closes the PropertiesPanel"
- "Chat input auto-resizes as user types, up to max-height 160px"
- "Enter sends message, Shift+Enter inserts newline, Escape clears input"
- "Chat panel uses theme CSS variables (bg-background, bg-card, border-border)"
artifacts:
- path: "ui/src/context/ChatPanelContext.tsx"
provides: "ChatPanelProvider and useChatPanel hook"
exports: ["ChatPanelProvider", "useChatPanel"]
- path: "ui/src/components/ChatPanel.tsx"
provides: "Right-side chat drawer shell"
exports: ["ChatPanel"]
- path: "ui/src/components/ChatInput.tsx"
provides: "Auto-resize textarea with keyboard shortcuts"
exports: ["ChatInput"]
- path: "ui/src/components/ChatMessage.tsx"
provides: "Message wrapper for user vs assistant alignment"
exports: ["ChatMessage"]
key_links:
- from: "ui/src/components/Layout.tsx"
to: "ui/src/components/ChatPanel.tsx"
via: "ChatPanel rendered as sibling before PropertiesPanel"
pattern: "<ChatPanel"
- from: "ui/src/components/Layout.tsx"
to: "ui/src/context/ChatPanelContext.tsx"
via: "useChatPanel hook for toggle button"
pattern: "useChatPanel"
- from: "ui/src/main.tsx"
to: "ui/src/context/ChatPanelContext.tsx"
via: "ChatPanelProvider wrapping app"
pattern: "<ChatPanelProvider"
---
<objective>
Create the chat panel shell, context provider, input component, and Layout integration.
Purpose: Wire the chat UI skeleton into the app -- a toggle button in the Layout, a right-side drawer with open/close animation, and an auto-resizing input with keyboard shortcuts. This gives users a visible, interactive chat panel.
Output: Functional chat drawer that opens/closes, with working text input.
</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-02-SUMMARY.md
<interfaces>
From ui/src/context/PanelContext.tsx:
```typescript
const STORAGE_KEY = "paperclip:panel-visible";
interface PanelContextValue {
panelContent: ReactNode | null;
panelVisible: boolean;
openPanel: (content: ReactNode) => void;
closePanel: () => void;
setPanelVisible: (visible: boolean) => void;
togglePanelVisible: () => void;
}
export function PanelProvider({ children }: { children: ReactNode }): JSX.Element;
export function usePanel(): PanelContextValue;
```
From ui/src/components/Layout.tsx (line 416-435):
```tsx
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main id="main-content" ... >
<Outlet />
</main>
<PropertiesPanel />
</div>
```
From ui/src/main.tsx (line 50-51):
```tsx
<PanelProvider>
<DialogProvider>
```
From ui/src/components/ChatMarkdownMessage.tsx (created in Plan 02):
```typescript
export function ChatMarkdownMessage({ content, className }: { content: string; className?: string }): JSX.Element;
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create ChatPanelContext and ChatInput components</name>
<files>ui/src/context/ChatPanelContext.tsx, ui/src/components/ChatInput.tsx, ui/src/components/ChatMessage.tsx, ui/src/components/ChatInput.test.tsx</files>
<behavior>
- ChatInput calls onSend when Enter is pressed without Shift
- ChatInput inserts newline when Shift+Enter is pressed
- ChatInput clears content when Escape is pressed
- ChatInput does not call onSend when input is empty
- ChatInput disables send button when isSubmitting is true
</behavior>
<read_first>
- ui/src/context/PanelContext.tsx (mirror this pattern for localStorage persistence)
- ui/src/context/ThemeContext.tsx (context + hook export pattern)
- ui/src/components/MarkdownBody.tsx (understand existing markdown rendering approach)
- ui/src/components/ChatInput.test.tsx (test stub from Plan 00 -- fill in test implementations)
</read_first>
<action>
First, update `ui/src/components/ChatInput.test.tsx` to replace `.todo` stubs with real test implementations. Use `@testing-library/react` (if available) or `renderToStaticMarkup` to verify:
- Enter key triggers onSend callback with the textarea content
- Shift+Enter does NOT trigger onSend
- Escape clears the textarea value
- Empty textarea does not trigger onSend on Enter
- Send button has disabled state when isSubmitting=true
Then create the components:
**ChatPanelContext.tsx:**
Create `ui/src/context/ChatPanelContext.tsx` mirroring the PanelContext pattern:
```typescript
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
const STORAGE_KEY = "nexus:chat-panel-open";
interface ChatPanelContextValue {
chatOpen: boolean;
activeConversationId: string | null;
setChatOpen: (open: boolean) => void;
toggleChat: () => void;
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, activeConversationId, setChatOpen, toggleChat, setActiveConversationId }}>
{children}
</ChatPanelContext.Provider>
);
}
export function useChatPanel() {
const ctx = useContext(ChatPanelContext);
if (!ctx) throw new Error("useChatPanel must be used within ChatPanelProvider");
return ctx;
}
```
Key difference from PanelContext: default is `false` (chat starts closed), and uses `nexus:` prefix not `paperclip:`.
**ChatInput.tsx:**
Create `ui/src/components/ChatInput.tsx`:
- A `<form>` wrapper with `onSubmit` that calls the provided `onSend(text)` callback
- `<textarea>` with:
- `placeholder="Message your agent..."`
- CSS: `field-sizing: content` for auto-resize (Chrome 123+, Firefox 129+)
- Fallback: `useEffect` that sets `ref.current.style.height = "auto"; ref.current.style.height = ref.current.scrollHeight + "px"` on value change
- `max-height: 160px` (10rem) with `overflow-y: auto` when exceeded
- `className`: use shadcn textarea base classes (`flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ...`) plus `resize-none min-h-[40px] max-h-[160px]`
- `rows={1}` initially
- `onKeyDown` handler:
- `e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing` -> `e.preventDefault(); submit()`
- `e.key === "Escape"` -> clear textarea value, call `e.preventDefault()`
- `Shift+Enter` -> default behavior (inserts newline)
- Send button: `Button variant="ghost" size="icon"` with `Send` icon from lucide-react
- `disabled` when textarea is empty (after trim) or `isSubmitting` is true
- `aria-label="Send message"`
- When submitting: show `Loader2` icon with `animate-spin` class
- Component props:
```typescript
interface ChatInputProps {
onSend: (content: string) => void;
isSubmitting?: boolean;
disabled?: boolean;
}
```
**ChatMessage.tsx:**
Create `ui/src/components/ChatMessage.tsx`:
```typescript
import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
import { cn } from "../lib/utils";
interface ChatMessageProps {
role: "user" | "assistant" | "system";
content: string;
}
export function ChatMessage({ role, content }: ChatMessageProps) {
if (role === "user") {
return (
<div className="flex justify-end">
<div className="max-w-[85%] rounded-lg bg-secondary px-3 py-2 text-secondary-foreground text-sm">
{content}
</div>
</div>
);
}
// assistant or system
return (
<div className="max-w-full">
<ChatMarkdownMessage content={content} />
</div>
);
}
```
User messages: right-aligned bubble with `bg-secondary`, plain text (no markdown).
Assistant messages: left-aligned, full width, rendered via `ChatMarkdownMessage`.
Run tests after implementation:
```bash
pnpm vitest run ui/src/components/ChatInput.test.tsx
```
</action>
<verify>
<automated>cd /opt/nexus && pnpm vitest run ui/src/components/ChatInput.test.tsx 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- ui/src/context/ChatPanelContext.tsx contains `const STORAGE_KEY = "nexus:chat-panel-open"`
- ui/src/context/ChatPanelContext.tsx exports `ChatPanelProvider` and `useChatPanel`
- ChatPanelContext tracks `chatOpen`, `activeConversationId`, `setChatOpen`, `toggleChat`, `setActiveConversationId`
- ui/src/components/ChatInput.tsx contains `e.key === "Enter"` with `!e.shiftKey` check
- ui/src/components/ChatInput.tsx contains `e.key === "Escape"`
- ui/src/components/ChatInput.tsx contains `field-sizing` or `scrollHeight` for auto-resize
- ui/src/components/ChatInput.tsx contains `aria-label` with "Send message"
- ui/src/components/ChatInput.tsx contains `max-h-[160px]` or equivalent max-height
- ui/src/components/ChatMessage.tsx renders `ChatMarkdownMessage` for assistant role
- ui/src/components/ChatMessage.tsx renders `bg-secondary` bubble for user role
- ChatInput tests pass via vitest
</acceptance_criteria>
<done>ChatPanelContext provides open/close state with localStorage persistence. ChatInput auto-resizes and handles Enter/Shift+Enter/Escape keyboard shortcuts. ChatMessage renders user bubbles and assistant markdown. Tests pass.</done>
</task>
<task type="auto">
<name>Task 2: Create ChatPanel shell and wire into Layout + main.tsx</name>
<files>ui/src/components/ChatPanel.tsx, ui/src/components/Layout.tsx, ui/src/main.tsx</files>
<read_first>
- ui/src/components/Layout.tsx (full file -- understand the flex row structure, PropertiesPanel placement, existing imports, and the mobile/desktop branching)
- ui/src/main.tsx (full file -- understand provider nesting order)
- ui/src/components/PropertiesPanel.tsx (understand how it reads panelVisible, its width, and its rendering pattern)
</read_first>
<action>
**ChatPanel.tsx:**
Create `ui/src/components/ChatPanel.tsx` -- the right-side drawer shell:
```typescript
import { useChatPanel } from "../context/ChatPanelContext";
import { ChatInput } from "./ChatInput";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
export function ChatPanel() {
const { chatOpen, setChatOpen, activeConversationId } = useChatPanel();
return (
<aside
aria-label="Chat"
className="hidden md:flex overflow-hidden transition-[width] duration-100 ease-out flex-shrink-0 border-l border-border flex-col bg-background"
style={{ width: chatOpen ? 380 : 0 }}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-2 min-w-[380px]">
<span className="text-sm font-medium">Chat</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setChatOpen(false)}
aria-label="Close chat"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Two-column layout: conversation list (left) + thread (right) */}
<div className="flex flex-1 min-h-0 min-w-[380px]">
{/* Left column: conversation list -- placeholder for Plan 05 */}
<div className="w-[160px] flex-shrink-0 border-r border-border bg-card overflow-hidden">
<div className="p-3 text-center text-xs text-muted-foreground">
No conversations yet
</div>
</div>
{/* Right column: message thread + input */}
<div className="flex flex-1 flex-col min-w-0">
<ScrollArea className="flex-1 p-3">
{/* Messages placeholder -- wired in Plan 05 */}
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">Send a message to start this conversation.</p>
</div>
</ScrollArea>
{/* Input area */}
<div className="border-t border-border px-3 py-2">
<ChatInput
onSend={(content) => {
// TODO: Wire to API in Plan 05
console.log("send:", content);
}}
/>
</div>
</div>
</div>
</aside>
);
}
```
Key specs per UI-SPEC:
- Width: 380px when open, 0 when closed
- `transition-[width] duration-100 ease-out` matches sidebar
- `hidden md:flex` -- desktop only
- Two-column: left 160px for conversation list, right flex-1 for messages+input
- `min-w-[380px]` on inner elements prevents content collapsing during width animation
**Layout.tsx modifications:**
1. Add imports at top:
```typescript
import { MessageSquare } from "lucide-react";
import { ChatPanel } from "./ChatPanel";
import { useChatPanel } from "../context/ChatPanelContext";
```
2. Inside the `Layout` function, add:
```typescript
const { chatOpen, toggleChat, setChatOpen } = useChatPanel();
const { setPanelVisible } = usePanel();
```
3. Add a `useEffect` that closes PropertiesPanel when chat opens (per UI-SPEC: "When ChatPanel opens, PropertiesPanel closes"):
```typescript
useEffect(() => {
if (chatOpen) {
setPanelVisible(false);
}
}, [chatOpen, setPanelVisible]);
```
4. Add a chat toggle button in the BreadcrumbBar area. Find where the theme toggle button and settings link are rendered (likely near the end of the BreadcrumbBar or in the Layout's top-right controls). Add a `MessageSquare` icon button BEFORE the theme toggle:
```tsx
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden md:inline-flex h-7 w-7"
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>
```
5. Insert `<ChatPanel />` in the flex row BEFORE `<PropertiesPanel />`:
Change:
```tsx
<main ...>
<Outlet />
</main>
<PropertiesPanel />
```
To:
```tsx
<main ...>
<Outlet />
</main>
<ChatPanel />
<PropertiesPanel />
```
**main.tsx modifications:**
Add `ChatPanelProvider` in the provider stack. Insert it as a sibling of `PanelProvider` -- AFTER `PanelProvider` (since ChatPanel needs to call `setPanelVisible` from PanelContext):
```tsx
import { ChatPanelProvider } from "./context/ChatPanelContext";
```
In the render tree, wrap after PanelProvider:
```tsx
<PanelProvider>
<ChatPanelProvider>
<DialogProvider>
...
</DialogProvider>
</ChatPanelProvider>
</PanelProvider>
```
</action>
<verify>
<automated>cd /opt/nexus && grep -q "ChatPanel" ui/src/components/Layout.tsx && grep -q "MessageSquare" ui/src/components/Layout.tsx && grep -q "ChatPanelProvider" ui/src/main.tsx && grep -q "aria-label=\"Chat\"" ui/src/components/ChatPanel.tsx && grep -q "width: chatOpen ? 380 : 0" ui/src/components/ChatPanel.tsx && echo "OK"</automated>
</verify>
<acceptance_criteria>
- ui/src/components/ChatPanel.tsx contains `aria-label="Chat"` on the aside element
- ui/src/components/ChatPanel.tsx contains `style={{ width: chatOpen ? 380 : 0 }}`
- ui/src/components/ChatPanel.tsx contains `transition-[width] duration-100 ease-out`
- ui/src/components/ChatPanel.tsx contains `hidden md:flex`
- ui/src/components/Layout.tsx imports `ChatPanel` from "./ChatPanel"
- ui/src/components/Layout.tsx imports `MessageSquare` from "lucide-react"
- ui/src/components/Layout.tsx imports `useChatPanel` from "../context/ChatPanelContext"
- ui/src/components/Layout.tsx renders `<ChatPanel />` before `<PropertiesPanel />`
- ui/src/components/Layout.tsx contains `useEffect` that calls `setPanelVisible(false)` when `chatOpen` is true
- ui/src/components/Layout.tsx contains a button with `aria-label` containing "chat" (case-insensitive)
- ui/src/main.tsx imports `ChatPanelProvider`
- ui/src/main.tsx contains `<ChatPanelProvider>` in the provider tree
</acceptance_criteria>
<done>ChatPanel renders as a 380px right-side drawer in Layout, toggled by a MessageSquare button. Opening chat closes PropertiesPanel. ChatPanelProvider is in the provider tree. The panel has a two-column skeleton ready for conversation list and message thread wiring.</done>
</task>
</tasks>
<verification>
- `cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
- `pnpm vitest run ui/src/components/ChatInput.test.tsx` passes
- Chat panel open state persists in localStorage under "nexus:chat-panel-open"
- Layout flex row: main + ChatPanel + PropertiesPanel
- ChatInput handles Enter/Shift+Enter/Escape
</verification>
<success_criteria>
- Chat toggle button visible in Layout controls area
- Chat panel opens to 380px, closes to 0 with 100ms transition
- PropertiesPanel closes when chat opens
- Input auto-resizes, keyboard shortcuts work
- All theme CSS variables used (no hardcoded colors)
- ChatInput tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/21-chat-foundation/21-04-SUMMARY.md`
</output>