nexus/.planning/phases/21-chat-foundation/21-02-PLAN.md
Mikkel Georgsen af211e6a39 [nexus] docs(21-chat-foundation): create phase plan
4 plans across 3 waves for Chat Foundation phase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:52:49 +02:00

20 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
21-chat-foundation 02 execute 1
ui/src/components/ChatMarkdownMessage.tsx
ui/src/components/ChatMarkdownMessage.test.tsx
ui/src/components/ChatInput.tsx
ui/src/components/ChatInput.test.tsx
ui/src/index.css
true
CHAT-02
CHAT-03
INPUT-01
INPUT-07
THEME-01
THEME-02
truths artifacts key_links
Agent messages render with full markdown: code blocks with syntax highlighting, tables, lists, headings, links, inline images
Code blocks have a one-click copy button and a language label
Code block syntax highlighting colors match the active theme (Catppuccin Mocha, Tokyo Night, Catppuccin Latte)
Chat input auto-resizes from 1 line up to 6 lines then scrolls internally
Enter sends, Shift+Enter inserts newline, Escape clears input or closes panel
Chat interface respects the Nexus theme system via CSS variables
path provides contains
ui/src/components/ChatMarkdownMessage.tsx Markdown message renderer with syntax highlighting and copy button rehypeHighlight
path provides contains
ui/src/components/ChatInput.tsx Auto-resize textarea with keyboard shortcuts onKeyDown
path provides min_lines
ui/src/components/ChatMarkdownMessage.test.tsx Tests for markdown rendering, code block copy button 30
path provides min_lines
ui/src/components/ChatInput.test.tsx Tests for keyboard shortcuts 30
from to via pattern
ui/src/components/ChatMarkdownMessage.tsx ui/src/components/MarkdownBody.tsx extends MarkdownBody pattern with rehypeHighlight rehypeHighlight
from to via pattern
ui/src/index.css highlight.js themes CSS overrides for .hljs per theme class .hljs
Build the two core presentational components for chat: the markdown message renderer (with syntax highlighting and copy button) and the auto-resize text input (with keyboard shortcuts). Also install rehype-highlight and add theme-aware highlight.js CSS.

Purpose: These components are self-contained and have no dependency on the backend API. Building them in Wave 1 alongside Plan 01 maximizes parallelism. Output: Two tested React components ready to be composed into the ChatPanel in Plan 03.

<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

From ui/src/components/MarkdownBody.tsx:

import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { useTheme, THEME_META } from "../context/ThemeContext";

interface MarkdownBodyProps {
  children: string;
  className?: string;
  resolveImageSrc?: (src: string) => string | null;
}
// Uses: <Markdown remarkPlugins={[remarkGfm]} components={components}>{content}</Markdown>

From ui/src/context/ThemeContext.tsx:

export type Theme = "catppuccin-mocha" | "tokyo-night" | "catppuccin-latte";
export const THEME_META: Record<Theme, { label: string; dark: boolean; bg: string; primary: string }>;
// Theme classes on <html>: .dark (both dark themes), .theme-tokyo-night (tokyo-night only)
// No class for catppuccin-mocha (it's the default dark), no class for catppuccin-latte (it's light)

From ui/src/components/ui/textarea.tsx (shadcn Textarea):

// Standard shadcn Textarea component, wraps <textarea> with cn() classNames

From ui/src/lib/utils.ts:

export function cn(...inputs: ClassValue[]) { ... }
Task 1: Install rehype-highlight and add theme-aware highlight.js CSS to index.css ui/src/index.css ui/package.json, ui/src/index.css, ui/src/context/ThemeContext.tsx 1. Run: `pnpm --filter @paperclipai/ui add rehype-highlight` This pulls in `rehype-highlight` (7.0.2) and `highlight.js` as a transitive dependency.
2. Verify installation: `ls node_modules/highlight.js/styles/base16/` should contain catppuccin theme files. `ls node_modules/highlight.js/styles/` should contain `tokyo-night-dark.css`.

