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 { CopyButton } from '../common/CopyButton';
|
||||||
import { OngoingBanner } from '../common/OngoingIndicator';
|
import { OngoingBanner } from '../common/OngoingIndicator';
|
||||||
|
|
||||||
import { createMarkdownComponents, markdownComponents } from './markdownComponents';
|
import { createMarkdownComponents, markdownComponentsWithCodeCopy } from './markdownComponents';
|
||||||
import { createSearchContext, EMPTY_SEARCH_MATCHES } from './searchHighlightUtils';
|
import { createSearchContext, EMPTY_SEARCH_MATCHES } from './searchHighlightUtils';
|
||||||
|
|
||||||
import type { AIGroupLastOutput } from '@renderer/types/groups';
|
import type { AIGroupLastOutput } from '@renderer/types/groups';
|
||||||
|
|
@ -63,7 +63,9 @@ export const LastOutputDisplay = ({
|
||||||
// Create markdown components with optional search highlighting
|
// Create markdown components with optional search highlighting
|
||||||
// When search is active, create fresh each render (match counter is stateful and must start at 0)
|
// 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
|
// 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
|
// 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
|
// 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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { CopyButton } from '../common/CopyButton';
|
import { CopyButton } from '../common/CopyButton';
|
||||||
|
import { extractTextFromReactNode } from './markdownCopyUtils';
|
||||||
import {
|
import {
|
||||||
createSearchContext,
|
createSearchContext,
|
||||||
EMPTY_SEARCH_MATCHES,
|
EMPTY_SEARCH_MATCHES,
|
||||||
|
|
@ -284,18 +284,23 @@ function createUserMarkdownComponents(
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
pre: ({ children }) => (
|
pre: ({ children }) => {
|
||||||
<pre
|
const codeText = extractTextFromReactNode(children).trim();
|
||||||
className="my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed"
|
|
||||||
style={{
|
return (
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.15)',
|
<pre
|
||||||
border: '1px solid var(--chat-user-tag-border)',
|
className={`my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed ${codeText ? 'group relative' : ''}`.trim()}
|
||||||
color: userTextColor,
|
style={{
|
||||||
}}
|
backgroundColor: 'rgba(0, 0, 0, 0.15)',
|
||||||
>
|
border: '1px solid var(--chat-user-tag-border)',
|
||||||
{children}
|
color: userTextColor,
|
||||||
</pre>
|
}}
|
||||||
),
|
>
|
||||||
|
{codeText ? <CopyButton text={codeText} bgColor="var(--chat-user-bg)" /> : null}
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children }) => (
|
||||||
<blockquote
|
<blockquote
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,28 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||||
import { PROSE_BODY } from '@renderer/constants/cssVariables';
|
import { PROSE_BODY } from '@renderer/constants/cssVariables';
|
||||||
|
|
||||||
import { FileLink, isRelativeUrl } from './viewers/FileLink';
|
import { extractTextFromReactNode } from './markdownCopyUtils';
|
||||||
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
|
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
|
||||||
|
import { FileLink, isRelativeUrl } from './viewers/FileLink';
|
||||||
|
|
||||||
import type { Components } from 'react-markdown';
|
import type { Components } from 'react-markdown';
|
||||||
|
|
||||||
|
interface MarkdownComponentOptions {
|
||||||
|
copyCodeBlocks?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create inline markdown components for rendering prose content.
|
* Create inline markdown components for rendering prose content.
|
||||||
* When searchCtx is provided, search term highlighting is applied
|
* When searchCtx is provided, search term highlighting is applied
|
||||||
* to text nodes while preserving full markdown rendering.
|
* 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 =>
|
const hl = (children: React.ReactNode): React.ReactNode =>
|
||||||
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
|
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
|
||||||
|
|
||||||
|
|
@ -148,18 +158,23 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo
|
||||||
},
|
},
|
||||||
|
|
||||||
// Code blocks
|
// Code blocks
|
||||||
pre: ({ children }) => (
|
pre: ({ children }) => {
|
||||||
<pre
|
const codeText = copyCodeBlocks ? extractTextFromReactNode(children).trim() : '';
|
||||||
className="my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed"
|
|
||||||
style={{
|
return (
|
||||||
backgroundColor: 'var(--prose-pre-bg)',
|
<pre
|
||||||
border: '1px solid var(--prose-pre-border)',
|
className={`my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed ${codeText ? 'group relative' : ''}`.trim()}
|
||||||
color: 'var(--color-text)',
|
style={{
|
||||||
}}
|
backgroundColor: 'var(--prose-pre-bg)',
|
||||||
>
|
border: '1px solid var(--prose-pre-border)',
|
||||||
{children}
|
color: 'var(--color-text)',
|
||||||
</pre>
|
}}
|
||||||
),
|
>
|
||||||
|
{codeText ? <CopyButton text={codeText} /> : null}
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
// Blockquotes
|
// Blockquotes
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children }) => (
|
||||||
|
|
@ -235,3 +250,8 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo
|
||||||
|
|
||||||
/** Default markdown components without search highlighting (used by CompactBoundary) */
|
/** Default markdown components without search highlighting (used by CompactBoundary) */
|
||||||
export const markdownComponents: Components = createMarkdownComponents(null);
|
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 remarkGfm from 'remark-gfm';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { extractTextFromReactNode } from '../markdownCopyUtils';
|
||||||
import {
|
import {
|
||||||
createSearchContext,
|
createSearchContext,
|
||||||
EMPTY_SEARCH_MATCHES,
|
EMPTY_SEARCH_MATCHES,
|
||||||
|
|
@ -41,7 +42,6 @@ import {
|
||||||
type SearchContext,
|
type SearchContext,
|
||||||
} from '../searchHighlightUtils';
|
} from '../searchHighlightUtils';
|
||||||
import { highlightLine } from '../viewers/syntaxHighlighter';
|
import { highlightLine } from '../viewers/syntaxHighlighter';
|
||||||
|
|
||||||
import { FileLink, isRelativeUrl } from './FileLink';
|
import { FileLink, isRelativeUrl } from './FileLink';
|
||||||
import { MermaidDiagram } from './MermaidDiagram';
|
import { MermaidDiagram } from './MermaidDiagram';
|
||||||
|
|
||||||
|
|
@ -320,7 +320,8 @@ function createViewerMarkdownComponents(
|
||||||
searchCtx: SearchContext | null,
|
searchCtx: SearchContext | null,
|
||||||
isLight = false,
|
isLight = false,
|
||||||
teamColorByName: ReadonlyMap<string, string> = new Map(),
|
teamColorByName: ReadonlyMap<string, string> = new Map(),
|
||||||
onTeamClick?: (teamName: string) => void
|
onTeamClick?: (teamName: string) => void,
|
||||||
|
copyCodeBlocks: boolean = false
|
||||||
): Components {
|
): Components {
|
||||||
const hl = (children: React.ReactNode): React.ReactNode =>
|
const hl = (children: React.ReactNode): React.ReactNode =>
|
||||||
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
|
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
|
||||||
|
|
@ -577,14 +578,17 @@ function createViewerMarkdownComponents(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const codeText = copyCodeBlocks ? extractTextFromReactNode(children).trim() : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<pre
|
<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={{
|
style={{
|
||||||
backgroundColor: PROSE_PRE_BG,
|
backgroundColor: PROSE_PRE_BG,
|
||||||
border: `1px solid ${PROSE_PRE_BORDER}`,
|
border: `1px solid ${PROSE_PRE_BORDER}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{codeText ? <CopyButton text={codeText} /> : null}
|
||||||
{children}
|
{children}
|
||||||
</pre>
|
</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)
|
// 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
|
// useMemo would cache stale closures when parent re-renders without search deps changing
|
||||||
const baseComponents = searchCtx
|
const baseComponents = searchCtx
|
||||||
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick)
|
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable)
|
||||||
: isLight
|
: isLight
|
||||||
? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick)
|
? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable)
|
||||||
: createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick);
|
: createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable);
|
||||||
|
|
||||||
// When baseDir is set (editor preview), override img to load local files via IPC
|
// When baseDir is set (editor preview), override img to load local files via IPC
|
||||||
const components = baseDir
|
const components = baseDir
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue