nexus/.planning/phases/21-chat-foundation/21-02-PLAN.md

13 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
21-chat-foundation 02 execute 1
21-00
ui/src/components/ChatMarkdownMessage.tsx
ui/src/components/ChatCodeBlock.tsx
ui/src/index.css
ui/package.json
true
CHAT-02
CHAT-03
THEME-02
truths artifacts key_links
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
path provides exports
ui/src/components/ChatMarkdownMessage.tsx Markdown renderer with rehype-highlight
ChatMarkdownMessage
path provides exports
ui/src/components/ChatCodeBlock.tsx Code block wrapper with copy button and language label
ChatCodeBlock
from to via pattern
ui/src/components/ChatMarkdownMessage.tsx rehype-highlight rehypePlugins prop on react-markdown rehypePlugins.*rehypeHighlight
from to via pattern
ui/src/components/ChatCodeBlock.tsx navigator.clipboard writeText call on copy button click navigator.clipboard.writeText
from to via pattern
ui/src/index.css highlight.js themes CSS overrides per theme class (.dark .hljs, .theme-tokyo-night .hljs) .hljs
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.

<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: ```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:

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 };
Task 1: Install rehype-highlight and add hljs theme CSS overrides ui/package.json, ui/src/index.css - ui/package.json (current dependencies) - ui/src/index.css (existing theme CSS variables and .dark/.theme-tokyo-night selectors) 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:

/* -- 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. 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" <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> 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).

Task 2: Create ChatCodeBlock and ChatMarkdownMessage components ui/src/components/ChatCodeBlock.tsx, ui/src/components/ChatMarkdownMessage.tsx, ui/src/components/ChatMarkdownMessage.test.tsx - 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" - 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) 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 `

` 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:

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.

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:

pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx
cd /opt/nexus && pnpm vitest run ui/src/components/ChatMarkdownMessage.test.tsx 2>&1 | tail -5 - 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 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. - `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

<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>
After completion, create `.planning/phases/21-chat-foundation/21-02-SUMMARY.md`