diff --git a/packages/app/src/components/prompt-input-clipboard-structure.test.ts b/packages/app/src/components/prompt-input-clipboard-structure.test.ts new file mode 100644 index 0000000000..399da81a7a --- /dev/null +++ b/packages/app/src/components/prompt-input-clipboard-structure.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +// harmoniqs/amicode#261 — paste inserted the same text twice in the webview. +// +// Two mod+V handlers used to be live inside the framed app: +// 1. utils/global-clipboard.ts — window-level, CAPTURE phase. +// 2. The composer's own keydown handler (v1 handleKeyDown / v2 onKeyDown), +// which preventDefault'd but never stopPropagation'd, so BOTH bridge- +// inserted the same text. An opt-out marker (data-amc-clipboard="self") +// on the composer editors was tried, but the amicode fork hard-locks the +// v2 layout and the marker contract proved too fragile to maintain across +// the two composers. +// +// The current design removes the composers' keydown interception entirely: +// the window-level fallback is the SOLE ⌘V path in the webview (single +// insert), and the native paste event path (composer onPaste → handlePaste) +// remains for chords the fallback doesn't own (⌘⇧V). Image/screenshot paste +// over ⌘V is knowingly sacrificed — the fallback reads text only. +// +// This is a SOURCE assertion rather than a rendered-DOM one because the app has +// no component-render harness (no @solidjs/testing-library) — the same reason +// global-clipboard.test.ts exercises the fallback against a synthetic element +// it builds itself, which is exactly the gap that let #261 ship. Replace this +// with a render assertion the day a harness lands. +const source = readFileSync(join(import.meta.dir, "prompt-input.tsx"), "utf8") +const v2Interaction = readFileSync( + join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/interaction.ts"), + "utf8", +) +const v2Attachments = readFileSync( + join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/attachments.ts"), + "utf8", +) +const v2Editor = readFileSync( + join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/index.tsx"), + "utf8", +) +const globalClipboard = readFileSync(join(import.meta.dir, "../utils/global-clipboard.ts"), "utf8") + +describe("single ⌘V path in the webview (amicode#261)", () => { + test("the v1 composer no longer intercepts ⌘V at keydown", () => { + expect(source).not.toContain("inAmicode") + expect(source).not.toContain('event.key.toLowerCase() === "v"') + }) + + test("the v2 composer no longer intercepts ⌘V at keydown", () => { + expect(v2Interaction).not.toContain('event.key.toLowerCase() === "v"') + expect(v2Interaction).not.toContain("handleFramedPaste") + expect(v2Attachments).not.toContain("handleFramedPaste") + }) + + test("neither composer editor carries the opt-out marker", () => { + // The marker contract is gone: the fallback owns ⌘V everywhere. A marker + // here would silently re-orphan the composer (nothing would paste on ⌘V). + expect(source).not.toContain('data-amc-clipboard="self"') + expect(v2Editor).not.toContain('data-amc-clipboard="self"') + }) + + test("the window-level fallback still owns the ⌘V branch for editables", () => { + expect(globalClipboard).toContain('key !== "v"') + expect(globalClipboard).toContain("installGlobalClipboardFallback") + expect(globalClipboard).toContain("readClipboardViaBridge") + }) + + test("the composers still wire the native paste path with bridge fallbacks", () => { + // ⌘⇧V (and any non-prevented paste event) flows: onPaste → handlePaste, + // which falls back to the host-clipboard bridges when the event carries + // nothing readable. + expect(source).toContain("onPaste={handlePaste}") + expect(v2Interaction).toContain("handlePaste") + expect(source).toContain("readClipboardViaBridge") + }) +}) diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 7eee830c49..a66d143342 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -6,7 +6,7 @@ import { Icon } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" -import { createEffect, createMemo, on, Show } from "solid-js" +import { createEffect, createMemo, on, onCleanup, Show } from "solid-js" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { ReportBugButton } from "@/components/report-bug-button" @@ -29,6 +29,7 @@ import { useSync } from "@/context/sync" import { createSessionTabs } from "@/pages/session/helpers" import { showToast } from "@/utils/toast" import { bugReportEnabled } from "@/utils/amicode-bug-report" +import { setClipboardImageHandler } from "@/utils/global-clipboard" import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" import { createPromptInputV2Controller, @@ -386,7 +387,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): // the extension host where the framed app has no clipboard permission. // Bridge first (self-gates to null unframed), platform as fallback. readClipboardImage: async () => (await readClipboardImageViaBridge()) ?? (await platform.readClipboardImage?.()) ?? null, - // Text side of the same bridge — feeds session-ui's handleFramedPaste. + // Text side of the same bridge — feeds session-ui's handlePaste framed + // text fallback when the paste event carries nothing readable. readClipboardText: () => readClipboardViaBridge(), getPathForFile: platform.getPathForFile, store: platform.draftStore?.putBlob, @@ -419,6 +421,12 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): }) Object.defineProperty(controller, "model", { get: () => props.controls.model }) + // Framed webview: the window-level fallback (global-clipboard.ts) is the + // sole ⌘V owner. When the clipboard carries no text it offers the media to + // this slot, landing it in the composer's attachment pipeline. + setClipboardImageHandler((file) => controller.addAttachments([file])) + onCleanup(() => setClipboardImageHandler(undefined)) + command.register("prompt-input", () => [ { id: "file.attach", diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 698c12c83c..39fbd9dfad 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -51,11 +51,9 @@ import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { createSessionTabs } from "@/pages/session/helpers" -import { inAmicode } from "@/pages/session/use-amicode-commands" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" -import { readClipboardViaBridge, readClipboardImageViaBridge } from "./prompt-input/clipboard-bridge" -import { normalizePaste } from "./prompt-input/paste" +import { readClipboardViaBridge } from "./prompt-input/clipboard-bridge" import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" import { canNavigateHistoryAtCursor, @@ -1218,31 +1216,6 @@ export const PromptInput: Component = (props) => { }) const handleKeyDown = (event: KeyboardEvent) => { - // Amicode webview: the framed app has no clipboard-read permission, so the - // browser dispatches no usable paste event on ⌘V (unlike plain web/desktop, - // where onPaste handles it). Intercept the keystroke and read the OS - // clipboard over the extension bridge instead (see clipboard-bridge.ts). - // Image first (screenshots), then text — mirrors handlePaste's precedence. - if ( - (event.metaKey || event.ctrlKey) && - !event.altKey && - !event.shiftKey && - event.key.toLowerCase() === "v" && - inAmicode() - ) { - event.preventDefault() - void (async () => { - const image = await readClipboardImageViaBridge() - if (image) { - await addAttachment(image) - return - } - const text = await readClipboardViaBridge() - if (text) addPart({ type: "text", content: normalizePaste(text), start: 0, end: 0 }) - })() - return - } - if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { event.preventDefault() if (store.mode !== "normal") return diff --git a/packages/app/src/utils/global-clipboard.ts b/packages/app/src/utils/global-clipboard.ts index 86c6ca4a2e..5ed3ed742a 100644 --- a/packages/app/src/utils/global-clipboard.ts +++ b/packages/app/src/utils/global-clipboard.ts @@ -1,20 +1,34 @@ -// Framed-app editing fallback, generalized from the prompt input's bridge. -// Inside the VS Code webview iframe, native editing shortcuts never fire: -// paste never reaches the DOM, copy never writes the OS clipboard, and -// select-all / undo / redo are suppressed by the Electron platform layer -// (see prompt-input/clipboard-bridge.ts for the full why). This module -// intercepts mod+V/C/X/A/Z/Y at the window's capture phase and implements -// them explicitly in JS — clipboard ops route over the extension-host bridge, -// and select-all / undo / redo call the DOM APIs directly. Unframed -// (plain web/desktop), it does nothing — native editing behavior stands. - -import { readClipboardViaBridge, writeClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge" - -// Elements that carry their own bridged paste (the prompt input's ⌘V handler, -// the profile fields' pasteFallback) mark themselves so the fallback doesn't -// double-insert. The marker owns PASTE only: nothing element-local handles -// copy/cut, so ⌘C/⌘X still mirror to the OS clipboard even inside marked -// subtrees — otherwise copying from the prompt would paste stale content. +// Framed-app clipboard fallback, generalized from the prompt input's bridge. +// Inside the VS Code webview iframe, native paste never fires and native +// copy never reaches the OS clipboard (see prompt-input/clipboard-bridge.ts +// for the full why) — so every editable silently ignores ⌘V and poisons the +// next paste on ⌘C. This module intercepts mod+V/C/X at the window's capture +// phase and routes them over the existing extension-host bridge. It is the +// SOLE ⌘V path in the webview: the prompt composers (v1 and v2) no longer +// intercept the keystroke themselves, so the text lands exactly once. +// Unframed (plain web/desktop), it does nothing — native clipboard behavior +// stands. + +import { + readClipboardImageViaBridge, + readClipboardViaBridge, + writeClipboardViaBridge, +} from "@/components/prompt-input/clipboard-bridge" + +// Single-slot media hook: a paste that carries no text is offered to the +// registered consumer (the v2 composer's attachment pipeline) as an image. +// Deliberately one slot — a list would invite two owners of one gesture. +let clipboardImageHandler: ((file: File) => void) | undefined + +export function setClipboardImageHandler(handler?: (file: File) => void): void { + clipboardImageHandler = handler +} + +// Elements that carry their own bridged paste (the profile fields' +// pasteFallback) mark themselves so the fallback doesn't double-insert. The +// marker owns PASTE only: nothing element-local handles copy/cut, so ⌘C/⌘X +// still mirror to the OS clipboard even inside marked subtrees — otherwise +// copying from the prompt would paste stale content. export const CLIPBOARD_SELF_SELECTOR = '[data-amc-clipboard="self"]' type FormField = HTMLInputElement | HTMLTextAreaElement @@ -194,9 +208,18 @@ export function installGlobalClipboardFallback(win: Window = window): () => void // Native paste never fires in-frame, so preventDefault loses nothing; // an empty or dead bridge reply degrades to a no-op (see clipboard-bridge). event.preventDefault() - void readClipboardViaBridge(win).then((text) => { - if (!text) return - insertTextAtSelection(target, text) + void readClipboardViaBridge(win).then(async (text) => { + if (text) { + insertTextAtSelection(target, text) + return + } + // No text on the clipboard: offer media. Mirrors the composer's own + // handlePaste precedence (image only when there is no plain text), so + // text pastes keep their exact single-value sequence with no extra + // round-trip. + if (!clipboardImageHandler) return + const file = await readClipboardImageViaBridge(win) + if (file) clipboardImageHandler(file) }) return } diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.ts b/packages/session-ui/src/v2/components/prompt-input/attachments.ts index 8dbc3707a6..ac55b24ea4 100644 --- a/packages/session-ui/src/v2/components/prompt-input/attachments.ts +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.ts @@ -199,26 +199,6 @@ export function createPromptInputV2Attachments( if (files) await addAttachments(Array.from(files)) } - // Amicode webview ⌘V: a framed host whose paste event never fires at all - // (the VS Code webview grants no clipboard-read to the iframe), so the paste - // flow above never starts. Read the host clipboard through the wired bridge - // hooks instead — image first (screenshots), then text, same precedence as - // handlePaste. Both hooks self-gate to empty outside the webview. - const handleFramedPaste = async () => { - const target = capture() - if (!target) return - if (input.readClipboardImage) { - const file = await input.readClipboardImage() - if (file && (await add(file, true, target, true))) return - } - const plain = input.readClipboardText ? await input.readClipboardText() : "" - if (!plain) return - const text = plain.includes("\r") ? plain.replace(/\r\n?/g, "\n") : plain - if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return - input.focusEditor() - input.addPart({ type: "text", content: text, start: 0, end: 0 }) - } - onMount(() => { makeEventListener(document, "dragover", (event) => { if (input.isDialogActive()) return @@ -235,7 +215,6 @@ export function createPromptInputV2Attachments( return { addAttachments, handlePaste, - handleFramedPaste, handleDrop, pick(fallback: () => void) { if (!input.picker) { diff --git a/packages/session-ui/src/v2/components/prompt-input/interaction.ts b/packages/session-ui/src/v2/components/prompt-input/interaction.ts index 62ab2c3790..f4a9fa74b7 100644 --- a/packages/session-ui/src/v2/components/prompt-input/interaction.ts +++ b/packages/session-ui/src/v2/components/prompt-input/interaction.ts @@ -186,24 +186,6 @@ export function createPromptInputV2Controller(input: { } const onKeyDown = (event: KeyboardEvent) => { - // Amicode webview ⌘V (amicode patch #11 parity with the legacy composer): - // the framed app has no clipboard-read permission, so the browser dispatches - // no usable paste event on ⌘V. Intercept the keystroke and read the host - // clipboard over the wired bridge hooks instead (attachments.handleFramedPaste). - // Framed contexts only — plain web/desktop keeps the native paste event. - if ( - (event.metaKey || event.ctrlKey) && - !event.altKey && - !event.shiftKey && - event.key.toLowerCase() === "v" && - typeof window !== "undefined" && - window.parent !== window && - attachments - ) { - event.preventDefault() - void attachments.handleFramedPaste() - return true - } if ( state.mode === "normal" && (event.metaKey || event.ctrlKey) &&