feat(chat): add copy actions for code blocks
This commit is contained in:
parent
35970000b6
commit
fafaca4a38
5 changed files with 84 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
18
src/renderer/components/chat/markdownCopyUtils.ts
Normal file
18
src/renderer/components/chat/markdownCopyUtils.ts
Normal 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 '';
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue