feat(chat): add copy actions for code blocks

This commit is contained in:
777genius 2026-04-11 09:07:16 +03:00
parent 35970000b6
commit fafaca4a38
5 changed files with 84 additions and 35 deletions

View file

@ -10,7 +10,7 @@ import { useShallow } from 'zustand/react/shallow';
import { CopyButton } from '../common/CopyButton';
import { OngoingBanner } from '../common/OngoingIndicator';
import { createMarkdownComponents, markdownComponents } from './markdownComponents';
import { createMarkdownComponents, markdownComponentsWithCodeCopy } from './markdownComponents';
import { createSearchContext, EMPTY_SEARCH_MATCHES } from './searchHighlightUtils';
import type { AIGroupLastOutput } from '@renderer/types/groups';
@ -63,7 +63,9 @@ export const LastOutputDisplay = ({
// Create markdown components with optional search highlighting
// When search is active, create fresh each render (match counter is stateful and must start at 0)
// useMemo would cache stale closures when parent re-renders without search deps changing
const mdComponents = searchCtx ? createMarkdownComponents(searchCtx) : markdownComponents;
const mdComponents = searchCtx
? createMarkdownComponents(searchCtx, { copyCodeBlocks: true })
: markdownComponentsWithCodeCopy;
// Show ongoing banner if this is the last AI group and session is ongoing
// This uses the same source (sessions array) as the sidebar green dot for consistency

View file

@ -18,7 +18,7 @@ import remarkGfm from 'remark-gfm';
import { useShallow } from 'zustand/react/shallow';
import { CopyButton } from '../common/CopyButton';
import { extractTextFromReactNode } from './markdownCopyUtils';
import {
createSearchContext,
EMPTY_SEARCH_MATCHES,
@ -284,18 +284,23 @@ function createUserMarkdownComponents(
);
},
pre: ({ children }) => (
<pre
className="my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed"
style={{
backgroundColor: 'rgba(0, 0, 0, 0.15)',
border: '1px solid var(--chat-user-tag-border)',
color: userTextColor,
}}
>
{children}
</pre>
),
pre: ({ children }) => {
const codeText = extractTextFromReactNode(children).trim();
return (
<pre
className={`my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed ${codeText ? 'group relative' : ''}`.trim()}
style={{
backgroundColor: 'rgba(0, 0, 0, 0.15)',
border: '1px solid var(--chat-user-tag-border)',
color: userTextColor,
}}
>
{codeText ? <CopyButton text={codeText} bgColor="var(--chat-user-bg)" /> : null}
{children}
</pre>
);
},
blockquote: ({ children }) => (
<blockquote

View file

@ -1,18 +1,28 @@
import React from 'react';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { PROSE_BODY } from '@renderer/constants/cssVariables';
import { FileLink, isRelativeUrl } from './viewers/FileLink';
import { extractTextFromReactNode } from './markdownCopyUtils';
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
import { FileLink, isRelativeUrl } from './viewers/FileLink';
import type { Components } from 'react-markdown';
interface MarkdownComponentOptions {
copyCodeBlocks?: boolean;
}
/**
* Create inline markdown components for rendering prose content.
* When searchCtx is provided, search term highlighting is applied
* to text nodes while preserving full markdown rendering.
*/
export function createMarkdownComponents(searchCtx: SearchContext | null): Components {
export function createMarkdownComponents(
searchCtx: SearchContext | null,
options: MarkdownComponentOptions = {}
): Components {
const { copyCodeBlocks = false } = options;
const hl = (children: React.ReactNode): React.ReactNode =>
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
@ -148,18 +158,23 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo
},
// Code blocks
pre: ({ children }) => (
<pre
className="my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed"
style={{
backgroundColor: 'var(--prose-pre-bg)',
border: '1px solid var(--prose-pre-border)',
color: 'var(--color-text)',
}}
>
{children}
</pre>
),
pre: ({ children }) => {
const codeText = copyCodeBlocks ? extractTextFromReactNode(children).trim() : '';
return (
<pre
className={`my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed ${codeText ? 'group relative' : ''}`.trim()}
style={{
backgroundColor: 'var(--prose-pre-bg)',
border: '1px solid var(--prose-pre-border)',
color: 'var(--color-text)',
}}
>
{codeText ? <CopyButton text={codeText} /> : null}
{children}
</pre>
);
},
// Blockquotes
blockquote: ({ children }) => (
@ -235,3 +250,8 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo
/** Default markdown components without search highlighting (used by CompactBoundary) */
export const markdownComponents: Components = createMarkdownComponents(null);
/** Markdown components for message-style content with both whole-message and code-block copy */
export const markdownComponentsWithCodeCopy: Components = createMarkdownComponents(null, {
copyCodeBlocks: true,
});

View file

@ -0,0 +1,18 @@
import React from 'react';
/**
* Extract plain text from rendered markdown children for per-block copy actions.
*/
export function extractTextFromReactNode(node: React.ReactNode): string {
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (Array.isArray(node)) {
return node.map(extractTextFromReactNode).join('');
}
if (React.isValidElement(node)) {
const props = node.props as { children?: React.ReactNode };
return extractTextFromReactNode(props.children);
}
return '';
}

View file

@ -34,6 +34,7 @@ import { FileText, UsersRound } from 'lucide-react';
import remarkGfm from 'remark-gfm';
import { useShallow } from 'zustand/react/shallow';
import { extractTextFromReactNode } from '../markdownCopyUtils';
import {
createSearchContext,
EMPTY_SEARCH_MATCHES,
@ -41,7 +42,6 @@ import {
type SearchContext,
} from '../searchHighlightUtils';
import { highlightLine } from '../viewers/syntaxHighlighter';
import { FileLink, isRelativeUrl } from './FileLink';
import { MermaidDiagram } from './MermaidDiagram';
@ -320,7 +320,8 @@ function createViewerMarkdownComponents(
searchCtx: SearchContext | null,
isLight = false,
teamColorByName: ReadonlyMap<string, string> = new Map(),
onTeamClick?: (teamName: string) => void
onTeamClick?: (teamName: string) => void,
copyCodeBlocks: boolean = false
): Components {
const hl = (children: React.ReactNode): React.ReactNode =>
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
@ -577,14 +578,17 @@ function createViewerMarkdownComponents(
}
}
const codeText = copyCodeBlocks ? extractTextFromReactNode(children).trim() : '';
return (
<pre
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
className={`my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed ${codeText ? 'group relative' : ''}`.trim()}
style={{
backgroundColor: PROSE_PRE_BG,
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
>
{codeText ? <CopyButton text={codeText} /> : null}
{children}
</pre>
);
@ -863,10 +867,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
// When search is active, create fresh each render (match counter is stateful and must start at 0)
// useMemo would cache stale closures when parent re-renders without search deps changing
const baseComponents = searchCtx
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick)
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable)
: isLight
? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick)
: createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick);
? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable)
: createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable);
// When baseDir is set (editor preview), override img to load local files via IPC
const components = baseDir