From e079c822bb2a20e9e75d03716ac3622403a44a0c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 19:28:51 -0700 Subject: [PATCH 1/5] Add copy button for code blocks in mothership --- .../message/components/markdown-renderer.tsx | 34 +++++++++++++++- .../components/chat-content/chat-content.tsx | 40 +++++++++++++++---- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx index 88a34da84e8..9d85d625929 100644 --- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx +++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx @@ -1,7 +1,14 @@ -import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react' +import React, { + type HTMLAttributes, + memo, + type ReactNode, + useCallback, + useMemo, + useState, +} from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' -import { Tooltip } from '@/components/emcn' +import { Check, Copy, Tooltip } from '@/components/emcn' export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) { return ( @@ -23,6 +30,26 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re ) } +function CopyCodeButton({ code }: { code: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [code]) + + return ( + + ) +} + const REMARK_PLUGINS = [remarkGfm] function createCustomComponents(LinkComponent: typeof LinkWithPreview) { @@ -102,6 +129,9 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) { {codeProps.className?.replace('language-', '') || 'code'} +
             {codeContent}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index 8249422b73b..743d9769c7a 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -1,6 +1,13 @@
 'use client'
 
-import { Children, type ComponentPropsWithoutRef, isValidElement, useMemo } from 'react'
+import {
+  Children,
+  type ComponentPropsWithoutRef,
+  isValidElement,
+  useCallback,
+  useMemo,
+  useState,
+} from 'react'
 import ReactMarkdown from 'react-markdown'
 import remarkGfm from 'remark-gfm'
 import 'prismjs/components/prism-typescript'
@@ -8,7 +15,7 @@ import 'prismjs/components/prism-bash'
 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 { Check, Checkbox, Copy, highlight, languages } from '@/components/emcn'
 import { cn } from '@/lib/core/utils/cn'
 import {
   PendingTagIndicator,
@@ -43,6 +50,26 @@ function extractTextContent(node: React.ReactNode): string {
   return ''
 }
 
+function CopyCodeButton({ code }: { code: string }) {
+  const [copied, setCopied] = useState(false)
+
+  const handleCopy = useCallback(() => {
+    navigator.clipboard.writeText(code)
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }, [code])
+
+  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 +152,10 @@ const MARKDOWN_COMPONENTS: React.ComponentProps['component
 
     return (
       
- {language && ( -
- {language} -
- )} +
+ {language || 'code'} + +
Date: Tue, 7 Apr 2026 19:49:27 -0700
Subject: [PATCH 2/5] Move to shared copy code button

---
 .../message/components/markdown-renderer.tsx  | 33 ++---------------
 .../components/chat-content/chat-content.tsx  | 37 ++++---------------
 apps/sim/components/ui/copy-code-button.tsx   | 33 +++++++++++++++++
 3 files changed, 44 insertions(+), 59 deletions(-)
 create mode 100644 apps/sim/components/ui/copy-code-button.tsx

diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx
index 9d85d625929..55cbc6e4f5d 100644
--- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx
+++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx
@@ -1,14 +1,8 @@
-import React, {
-  type HTMLAttributes,
-  memo,
-  type ReactNode,
-  useCallback,
-  useMemo,
-  useState,
-} from 'react'
+import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react'
 import ReactMarkdown from 'react-markdown'
 import remarkGfm from 'remark-gfm'
-import { Check, Copy, Tooltip } from '@/components/emcn'
+import { Tooltip } from '@/components/emcn'
+import { CopyCodeButton } from '@/components/ui/copy-code-button'
 
 export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
   return (
@@ -30,26 +24,6 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re
   )
 }
 
-function CopyCodeButton({ code }: { code: string }) {
-  const [copied, setCopied] = useState(false)
-
-  const handleCopy = useCallback(() => {
-    navigator.clipboard.writeText(code)
-    setCopied(true)
-    setTimeout(() => setCopied(false), 2000)
-  }, [code])
-
-  return (
-    
-  )
-}
-
 const REMARK_PLUGINS = [remarkGfm]
 
 function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
@@ -131,6 +105,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
             
             
           
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index 743d9769c7a..dd2537102d9 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -1,13 +1,6 @@
 'use client'
 
-import {
-  Children,
-  type ComponentPropsWithoutRef,
-  isValidElement,
-  useCallback,
-  useMemo,
-  useState,
-} from 'react'
+import { Children, type ComponentPropsWithoutRef, isValidElement, useMemo } from 'react'
 import ReactMarkdown from 'react-markdown'
 import remarkGfm from 'remark-gfm'
 import 'prismjs/components/prism-typescript'
@@ -15,7 +8,8 @@ import 'prismjs/components/prism-bash'
 import 'prismjs/components/prism-css'
 import 'prismjs/components/prism-markup'
 import '@/components/emcn/components/code/code.css'
-import { Check, Checkbox, Copy, highlight, languages } from '@/components/emcn'
+import { Checkbox, highlight, languages } from '@/components/emcn'
+import { CopyCodeButton } from '@/components/ui/copy-code-button'
 import { cn } from '@/lib/core/utils/cn'
 import {
   PendingTagIndicator,
@@ -50,26 +44,6 @@ function extractTextContent(node: React.ReactNode): string {
   return ''
 }
 
-function CopyCodeButton({ code }: { code: string }) {
-  const [copied, setCopied] = useState(false)
-
-  const handleCopy = useCallback(() => {
-    navigator.clipboard.writeText(code)
-    setCopied(true)
-    setTimeout(() => setCopied(false), 2000)
-  }, [code])
-
-  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]',
@@ -154,7 +128,10 @@ const MARKDOWN_COMPONENTS: React.ComponentProps['component
       
{language || 'code'} - +
 {
+    navigator.clipboard.writeText(code)
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }, [code])
+
+  return (
+    
+  )
+}

From c386595ea324ddcba32422dc3b5ba78dc294f8b2 Mon Sep 17 00:00:00 2001
From: Theodore Li 
Date: Tue, 7 Apr 2026 19:57:13 -0700
Subject: [PATCH 3/5] Handle react node case for copy

---
 .../message/components/markdown-renderer.tsx       | 14 ++++++++++++--
 1 file changed, 12 insertions(+), 2 deletions(-)

diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx
index 55cbc6e4f5d..01c95f58351 100644
--- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx
+++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx
@@ -1,9 +1,19 @@
-import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react'
+import React, { type HTMLAttributes, isValidElement, 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'
 
+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 ''
+}
+
 export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
   return (
     
@@ -104,7 +114,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
               {codeProps.className?.replace('language-', '') || 'code'}
             
             
           
From cf9de4486420aaaf69aed137f2a16f63ab791e11 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 20:22:13 -0700 Subject: [PATCH 4/5] 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) --- .../message/components/markdown-renderer.tsx | 13 ++----------- .../components/chat-content/chat-content.tsx | 13 ++----------- apps/sim/components/ui/copy-code-button.tsx | 14 ++++++++++---- apps/sim/lib/core/utils/react-node-text.ts | 14 ++++++++++++++ 4 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 apps/sim/lib/core/utils/react-node-text.ts diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx index 01c95f58351..a62c901ae2f 100644 --- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx +++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx @@ -1,18 +1,9 @@ -import React, { type HTMLAttributes, isValidElement, memo, type ReactNode, useMemo } from 'react' +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' - -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 '' -} +import { extractTextContent } from '@/lib/core/utils/react-node-text' export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index dd2537102d9..018967deae2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -11,6 +11,7 @@ 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, @@ -34,16 +35,6 @@ const LANG_ALIASES: Record = { 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]', @@ -130,7 +121,7 @@ const MARKDOWN_COMPONENTS: React.ComponentProps['component {language || 'code'}
diff --git a/apps/sim/components/ui/copy-code-button.tsx b/apps/sim/components/ui/copy-code-button.tsx index 3ea4cc8ff8e..dba80f5e914 100644 --- a/apps/sim/components/ui/copy-code-button.tsx +++ b/apps/sim/components/ui/copy-code-button.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Check, Copy } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' @@ -11,13 +11,19 @@ interface CopyCodeButtonProps { export function CopyCodeButton({ code, className }: CopyCodeButtonProps) { const [copied, setCopied] = useState(false) + const timerRef = useRef | null>(null) - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(code) + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(code) setCopied(true) - setTimeout(() => setCopied(false), 2000) + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => setCopied(false), 2000) }, [code]) + useEffect(() => () => { + if (timerRef.current) clearTimeout(timerRef.current) + }, []) + return (