diff --git a/web-app/src/components/CopyableInlineCode.tsx b/web-app/src/components/CopyableInlineCode.tsx new file mode 100644 index 0000000000..8fc73ba525 --- /dev/null +++ b/web-app/src/components/CopyableInlineCode.tsx @@ -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(null) + const timer = useRef | undefined>(undefined) + + useEffect(() => () => clearTimeout(timer.current), []) + + const handleClick = useCallback((e: React.MouseEvent) => { + const el = (e.target as HTMLElement).closest( + 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 ( +
+ {children} + {badge && + createPortal( +
+ Copied! +
, + document.body + )} +
+ ) +} diff --git a/web-app/src/components/__tests__/CopyableInlineCode.test.tsx b/web-app/src/components/__tests__/CopyableInlineCode.test.tsx new file mode 100644 index 0000000000..5c9b96ebac --- /dev/null +++ b/web-app/src/components/__tests__/CopyableInlineCode.test.tsx @@ -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 + + beforeEach(() => { + writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + // Mirrors how Streamdown renders inline code: a tagged with + // data-streamdown="inline-code", which the component targets via delegation. + const renderWithCode = () => + render( + +

+ Set upgrade_disk to false +

+
+ ) + + 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( + +

plain text only

+
+ ) + 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() + }) +}) diff --git a/web-app/src/containers/MessageItem.tsx b/web-app/src/containers/MessageItem.tsx index 0971121abd..0e288f3d4c 100644 --- a/web-app/src/containers/MessageItem.tsx +++ b/web-app/src/containers/MessageItem.tsx @@ -289,6 +289,7 @@ export const MessageItem = memo( isStreaming={isStreaming && isLastPart} messageId={message.id} isAnimating={isAnimating} + copyableInlineCode /> )} diff --git a/web-app/src/containers/RenderMarkdown.tsx b/web-app/src/containers/RenderMarkdown.tsx index b79d052c09..0d5eea8166 100644 --- a/web-app/src/containers/RenderMarkdown.tsx +++ b/web-app/src/containers/RenderMarkdown.tsx @@ -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 @@ -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 @@ -94,6 +97,7 @@ function RenderMarkdownComponent({ messageId, isAnimating, isStreaming, + copyableInlineCode, }: MarkdownProps) { // Memoize the normalized content to avoid reprocessing on every render @@ -117,6 +121,46 @@ function RenderMarkdownComponent({ }, [components]) // Render the markdown content + const body = ( + *: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) => ( + + ), + } + : {} + } + > + {normalizedContent} + + ) + return (
- *: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) => ( - - ), - } - : {} - } - > - {normalizedContent} - + {copyableInlineCode ? ( + {body} + ) : ( + body + )}
) } diff --git a/web-app/src/styles/markdown.css b/web-app/src/styles/markdown.css index 97c5c0c63e..bc4b4e0ca3 100644 --- a/web-app/src/styles/markdown.css +++ b/web-app/src/styles/markdown.css @@ -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);