fix(vscode): make message copy buttons reliable (#12123)

* fix(vscode): make message copy buttons reliable

Route message and response copy actions through the VS Code extension
host so webview focus changes do not block clipboard writes.

* fix(vscode): await host clipboard writes

Correlate clipboard requests with extension-host results so copy
controls only report success after the system clipboard write completes.
This commit is contained in:
Mohammad Javad Naderi
2026-07-28 16:38:51 +03:30
committed by GitHub
parent 5d87ca598c
commit 3075d35f13
10 changed files with 139 additions and 5 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep message and response copy buttons working after switching focus away from VS Code.
+1
View File
@@ -66,6 +66,7 @@
"./text-strikethrough": "./src/components/text-strikethrough.tsx",
"./context": "./src/context/index.ts",
"./context/code": "./src/context/code.tsx",
"./context/clipboard": "./src/context/clipboard.tsx",
"./context/data": "./src/context/data.tsx",
"./context/dialog": "./src/context/dialog.tsx",
"./context/diff": "./src/context/diff.tsx",
@@ -30,6 +30,7 @@ import {
import { useData } from "../context"
import { useFileComponent } from "../context/file"
import { useDialog } from "../context/dialog"
import { useClipboard } from "../context/clipboard"
import { type UiI18n, useI18n } from "../context/i18n"
import { BasicTool, useToolApprovalLine } from "./basic-tool"
import { Accordion } from "./accordion"
@@ -753,6 +754,7 @@ export function UserMessageDisplay(props: {
const data = useData()
const dialog = useDialog()
const i18n = useI18n()
const clipboard = useClipboard()
const [copied, setCopied] = createSignal(false)
const textPart = createMemo(
@@ -815,7 +817,7 @@ export function UserMessageDisplay(props: {
const handleCopy = async () => {
const content = props.copyText ?? text()
if (!content) return
await navigator.clipboard.writeText(content)
await clipboard.write(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
@@ -1365,6 +1367,7 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
PART_MAPPING["text"] = function TextPartDisplay(props) {
const data = useData()
const i18n = useI18n()
const clipboard = useClipboard()
const part = () => props.part as TextPart
const displayText = () => (part().text ?? "").trim()
@@ -1403,7 +1406,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const handleCopy = async () => {
const content = displayText()
if (!content) return
await navigator.clipboard.writeText(content)
await clipboard.write(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
@@ -0,0 +1,17 @@
import { createContext, type ParentComponent, useContext } from "solid-js"
type ClipboardContextValue = {
write: (text: string) => void | Promise<void>
}
const ClipboardContext = createContext<ClipboardContextValue>({
write: (text) => navigator.clipboard.writeText(text),
})
export const ClipboardProvider: ParentComponent<ClipboardContextValue> = (props) => (
<ClipboardContext.Provider value={{ write: props.write }}>{props.children}</ClipboardContext.Provider>
)
export function useClipboard() {
return useContext(ClipboardContext)
}
+1
View File
@@ -952,6 +952,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
dir: this.getWorkspaceDirectory(this.currentSession?.id),
post: (msg) => this.postMessage(msg),
exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID),
copy: (text) => vscode.env.clipboard.writeText(text),
openSessions: (ids) => this.trackOpenSessions(ids),
})
) {
@@ -12,10 +12,32 @@ type Ctx = {
dir: string
post: (msg: unknown) => void
exportTranscript: (sessionID: string) => Promise<void>
copy: (text: string) => PromiseLike<void>
openSessions: (ids: string[]) => void
}
export async function routeEarlyMessage(message: { type: string }, ctx: Ctx): Promise<boolean> {
export async function routeEarlyMessage(
message: { type: string; id?: unknown; text?: unknown },
ctx: Ctx,
): Promise<boolean> {
if (message.type === "copyToClipboard") {
if (typeof message.id !== "string") return true
if (typeof message.text !== "string") {
ctx.post({ type: "clipboardWriteResult", id: message.id, ok: false, error: "Invalid clipboard text" })
return true
}
await ctx.copy(message.text).then(
() => ctx.post({ type: "clipboardWriteResult", id: message.id, ok: true }),
(err) =>
ctx.post({
type: "clipboardWriteResult",
id: message.id,
ok: false,
error: err instanceof Error ? err.message : String(err),
}),
)
return true
}
await routeSuggestionWebviewMessage(ctx.question, message)
if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true
if (message.type === "exportSessionTranscript") {
@@ -0,0 +1,44 @@
import { describe, expect, it } from "bun:test"
import { routeEarlyMessage } from "../../src/kilo-provider/early-message"
type Ctx = Parameters<typeof routeEarlyMessage>[1]
function context(copied: string[], posted: unknown[], fail = false) {
return {
copy: async (text: string) => {
if (fail) throw new Error("clipboard unavailable")
copied.push(text)
},
post: (message: unknown) => posted.push(message),
} as Ctx
}
describe("routeEarlyMessage clipboard handling", () => {
it("routes clipboard text to the host", async () => {
const copied: string[] = []
const posted: unknown[] = []
const handled = await routeEarlyMessage(
{ type: "copyToClipboard", id: "copy-1", text: "message text" },
context(copied, posted),
)
expect(handled).toBe(true)
expect(copied).toEqual(["message text"])
expect(posted).toEqual([{ type: "clipboardWriteResult", id: "copy-1", ok: true }])
})
it("reports host clipboard failures", async () => {
const copied: string[] = []
const posted: unknown[] = []
const handled = await routeEarlyMessage(
{ type: "copyToClipboard", id: "copy-2", text: "message text" },
context(copied, posted, true),
)
expect(handled).toBe(true)
expect(copied).toEqual([])
expect(posted).toEqual([{ type: "clipboardWriteResult", id: "copy-2", ok: false, error: "clipboard unavailable" }])
})
})
@@ -5,6 +5,7 @@
import { createContext, useContext, onCleanup, ParentComponent, createSignal } from "solid-js"
import type { VSCodeAPI, WebviewMessage, ExtensionMessage } from "../types/messages"
import { ClipboardProvider } from "@kilocode/kilo-ui/context/clipboard"
// Get the VS Code API (only available in webview context)
let vscodeApi: VSCodeAPI | undefined
@@ -42,6 +43,7 @@ const VSCodeContext = createContext<VSCodeContextValue>()
export const VSCodeProvider: ParentComponent = (props) => {
const api = getVSCodeAPI()
const handlers = new Set<(message: ExtensionMessage) => void>()
const copies = new Map<string, { resolve: () => void; reject: (err: Error) => void }>()
// Model-selector expand/collapse preference. Stored in extension globalState
// so it is shared across webviews (sidebar + agent-manager panel); a local
@@ -51,6 +53,17 @@ export const VSCodeProvider: ParentComponent = (props) => {
// Listen for messages from the extension
const messageListener = (event: MessageEvent) => {
const message = event.data as ExtensionMessage
if (message.type === "clipboardWriteResult") {
const copy = copies.get(message.id)
if (!copy) return
copies.delete(message.id)
if (message.ok) {
copy.resolve()
return
}
copy.reject(new Error(message.error ?? "Failed to write to clipboard"))
return
}
handlers.forEach((handler) => handler(message))
}
@@ -69,6 +82,7 @@ export const VSCodeProvider: ParentComponent = (props) => {
window.removeEventListener("focus", reportFocus)
window.removeEventListener("blur", reportFocus)
handlers.clear()
copies.clear()
})
const value: VSCodeContextValue = {
@@ -88,7 +102,25 @@ export const VSCodeProvider: ParentComponent = (props) => {
},
}
return <VSCodeContext.Provider value={value}>{props.children}</VSCodeContext.Provider>
return (
<VSCodeContext.Provider value={value}>
<ClipboardProvider
write={(text) =>
new Promise((resolve, reject) => {
const id = crypto.randomUUID()
copies.set(id, { resolve, reject })
api.postMessage({ type: "copyToClipboard", id, text })
setTimeout(() => {
if (!copies.delete(id)) return
reject(new Error("Clipboard write timed out"))
}, 5000)
})
}
>
{props.children}
</ClipboardProvider>
</VSCodeContext.Provider>
)
}
export function useVSCode(): VSCodeContextValue {
@@ -1114,6 +1114,13 @@ export interface ValidateFilesResultMessage {
existing: string[]
}
export interface ClipboardWriteResultMessage {
type: "clipboardWriteResult"
id: string
ok: boolean
error?: string
}
export type ExtensionMessage =
| ReadyMessage
| FontSizeChangedMessage
@@ -1280,6 +1287,7 @@ export type ExtensionMessage =
| TelemetryStateMessage
| RemoteStatusMessage
| ValidateFilesResultMessage
| ClipboardWriteResultMessage
| MemoryLoadedMessage
| MemoryEventMessage
| MemoryOperationResultMessage
@@ -703,7 +703,8 @@ export interface OpenWorktreeRequest {
// Copy text to the system clipboard via the extension host
export interface CopyToClipboardRequest {
type: "agentManager.copyToClipboard"
type: "copyToClipboard"
id: string
text: string
}