diff --git a/src/renderer/components/chat/LastOutputDisplay.tsx b/src/renderer/components/chat/LastOutputDisplay.tsx index db710805..3df2c96b 100644 --- a/src/renderer/components/chat/LastOutputDisplay.tsx +++ b/src/renderer/components/chat/LastOutputDisplay.tsx @@ -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 diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 53575bc3..f931f2dd 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -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 }) => ( -
-        {children}
-      
- ), + pre: ({ children }) => { + const codeText = extractTextFromReactNode(children).trim(); + + return ( +
+          {codeText ?  : null}
+          {children}
+        
+ ); + }, blockquote: ({ children }) => (
searchCtx ? highlightSearchInChildren(children, searchCtx) : children; @@ -148,18 +158,23 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo }, // Code blocks - pre: ({ children }) => ( -
-        {children}
-      
- ), + pre: ({ children }) => { + const codeText = copyCodeBlocks ? extractTextFromReactNode(children).trim() : ''; + + return ( +
+          {codeText ?  : null}
+          {children}
+        
+ ); + }, // 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, +}); diff --git a/src/renderer/components/chat/markdownCopyUtils.ts b/src/renderer/components/chat/markdownCopyUtils.ts new file mode 100644 index 00000000..0f2cf1e1 --- /dev/null +++ b/src/renderer/components/chat/markdownCopyUtils.ts @@ -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 ''; +} diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index d1d2a941..980e03f6 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -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 = 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 (
+          {codeText ?  : null}
           {children}
         
); @@ -863,10 +867,10 @@ export const MarkdownViewer: React.FC = ({ // 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