diff --git a/apps/sim/app/(interfaces)/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/markdown-renderer.tsx
index dd6199e063e..8536d1ffc70 100644
--- a/apps/sim/app/(interfaces)/chat/components/message/components/markdown-renderer.tsx
+++ b/apps/sim/app/(interfaces)/chat/components/message/components/markdown-renderer.tsx
@@ -88,10 +88,7 @@ const COMPONENTS = {
{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 dc3537b8e1e..641fa983fff 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
@@ -315,10 +315,7 @@ const MARKDOWN_COMPONENTS = {
{language || 'code'}
-
+
diff --git a/packages/emcn/src/components/code/copy-code-button.test.tsx b/packages/emcn/src/components/code/copy-code-button.test.tsx
new file mode 100644
index 00000000000..ac5b4d3fe88
--- /dev/null
+++ b/packages/emcn/src/components/code/copy-code-button.test.tsx
@@ -0,0 +1,80 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot } from 'react-dom/client'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+import { CopyCodeButton } from './copy-code-button'
+
+describe('CopyCodeButton', () => {
+ it.each(['default', 'code-header'] as const)(
+ 'names the icon-only button in the %s appearance',
+ (appearance) => {
+ const markup = renderToStaticMarkup( )
+ const host = document.createElement('div')
+ host.innerHTML = markup
+ const button = host.querySelector('button')
+
+ expect(markup).toContain('aria-label="Copy code"')
+ expect(markup).toContain('type="button"')
+ expect(button).not.toBeNull()
+ for (const className of [
+ 'flex',
+ 'items-center',
+ 'gap-1',
+ 'rounded',
+ 'px-1.5',
+ 'py-0.5',
+ 'text-xs',
+ ]) {
+ expect(button?.classList.contains(className)).toBe(true)
+ }
+
+ const codeHeaderClasses = [
+ 'text-[var(--text-tertiary)]',
+ 'hover-hover:bg-[var(--surface-5)]',
+ 'hover-hover:text-[var(--text-secondary)]',
+ ]
+ for (const className of codeHeaderClasses) {
+ expect(button?.classList.contains(className)).toBe(appearance === 'code-header')
+ }
+ }
+ )
+
+ it('copies code, swaps to the check icon, and keeps its accessible name', async () => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ })
+ const host = document.createElement('div')
+ document.body.appendChild(host)
+ const root = createRoot(host)
+
+ try {
+ await act(async () => {
+ root.render( )
+ })
+ const button = host.querySelector('button')
+ expect(button?.getAttribute('aria-label')).toBe('Copy code')
+ expect(button?.querySelector('svg rect')).not.toBeNull()
+
+ await act(async () => {
+ button?.click()
+ })
+
+ expect(writeText).toHaveBeenCalledExactlyOnceWith('example')
+ expect(button?.querySelector('svg rect')).toBeNull()
+ expect(button?.querySelector('svg path')).not.toBeNull()
+ expect(button?.getAttribute('aria-label')).toBe('Copy code')
+ } finally {
+ await act(async () => root.unmount())
+ host.remove()
+ if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard)
+ else Reflect.deleteProperty(navigator, 'clipboard')
+ }
+ })
+})
diff --git a/packages/emcn/src/components/code/copy-code-button.tsx b/packages/emcn/src/components/code/copy-code-button.tsx
index 2c1c30046b3..0be0fba0d85 100644
--- a/packages/emcn/src/components/code/copy-code-button.tsx
+++ b/packages/emcn/src/components/code/copy-code-button.tsx
@@ -1,25 +1,44 @@
'use client'
+import { cva, type VariantProps } from 'class-variance-authority'
import { useCopyToClipboard } from '../../hooks/use-copy-to-clipboard'
import { Button, Check, Duplicate } from '../../index'
import { cn } from '../../lib/cn'
+const copyCodeButtonVariants = cva('flex items-center gap-1 rounded px-1.5 py-0.5 text-xs', {
+ variants: {
+ appearance: {
+ default: '',
+ 'code-header':
+ 'text-[var(--text-tertiary)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-secondary)]',
+ },
+ },
+ defaultVariants: {
+ appearance: 'default',
+ },
+})
+
interface CopyCodeButtonProps {
code: string
className?: string
+ /** Use the muted copy action treatment in code block headers. */
+ appearance?: VariantProps['appearance']
}
-export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
+export function CopyCodeButton({ code, className, appearance }: CopyCodeButtonProps) {
const { copied, copy } = useCopyToClipboard()
return (
)
}
+
+export { copyCodeButtonVariants }
diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts
index 8a6c0391020..f0994a75e23 100644
--- a/packages/emcn/src/components/index.ts
+++ b/packages/emcn/src/components/index.ts
@@ -117,7 +117,7 @@ export {
calculateGutterWidth,
getCodeEditorProps,
} from './code/code'
-export { CopyCodeButton } from './code/copy-code-button'
+export { CopyCodeButton, copyCodeButtonVariants } from './code/copy-code-button'
export { highlight, languages } from './code/prism'
export { CollapsibleCard, type CollapsibleCardProps } from './collapsible-card/collapsible-card'
export {