mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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:
committed by
GitHub
parent
5d87ca598c
commit
3075d35f13
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep message and response copy buttons working after switching focus away from VS Code.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user