From baa2a705fdd1d9b28beefde2d2eb0bf793561cbd Mon Sep 17 00:00:00 2001 From: wen2zhou Date: Fri, 24 Jul 2026 09:25:34 +0000 Subject: [PATCH] feat(workspace): copy final agent replies as markdown --- .../WorkspaceMessageItem.actions.test.tsx | 40 ++++++++++ .../pages/workspace/WorkspaceMessageItem.tsx | 44 ++++++++-- .../WorkspaceMessageScroller.render.test.tsx | 80 +++++++++++++++++++ .../workspace/WorkspaceMessageScroller.tsx | 31 ++++++- 4 files changed, 188 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/pages/workspace/WorkspaceMessageItem.actions.test.tsx b/src/renderer/src/pages/workspace/WorkspaceMessageItem.actions.test.tsx index 9d4ea5c3b..d91fb57cb 100644 --- a/src/renderer/src/pages/workspace/WorkspaceMessageItem.actions.test.tsx +++ b/src/renderer/src/pages/workspace/WorkspaceMessageItem.actions.test.tsx @@ -43,6 +43,7 @@ const renderItem = async ( message: ChatMessage, options: { canEditMessage?: boolean + canCopyMarkdown?: boolean onSendEditedMessage?: (messageId: string, doc: ComposerDoc) => void subsequentTurns?: number } = {} @@ -56,6 +57,7 @@ const renderItem = async ( onOpenSkillMention={noop} onPreviewMentionArtifact={noop} canEditMessage={options.canEditMessage ?? false} + canCopyMarkdown={options.canCopyMarkdown ?? false} onSendEditedMessage={options.onSendEditedMessage} subsequentTurns={options.subsequentTurns ?? 0} /> @@ -278,3 +280,41 @@ describe('WorkspaceMessageItem user message actions', () => { expect(getEditor()).not.toBeNull() }) }) + +describe('WorkspaceMessageItem agent markdown copy', () => { + it('copies the original markdown and confirms only after the clipboard write succeeds', async () => { + vi.useFakeTimers() + try { + const markdown = '## Findings\n\n- Original **Markdown**' + await renderItem(createMessage({ role: 'agent', content: markdown }), { + canCopyMarkdown: true + }) + + await click(getButton('Copy as Markdown')) + + expect(writeText).toHaveBeenCalledWith(markdown) + expect(container.querySelector('[aria-label="Markdown copied"]')).not.toBeNull() + + act(() => { + vi.advanceTimersByTime(2000) + }) + + expect(container.querySelector('[aria-label="Copy as Markdown"]')).not.toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('does not confirm markdown copy when the clipboard write fails', async () => { + writeText.mockRejectedValueOnce(new Error('Clipboard unavailable')) + await renderItem(createMessage({ role: 'agent', content: '# Findings' }), { + canCopyMarkdown: true + }) + + await click(getButton('Copy as Markdown')) + + expect(writeText).toHaveBeenCalledWith('# Findings') + expect(container.querySelector('[aria-label="Markdown copied"]')).toBeNull() + expect(container.querySelector('[aria-label="Copy as Markdown"]')).not.toBeNull() + }) +}) diff --git a/src/renderer/src/pages/workspace/WorkspaceMessageItem.tsx b/src/renderer/src/pages/workspace/WorkspaceMessageItem.tsx index 4cd7999e2..40629200c 100644 --- a/src/renderer/src/pages/workspace/WorkspaceMessageItem.tsx +++ b/src/renderer/src/pages/workspace/WorkspaceMessageItem.tsx @@ -1,5 +1,6 @@ import { AgentMarkdown } from '@/components/streamdown/AgentMarkdown' import { MessageScrollerItem } from '@/components/ui/message-scroller' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { cn, formatByteSize } from '@/lib/utils' import type { ChatMessage, ChatSession } from '@/stores/session-store' import { Collapsible } from 'radix-ui' @@ -45,6 +46,8 @@ type WorkspaceMessageItemProps = { onSendEditedMessage?: (messageId: string, doc: ComposerDoc) => void // Number of user turns after this message; drives the destructive-resend warning threshold. subsequentTurns?: number + // The scroller enables this for each completed user turn's final agent reply. + canCopyMarkdown?: boolean } const ARTIFACT_GALLERY_VISIBLE_COUNT = 5 @@ -368,6 +371,7 @@ const WorkspaceMessageItem = ({ canEditMessage = false, onSendEditedMessage, subsequentTurns = 0, + canCopyMarkdown = false, artifacts = [] }: WorkspaceMessageItemProps): React.JSX.Element => { const isUserMessage = message.role === 'user' @@ -389,13 +393,18 @@ const WorkspaceMessageItem = ({ [] ) - // Copies the prompt text and briefly swaps the icon to confirm the clipboard write succeeded. + // Copies raw message text and briefly swaps the icon only after a successful clipboard write. const handleCopyMessage = (): void => { - void navigator.clipboard.writeText(message.content).then(() => { - setCopied(true) - if (copyResetTimeoutRef.current !== null) window.clearTimeout(copyResetTimeoutRef.current) - copyResetTimeoutRef.current = window.setTimeout(() => setCopied(false), 2000) - }) + if (!navigator.clipboard?.writeText) return + + void navigator.clipboard + .writeText(message.content) + .then(() => { + setCopied(true) + if (copyResetTimeoutRef.current !== null) window.clearTimeout(copyResetTimeoutRef.current) + copyResetTimeoutRef.current = window.setTimeout(() => setCopied(false), 2000) + }) + .catch(() => {}) } // Opens the inline editor with the prompt rebuilt as a composer doc (mention chips restored when @@ -530,6 +539,29 @@ const WorkspaceMessageItem = ({ ) : null} + {canCopyMarkdown && message.content ? ( +
+ + + + + + Copy as Markdown + + +
+ ) : null} )} diff --git a/src/renderer/src/pages/workspace/WorkspaceMessageScroller.render.test.tsx b/src/renderer/src/pages/workspace/WorkspaceMessageScroller.render.test.tsx index 449b0972c..60b99fb96 100644 --- a/src/renderer/src/pages/workspace/WorkspaceMessageScroller.render.test.tsx +++ b/src/renderer/src/pages/workspace/WorkspaceMessageScroller.render.test.tsx @@ -925,6 +925,86 @@ describe('WorkspaceMessageScroller loading render', () => { }) }) +describe('WorkspaceMessageScroller agent markdown copy', () => { + it('shows a left-aligned copy button for every completed agent reply', async () => { + const html = await renderScroller( + createSession({ + status: 'running', + messages: [ + createMessage({ id: 'prompt-1', content: 'First question' }), + createMessage({ + id: 'thinking-1', + role: 'agent', + content: 'Intermediate reasoning', + responseToMessageId: 'prompt-1', + createdAt: 1710000000001, + updatedAt: 1710000000001 + }), + createMessage({ + id: 'reply-1', + role: 'agent', + content: 'Final reply for the first question', + responseToMessageId: 'prompt-1', + createdAt: 1710000000002, + updatedAt: 1710000000002 + }), + createMessage({ + id: 'prompt-2', + content: 'Follow-up question', + createdAt: 1710000000003, + updatedAt: 1710000000003 + }), + createMessage({ + id: 'reply-2', + role: 'agent', + content: 'Latest completed reply', + responseToMessageId: 'prompt-2', + createdAt: 1710000000004, + updatedAt: 1710000000004 + }), + createMessage({ + id: 'prompt-3', + content: 'One more question', + createdAt: 1710000000005, + updatedAt: 1710000000005 + }), + createMessage({ + id: 'reply-3', + role: 'agent', + content: 'Still streaming', + status: 'streaming', + responseToMessageId: 'prompt-3', + createdAt: 1710000000006, + updatedAt: 1710000000006 + }), + createMessage({ + id: 'reply-4', + role: 'agent', + content: 'Failed reply', + status: 'error', + responseToMessageId: 'prompt-3', + createdAt: 1710000000007, + updatedAt: 1710000000007 + }) + ] + }) + ) + + const copyButtons = html.match(/aria-label="Copy as Markdown"/g) ?? [] + const thinkingStart = html.indexOf('data-message-id="thinking-1"') + const earlierReplyStart = html.indexOf('data-message-id="reply-1"') + const latestReplyStart = html.indexOf('data-message-id="reply-2"') + const streamingReplyStart = html.indexOf('data-message-id="reply-3"') + + expect(copyButtons).toHaveLength(2) + expect(html).toContain('mt-2 flex justify-start') + expect(html.slice(thinkingStart, earlierReplyStart)).not.toContain('Copy as Markdown') + expect(html.slice(earlierReplyStart, latestReplyStart)).toContain('Copy as Markdown') + expect(html.slice(latestReplyStart, streamingReplyStart)).toContain('Copy as Markdown') + expect(html.slice(streamingReplyStart)).not.toContain('Copy as Markdown') + }) +}) + describe('WorkspaceMessageScroller agent images', () => { it('renders bounded ACP message images as data URLs alongside text', async () => { const html = await renderScroller( diff --git a/src/renderer/src/pages/workspace/WorkspaceMessageScroller.tsx b/src/renderer/src/pages/workspace/WorkspaceMessageScroller.tsx index ba8bc27aa..277830e48 100644 --- a/src/renderer/src/pages/workspace/WorkspaceMessageScroller.tsx +++ b/src/renderer/src/pages/workspace/WorkspaceMessageScroller.tsx @@ -11,7 +11,7 @@ import { } from '@/stores/preview-workbench-store' import { useReviewStore } from '@/stores/review-store' import { useSettingsStore } from '@/stores/settings-store' -import type { ChatSession } from '@/stores/session-store' +import type { ChatMessage, ChatSession } from '@/stores/session-store' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { shouldShowAgentLoadingMessage } from './agent-loading-message' @@ -75,6 +75,30 @@ const getMessageArtifacts = ( .filter((artifact): artifact is MessageArtifact => Boolean(artifact)) } +// Each prompt may yield intermediate agent messages before its final answer. Keep only the last +// agent message for a prompt, then expose copy only when that message completed successfully. +const getFinalCompletedAgentMessageIds = (messages: ChatMessage[]): Set => { + const finalAgentMessageByPromptId = new Map() + let latestUserMessageId: string | undefined + + for (const message of messages) { + if (message.role === 'user') { + latestUserMessageId = message.id + continue + } + + // Older persisted sessions may lack response linkage, so use the preceding user turn. + const promptMessageId = message.responseToMessageId ?? latestUserMessageId ?? message.id + finalAgentMessageByPromptId.set(promptMessageId, message) + } + + return new Set( + [...finalAgentMessageByPromptId.values()] + .filter((message) => message.status === 'complete') + .map((message) => message.id) + ) +} + // Sends an app-managed generated file to the preview workbench instead of opening it locally. const previewArtifact = (artifact: MessageArtifact, sessionId: string): void => { const previewItem = createPreviewFileItemFromArtifact(artifact, sessionId) @@ -185,6 +209,10 @@ const WorkspaceMessageScroller = ({ [activeSession] ) const showAgentLoadingMessage = shouldShowAgentLoadingMessage(activeSession) + const finalCompletedAgentMessageIds = useMemo( + () => getFinalCompletedAgentMessageIds(activeSession?.messages ?? []), + [activeSession?.messages] + ) // Counts the user turns after each message; the destructive-resend warning keys off turns, not // raw message count, so a single follow-up turn stays warning-free. @@ -455,6 +483,7 @@ const WorkspaceMessageScroller = ({ canEditMessage={canEditMessage} onSendEditedMessage={onSendEditedMessage} subsequentTurns={subsequentTurnCountByMessageId.get(item.message.id) ?? 0} + canCopyMarkdown={finalCompletedAgentMessageIds.has(item.message.id)} artifacts={artifacts} /> {review ? (