Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const renderItem = async (
message: ChatMessage,
options: {
canEditMessage?: boolean
canCopyMarkdown?: boolean
onSendEditedMessage?: (messageId: string, doc: ComposerDoc) => void
subsequentTurns?: number
} = {}
Expand All @@ -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}
/>
Expand Down Expand Up @@ -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()
})
})
44 changes: 38 additions & 6 deletions src/renderer/src/pages/workspace/WorkspaceMessageItem.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -368,6 +371,7 @@ const WorkspaceMessageItem = ({
canEditMessage = false,
onSendEditedMessage,
subsequentTurns = 0,
canCopyMarkdown = false,
artifacts = []
}: WorkspaceMessageItemProps): React.JSX.Element => {
const isUserMessage = message.role === 'user'
Expand All @@ -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
Expand Down Expand Up @@ -530,6 +539,29 @@ const WorkspaceMessageItem = ({
) : null}
<MessageImageList images={message.images ?? []} />
<MessageArtifactList onPreviewArtifact={onPreviewArtifact} artifacts={artifacts} />
{canCopyMarkdown && message.content ? (
<div className="mt-2 flex justify-start">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={userMessageActionButtonClassName}
aria-label={copied ? 'Markdown copied' : 'Copy as Markdown'}
onClick={handleCopyMessage}
>
{copied ? (
<Check className="size-3.5" strokeWidth={2} aria-hidden="true" />
) : (
<Copy className="size-3.5" strokeWidth={2} aria-hidden="true" />
)}
</button>
</TooltipTrigger>
<TooltipContent>Copy as Markdown</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : null}
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
31 changes: 30 additions & 1 deletion src/renderer/src/pages/workspace/WorkspaceMessageScroller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string> => {
const finalAgentMessageByPromptId = new Map<string, ChatMessage>()
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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ? (
Expand Down
Loading