3. Add theme-aware highlight.js CSS overrides to `ui/src/index.css`. Append AFTER the existing theme CSS variable blocks (after the last `}` of theme definitions, before any Tailwind utility layers). Do NOT import three separate CSS files — instead, define a single CSS block that maps `.hljs` variables per theme class:

   ```css
   /* ── highlight.js theme-aware overrides ───────────────────────── */
   @import "highlight.js/styles/base16/catppuccin-mocha.css" layer(hljs);

   /*
    * The base import gives us catppuccin-mocha as default.
    * Override for tokyo-night and catppuccin-latte via specificity.
    */
   .theme-tokyo-night .hljs {
     background: var(--card);
     color: #a9b1d6;
   }
   .theme-tokyo-night .hljs-keyword { color: #bb9af7; }
   .theme-tokyo-night .hljs-string { color: #9ece6a; }
   .theme-tokyo-night .hljs-number { color: #ff9e64; }
   .theme-tokyo-night .hljs-comment { color: #565f89; }
   .theme-tokyo-night .hljs-function,
   .theme-tokyo-night .hljs-title { color: #7aa2f7; }
   .theme-tokyo-night .hljs-built_in { color: #e0af68; }
   .theme-tokyo-night .hljs-type { color: #2ac3de; }
   .theme-tokyo-night .hljs-attr { color: #73daca; }
   .theme-tokyo-night .hljs-literal { color: #ff9e64; }
   .theme-tokyo-night .hljs-selector-class { color: #7aa2f7; }

   :root:not(.dark) .hljs {
     background: var(--card);
     color: #4c4f69;
   }
   :root:not(.dark) .hljs-keyword { color: #8839ef; }
   :root:not(.dark) .hljs-string { color: #40a02b; }
   :root:not(.dark) .hljs-number { color: #fe640b; }
   :root:not(.dark) .hljs-comment { color: #9ca0b0; }
   :root:not(.dark) .hljs-function,
   :root:not(.dark) .hljs-title { color: #1e66f5; }
   :root:not(.dark) .hljs-built_in { color: #df8e1d; }
   :root:not(.dark) .hljs-type { color: #179299; }
   :root:not(.dark) .hljs-attr { color: #179299; }
   :root:not(.dark) .hljs-literal { color: #fe640b; }
   :root:not(.dark) .hljs-selector-class { color: #1e66f5; }
   ```

   This approach: imports catppuccin-mocha as the base layer (matches default dark theme), overrides for tokyo-night via `.theme-tokyo-night` class (which ThemeContext applies to `<html>`), and overrides for catppuccin-latte via `:root:not(.dark)` (light mode). Uses `var(--card)` for code block background so it integrates with the theme system. The `layer(hljs)` import keeps specificity manageable.
cd /Volumes/UsbNvme/repos/nexus && grep -c "rehype-highlight" ui/package.json && grep -c "\.hljs" ui/src/index.css - ui/package.json contains "rehype-highlight" in dependencies - ui/src/index.css contains `.theme-tokyo-night .hljs` - ui/src/index.css contains `:root:not(.dark) .hljs` - ui/src/index.css contains `@import "highlight.js/styles/base16/catppuccin-mocha.css"` - ui/src/index.css does NOT contain three separate `@import` for highlight.js (only one base import) rehype-highlight installed. Theme-aware highlight.js CSS added to index.css covering all three themes. Task 2: ChatMarkdownMessage component with syntax highlighting and copy button ui/src/components/ChatMarkdownMessage.tsx, ui/src/components/ChatMarkdownMessage.test.tsx ui/src/components/MarkdownBody.tsx, ui/src/context/ThemeContext.tsx, ui/src/lib/utils.ts, ui/src/index.css - Test: renders markdown with headings, bold, italic, links, lists, tables - Test: renders code blocks with `language-` class from highlight.js - Test: code block container has a copy button with aria-label="Copy code" - Test: code block shows language label when language is specified (e.g. "typescript") - Test: inline code renders without copy button - Test: renders inline images with img tag Create `ui/src/components/ChatMarkdownMessage.tsx`:
```typescript
import { useCallback, useState, type ReactNode } from "react";
import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import { Copy, Check } from "lucide-react";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";

interface ChatMarkdownMessageProps {
  content: string;
  className?: string;
}

function extractText(node: ReactNode): string {
  if (typeof node === "string") return node;
  if (typeof node === "number") return String(node);
  if (Array.isArray(node)) return node.map(extractText).join("");
  if (node && typeof node === "object" && "props" in node) {
    return extractText((node as any).props.children);
  }
  return "";
}

function CodeBlock({ children, className, ...props }: { children?: ReactNode; className?: string }) {
  const [copied, setCopied] = useState(false);
  const language = className?.replace(/^language-/, "") ?? null;
  const codeText = extractText(children);

  const handleCopy = useCallback(() => {
    navigator.clipboard.writeText(codeText);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  }, [codeText]);

  return (
    <div className="relative bg-card border border-border my-2">
      {language && (
        <span className="absolute top-2 left-3 text-xs text-muted-foreground select-none">
          {language}
        </span>
      )}
      <Button
        variant="ghost"
        size="icon"
        className="absolute top-1.5 right-1.5 h-7 w-7"
        onClick={handleCopy}
        aria-label="Copy code"
      >
        {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
      </Button>
      <pre className={cn("overflow-x-auto p-4 pt-8 text-sm", className)} {...props}>
        <code className={className}>{children}</code>
      </pre>
    </div>
  );
}

const components: Partial<Components> = {
  pre({ children, ...props }) {
    // Check if child is a <code> element with language class (code block)
    if (children && typeof children === "object" && "props" in (children as any)) {
      const childProps = (children as any).props;
      const childClassName = childProps?.className ?? "";
      return (
        <CodeBlock className={childClassName} {...props}>
          {childProps?.children}
        </CodeBlock>
      );
    }
    return <pre {...props}>{children}</pre>;
  },
};

export function ChatMarkdownMessage({ content, className }: ChatMarkdownMessageProps) {
  return (
    <div className={cn("prose prose-sm max-w-none dark:prose-invert", className)}>
      <Markdown
        remarkPlugins={[remarkGfm]}
        rehypePlugins={[rehypeHighlight]}
        components={components}
      >
        {content}
      </Markdown>
    </div>
  );
}
```

