Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
73 changes: 73 additions & 0 deletions web-app/src/components/CopyableInlineCode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'

// Streamdown tags single-line inline code with this attribute. We rely on it
// (rather than overriding the `code` component) so we don't disturb Streamdown's
// own rendering of fenced code blocks, which already ship their own copy button.
const INLINE_CODE_SELECTOR = '[data-streamdown="inline-code"]'
// How long the "Copied!" badge stays visible after a copy. ~1.2s reads as a
// clear confirmation without lingering over the text the user clicked.
const FEEDBACK_MS = 1200

type Badge = { x: number; y: number }

/**
* Wraps rendered markdown and makes inline code (`like this`) click-to-copy,
* showing a transient "Copied!" badge at the cursor.
*
* Uses event delegation + `display: contents` so it adds no layout box and does
* not re-render the markdown body when the badge toggles.
*/
export function CopyableInlineCode({ children }: { children: React.ReactNode }) {
const [badge, setBadge] = useState<Badge | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)

useEffect(() => () => clearTimeout(timer.current), [])

const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const el = (e.target as HTMLElement).closest<HTMLElement>(
INLINE_CODE_SELECTOR
)
if (!el) return

// Don't hijack text selection: if the user dragged to select text, let them.
const selection = window.getSelection()
if (selection && !selection.isCollapsed && selection.toString().length > 0) {
return
}

const text = el.textContent ?? ''
if (!text || !navigator.clipboard?.writeText) return

navigator.clipboard
.writeText(text)
.then(() => {
setBadge({ x: e.clientX, y: e.clientY })
clearTimeout(timer.current)
timer.current = setTimeout(() => setBadge(null), FEEDBACK_MS)
})
.catch(() => {
// clipboard denied — silently do nothing
})
}, [])

return (
<div className="contents copyable-inline-code" onClick={handleClick}>
{children}
{badge &&
createPortal(
<div
className={cn(
'pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full rounded-md px-2 py-1 text-xs font-medium shadow-md',
'bg-foreground text-background animate-in fade-in-0 zoom-in-95'
)}
style={{ left: badge.x, top: badge.y - 8 }}
>
Copied!
</div>,
document.body
)}
</div>
)
}
73 changes: 73 additions & 0 deletions web-app/src/components/__tests__/CopyableInlineCode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { CopyableInlineCode } from '../CopyableInlineCode'

describe('CopyableInlineCode', () => {
let writeText: ReturnType<typeof vi.fn>

beforeEach(() => {
writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
})

afterEach(() => {
vi.restoreAllMocks()
})

// Mirrors how Streamdown renders inline code: a <code> tagged with
// data-streamdown="inline-code", which the component targets via delegation.
const renderWithCode = () =>
render(
<CopyableInlineCode>
<p>
Set <code data-streamdown="inline-code">upgrade_disk</code> to false
</p>
</CopyableInlineCode>
)

it('copies the inline code text to the clipboard on click', async () => {
renderWithCode()
fireEvent.click(screen.getByText('upgrade_disk'))
await waitFor(() => expect(writeText).toHaveBeenCalledWith('upgrade_disk'))
})

it('shows a "Copied!" badge after copying', async () => {
renderWithCode()
fireEvent.click(screen.getByText('upgrade_disk'))
expect(await screen.findByText('Copied!')).toBeInTheDocument()
})

it('does not copy when the user has an active text selection (drag-select)', () => {
vi.spyOn(window, 'getSelection').mockReturnValue({
isCollapsed: false,
toString: () => 'upgrade',
} as unknown as Selection)

renderWithCode()
fireEvent.click(screen.getByText('upgrade_disk'))

expect(writeText).not.toHaveBeenCalled()
expect(screen.queryByText('Copied!')).not.toBeInTheDocument()
})

it('ignores clicks outside inline code', () => {
render(
<CopyableInlineCode>
<p>plain text only</p>
</CopyableInlineCode>
)
fireEvent.click(screen.getByText('plain text only'))
expect(writeText).not.toHaveBeenCalled()
})

it('degrades gracefully when the clipboard write is rejected', async () => {
writeText.mockRejectedValueOnce(new Error('denied'))
renderWithCode()
fireEvent.click(screen.getByText('upgrade_disk'))
await waitFor(() => expect(writeText).toHaveBeenCalled())
expect(screen.queryByText('Copied!')).not.toBeInTheDocument()
})
})
1 change: 1 addition & 0 deletions web-app/src/containers/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ export const MessageItem = memo(
isStreaming={isStreaming && isLastPart}
messageId={message.id}
isAnimating={isAnimating}
copyableInlineCode
/>
</>
)}
Expand Down
89 changes: 49 additions & 40 deletions web-app/src/containers/RenderMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'katex/dist/katex.min.css'
import { MermaidError } from '@/components/MermaidError'
import { CitationLink } from '@/components/CitationLink'
import { MarkdownTable } from '@/components/MarkdownTable'
import { CopyableInlineCode } from '@/components/CopyableInlineCode'

