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

483 lines
20 KiB
Markdown

---
phase: 21-chat-foundation
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- 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
autonomous: true
requirements:
- CHAT-02
- CHAT-03
- INPUT-01
- INPUT-07
- THEME-01
- THEME-02
must_haves:
truths:
- "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"
artifacts:
- path: "ui/src/components/ChatMarkdownMessage.tsx"
provides: "Markdown message renderer with syntax highlighting and copy button"
contains: "rehypeHighlight"
- path: "ui/src/components/ChatInput.tsx"
provides: "Auto-resize textarea with keyboard shortcuts"
contains: "onKeyDown"
- path: "ui/src/components/ChatMarkdownMessage.test.tsx"
provides: "Tests for markdown rendering, code block copy button"
min_lines: 30
- path: "ui/src/components/ChatInput.test.tsx"
provides: "Tests for keyboard shortcuts"
min_lines: 30
key_links:
- from: "ui/src/components/ChatMarkdownMessage.tsx"
to: "ui/src/components/MarkdownBody.tsx"
via: "extends MarkdownBody pattern with rehypeHighlight"
pattern: "rehypeHighlight"
- from: "ui/src/index.css"
to: "highlight.js themes"
via: "CSS overrides for .hljs per theme class"
pattern: "\\.hljs"
---
<objective>
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.
</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
<interfaces>
<!-- Existing components and patterns the executor must reference -->
From ui/src/components/MarkdownBody.tsx:
```typescript
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:
```typescript
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):
```typescript
// Standard shadcn Textarea component, wraps <textarea> with cn() classNames
```
From ui/src/lib/utils.ts:
```typescript
export function cn(...inputs: ClassValue[]) { ... }
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Install rehype-highlight and add theme-aware highlight.js CSS to index.css</name>
<files>
ui/src/index.css
</files>
<read_first>
ui/package.json,
ui/src/index.css,
ui/src/context/ThemeContext.tsx
</read_first>
<action>
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.
</action>
<verify>
<automated>cd /Volumes/UsbNvme/repos/nexus && grep -c "rehype-highlight" ui/package.json && grep -c "\.hljs" ui/src/index.css</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>rehype-highlight installed. Theme-aware highlight.js CSS added to index.css covering all three themes.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: ChatMarkdownMessage component with syntax highlighting and copy button</name>
<files>
ui/src/components/ChatMarkdownMessage.tsx,
ui/src/components/ChatMarkdownMessage.test.tsx
</files>
<read_first>
ui/src/components/MarkdownBody.tsx,
ui/src/context/ThemeContext.tsx,
ui/src/lib/utils.ts,
ui/src/index.css
</read_first>
<behavior>
- 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
</behavior>
<action>
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
</action>
<verify>
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>ChatMarkdownMessage renders full markdown with syntax-highlighted code blocks, language labels, and copy buttons. Test suite green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: ChatInput component with auto-resize and keyboard shortcuts</name>
<files>
ui/src/components/ChatInput.tsx,
ui/src/components/ChatInput.test.tsx
</files>
<read_first>
ui/src/components/ui/textarea.tsx,
ui/src/lib/utils.ts,
ui/src/components/MarkdownBody.tsx
</read_first>
<behavior>
- 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
</behavior>
<action>
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
</action>
<verify>
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run ui/src/components/ChatInput.test.tsx</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>ChatInput component handles auto-resize, Enter/Shift+Enter/Escape shortcuts, and submit/loading states. Test suite green.</done>
</task>
</tasks>
<verification>
- `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
</verification>
<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>
<output>
After completion, create `.planning/phases/21-chat-foundation/21-02-SUMMARY.md`
</output>