Skip to content

Commit 6f9f336

Browse files
TheodoreSpeaksTheodore Liclaude
authored
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>
1 parent 712e58a commit 6f9f336

File tree

4 files changed

+71
-15
lines changed

4 files changed

+71
-15
lines changed

apps/sim/app/chat/components/message/components/markdown-renderer.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react
22
import ReactMarkdown from 'react-markdown'
33
import remarkGfm from 'remark-gfm'
44
import { Tooltip } from '@/components/emcn'
5+
import { CopyCodeButton } from '@/components/ui/copy-code-button'
6+
import { extractTextContent } from '@/lib/core/utils/react-node-text'
57

68
export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
79
return (
@@ -102,6 +104,10 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
102104
<span className='font-sans text-gray-400 text-xs'>
103105
{codeProps.className?.replace('language-', '') || 'code'}
104106
</span>
107+
<CopyCodeButton
108+
code={extractTextContent(codeContent)}
109+
className='text-gray-400 hover:bg-gray-700 hover:text-gray-200'
110+
/>
105111
</div>
106112
<pre className='overflow-x-auto p-4 font-mono text-gray-200 dark:text-gray-100'>
107113
{codeContent}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import 'prismjs/components/prism-css'
99
import 'prismjs/components/prism-markup'
1010
import '@/components/emcn/components/code/code.css'
1111
import { Checkbox, highlight, languages } from '@/components/emcn'
12+
import { CopyCodeButton } from '@/components/ui/copy-code-button'
1213
import { cn } from '@/lib/core/utils/cn'
14+
import { extractTextContent } from '@/lib/core/utils/react-node-text'
1315
import {
1416
PendingTagIndicator,
1517
parseSpecialTags,
@@ -33,16 +35,6 @@ const LANG_ALIASES: Record<string, string> = {
3335
py: 'python',
3436
}
3537

36-
function extractTextContent(node: React.ReactNode): string {
37-
if (typeof node === 'string') return node
38-
if (typeof node === 'number') return String(node)
39-
if (!node) return ''
40-
if (Array.isArray(node)) return node.map(extractTextContent).join('')
41-
if (isValidElement(node))
42-
return extractTextContent((node.props as { children?: React.ReactNode }).children)
43-
return ''
44-
}
45-
4638
const PROSE_CLASSES = cn(
4739
'prose prose-base dark:prose-invert max-w-none',
4840
'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
125117

126118
return (
127119
<div className='not-prose my-6 overflow-hidden rounded-lg border border-[var(--divider)]'>
128-
{language && (
129-
<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)]'>
130-
{language}
131-
</div>
132-
)}
120+
<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)]'>
121+
<span className='text-[var(--text-tertiary)] text-xs'>{language || 'code'}</span>
122+
<CopyCodeButton
123+
code={codeString}
124+
className='text-[var(--text-tertiary)] hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]'
125+
/>
126+
</div>
133127
<div className='code-editor-theme bg-[var(--surface-5)] dark:bg-[var(--code-bg)]'>
134128
<pre
135129
className='m-0 overflow-x-auto whitespace-pre p-4 font-[430] font-mono text-[var(--text-primary)] text-small leading-[21px]'
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use client'
2+
3+
import { useCallback, useEffect, useRef, useState } from 'react'
4+
import { Check, Copy } from '@/components/emcn'
5+
import { cn } from '@/lib/core/utils/cn'
6+
7+
interface CopyCodeButtonProps {
8+
code: string
9+
className?: string
10+
}
11+
12+
export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
13+
const [copied, setCopied] = useState(false)
14+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
15+
16+
const handleCopy = useCallback(async () => {
17+
await navigator.clipboard.writeText(code)
18+
setCopied(true)
19+
if (timerRef.current) clearTimeout(timerRef.current)
20+
timerRef.current = setTimeout(() => setCopied(false), 2000)
21+
}, [code])
22+
23+
useEffect(
24+
() => () => {
25+
if (timerRef.current) clearTimeout(timerRef.current)
26+
},
27+
[]
28+
)
29+
30+
return (
31+
<button
32+
type='button'
33+
onClick={handleCopy}
34+
className={cn(
35+
'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors',
36+
className
37+
)}
38+
>
39+
{copied ? <Check className='size-3.5' /> : <Copy className='size-3.5' />}
40+
</button>
41+
)
42+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { isValidElement, type ReactNode } from 'react'
2+
3+
/**
4+
* Recursively extracts plain text content from a React node tree.
5+
*/
6+
export function extractTextContent(node: ReactNode): string {
7+
if (typeof node === 'string') return node
8+
if (typeof node === 'number') return String(node)
9+
if (!node) return ''
10+
if (Array.isArray(node)) return node.map(extractTextContent).join('')
11+
if (isValidElement(node))
12+
return extractTextContent((node.props as { children?: ReactNode }).children)
13+
return ''
14+
}

0 commit comments

Comments
 (0)