---
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>
