feat(ui): Add copy button for code blocks in mothership (#4033)

* Add copy button for code blocks in mothership

* Move to shared copy code button

* Handle react node case for copy

* fix(copy-button): address PR review feedback

- Await clipboard write and clear timeout on unmount in CopyCodeButton
- Fix hover bg color matching container bg (surface-4 -> surface-5)
- Extract extractTextContent to shared util at lib/core/utils/react-node-text.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Theodore Li
2026-04-07 20:33:04 -07:00
committed by GitHub
parent 712e58a7b5
commit 6f9f336f16
4 changed files with 71 additions and 15 deletions

View File

@@ -2,6 +2,8 @@ import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Tooltip } from '@/components/emcn'
import { CopyCodeButton } from '@/components/ui/copy-code-button'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
return (
@@ -102,6 +104,10 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
<span className='font-sans text-gray-400 text-xs'>
{codeProps.className?.replace('language-', '') || 'code'}
</span>
<CopyCodeButton
code={extractTextContent(codeContent)}
className='text-gray-400 hover:bg-gray-700 hover:text-gray-200'
/>
</div>
<pre className='overflow-x-auto p-4 font-mono text-gray-200 dark:text-gray-100'>
{codeContent}

View File

@@ -9,7 +9,9 @@ import 'prismjs/components/prism-css'
import 'prismjs/components/prism-markup'
import '@/components/emcn/components/code/code.css'
import { Checkbox, highlight, languages } from '@/components/emcn'
import { CopyCodeButton } from '@/components/ui/copy-code-button'
import { cn } from '@/lib/core/utils/cn'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
import {
PendingTagIndicator,
parseSpecialTags,
@@ -33,16 +35,6 @@ const LANG_ALIASES: Record<string, string> = {
py: 'python',
}
function extractTextContent(node: React.ReactNode): string {
if (typeof node === 'string') return node
if (typeof node === 'number') return String(node)
if (!node) return ''
if (Array.isArray(node)) return node.map(extractTextContent).join('')
if (isValidElement(node))
return extractTextContent((node.props as { children?: React.ReactNode }).children)
return ''
}
const PROSE_CLASSES = cn(
'prose prose-base dark:prose-invert max-w-none',
'font-[family-name:var(--font-inter)] antialiased break-words font-[430] tracking-[0]',
@@ -125,11 +117,13 @@ const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>['component
return (
<div className='not-prose my-6 overflow-hidden rounded-lg border border-[var(--divider)]'>
{language && (
<div className='border-[var(--divider)] border-b bg-[var(--surface-4)] px-4 py-2 text-[var(--text-tertiary)] text-xs dark:bg-[var(--surface-4)]'>
{language}
</div>
)}
<div className='flex items-center justify-between border-[var(--divider)] border-b bg-[var(--surface-4)] px-4 py-2 dark:bg-[var(--surface-4)]'>
<span className='text-[var(--text-tertiary)] text-xs'>{language || 'code'}</span>
<CopyCodeButton
code={codeString}
className='text-[var(--text-tertiary)] hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]'
/>
</div>
<div className='code-editor-theme bg-[var(--surface-5)] dark:bg-[var(--code-bg)]'>
<pre
className='m-0 overflow-x-auto whitespace-pre p-4 font-[430] font-mono text-[var(--text-primary)] text-small leading-[21px]'

View File

@@ -0,0 +1,42 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, Copy } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
interface CopyCodeButtonProps {
code: string
className?: string
}
export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
const [copied, setCopied] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(code)
setCopied(true)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setCopied(false), 2000)
}, [code])
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current)
},
[]
)
return (
<button
type='button'
onClick={handleCopy}
className={cn(
'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors',
className
)}
>
{copied ? <Check className='size-3.5' /> : <Copy className='size-3.5' />}
</button>
)
}

View File

@@ -0,0 +1,14 @@
import { isValidElement, type ReactNode } from 'react'
/**
* Recursively extracts plain text content from a React node tree.
*/
export function extractTextContent(node: ReactNode): string {
if (typeof node === 'string') return node
if (typeof node === 'number') return String(node)
if (!node) return ''
if (Array.isArray(node)) return node.map(extractTextContent).join('')
if (isValidElement(node))
return extractTextContent((node.props as { children?: ReactNode }).children)
return ''
}