497 lines
18 KiB
Markdown
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>
|