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

18 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
21-chat-foundation 04 execute 2
21-00
21-02
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
true
INPUT-01
INPUT-07
THEME-01
truths artifacts key_links
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)
path provides exports
ui/src/context/ChatPanelContext.tsx ChatPanelProvider and useChatPanel hook
ChatPanelProvider
useChatPanel
path provides exports
ui/src/components/ChatPanel.tsx Right-side chat drawer shell
ChatPanel
path provides exports
ui/src/components/ChatInput.tsx Auto-resize textarea with keyboard shortcuts
ChatInput
path provides exports
ui/src/components/ChatMessage.tsx Message wrapper for user vs assistant alignment
ChatMessage
from to via pattern
ui/src/components/Layout.tsx ui/src/components/ChatPanel.tsx ChatPanel rendered as sibling before PropertiesPanel <ChatPanel
from to via pattern
ui/src/components/Layout.tsx ui/src/context/ChatPanelContext.tsx useChatPanel hook for toggle button useChatPanel
from to via pattern
ui/src/main.tsx ui/src/context/ChatPanelContext.tsx ChatPanelProvider wrapping app <ChatPanelProvider
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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):

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

<PanelProvider>
  <DialogProvider>

From ui/src/components/ChatMarkdownMessage.tsx (created in Plan 02):

export function ChatMarkdownMessage({ content, className }: { content: string; className?: string }): JSX.Element;
Task 1: Create ChatPanelContext and ChatInput components ui/src/context/ChatPanelContext.tsx, ui/src/components/ChatInput.tsx, ui/src/components/ChatMessage.tsx, ui/src/components/ChatInput.test.tsx - 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 - 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) 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:

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:
    interface ChatInputProps {
      onSend: (content: string) => void;
      isSubmitting?: boolean;
      disabled?: boolean;
    }
    

ChatMessage.tsx:

Create ui/src/components/ChatMessage.tsx:

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:

pnpm vitest run ui/src/components/ChatInput.test.tsx
cd /opt/nexus && pnpm vitest run ui/src/components/ChatInput.test.tsx 2>&1 | tail -5 - 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 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. Task 2: Create ChatPanel shell and wire into Layout + main.tsx ui/src/components/ChatPanel.tsx, ui/src/components/Layout.tsx, ui/src/main.tsx - 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) **ChatPanel.tsx:**

Create ui/src/components/ChatPanel.tsx -- the right-side drawer shell:

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:

    import { MessageSquare } from "lucide-react";
    import { ChatPanel } from "./ChatPanel";
    import { useChatPanel } from "../context/ChatPanelContext";
    
  2. Inside the Layout function, add:

    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"):

    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:

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

    <main ...>
      <Outlet />
    </main>
    <PropertiesPanel />
    

    To:

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

import { ChatPanelProvider } from "./context/ChatPanelContext";

In the render tree, wrap after PanelProvider:

<PanelProvider>
  <ChatPanelProvider>
    <DialogProvider>
      ...
    </DialogProvider>
  </ChatPanelProvider>
</PanelProvider>
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" - 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 `` before `` - 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 `` in the provider tree 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. - `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

<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>
After completion, create `.planning/phases/21-chat-foundation/21-04-SUMMARY.md`