4 plans across 3 waves for Chat Foundation phase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
483 lines
20 KiB
Markdown
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>
|