293 lines
13 KiB
Markdown
293 lines
13 KiB
Markdown
---
|
|
phase: 21-chat-foundation
|
|
plan: 02
|
|
type: execute
|
|
wave: 1
|
|
depends_on: ["21-00"]
|
|
files_modified:
|
|
- ui/src/components/ChatMarkdownMessage.tsx
|
|
- ui/src/components/ChatCodeBlock.tsx
|
|
- ui/src/index.css
|
|
- ui/package.json
|
|
autonomous: true
|
|
requirements: [CHAT-02, CHAT-03, THEME-02]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Markdown messages render with syntax-highlighted code blocks"
|
|
- "Code blocks show a language label and a one-click copy button"
|
|
- "Code block highlighting changes correctly when switching between Catppuccin Mocha, Tokyo Night, and Catppuccin Latte"
|
|
artifacts:
|
|
- path: "ui/src/components/ChatMarkdownMessage.tsx"
|
|
provides: "Markdown renderer with rehype-highlight"
|
|
exports: ["ChatMarkdownMessage"]
|
|
- path: "ui/src/components/ChatCodeBlock.tsx"
|
|
provides: "Code block wrapper with copy button and language label"
|
|
exports: ["ChatCodeBlock"]
|
|
key_links:
|
|
- from: "ui/src/components/ChatMarkdownMessage.tsx"
|
|
to: "rehype-highlight"
|
|
via: "rehypePlugins prop on react-markdown"
|
|
pattern: "rehypePlugins.*rehypeHighlight"
|
|
- from: "ui/src/components/ChatCodeBlock.tsx"
|
|
to: "navigator.clipboard"
|
|
via: "writeText call on copy button click"
|
|
pattern: "navigator\\.clipboard\\.writeText"
|
|
- from: "ui/src/index.css"
|
|
to: "highlight.js themes"
|
|
via: "CSS overrides per theme class (.dark .hljs, .theme-tokyo-night .hljs)"
|
|
pattern: "\\.hljs"
|
|
---
|
|
|
|
<objective>
|
|
Install rehype-highlight and build the theme-aware markdown message renderer with code block copy functionality.
|
|
|
|
Purpose: Satisfy CHAT-02 (markdown rendering with syntax highlighting), CHAT-03 (copy button + language label on code blocks), and THEME-02 (theme-appropriate highlighting colors). This is the rendering layer used by the message list in later plans.
|
|
Output: ChatMarkdownMessage and ChatCodeBlock components, ready for use.
|
|
</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>
|
|
From ui/src/components/MarkdownBody.tsx:
|
|
```typescript
|
|
import Markdown, { type Components } from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import { cn } from "../lib/utils";
|
|
import { useTheme, THEME_META } from "../context/ThemeContext";
|
|
```
|
|
|
|
From ui/src/context/ThemeContext.tsx:
|
|
```typescript
|
|
export type Theme = "catppuccin-mocha" | "tokyo-night" | "catppuccin-latte";
|
|
export const THEME_META: Record<Theme, { dark: boolean; label: string }>;
|
|
export function useTheme(): { theme: Theme; toggleTheme: () => void };
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Install rehype-highlight and add hljs theme CSS overrides</name>
|
|
<files>ui/package.json, ui/src/index.css</files>
|
|
<read_first>
|
|
- ui/package.json (current dependencies)
|
|
- ui/src/index.css (existing theme CSS variables and .dark/.theme-tokyo-night selectors)
|
|
</read_first>
|
|
<action>
|
|
Install the dependency:
|
|
```bash
|
|
cd /opt/nexus && pnpm --filter @paperclipai/ui add rehype-highlight
|
|
```
|
|
|
|
Then add highlight.js theme CSS overrides to `ui/src/index.css`. Do NOT import highlight.js CSS files directly — instead, add CSS custom property overrides scoped to each theme class. Add the following AFTER the existing theme variable blocks (after the `.theme-tokyo-night.dark` block), but BEFORE any component-specific styles:
|
|
|
|
```css
|
|
/* -- highlight.js syntax theme overrides (chat code blocks) ------------- */
|
|
|
|
/* Base hljs reset -- ensure code blocks use our themed variables */
|
|
.hljs {
|
|
background: var(--code-block-bg, hsl(var(--card))) !important;
|
|
color: var(--code-block-fg, hsl(var(--card-foreground))) !important;
|
|
}
|
|
|
|
/* Catppuccin Mocha (default dark) */
|
|
.dark .hljs { --code-block-bg: #1e1e2e; --code-block-fg: #cdd6f4; }
|
|
.dark .hljs-keyword { color: #cba6f7; }
|
|
.dark .hljs-string { color: #a6e3a1; }
|
|
.dark .hljs-number { color: #fab387; }
|
|
.dark .hljs-comment { color: #6c7086; font-style: italic; }
|
|
.dark .hljs-function { color: #89b4fa; }
|
|
.dark .hljs-title { color: #89b4fa; }
|
|
.dark .hljs-built_in { color: #f38ba8; }
|
|
.dark .hljs-type { color: #f9e2af; }
|
|
.dark .hljs-attr { color: #89dceb; }
|
|
.dark .hljs-variable { color: #cdd6f4; }
|
|
.dark .hljs-literal { color: #fab387; }
|
|
.dark .hljs-meta { color: #f5e0dc; }
|
|
.dark .hljs-selector-class { color: #89dceb; }
|
|
.dark .hljs-selector-tag { color: #cba6f7; }
|
|
|
|
/* Tokyo Night */
|
|
.theme-tokyo-night .hljs { --code-block-bg: #1a1b26; --code-block-fg: #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; font-style: italic; }
|
|
.theme-tokyo-night .hljs-function { color: #7aa2f7; }
|
|
.theme-tokyo-night .hljs-title { color: #7aa2f7; }
|
|
.theme-tokyo-night .hljs-built_in { color: #f7768e; }
|
|
.theme-tokyo-night .hljs-type { color: #e0af68; }
|
|
.theme-tokyo-night .hljs-attr { color: #73daca; }
|
|
.theme-tokyo-night .hljs-variable { color: #a9b1d6; }
|
|
.theme-tokyo-night .hljs-literal { color: #ff9e64; }
|
|
.theme-tokyo-night .hljs-meta { color: #c0caf5; }
|
|
.theme-tokyo-night .hljs-selector-class { color: #73daca; }
|
|
.theme-tokyo-night .hljs-selector-tag { color: #bb9af7; }
|
|
|
|
/* Catppuccin Latte (light) */
|
|
:root .hljs { --code-block-bg: #eff1f5; --code-block-fg: #4c4f69; }
|
|
:root .hljs-keyword { color: #8839ef; }
|
|
:root .hljs-string { color: #40a02b; }
|
|
:root .hljs-number { color: #fe640b; }
|
|
:root .hljs-comment { color: #9ca0b0; font-style: italic; }
|
|
:root .hljs-function { color: #1e66f5; }
|
|
:root .hljs-title { color: #1e66f5; }
|
|
:root .hljs-built_in { color: #d20f39; }
|
|
:root .hljs-type { color: #df8e1d; }
|
|
:root .hljs-attr { color: #179299; }
|
|
:root .hljs-variable { color: #4c4f69; }
|
|
:root .hljs-literal { color: #fe640b; }
|
|
:root .hljs-meta { color: #dc8a78; }
|
|
:root .hljs-selector-class { color: #179299; }
|
|
:root .hljs-selector-tag { color: #8839ef; }
|
|
```
|
|
|
|
IMPORTANT: The `.dark` selector matches Catppuccin Mocha. The `.theme-tokyo-night` selector overrides for Tokyo Night. The `:root` selector (without `.dark`) matches Catppuccin Latte. This aligns with the existing theme CSS variable structure in index.css.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /opt/nexus && grep -q "rehype-highlight" ui/package.json && grep -q "\.hljs-keyword" ui/src/index.css && grep -q "theme-tokyo-night .hljs" ui/src/index.css && echo "OK"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- ui/package.json contains "rehype-highlight" in dependencies
|
|
- ui/src/index.css contains `.dark .hljs-keyword { color: #cba6f7; }`
|
|
- ui/src/index.css contains `.theme-tokyo-night .hljs-keyword { color: #bb9af7; }`
|
|
- ui/src/index.css contains `:root .hljs-keyword { color: #8839ef; }`
|
|
- The CSS block appears after existing theme variable blocks, not inside them
|
|
</acceptance_criteria>
|
|
<done>rehype-highlight is installed, and highlight.js theme CSS overrides exist in index.css for all three Nexus themes (Catppuccin Mocha, Tokyo Night, Catppuccin Latte).</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Create ChatCodeBlock and ChatMarkdownMessage components</name>
|
|
<files>ui/src/components/ChatCodeBlock.tsx, ui/src/components/ChatMarkdownMessage.tsx, ui/src/components/ChatMarkdownMessage.test.tsx</files>
|
|
<behavior>
|
|
- ChatMarkdownMessage renders plain text as a paragraph
|
|
- ChatMarkdownMessage renders fenced code blocks with hljs classes applied by rehype-highlight
|
|
- ChatCodeBlock extracts language from className "language-xxx" and displays it as a label
|
|
- ChatCodeBlock renders a copy button with aria-label="Copy code"
|
|
</behavior>
|
|
<read_first>
|
|
- ui/src/components/MarkdownBody.tsx (existing markdown component -- understand the Components override pattern, mermaid handling, and remark-gfm usage)
|
|
- ui/src/context/ThemeContext.tsx (useTheme, THEME_META exports)
|
|
- ui/src/lib/utils.ts (cn utility)
|
|
- ui/src/components/ChatMarkdownMessage.test.tsx (test stub from Plan 00 -- fill in test expectations)
|
|
</read_first>
|
|
<action>
|
|
First, update `ui/src/components/ChatMarkdownMessage.test.tsx` to replace the `.todo` stubs with real test expectations. Use `renderToStaticMarkup` (same pattern as MarkdownBody.test.tsx) to verify:
|
|
- Plain text renders as `<p>` tag
|
|
- A fenced code block (` ```typescript ... ``` `) produces output containing `hljs` class
|
|
- The rendered output contains a copy button element with appropriate aria-label
|
|
- Language label is extracted from the code fence
|
|
|
|
Then create `ui/src/components/ChatCodeBlock.tsx`:
|
|
|
|
A `pre` component override for react-markdown that wraps code blocks with:
|
|
1. A toolbar bar at the top showing the language label (extracted from the `className` on the child `<code>` element, e.g., `language-typescript` -> `typescript`)
|
|
2. A copy button in the toolbar that calls `navigator.clipboard.writeText(codeText)` where `codeText` is extracted by recursively flattening the children's text content
|
|
3. Copy button shows `Copy` icon (lucide-react) by default, switches to `Check` icon for 1500ms after a successful copy, then reverts
|
|
4. Toolbar background: `bg-card` -- same as code block background, with `border-b border-border`
|
|
5. Language label: `text-xs text-muted-foreground font-mono`
|
|
6. Copy button: `Button variant="ghost" size="icon"` with `className="h-6 w-6"`, `aria-label="Copy code"` (changes to `"Copied!"` during success state)
|
|
7. For `pre` blocks without a code child (plain preformatted text), render a plain `<pre>` without the toolbar
|
|
|
|
Component signature:
|
|
```typescript
|
|
interface ChatCodeBlockProps {
|
|
children?: React.ReactNode;
|
|
className?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
export function ChatCodeBlock({ children, className, ...props }: ChatCodeBlockProps): JSX.Element;
|
|
```
|
|
|
|
Then create `ui/src/components/ChatMarkdownMessage.tsx`:
|
|
|
|
Builds on the existing `MarkdownBody` pattern but uses `rehype-highlight` for syntax highlighting and the custom `ChatCodeBlock` for code block rendering.
|
|
|
|
```typescript
|
|
import Markdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import rehypeHighlight from "rehype-highlight";
|
|
import { ChatCodeBlock } from "./ChatCodeBlock";
|
|
import { cn } from "../lib/utils";
|
|
|
|
interface ChatMarkdownMessageProps {
|
|
content: string;
|
|
className?: string;
|
|
}
|
|
|
|
export function ChatMarkdownMessage({ content, className }: ChatMarkdownMessageProps) {
|
|
return (
|
|
<div className={cn("paperclip-markdown", className)}>
|
|
<Markdown
|
|
remarkPlugins={[remarkGfm]}
|
|
rehypePlugins={[rehypeHighlight]}
|
|
components={{
|
|
pre: ChatCodeBlock,
|
|
}}
|
|
>
|
|
{content}
|
|
</Markdown>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
The `paperclip-markdown` class ensures existing markdown prose styles from index.css apply (font-size 0.9375rem, line-height 1.6, heading styles, table styles, etc.).
|
|
|
|
Do NOT duplicate mermaid handling from MarkdownBody -- mermaid diagrams are not expected in chat responses for Phase 21. If needed later, it can be added.
|
|
|
|
Run tests after implementation to verify:
|
|
```bash
|
|
pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /opt/nexus && pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx 2>&1 | tail -5</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 `pre: ChatCodeBlock`
|
|
- ui/src/components/ChatMarkdownMessage.tsx exports `ChatMarkdownMessage`
|
|
- ui/src/components/ChatCodeBlock.tsx contains `navigator.clipboard.writeText`
|
|
- ui/src/components/ChatCodeBlock.tsx contains `aria-label` with "Copy code" or "Copied!"
|
|
- ui/src/components/ChatCodeBlock.tsx extracts language from className pattern `language-`
|
|
- ui/src/components/ChatCodeBlock.tsx uses `Check` and `Copy` icons from lucide-react
|
|
- ChatMarkdownMessage tests pass via vitest
|
|
</acceptance_criteria>
|
|
<done>ChatMarkdownMessage renders markdown with syntax-highlighted code blocks via rehype-highlight. ChatCodeBlock shows a language label and copy button on every code block, with a 1500ms success state on copy. Tests pass.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes (type checks)
|
|
- `pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx` passes
|
|
- Code blocks in ChatMarkdownMessage render with .hljs classes
|
|
- Copy button wires to navigator.clipboard.writeText
|
|
- Theme CSS in index.css covers all three themes
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- rehype-highlight installed in ui package
|
|
- ChatMarkdownMessage renders markdown with syntax highlighting
|
|
- ChatCodeBlock provides language label + copy button
|
|
- highlight.js theme overrides in index.css for Mocha, Tokyo Night, and Latte
|
|
- ChatMarkdownMessage tests pass
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/21-chat-foundation/21-02-SUMMARY.md`
|
|
</output>
|