From 0d00a07c02e334ca904077b2ea8c56cf58b44586 Mon Sep 17 00:00:00 2001 From: "Chen, Yicun" Date: Fri, 24 Jul 2026 16:38:36 +0800 Subject: [PATCH] fix(web): preserve selected text when copying over HTTP (#2120) Co-authored-by: chenyicun --- .changeset/fix-web-clipboard-paste.md | 5 +++ .../kimi-web/src/components/chat/Markdown.vue | 11 +------ apps/kimi-web/src/lib/clipboard.ts | 12 +++++++ apps/kimi-web/test/clipboard.test.ts | 32 ++++++++++++++++--- 4 files changed, 46 insertions(+), 14 deletions(-) create mode 100644 .changeset/fix-web-clipboard-paste.md diff --git a/.changeset/fix-web-clipboard-paste.md b/.changeset/fix-web-clipboard-paste.md new file mode 100644 index 000000000..c13dac077 --- /dev/null +++ b/.changeset/fix-web-clipboard-paste.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix copying selected chat text over plain HTTP from replacing the clipboard with an event placeholder. diff --git a/apps/kimi-web/src/components/chat/Markdown.vue b/apps/kimi-web/src/components/chat/Markdown.vue index 5075ad4d0..42535be66 100644 --- a/apps/kimi-web/src/components/chat/Markdown.vue +++ b/apps/kimi-web/src/components/chat/Markdown.vue @@ -16,7 +16,7 @@ import { useIsDark } from '../../composables/useIsDark'; import type { FilePreviewRequest } from '../../types'; import { collectFilePathAliases, findFilePathLinks } from '../../lib/filePathLinks'; import { markdownRenderPlan } from '../../lib/markdownPerformance'; -import { copyTextToClipboard } from '../../lib/clipboard'; +import { copyCodeBlockFallback, copyTextToClipboard } from '../../lib/clipboard'; import * as katexWorkerModule from 'markstream-vue/workers/katexRenderer.worker?worker&type=module'; import * as mermaidWorkerModule from 'markstream-vue/workers/mermaidParser.worker?worker&type=module'; import Tooltip from '../ui/Tooltip.vue'; @@ -338,15 +338,6 @@ const codeBlockProps = { loading: false, }; -function copyCodeBlockFallback(code: string): void { - // markstream emits `copy` even when it skipped the write because the - // Clipboard API is unavailable. Reuse our plain-HTTP fallback in that case, - // while avoiding a duplicate write after markstream succeeds on HTTPS. - const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined; - if (clipboard && typeof clipboard.writeText === 'function') return; - void copyTextToClipboard(code); -} - // Root cause for the "large session turns into code skeletons" failure: // markstream mounts every code block in the loaded transcript, then shiki has // to tokenize all of them. `loading: false` removes the visible skeleton gate, diff --git a/apps/kimi-web/src/lib/clipboard.ts b/apps/kimi-web/src/lib/clipboard.ts index 832d9c445..7e8507f54 100644 --- a/apps/kimi-web/src/lib/clipboard.ts +++ b/apps/kimi-web/src/lib/clipboard.ts @@ -30,6 +30,18 @@ export async function copyTextToClipboard(text: string): Promise { return legacyCopy(text); } +/** + * Complete markstream's code-block copy on plain HTTP without intercepting + * native selection-copy events that bubble through MarkdownRender. + */ +export function copyCodeBlockFallback(payload: unknown): void { + if (typeof payload !== 'string') return; + + const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined; + if (clipboard && typeof clipboard.writeText === 'function') return; + void copyTextToClipboard(payload); +} + function legacyCopy(text: string): boolean { if (typeof document === 'undefined' || typeof document.execCommand !== 'function') { return false; diff --git a/apps/kimi-web/test/clipboard.test.ts b/apps/kimi-web/test/clipboard.test.ts index 19a9397e7..76fbe9cc9 100644 --- a/apps/kimi-web/test/clipboard.test.ts +++ b/apps/kimi-web/test/clipboard.test.ts @@ -1,8 +1,9 @@ +// Scenario: clipboard writes in secure and plain-HTTP web contexts. +// Responsibilities: preserve native selection copies and provide the legacy +// code-block fallback. The test stubs only navigator/document browser APIs. +// Run: pnpm --filter @moonshot-ai/kimi-web test -- clipboard.test.ts import { afterEach, describe, expect, it, vi } from 'vitest'; -import { copyTextToClipboard } from '../src/lib/clipboard'; - -// The web test suite runs in the default node environment (no jsdom), so we -// mock the tiny `navigator` / `document` surface that the helper touches. +import { copyCodeBlockFallback, copyTextToClipboard } from '../src/lib/clipboard'; interface FakeDocument { execCommand: ReturnType; @@ -78,3 +79,26 @@ describe('copyTextToClipboard', () => { await expect(copyTextToClipboard('nope')).resolves.toBe(false); }); }); + +describe('code-block copy fallback', () => { + it('does not overwrite selected text when a native copy event bubbles on plain HTTP', () => { + installNavigator(undefined); + const doc = installDocument(true); + const copyEvent = { toString: () => '[object ClipboardEvent]' }; + + copyCodeBlockFallback(copyEvent); + + expect(doc.execCommand).not.toHaveBeenCalled(); + expect(doc.textarea.value).toBe(''); + }); + + it('copies emitted code text when the Clipboard API is unavailable', () => { + installNavigator(undefined); + const doc = installDocument(true); + + copyCodeBlockFallback('const host = "example.test";'); + + expect(doc.execCommand).toHaveBeenCalledWith('copy'); + expect(doc.textarea.value).toBe('const host = "example.test";'); + }); +});