fix(chat): support code block copy feedback (#349)

This commit is contained in:
Lanke
2026-04-30 18:36:00 +08:00
committed by GitHub
parent e82674039c
commit 05f15da90b
5 changed files with 66 additions and 20 deletions
@@ -199,8 +199,16 @@ onBeforeUnmount(() => {
renderGeneration += 1
})
function handleMarkdownClick(event: MouseEvent): void {
void handleCodeBlockCopyClick(event)
async function handleMarkdownClick(event: MouseEvent): Promise<void> {
const copyResult = await handleCodeBlockCopyClick(event)
if (copyResult !== null) {
if (copyResult) {
message.success(t('common.copied'))
} else {
message.error(t('chat.copyFailed'))
}
return
}
// Handle file path link clicks for download
const target = event.target as HTMLElement
@@ -4,6 +4,7 @@ import { computed, onBeforeUnmount, ref, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { useMessage } from "naive-ui";
import { downloadFile } from "@/api/hermes/download";
import { copyToClipboard } from "@/utils/clipboard";
import MarkdownRenderer from "./MarkdownRenderer.vue";
import { parseThinking, countThinkingChars } from "@/utils/thinking-parser";
import { useChatStore } from "@/stores/hermes/chat";
@@ -38,12 +39,12 @@ const copyableContent = computed(() => {
async function copyBubbleContent() {
const text = copyableContent.value
if (!text) return
try {
await navigator.clipboard.writeText(text)
const ok = await copyToClipboard(text)
if (ok) {
toast.success(t('chat.copiedBubble'))
} catch {
toast.error(t('chat.copyFailed'))
return
}
toast.error(t('chat.copyFailed'))
}
const parsedThinking = computed(() =>
@@ -229,15 +230,21 @@ async function handleToolDetailClick(event: MouseEvent): Promise<void> {
const source = button.closest<HTMLElement>("[data-copy-source]")?.dataset.copySource;
if (source === "tool-args" && fullToolArgs.value) {
await copyTextToClipboard(fullToolArgs.value);
const ok = await copyTextToClipboard(fullToolArgs.value);
if (ok) toast.success(t("common.copied"));
else toast.error(t("chat.copyFailed"));
return;
}
if (source === "tool-result" && fullToolResult.value) {
await copyTextToClipboard(fullToolResult.value);
const ok = await copyTextToClipboard(fullToolResult.value);
if (ok) toast.success(t("common.copied"));
else toast.error(t("chat.copyFailed"));
return;
}
await handleCodeBlockCopyClick(event);
const copyResult = await handleCodeBlockCopyClick(event);
if (copyResult) toast.success(t("common.copied"));
else if (copyResult === false) toast.error(t("chat.copyFailed"));
}
const hasAttachments = computed(
@@ -1,4 +1,5 @@
import hljs from 'highlight.js'
import { copyToClipboard } from '@/utils/clipboard'
const LANGUAGE_ALIASES: Record<string, string> = {
shellscript: 'bash',
@@ -80,27 +81,23 @@ export function renderHighlightedCodeBlock(
return `<pre class="hljs-code-block"><div class="code-header">${languageLabelHtml}<button type="button" class="copy-btn" data-copy-code="true">${escapeHtml(copyLabel)}</button></div><code class="hljs language-${sanitizeLanguageClass(codeClassLanguage)}">${highlighted}</code></pre>`
}
export async function copyTextToClipboard(text: string): Promise<void> {
try {
await navigator.clipboard?.writeText?.(text)
} catch {
// Ignore clipboard failures; the code block still renders safely.
}
export async function copyTextToClipboard(text: string): Promise<boolean> {
return copyToClipboard(text)
}
export async function handleCodeBlockCopyClick(event: MouseEvent): Promise<void> {
export async function handleCodeBlockCopyClick(event: MouseEvent): Promise<boolean | null> {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (!(target instanceof HTMLElement)) return null
const button = target.closest<HTMLElement>('[data-copy-code="true"]')
if (!button) return
if (!button) return null
event.preventDefault()
const block = button.closest('.hljs-code-block')
const code = block?.querySelector('code')
const text = code?.textContent ?? ''
if (!text) return
if (!text) return false
await copyTextToClipboard(text)
return copyTextToClipboard(text)
}
+30
View File
@@ -50,6 +50,10 @@ describe('MarkdownRenderer', () => {
svg: `<svg id="${id}" data-testid="mermaid-svg"><text>${source}</text></svg>`,
}))
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: true,
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
@@ -466,4 +470,30 @@ describe('MarkdownRenderer', () => {
expect(writeText).toHaveBeenCalledWith(expected)
})
it('falls back to legacy clipboard copy when the Clipboard API is unavailable', async () => {
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: false,
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
const execCommand = vi.fn(() => true)
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: execCommand,
})
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```ts\nconst answer = 42\n```',
},
})
await wrapper.find('[data-copy-code="true"]').trigger('click')
expect(execCommand).toHaveBeenCalledWith('copy')
})
})
@@ -24,6 +24,10 @@ import type { Message } from '@/stores/hermes/chat'
describe('MessageItem tool details', () => {
beforeEach(() => {
setActivePinia(createPinia())
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: true,
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {