From f1ea2e2a99bc20ade126927e228dc77639b4a1cd Mon Sep 17 00:00:00 2001 From: Amicode Sweep Date: Fri, 7 Aug 2026 08:05:48 +0000 Subject: [PATCH 1/4] fix(app): composer opts out of the global clipboard fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paste inserted the same text twice inside the amicode webview. Two mod+V handlers are live in the framed app: the window-level capture-phase fallback in utils/global-clipboard.ts, and the composer's own image-first handler in prompt-input.tsx. The fallback exempts elements matching CLIPBOARD_SELF_SELECTOR, and it calls preventDefault() but deliberately not stopPropagation() — so an unmarked composer receives both insertions. The marker had been applied to the home-cards credential fields but never to the composer, even though global-clipboard.ts's own header names "the prompt input's Cmd+V handler" as the element the exemption exists for. Marking the composer is the correct fix rather than adding stopPropagation(): the composer's handler is the only path that tries the image bridge, so suppressing it would silently kill screenshot paste. The marker owns PASTE only; Cmd+C / Cmd+X keep mirroring through the global path. Adds a structural guard. The existing global-clipboard test asserts the exemption against a synthetic element it builds itself, which is precisely why this shipped — the mechanism was covered, its single real integration was not. Closes harmoniqs/amicode#261 --- .../prompt-input-clipboard-structure.test.ts | 50 +++++++++++++++++++ packages/app/src/components/prompt-input.tsx | 8 +++ 2 files changed, 58 insertions(+) create mode 100644 packages/app/src/components/prompt-input-clipboard-structure.test.ts 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..eab0537f15 --- /dev/null +++ b/packages/app/src/components/prompt-input-clipboard-structure.test.ts @@ -0,0 +1,50 @@ +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 are live inside the framed app: +// 1. utils/global-clipboard.ts — window-level, CAPTURE phase, for every +// editable that has no bridged paste of its own. +// 2. prompt-input.tsx's handleKeyDown — the composer's own, image-first. +// +// (1) exempts elements matching CLIPBOARD_SELF_SELECTOR. It calls +// preventDefault() but deliberately NOT stopPropagation() — so an unmarked +// composer receives BOTH insertions. The marker is the whole mechanism, and it +// had been applied to the home-cards fields but never to the composer the +// mechanism was written for. +// +// 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 exemption 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; until then it is the only +// thing standing between the composer and a silent regression. +const source = readFileSync(join(import.meta.dir, "prompt-input.tsx"), "utf8") + +describe("composer opts out of the global clipboard fallback (amicode#261)", () => { + test('the editor element carries data-amc-clipboard="self"', () => { + expect(source).toContain('data-amc-clipboard="self"') + }) + + test("the marker sits on the same element as the composer's own key handler", () => { + // Guard against the marker drifting onto a wrapper: closest() would still + // match, but a future refactor that moves the handler and not the marker + // (or vice versa) silently restores the double insert. Both attributes must + // live in the one editor element's prop block. + const editor = source.slice( + source.indexOf('data-component="prompt-input"'), + source.indexOf("classList={{", source.indexOf('data-component="prompt-input"')), + ) + expect(editor).toContain('data-amc-clipboard="self"') + expect(editor).toContain("onKeyDown={handleKeyDown}") + }) + + test("the composer still owns an image-first bridged paste", () => { + // The reason the marker is the correct fix and stopPropagation() in + // global-clipboard.ts is not: only this path tries the image bridge, so + // suppressing it would silently kill screenshot paste. + expect(source).toContain("readClipboardImageViaBridge") + }) +}) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 698c12c83c..ec7fa90ccc 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1526,6 +1526,14 @@ export const PromptInput: Component = (props) => { >
Date: Fri, 7 Aug 2026 13:13:47 +0000 Subject: [PATCH 2/4] Improved fix; v2 composer opting out of clipboard fallback --- .../prompt-input-clipboard-structure.test.ts | 44 +++++++++++++++++-- .../src/v2/components/prompt-input/index.tsx | 6 +++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/prompt-input-clipboard-structure.test.ts b/packages/app/src/components/prompt-input-clipboard-structure.test.ts index eab0537f15..2172361a1d 100644 --- a/packages/app/src/components/prompt-input-clipboard-structure.test.ts +++ b/packages/app/src/components/prompt-input-clipboard-structure.test.ts @@ -7,21 +7,28 @@ import { join } from "node:path" // Two mod+V handlers are live inside the framed app: // 1. utils/global-clipboard.ts — window-level, CAPTURE phase, for every // editable that has no bridged paste of its own. -// 2. prompt-input.tsx's handleKeyDown — the composer's own, image-first. +// 2. The composer's own handler — the legacy prompt-input.tsx handleKeyDown +// or the v2 composer's onKeyDown (interaction.ts → handleFramedPaste). // // (1) exempts elements matching CLIPBOARD_SELF_SELECTOR. It calls // preventDefault() but deliberately NOT stopPropagation() — so an unmarked // composer receives BOTH insertions. The marker is the whole mechanism, and it -// had been applied to the home-cards fields but never to the composer the -// mechanism was written for. +// had been applied to the home-cards fields but never to the composers the +// mechanism was written for. The amicode fork hard-locks the v2 layout +// (settings.tsx newLayoutDesigns), so BOTH composers must carry the marker — +// the v2 one is the one actually rendered in the webview. // // 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 exemption 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; until then it is the only -// thing standing between the composer and a silent regression. +// thing standing between the composers and a silent regression. const source = readFileSync(join(import.meta.dir, "prompt-input.tsx"), "utf8") +const v2Source = readFileSync( + join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/index.tsx"), + "utf8", +) describe("composer opts out of the global clipboard fallback (amicode#261)", () => { test('the editor element carries data-amc-clipboard="self"', () => { @@ -48,3 +55,32 @@ describe("composer opts out of the global clipboard fallback (amicode#261)", () expect(source).toContain("readClipboardImageViaBridge") }) }) + +describe("v2 composer opts out of the global clipboard fallback (amicode#261)", () => { + test('the editor element carries data-amc-clipboard="self"', () => { + expect(v2Source).toContain('data-amc-clipboard="self"') + }) + + test("the marker sits on the same element as the composer's own key handler", () => { + // Same drift guard as the v1 case: both attributes must live in the one + // editor element's prop block. + const editor = v2Source.slice( + v2Source.indexOf('data-component="prompt-input"'), + v2Source.indexOf("onKeyUp={updateCursor}", v2Source.indexOf('data-component="prompt-input"')), + ) + expect(editor).toContain('data-amc-clipboard="self"') + expect(editor).toContain("onKeyDown={") + }) + + test("the composer still owns a bridged framed paste", () => { + // The marker's counterpart: interaction.ts intercepts mod+V in framed + // contexts and routes it over the extension-host bridge, so the global + // fallback's opt-out cannot strand the webview without paste. + const interaction = readFileSync( + join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/interaction.ts"), + "utf8", + ) + expect(interaction).toContain("handleFramedPaste") + expect(interaction).toContain("window.parent !== window") + }) +}) diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index 3fe5b19723..0cd81c3420 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -156,6 +156,12 @@ export function PromptInputV2(props: PromptInputV2Props) { renderPromptInputV2Editor(element, props.controller.parts()) }} data-component="prompt-input" + // amicode webview: this editor runs its own bridged mod+V handler + // (interaction.ts onKeyDown → attachments.handleFramedPaste), so the + // global window-level clipboard fallback must opt out here or both + // insert the same text (harmoniqs/amicode#261). Marker owns paste + // only — copy/cut still mirror through the fallback. + data-amc-clipboard="self" role="textbox" aria-multiline="true" aria-label="Prompt" From e2cd50348ba620f5813da732822b3048c9842751 Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Fri, 7 Aug 2026 14:26:30 +0000 Subject: [PATCH 3/4] Reverting handleFramedPaste (temporary patch of image pasting functionality) --- .../prompt-input-clipboard-structure.test.ts | 109 ++++++++---------- .../app/src/components/prompt-input-v2.tsx | 3 +- packages/app/src/components/prompt-input.tsx | 37 +----- packages/app/src/utils/global-clipboard.ts | 20 ++-- .../v2/components/prompt-input/attachments.ts | 21 ---- .../src/v2/components/prompt-input/index.tsx | 6 - .../v2/components/prompt-input/interaction.ts | 18 --- 7 files changed, 63 insertions(+), 151 deletions(-) diff --git a/packages/app/src/components/prompt-input-clipboard-structure.test.ts b/packages/app/src/components/prompt-input-clipboard-structure.test.ts index 2172361a1d..399da81a7a 100644 --- a/packages/app/src/components/prompt-input-clipboard-structure.test.ts +++ b/packages/app/src/components/prompt-input-clipboard-structure.test.ts @@ -4,83 +4,72 @@ import { join } from "node:path" // harmoniqs/amicode#261 — paste inserted the same text twice in the webview. // -// Two mod+V handlers are live inside the framed app: -// 1. utils/global-clipboard.ts — window-level, CAPTURE phase, for every -// editable that has no bridged paste of its own. -// 2. The composer's own handler — the legacy prompt-input.tsx handleKeyDown -// or the v2 composer's onKeyDown (interaction.ts → handleFramedPaste). +// 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. // -// (1) exempts elements matching CLIPBOARD_SELF_SELECTOR. It calls -// preventDefault() but deliberately NOT stopPropagation() — so an unmarked -// composer receives BOTH insertions. The marker is the whole mechanism, and it -// had been applied to the home-cards fields but never to the composers the -// mechanism was written for. The amicode fork hard-locks the v2 layout -// (settings.tsx newLayoutDesigns), so BOTH composers must carry the marker — -// the v2 one is the one actually rendered in the webview. +// 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 exemption against a synthetic element +// 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; until then it is the only -// thing standing between the composers and a silent regression. +// with a render assertion the day a harness lands. const source = readFileSync(join(import.meta.dir, "prompt-input.tsx"), "utf8") -const v2Source = readFileSync( +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("composer opts out of the global clipboard fallback (amicode#261)", () => { - test('the editor element carries data-amc-clipboard="self"', () => { - expect(source).toContain('data-amc-clipboard="self"') +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 marker sits on the same element as the composer's own key handler", () => { - // Guard against the marker drifting onto a wrapper: closest() would still - // match, but a future refactor that moves the handler and not the marker - // (or vice versa) silently restores the double insert. Both attributes must - // live in the one editor element's prop block. - const editor = source.slice( - source.indexOf('data-component="prompt-input"'), - source.indexOf("classList={{", source.indexOf('data-component="prompt-input"')), - ) - expect(editor).toContain('data-amc-clipboard="self"') - expect(editor).toContain("onKeyDown={handleKeyDown}") + 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("the composer still owns an image-first bridged paste", () => { - // The reason the marker is the correct fix and stopPropagation() in - // global-clipboard.ts is not: only this path tries the image bridge, so - // suppressing it would silently kill screenshot paste. - expect(source).toContain("readClipboardImageViaBridge") - }) -}) - -describe("v2 composer opts out of the global clipboard fallback (amicode#261)", () => { - test('the editor element carries data-amc-clipboard="self"', () => { - expect(v2Source).toContain('data-amc-clipboard="self"') + 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 marker sits on the same element as the composer's own key handler", () => { - // Same drift guard as the v1 case: both attributes must live in the one - // editor element's prop block. - const editor = v2Source.slice( - v2Source.indexOf('data-component="prompt-input"'), - v2Source.indexOf("onKeyUp={updateCursor}", v2Source.indexOf('data-component="prompt-input"')), - ) - expect(editor).toContain('data-amc-clipboard="self"') - expect(editor).toContain("onKeyDown={") + 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 composer still owns a bridged framed paste", () => { - // The marker's counterpart: interaction.ts intercepts mod+V in framed - // contexts and routes it over the extension-host bridge, so the global - // fallback's opt-out cannot strand the webview without paste. - const interaction = readFileSync( - join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/interaction.ts"), - "utf8", - ) - expect(interaction).toContain("handleFramedPaste") - expect(interaction).toContain("window.parent !== window") + 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..7a996c862f 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -386,7 +386,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, diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index ec7fa90ccc..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 @@ -1526,14 +1499,6 @@ export const PromptInput: Component = (props) => { >
{ - 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/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index 0cd81c3420..3fe5b19723 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -156,12 +156,6 @@ export function PromptInputV2(props: PromptInputV2Props) { renderPromptInputV2Editor(element, props.controller.parts()) }} data-component="prompt-input" - // amicode webview: this editor runs its own bridged mod+V handler - // (interaction.ts onKeyDown → attachments.handleFramedPaste), so the - // global window-level clipboard fallback must opt out here or both - // insert the same text (harmoniqs/amicode#261). Marker owns paste - // only — copy/cut still mirror through the fallback. - data-amc-clipboard="self" role="textbox" aria-multiline="true" aria-label="Prompt" 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) && From b5849d6d207f42640453dabd4b484c03a062f7e5 Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Fri, 7 Aug 2026 21:17:42 +0000 Subject: [PATCH 4/4] Round 2 of fixing copy bug; now accounting properly (hopefully) for cleanup of inputs forwarded to controller that should not be --- .../app/src/components/prompt-input-v2.tsx | 9 +++++- packages/app/src/utils/global-clipboard.ts | 30 ++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 7a996c862f..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, @@ -420,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/utils/global-clipboard.ts b/packages/app/src/utils/global-clipboard.ts index 9b97ad28a0..6feaf255be 100644 --- a/packages/app/src/utils/global-clipboard.ts +++ b/packages/app/src/utils/global-clipboard.ts @@ -9,7 +9,20 @@ // Unframed (plain web/desktop), it does nothing — native clipboard behavior // stands. -import { readClipboardViaBridge, writeClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge" +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 @@ -154,9 +167,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 }