Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/fix-web-copy-clipboard-event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix kimi web code-block copy writing "[object ClipboardEvent]" when a non-string copy payload is emitted.
8 changes: 6 additions & 2 deletions apps/kimi-web/src/components/chat/Markdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 { asCopyableText, 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';
Expand Down Expand Up @@ -338,10 +338,14 @@ const codeBlockProps = {
loading: false,
};

function copyCodeBlockFallback(code: string): void {
function copyCodeBlockFallback(payload: unknown): 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.
// Also ignore non-string payloads (e.g. native ClipboardEvent) — coercing
// those would write the literal "[object ClipboardEvent]" to the clipboard.
const code = asCopyableText(payload);
if (!code) return;
const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
if (clipboard && typeof clipboard.writeText === 'function') return;
void copyTextToClipboard(code);
Expand Down
11 changes: 11 additions & 0 deletions apps/kimi-web/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@
// so a `.then().catch()` chain cannot recover. We therefore probe for the API
// first and fall back to a temporary <textarea> + `document.execCommand`.

/**
* Normalize payloads from markstream `@copy` (and similar) handlers.
*
* Some renderers emit a native `ClipboardEvent` on the same channel as the
* code-string payload. Coercing that object to a string yields the useless
* clipboard value `[object ClipboardEvent]`, so callers must reject non-strings.
*/
export function asCopyableText(payload: unknown): string | null {
return typeof payload === 'string' && payload.length > 0 ? payload : null;
}

/**
* Copy `text` to the system clipboard.
*
Expand Down
16 changes: 15 additions & 1 deletion apps/kimi-web/test/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { copyTextToClipboard } from '../src/lib/clipboard';
import { asCopyableText, 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.
Expand Down Expand Up @@ -40,6 +40,20 @@ afterEach(() => {
vi.unstubAllGlobals();
});

describe('asCopyableText', () => {
it('returns non-empty strings', () => {
expect(asCopyableText('hello')).toBe('hello');
});

it('rejects empty strings and non-strings', () => {
expect(asCopyableText('')).toBeNull();
expect(asCopyableText(undefined)).toBeNull();
expect(asCopyableText({ type: 'copy' })).toBeNull();
// Simulate a native ClipboardEvent-like object that stringifies badly.
expect(asCopyableText({ toString: () => '[object ClipboardEvent]' })).toBeNull();
});
});

describe('copyTextToClipboard', () => {
it('uses navigator.clipboard when available', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Expand Down