From 05f15da90b4e17909e027fc44c721429122996b5 Mon Sep 17 00:00:00 2001 From: Lanke <129925425+Keduoli03@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:36:00 +0800 Subject: [PATCH] fix(chat): support code block copy feedback (#349) --- .../hermes/chat/MarkdownRenderer.vue | 12 ++++++-- .../components/hermes/chat/MessageItem.vue | 21 ++++++++----- .../src/components/hermes/chat/highlight.ts | 19 +++++------- tests/client/markdown-rendering.test.ts | 30 +++++++++++++++++++ tests/client/message-item-highlight.test.ts | 4 +++ 5 files changed, 66 insertions(+), 20 deletions(-) diff --git a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue index 5811c67..9454f84 100644 --- a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue +++ b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue @@ -199,8 +199,16 @@ onBeforeUnmount(() => { renderGeneration += 1 }) -function handleMarkdownClick(event: MouseEvent): void { - void handleCodeBlockCopyClick(event) +async function handleMarkdownClick(event: MouseEvent): Promise { + 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 diff --git a/packages/client/src/components/hermes/chat/MessageItem.vue b/packages/client/src/components/hermes/chat/MessageItem.vue index 49192e1..17dc65c 100644 --- a/packages/client/src/components/hermes/chat/MessageItem.vue +++ b/packages/client/src/components/hermes/chat/MessageItem.vue @@ -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 { const source = button.closest("[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( diff --git a/packages/client/src/components/hermes/chat/highlight.ts b/packages/client/src/components/hermes/chat/highlight.ts index 73636ba..30a2ae3 100644 --- a/packages/client/src/components/hermes/chat/highlight.ts +++ b/packages/client/src/components/hermes/chat/highlight.ts @@ -1,4 +1,5 @@ import hljs from 'highlight.js' +import { copyToClipboard } from '@/utils/clipboard' const LANGUAGE_ALIASES: Record = { shellscript: 'bash', @@ -80,27 +81,23 @@ export function renderHighlightedCodeBlock( return `
${languageLabelHtml}
${highlighted}
` } -export async function copyTextToClipboard(text: string): Promise { - try { - await navigator.clipboard?.writeText?.(text) - } catch { - // Ignore clipboard failures; the code block still renders safely. - } +export async function copyTextToClipboard(text: string): Promise { + return copyToClipboard(text) } -export async function handleCodeBlockCopyClick(event: MouseEvent): Promise { +export async function handleCodeBlockCopyClick(event: MouseEvent): Promise { const target = event.target - if (!(target instanceof HTMLElement)) return + if (!(target instanceof HTMLElement)) return null const button = target.closest('[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) } diff --git a/tests/client/markdown-rendering.test.ts b/tests/client/markdown-rendering.test.ts index 1f4962b..c6b1e2a 100644 --- a/tests/client/markdown-rendering.test.ts +++ b/tests/client/markdown-rendering.test.ts @@ -50,6 +50,10 @@ describe('MarkdownRenderer', () => { svg: `${source}`, })) + 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') + }) }) diff --git a/tests/client/message-item-highlight.test.ts b/tests/client/message-item-highlight.test.ts index 86cab9b..df6db45 100644 --- a/tests/client/message-item-highlight.test.ts +++ b/tests/client/message-item-highlight.test.ts @@ -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: {