Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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>
  • Loading branch information
3 people authored Apr 8, 2026
commit 6f9f336f16a9a75a3731cf36f107e2cc860ffd02
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]',
Expand Down Expand Up @@ -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]'
Expand Down
42 changes: 42 additions & 0 deletions apps/sim/components/ui/copy-code-button.tsx
Original file line number Diff line number Diff line change
@@ -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])
Comment thread
waleedlatif1 marked this conversation as resolved.

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>
)
}
14 changes: 14 additions & 0 deletions apps/sim/lib/core/utils/react-node-text.ts
Original file line number Diff line number Diff line change
@@ -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 ''
}
Loading