interface MarkdownProps {
content: string
Expand All @@ -24,6 +25,8 @@ interface MarkdownProps {
isStreaming?: boolean
messageId?: string
isAnimating?: boolean
/** Make inline code click-to-copy (used for assistant chat messages). */
copyableInlineCode?: boolean
}

// Cache for normalized LaTeX content
Expand Down Expand Up @@ -94,6 +97,7 @@ function RenderMarkdownComponent({
messageId,
isAnimating,
isStreaming,
copyableInlineCode,
}: MarkdownProps) {

// Memoize the normalized content to avoid reprocessing on every render
Expand All @@ -117,6 +121,46 @@ function RenderMarkdownComponent({
}, [components])

// Render the markdown content
const body = (
<Streamdown
mode={isStreaming ? 'streaming' : 'static'}
parseIncompleteMarkdown={isStreaming ?? false}
animate={isAnimating ?? true}
animationDuration={500}
linkSafety={{
enabled: false,
}}
className={cn(
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className
)}
remarkPlugins={[remarkGfm, remarkMath, disableIndentedCodeBlockPlugin]}
rehypePlugins={[rehypeKatex, defaultRehypePlugins.harden]}
components={mergedComponents}
plugins={{
code: code,
mermaid: mermaid,
cjk: cjk,
}}
controls={{
mermaid: {
fullscreen: false,
},
}}
mermaid={
messageId
? {
errorComponent: (props) => (
<MermaidError messageId={messageId} {...props} />
),
}
: {}
}
>
{normalizedContent}
</Streamdown>
)

return (
<div
dir="auto"
Expand All @@ -126,46 +170,11 @@ function RenderMarkdownComponent({
className
)}
>
<Streamdown
mode={isStreaming ? 'streaming' : 'static'}
parseIncompleteMarkdown={isStreaming ?? false}
animate={isAnimating ?? true}
animationDuration={500}
linkSafety={{
enabled: false,
}}
className={cn(
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className
)}
remarkPlugins={[remarkGfm, remarkMath, disableIndentedCodeBlockPlugin]}
rehypePlugins={[
rehypeKatex,
defaultRehypePlugins.harden,
]}
components={mergedComponents}
plugins={{
code: code,
mermaid: mermaid,
cjk: cjk,
}}
controls={{
mermaid: {
fullscreen: false,
},
}}
mermaid={
messageId
? {
errorComponent: (props) => (
<MermaidError messageId={messageId} {...props} />
),
}
: {}
}
>
{normalizedContent}
</Streamdown>
{copyableInlineCode ? (
<CopyableInlineCode>{body}</CopyableInlineCode>
) : (
body
)}
</div>
)
}
Expand Down
9 changes: 9 additions & 0 deletions web-app/src/styles/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@
display: inline-block;
}

/* Click-to-copy inline code (assistant messages only) */
.copyable-inline-code [data-streamdown='inline-code'] {
cursor: pointer;
transition: background-color 0.1s ease;
}
.copyable-inline-code [data-streamdown='inline-code']:hover {
@apply bg-secondary brightness-95;
}

/* Links */
a {
color: var(--color-primary);
Expand Down
Loading