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 ? (