Create `ui/src/components/ChatMarkdownMessage.test.tsx`:
- Import `render, screen` from `@testing-library/react`
- Test rendering markdown headings: `render(<ChatMarkdownMessage content="# Hello" />)`, expect `screen.getByRole("heading", { level: 1 })` to have text "Hello"
- Test code block: `render(<ChatMarkdownMessage content={"```typescript\nconst x = 1;\n```"} />)`, expect `screen.getByLabelText("Copy code")` to exist, expect language label "typescript" to be in the document
- Test inline code does NOT have copy button: render with `\`inline\`` and verify no "Copy code" button
- Test tables, lists, links render as expected HTML elements
cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx - ui/src/components/ChatMarkdownMessage.tsx contains `import rehypeHighlight from "rehype-highlight"` - ui/src/components/ChatMarkdownMessage.tsx contains `rehypePlugins={[rehypeHighlight]}` - ui/src/components/ChatMarkdownMessage.tsx contains `aria-label="Copy code"` - ui/src/components/ChatMarkdownMessage.tsx contains `navigator.clipboard.writeText` - ui/src/components/ChatMarkdownMessage.tsx exports `ChatMarkdownMessage` - ui/src/components/ChatMarkdownMessage.test.tsx exits 0 ChatMarkdownMessage renders full markdown with syntax-highlighted code blocks, language labels, and copy buttons. Test suite green. Task 3: ChatInput component with auto-resize and keyboard shortcuts ui/src/components/ChatInput.tsx, ui/src/components/ChatInput.test.tsx ui/src/components/ui/textarea.tsx, ui/src/lib/utils.ts, ui/src/components/MarkdownBody.tsx - Test: Enter key (without Shift) calls onSend with current value and clears input - Test: Shift+Enter inserts a newline (does not call onSend) - Test: Escape clears input when input has content - Test: Escape calls onClose when input is empty - Test: Send button is disabled when input is empty - Test: Send button has aria-label="Send message" - Test: textarea has aria-label="Message input" - Test: input is disabled and send shows loader when isSubmitting=true Create `ui/src/components/ChatInput.tsx`:
```typescript
import { useCallback, useRef, useState, type KeyboardEvent } from "react";
import { Loader2, Send } from "lucide-react";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";

interface ChatInputProps {
  onSend: (content: string) => void;
  onClose?: () => void;
  isSubmitting?: boolean;
  className?: string;
}

export function ChatInput({ onSend, onClose, isSubmitting = false, className }: ChatInputProps) {
  const [value, setValue] = useState("");
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const adjustHeight = useCallback(() => {
    const el = textareaRef.current;
    if (!el) return;
    el.style.height = "auto";
    el.style.height = Math.min(el.scrollHeight, 160) + "px";
  }, []);

  const handleSend = useCallback(() => {
    const trimmed = value.trim();
    if (!trimmed || isSubmitting) return;
    onSend(trimmed);
    setValue("");
    if (textareaRef.current) {
      textareaRef.current.style.height = "auto";
    }
  }, [value, isSubmitting, onSend]);

  const handleKeyDown = useCallback(
    (e: KeyboardEvent<HTMLTextAreaElement>) => {
      if (e.key === "Enter" && !e.shiftKey) {
        e.preventDefault();
        handleSend();
      } else if (e.key === "Escape") {
        e.preventDefault();
        if (value.trim()) {
          setValue("");
          if (textareaRef.current) {
            textareaRef.current.style.height = "auto";
          }
        } else {
          onClose?.();
        }
      }
    },
    [handleSend, value, onClose],
  );

  const isEmpty = value.trim().length === 0;

  return (
    <div className={cn("flex items-end gap-2 border-t border-border p-3", className)}>
      <textarea
        ref={textareaRef}
        value={value}
        onChange={(e) => {
          setValue(e.target.value);
          adjustHeight();
        }}
        onKeyDown={handleKeyDown}
        placeholder="Send a message..."
        disabled={isSubmitting}
        aria-label="Message input"
        className={cn(
          "flex-1 resize-none bg-muted border border-border px-3 py-2 text-sm",
          "placeholder:text-muted-foreground",
          "focus:outline-none focus:ring-1 focus:ring-ring",
          "disabled:opacity-50 disabled:cursor-not-allowed",
        )}
        style={{ minHeight: 40, maxHeight: 160, fieldSizing: "content" } as any}
        rows={1}
      />
      <Button
        variant="default"
        size="icon"
        onClick={handleSend}
        disabled={isEmpty || isSubmitting}
        aria-label="Send message"
        aria-disabled={isEmpty || isSubmitting}
        className="h-10 w-10 shrink-0"
      >
        {isSubmitting ? (
          <Loader2 className="h-4 w-4 animate-spin" />
        ) : (
          <Send className="h-4 w-4" />
        )}
      </Button>
    </div>
  );
}
```

