4 plans across 3 waves for Chat Foundation phase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
20 KiB
20 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 21-chat-foundation | 02 | execute | 1 |
|
true |
|
|
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.mdFrom 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>