Create `ui/src/components/ChatInput.test.tsx`:
- Import `render, screen, fireEvent` from `@testing-library/react`
- Test Enter sends: render with `onSend` spy, type "hello", fire Enter keydown (without shiftKey), assert `onSend` called with "hello"
- Test Shift+Enter does not send: fire keydown with `key: "Enter", shiftKey: true`, assert `onSend` NOT called
- Test Escape clears: type "hello", fire Escape, assert textarea value is empty
- Test Escape calls onClose when empty: render with `onClose` spy, fire Escape on empty textarea, assert `onClose` called
- Test disabled send button: render without typing, assert send button has `disabled` attribute
- Test isSubmitting: render with `isSubmitting={true}`, assert textarea is disabled
cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run ui/src/components/ChatInput.test.tsx - ui/src/components/ChatInput.tsx contains `aria-label="Message input"` - ui/src/components/ChatInput.tsx contains `aria-label="Send message"` - ui/src/components/ChatInput.tsx contains `e.key === "Enter" && !e.shiftKey` - ui/src/components/ChatInput.tsx contains `e.key === "Escape"` - ui/src/components/ChatInput.tsx contains `onClose?.()` - ui/src/components/ChatInput.tsx contains `style={{ minHeight: 40, maxHeight: 160` - ui/src/components/ChatInput.tsx exports `ChatInput` - ui/src/components/ChatInput.test.tsx exits 0 ChatInput component handles auto-resize, Enter/Shift+Enter/Escape shortcuts, and submit/loading states. Test suite green. - `pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx` passes - `pnpm vitest run ui/src/components/ChatInput.test.tsx` passes - `grep "rehype-highlight" ui/package.json` returns a match - `grep ".hljs" ui/src/index.css` shows theme-aware overrides

<success_criteria>

  • ChatMarkdownMessage renders markdown with GFM, syntax-highlighted code blocks, language labels, and copy buttons
  • ChatInput auto-resizes from 1 to 6 lines, sends on Enter, newline on Shift+Enter, clears/closes on Escape
  • All three themes have matching highlight.js CSS applied via class overrides
  • All component tests pass </success_criteria>
After completion, create `.planning/phases/21-chat-foundation/21-02-SUMMARY.md`