From 5b93c6216a3714023489893ac2abc79f45d8be35 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:03:25 -0700 Subject: [PATCH 1/7] Fix Chat UI paste intake and pane routing (#23784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chat): separate text paste from attachments and route by pane Keep composer text independent of image checks and saving, and route pastes caught underneath chat to the originating pane's mounted input. Preserve native event data, selection replacement, undo, and target lifetime checks. Co-authored-by: Wooseong Kim Co-authored-by: lurunzi * fix(chat): keep focus and quiet text paste after routing it to chat - A paste inserted into the composer now moves focus there, as the old menu-paste insert did; otherwise a paste routed from the hidden terminal left the next keystrokes going to that terminal. - With a remote-server or not-ready workspace, pasted text no longer shows the "Local attachments are not available" refusal because the clipboard also held an image rendition (common for Office copies). The menu path probes for an image only when the text read is empty, so a paired browser does one permission-gated clipboard read for a text paste, not two. - Latest-value refs update in a layout effect instead of during render. * perf(clipboard): answer "is there an image?" from the format list The chat composer asks the main process whether the clipboard holds an image before explaining an image-only paste on a remote-server workspace. That probe decoded the whole image (readImage().isEmpty()) on the main thread just to return a boolean. Read clipboard.availableFormats() instead, and share the MIME check with the paired-web probe. * fix(chat): a chat cover owns focus, so input never reaches the hidden terminal When a Chat UI tab opened over its terminal, the terminal's xterm kept keyboard focus until the composer claimed it a frame later, and forever if the composer never became ready (still starting, a question card, a phone holding input). The previous commits rerouted paste from that hidden terminal to the chat, but typing and Enter still went to the terminal, an image-only or refused paste left focus there, and about twenty terminal.focus() call sites could put it back. Make "a covered terminal cannot hold focus" structural instead: - The chat cover takes focus in the commit that mounts it and marks the covered xterm inert, so every terminal.focus() path is refused by the browser. Split siblings are untouched. When the chat goes away the xterm is un-inerted, and gets focus back only if focus was inside that chat. - Terminal paste listeners skip anything inside a chat cover (previously only inside a mounted chat root). The reroute from terminal to chat is gone; terminal-only paste is back to main's code. - A paste that finds no chat input (before the chat mounts, or an approval card with no text field) gets a visible refusal from the cover. A disabled composer shows the same notice inline instead of dropping the paste. New copy: "Can't paste — this chat isn't accepting input right now." (the old "Worktree not ready" toast was wrong for a chat that is still starting). - The terminal context menu, which names its pane, keeps a small request event to that pane's chat, now without a clipboard payload and using the existing covered-pane check. - Cmd/Ctrl+V or Shift+Insert on a non-input part of the chat focuses the composer (or question answer) first, so the paste lands there. - The composer-scope check used to decide whether a text field inside the chat keeps its own paste matched the whole pane (the file-drop surface carries the same attribute). It now asks the composer whether the target is inside its input. * fix(chat): don't paste a copied file's name next to the file Copying a file in Finder or another file manager puts its name on the clipboard as text/plain beside the file itself. Since text and images are now pasted independently, pasting such a copy into a local or SSH chat inserted the file name into the prompt as well as attaching the image. On the paste-event path, text/plain that is exactly the names of the pasted files (one per line) is the file's label, not prompt text, so it is dropped when an image from that paste is being attached. Rich-text copies (text plus an image rendition) still insert their text, and a copied non-image file, which is not attached, still pastes its name as before. * fix(chat): don't type a Finder file's name on Cmd+V either On macOS, Cmd+V in the chat goes through the app-menu paste, which reads the clipboard text and saves the clipboard image separately. A file copied in Finder also puts its name on the clipboard as text, so the composer typed the name next to the attachment. On main the menu path never read text once an image saved. The main process now reports the paths of the files a file manager copied (macOS filenames plist or file URL, Explorer's FileNameW, a Linux uri-list). Text that only labels those files waits for the image outcome: dropped when an image is attached (or refused on a remote owner), typed when none came. The same label rule now also accepts a path or file URL per line, which is how Linux file managers label copied files on the paste-event path. * fix(chat): pane focus aimed at a chat lands on the chat Since the covered terminal became inert, focusing a pane that shows a chat (keyboard pane navigation, focus-follows-mouse, split activation) was refused and focus stayed on the pane the user left, so typing went to that visible sibling terminal. The one place a pane's focus is requested now puts it on the pane's chat cover, which hands it to the composer when the pane is revealed. Focus already inside the chat is left alone. * test(terminal): give fake panes the container pane focus now reads Pane focus checks the pane's container for a chat cover, and these two fixtures built panes with only a terminal, so four tests threw. * refactor(native-chat): move composer paste handle and chat-root key routing into their own modules Brings NativeChatComposer.tsx and NativeChatResolvedView.tsx back under the 400-line limit after merging main. No behavior change. --------- Co-authored-by: Wooseong Kim Co-authored-by: lurunzi --- .../clipboard-copied-file-paths.test.ts | 90 ++++ .../window/clipboard-copied-file-paths.ts | 89 ++++ .../clipboard-dashboard-popout-access.test.ts | 3 + .../window/clipboard-ipc-handlers.test.ts | 19 + src/main/window/clipboard-ipc-handlers.ts | 13 + .../window/clipboard-windows-image-file.ts | 21 +- ...ui-bridge-clipboard-and-window-controls.ts | 2 + src/preload/api/ui-window-api.ts | 3 + .../native-chat/NativeChatComposer.tsx | 42 +- .../NativeChatPromptEditor.test.tsx | 30 +- .../native-chat/NativeChatPromptEditor.tsx | 6 + .../native-chat/NativeChatResolvedView.tsx | 22 +- ...tiveChatStructuredSession.test-harness.tsx | 3 +- ...tiveChatStructuredSessionDelivery.test.tsx | 3 +- .../native-chat-clipboard-payload.ts | 75 ++++ .../native-chat/native-chat-composer-input.ts | 14 + ...native-chat-composer-reveal-focus.test.tsx | 5 +- .../native-chat/native-chat-composer-types.ts | 2 + .../native-chat-paste-routing.test.tsx | 416 +++++++++++++++++ .../native-chat-root-key-routing.ts | 42 ++ .../native-chat-typing-redirect.test.ts | 42 ++ .../native-chat-typing-redirect.ts | 18 +- .../use-native-chat-composer-handle.ts | 41 ++ .../use-native-chat-composer-paste.test.tsx | 425 +++++++++++++++++- .../use-native-chat-composer-paste.ts | 253 ++++++----- .../use-native-chat-paste-bridge.ts | 64 ++- .../use-native-chat-typed-insertion.ts | 25 +- .../terminal-pane/NativeChatPaneCover.tsx | 34 ++ .../TerminalPaneNativeChatPortal.test.tsx | 3 +- .../TerminalPaneNativeChatPortal.tsx | 5 +- .../terminal-pane/native-chat-cover-paste.ts | 24 + .../terminal-pane/native-chat-covered-pane.ts | 22 + .../terminal-pane/pane-helpers.test.ts | 6 +- .../terminal-layout-overlay-focus.test.tsx | 6 +- .../terminal-pane/terminal-pane-menu-paste.ts | 4 + .../terminal-pane-paste-listeners.ts | 18 +- .../use-covered-terminal-focus-handoff.ts | 33 ++ src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../src/lib/native-chat-paste-request.ts | 18 + .../pane-manager/pane-overlay-focus.test.ts | 29 ++ .../lib/pane-manager/pane-overlay-focus.ts | 4 + .../src/web/preload-api/web-clipboard-api.ts | 9 + .../src/web/preload-api/web-ui-api.ts | 4 + .../web/web-clipboard-image-presence.test.ts | 33 ++ src/shared/clipboard-image.ts | 6 + 50 files changed, 1822 insertions(+), 210 deletions(-) create mode 100644 src/main/window/clipboard-copied-file-paths.test.ts create mode 100644 src/main/window/clipboard-copied-file-paths.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-clipboard-payload.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-paste-routing.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-root-key-routing.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts create mode 100644 src/renderer/src/components/terminal-pane/NativeChatPaneCover.tsx create mode 100644 src/renderer/src/components/terminal-pane/native-chat-cover-paste.ts create mode 100644 src/renderer/src/components/terminal-pane/use-covered-terminal-focus-handoff.ts create mode 100644 src/renderer/src/lib/native-chat-paste-request.ts create mode 100644 src/renderer/src/web/web-clipboard-image-presence.test.ts diff --git a/src/main/window/clipboard-copied-file-paths.test.ts b/src/main/window/clipboard-copied-file-paths.test.ts new file mode 100644 index 00000000000..2282ecfca9c --- /dev/null +++ b/src/main/window/clipboard-copied-file-paths.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths' + +function clipboardWith(formats: Record) { + return { + readBuffer: (format: string): Buffer => { + const value = formats[format] + return typeof value === 'string' ? Buffer.from(value, 'utf8') : (value ?? Buffer.alloc(0)) + } + } +} + +function filenamesPlist(paths: string[]): string { + const entries = paths.map((path) => `${path}`).join('') + return `${entries}` +} + +describe('readClipboardCopiedFilePaths', () => { + it('lists every file Finder copied, decoding XML entities', () => { + const clipboard = clipboardWith({ + NSFilenamesPboardType: filenamesPlist(['/Users/me/Q&A shot.png', '/Users/me/b.pdf']), + 'public.file-url': 'file:///Users/me/Q&A%20shot.png' + }) + expect(readClipboardCopiedFilePaths(clipboard, 'darwin')).toEqual([ + '/Users/me/Q&A shot.png', + '/Users/me/b.pdf' + ]) + }) + + it('falls back to the first file URL on macOS, but not a file-reference URL', () => { + expect( + readClipboardCopiedFilePaths( + clipboardWith({ 'public.file-url': 'file:///Users/me/my%20shot.png' }), + 'darwin' + ) + ).toEqual(['/Users/me/my shot.png']) + expect( + readClipboardCopiedFilePaths( + clipboardWith({ 'public.file-url': 'file:///.file/id=6571367.2773272' }), + 'darwin' + ) + ).toEqual([]) + }) + + it('reads a Linux file manager uri-list and rejects non-file entries', () => { + expect( + readClipboardCopiedFilePaths( + clipboardWith({ + 'text/uri-list': '# copied\r\nfile:///home/me/a.png\r\nfile:///home/me/b%20c.txt\r\n' + }), + 'linux' + ) + ).toEqual(['/home/me/a.png', '/home/me/b c.txt']) + expect( + readClipboardCopiedFilePaths( + clipboardWith({ 'text/uri-list': 'file:///home/me/a.png\nhttps://example.com/x' }), + 'linux' + ) + ).toEqual([]) + }) + + it('reads the single file Explorer copied and nothing when it copied several', () => { + const shellItems = (count: number): Buffer => { + const cida = Buffer.alloc(4 + 4 * (count + 1)) + cida.writeUInt32LE(count) + return cida + } + const explorer = (count: number) => + clipboardWith({ + FileNameW: Buffer.from('C:\\Users\\me\\shot.png\0', 'utf16le'), + 'Shell IDList Array': shellItems(count) + }) + expect(readClipboardCopiedFilePaths(explorer(1), 'win32')).toEqual(['C:\\Users\\me\\shot.png']) + expect(readClipboardCopiedFilePaths(explorer(2), 'win32')).toEqual([]) + }) + + it('returns nothing for plain text, oversized lists, or a failing clipboard', () => { + expect(readClipboardCopiedFilePaths(clipboardWith({}), 'darwin')).toEqual([]) + const huge = filenamesPlist(['/a'.padEnd(300 * 1024, 'a')]) + expect( + readClipboardCopiedFilePaths(clipboardWith({ NSFilenamesPboardType: huge }), 'darwin') + ).toEqual([]) + const failing = { + readBuffer: (): Buffer => { + throw new Error('format unavailable') + } + } + expect(readClipboardCopiedFilePaths(failing, 'linux')).toEqual([]) + }) +}) diff --git a/src/main/window/clipboard-copied-file-paths.ts b/src/main/window/clipboard-copied-file-paths.ts new file mode 100644 index 00000000000..77f74e5e34c --- /dev/null +++ b/src/main/window/clipboard-copied-file-paths.ts @@ -0,0 +1,89 @@ +import { fileURLToPath } from 'node:url' +import { readWindowsCopiedFilePath } from './clipboard-windows-image-file' + +type ClipboardFormatReader = { readBuffer: (format: string) => Buffer } + +const FILE_LIST_MAX_BYTES = 256 * 1024 +const XML_ENTITIES: Record = { + amp: '&', + apos: "'", + gt: '>', + lt: '<', + quot: '"' +} + +function readBoundedText(clipboard: ClipboardFormatReader, format: string): string { + const buffer = clipboard.readBuffer(format) + return buffer.byteLength <= FILE_LIST_MAX_BYTES ? buffer.toString('utf8') : '' +} + +function decodeXmlText(value: string): string { + return value.replace( + /&(?:#(\d+)|#x([0-9a-fA-F]+)|(amp|apos|gt|lt|quot));/g, + (_entity, decimal: string | undefined, hex: string | undefined, name: string | undefined) => + decimal + ? String.fromCodePoint(Number(decimal)) + : hex + ? String.fromCodePoint(Number.parseInt(hex, 16)) + : XML_ENTITIES[name ?? ''] + ) +} + +/** macOS/Linux file URLs; any other entry means this is not a file copy. */ +function filePathsFromUrls(urls: readonly string[]): string[] { + const paths: string[] = [] + for (const url of urls) { + // Finder can hand out file-reference URLs (/.file/id=…), which name no file. + if (!url.startsWith('file://') || url.startsWith('file:///.file/id=')) { + return [] + } + paths.push(fileURLToPath(url, { windows: false })) + } + return paths +} + +function readMacCopiedFilePaths(clipboard: ClipboardFormatReader): string[] { + // Finder's legacy filenames plist lists every copied file; public.file-url holds only the first. + const plist = readBoundedText(clipboard, 'NSFilenamesPboardType') + const listed = Array.from(plist.matchAll(/([^<]*)<\/string>/g), (match) => + decodeXmlText(match[1]) + ) + if (listed.length > 0) { + return listed + } + const url = readBoundedText(clipboard, 'public.file-url').trim() + return url ? filePathsFromUrls([url]) : [] +} + +function readLinuxCopiedFilePaths(clipboard: ClipboardFormatReader): string[] { + const urls = readBoundedText(clipboard, 'text/uri-list') + .split(/\r\n|\r|\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + return filePathsFromUrls(urls) +} + +/** + * Paths of the files a file manager copied, so a paste can tell the text that + * labels them from prompt text. A list it cannot read in full comes back empty. + */ +export function readClipboardCopiedFilePaths( + clipboard: ClipboardFormatReader, + platform: NodeJS.Platform = process.platform +): string[] { + try { + if (platform === 'darwin') { + return readMacCopiedFilePaths(clipboard) + } + if (platform === 'win32') { + const filePath = readWindowsCopiedFilePath({ + fileNameW: clipboard.readBuffer('FileNameW'), + shellIdListArray: clipboard.readBuffer('Shell IDList Array') + }) + return filePath ? [filePath] : [] + } + return readLinuxCopiedFilePaths(clipboard) + } catch { + return [] + } +} diff --git a/src/main/window/clipboard-dashboard-popout-access.test.ts b/src/main/window/clipboard-dashboard-popout-access.test.ts index df91e29a2ed..9a9c50bd3af 100644 --- a/src/main/window/clipboard-dashboard-popout-access.test.ts +++ b/src/main/window/clipboard-dashboard-popout-access.test.ts @@ -106,6 +106,9 @@ describe('dashboard popout clipboard access', () => { await expect(handlers.get('clipboard:saveImageAsTempFile')?.(popoutEvent)).rejects.toThrow( 'Unauthorized clipboard IPC sender' ) + expect(() => handlers.get('clipboard:readFilePaths')?.(popoutEvent)).toThrow( + 'Unauthorized clipboard IPC sender' + ) expect(() => handlers.get('clipboard:writeFile')?.(popoutEvent, { filePath: '/tmp/copied-file.txt', diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index ea337747421..1bcf1749c30 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -26,6 +26,7 @@ const { clipboardReadBufferMock, clipboardWriteTextMock, clipboardReadImageMock, + clipboardAvailableFormatsMock, clipboardWriteImageMock, clipboardWriteBufferMock, nativeImageCreateFromBufferMock, @@ -62,6 +63,7 @@ const { clipboardReadBufferMock: vi.fn(), clipboardWriteTextMock: vi.fn(), clipboardReadImageMock: vi.fn(), + clipboardAvailableFormatsMock: vi.fn(), clipboardWriteImageMock: vi.fn(), clipboardWriteBufferMock: vi.fn(), nativeImageCreateFromBufferMock: vi.fn(), @@ -109,6 +111,7 @@ vi.mock('electron', () => ({ readBuffer: clipboardReadBufferMock, writeText: clipboardWriteTextMock, readImage: clipboardReadImageMock, + availableFormats: clipboardAvailableFormatsMock, writeImage: clipboardWriteImageMock, writeBuffer: clipboardWriteBufferMock }, @@ -552,6 +555,8 @@ describe('registerClipboardHandlers', () => { expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeFile') expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:saveImageAsTempFile') expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readImageThumbnail') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:hasImage') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readFilePaths') }) it('does not inspect FileNameW when an empty image clipboard is read outside Windows', async () => { @@ -874,4 +879,18 @@ describe('registerClipboardHandlers', () => { expect(nativeImageCreateFromBufferMock).toHaveBeenCalled() expect(clipboardWriteImageMock).not.toHaveBeenCalled() }) + + it.each([ + [['text/plain'], false], + [['text/plain', 'image/png'], true] + ])('reports image presence for %j from the format list without decoding', (formats, expected) => { + setTrustedClipboardRendererWebContentsId(17) + clipboardAvailableFormatsMock.mockReturnValue(formats) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registering handlers never reads the store for clipboard image presence. + registerClipboardHandlers({} as never) + const probe = getRegisteredHandlers().get('clipboard:hasImage') + expect(probe?.(makeClipboardEvent())).toBe(expected) + expect(clipboardReadImageMock).not.toHaveBeenCalled() + expect(() => probe?.(makeClipboardEvent({ id: 42 }))).toThrow() + }) }) diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index f6a688ea3d3..e81140d734a 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -24,6 +24,7 @@ import { assertClipboardImageBase64LengthWithinLimit, assertClipboardImageByteLengthWithinLimit, assertClipboardImageDimensionsWithinLimit, + clipboardFormatsIncludeImage, type ClipboardImageThumbnail } from '../../shared/clipboard-image' import { @@ -38,6 +39,7 @@ import { } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' +import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths' import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail' import { writeClipboardTextAndVerify } from './clipboard-text-write-verify' import { isDashboardPopoutRenderer } from './dashboard-popout-window' @@ -96,6 +98,8 @@ export function registerClipboardHandlers(store: Store): void { ipcMain.removeHandler('clipboard:writeFile') ipcMain.removeHandler('clipboard:saveImageAsTempFile') ipcMain.removeHandler('clipboard:readImageThumbnail') + ipcMain.removeHandler('clipboard:hasImage') + ipcMain.removeHandler('clipboard:readFilePaths') void cleanupExpiredRemoteClipboardFiles() scheduleLegacyRemoteClipboardFileCleanup() @@ -117,6 +121,15 @@ export function registerClipboardHandlers(store: Store): void { assertTrustedClipboardSender(event) return buildClipboardImageThumbnail(clipboard.readImage()) }) + ipcMain.handle('clipboard:hasImage', (event): boolean => { + assertTrustedClipboardSender(event) + return clipboardFormatsIncludeImage(clipboard.availableFormats()) + }) + // Why: a file-manager copy also carries the files' names as text, which a paste must not type. + ipcMain.handle('clipboard:readFilePaths', (event): string[] => { + assertTrustedClipboardSender(event) + return readClipboardCopiedFilePaths(clipboard) + }) // Why: terminals need to detect clipboard images to support tools like Claude // Code that accept image input via paste. Writes the clipboard image to a // temp file and returns the path, or null if the clipboard has no image. diff --git a/src/main/window/clipboard-windows-image-file.ts b/src/main/window/clipboard-windows-image-file.ts index 3c26bb70c4f..39099332958 100644 --- a/src/main/window/clipboard-windows-image-file.ts +++ b/src/main/window/clipboard-windows-image-file.ts @@ -13,7 +13,7 @@ type WindowsClipboardImageFileDeps = { openFile: (filePath: string) => Promise } -type WindowsClipboardImageFileFormats = { +export type WindowsClipboardFileFormats = { fileNameW: Buffer shellIdListArray: Buffer } @@ -65,7 +65,7 @@ function decodeFileNameW(value: Buffer): string | null { if (!filePath || filePath.includes('\0') || !isFullyQualifiedWindowsPath(filePath)) { return null } - return IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase()) ? filePath : null + return filePath } function hasAtMostOneShellItem(value: Buffer): boolean { @@ -76,6 +76,14 @@ function hasAtMostOneShellItem(value: Buffer): boolean { return value.byteLength >= 12 && value.readUInt32LE(0) === 1 } +/** The one file Explorer copied; null when it copied none or several. */ +export function readWindowsCopiedFilePath({ + fileNameW, + shellIdListArray +}: WindowsClipboardFileFormats): string | null { + return hasAtMostOneShellItem(shellIdListArray) ? decodeFileNameW(fileNameW) : null +} + function readPngDimensions(source: Buffer): { height: number; width: number } | null { if ( source.byteLength < 24 || @@ -153,14 +161,11 @@ async function readStableFile( } export async function readWindowsClipboardImageFileAsPng( - { fileNameW, shellIdListArray }: WindowsClipboardImageFileFormats, + formats: WindowsClipboardFileFormats, { createImageFromBuffer, openFile }: WindowsClipboardImageFileDeps ): Promise { - if (!hasAtMostOneShellItem(shellIdListArray)) { - return null - } - const filePath = decodeFileNameW(fileNameW) - if (!filePath) { + const filePath = readWindowsCopiedFilePath(formats) + if (!filePath || !IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase())) { return null } diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 80f2c2dd383..456adfef27a 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -91,6 +91,8 @@ export const uiClipboardAndWindowControlsApi = { connectionId?: string | null runtimeEnvironmentId?: string | null }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), + clipboardHasImage: (): Promise => ipcRenderer.invoke('clipboard:hasImage'), + readClipboardFilePaths: (): Promise => ipcRenderer.invoke('clipboard:readFilePaths'), readClipboardImageThumbnail: (): Promise => ipcRenderer.invoke('clipboard:readImageThumbnail'), writeClipboardText: (text: string): Promise => diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts index b0fa905efa7..c36896a2481 100644 --- a/src/preload/api/ui-window-api.ts +++ b/src/preload/api/ui-window-api.ts @@ -13,6 +13,9 @@ export type UiWindowApi = { connectionId?: string | null runtimeEnvironmentId?: string | null }) => Promise + clipboardHasImage: () => Promise + /** Paths of files a file manager copied; empty when there are none or the host cannot list them. */ + readClipboardFilePaths: () => Promise readClipboardImageThumbnail: () => Promise writeClipboardText: (text: string) => Promise writeTerminalClipboardText: (text: string) => Promise diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index f6120d50861..87ad862f5ec 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,5 +1,5 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { forwardRef, useCallback, useImperativeHandle, useState } from 'react' +import { forwardRef, useCallback, useState } from 'react' import { useAppStore } from '../../store' import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt' import { useNativeChatContextUsageSummary } from './use-native-chat-context-usage-summary' @@ -14,7 +14,7 @@ import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft import { NativeChatComposerField } from './NativeChatComposerField' import type { NativeChatResolvedTarget } from './native-chat-composer-target' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' -import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' +import { useNativeChatComposerHandle } from './use-native-chat-composer-handle' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' @@ -25,7 +25,6 @@ import { useNativeChatSessionOptionCommand } from './use-native-chat-session-opt import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog' import { useNativeChatPickerState } from './use-native-chat-picker-state' import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch' -import { useNativeChatTypedInsertion } from './use-native-chat-typed-insertion' import type { NativeChatComposerHandle, NativeChatComposerProps @@ -189,16 +188,6 @@ const NativeChatComposerPane = forwardRef ({ focus, insertTypedText, handlePasteEvent: handlePaste, pasteFromClipboard }), - [focus, insertTypedText, handlePaste, pasteFromClipboard] - ) - const { pickAttachment } = useNativeChatFileAttachmentActions(paneKey, attachExternalPaths) const { toggleDictation, startHoldDictation, stopHoldDictation } = useNativeChatDictationActions({ textareaRef, setDictationPressed }) @@ -395,7 +389,7 @@ const NativeChatComposerPane = forwardRef { expect(container.querySelector('[data-native-chat-skill]')).toBeNull() }) }) + +it('replaces the selection with literal pasted text as one undoable edit', async () => { + const { input, editor } = setup('hello world') + act(() => input.setSelectionRange(6, 11)) + await act(async () => input.insertText?.('안녕\n$literal')) + expect(input.value).toBe('hello 안녕\n$literal') + await act(async () => { + editor.commands.undo() + }) + expect(input.value).toBe('hello world') +}) + +it('moves focus into the composer when a paste is routed from elsewhere', async () => { + const { input, editor } = setup('hi') + act(() => input.setSelectionRange(2, 2)) + const hiddenTerminal = document.createElement('textarea') + document.body.append(hiddenTerminal) + hiddenTerminal.focus() + await act(async () => { + insertNativeChatPastedText(input, ' there') + }) + expect(input.value).toBe('hi there') + expect(editor.view.dom.contains(document.activeElement)).toBe(true) + hiddenTerminal.remove() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx index efe0bf5de93..ceb812e6291 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx @@ -134,6 +134,12 @@ export function NativeChatPromptEditor({ () => editor ? { + insertText: (text) => { + const content = editor.schema.nodeFromJSON(promptTextContent(text)) + editor.view.dispatch( + editor.state.tr.replaceSelection(new Slice(content.content, 1, 1)) + ) + }, get value() { return promptTextMap(editor.state.doc).text }, diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 786ade25ebc..d5163329e4d 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -39,11 +39,8 @@ import { deriveNativeChatStreamingText, nativeChatStreamingMessage } from '../../../../shared/native-chat-streaming' -import { - shouldFocusNativeChatComposerFromEditingKey, - shouldFocusNativeChatPaneFromPointerTarget, - shouldRedirectNativeChatTyping -} from './native-chat-typing-redirect' +import { shouldFocusNativeChatPaneFromPointerTarget } from './native-chat-typing-redirect' +import { routeNativeChatRootKeyToInput } from './native-chat-root-key-routing' import { emptyNativeChatContextMenuActions, useNativeChatContextMenu @@ -376,20 +373,7 @@ export function NativeChatResolvedView({ } return } - // Backspace/Delete outside an input focuses the composer (like typing) - // but inserts nothing — let the now-focused field handle the keystroke. - if (shouldFocusNativeChatComposerFromEditingKey(event)) { - composerRef.current?.focus() - return - } - if (!shouldRedirectNativeChatTyping(event)) { - return - } - if (!composerRef.current?.insertTypedText(event.key)) { - return - } - event.preventDefault() - event.stopPropagation() + routeNativeChatRootKeyToInput(event, composerRef.current, questionAnswerInputRef.current) }} onMouseUpCapture={contextMenu.onSelectionCapture} onKeyUpCapture={contextMenu.onSelectionCapture} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 29dd7f4bdf0..dd43358c30d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -268,7 +268,8 @@ export function createStructuredSessionMocks() { }, insertTypedText: () => true, handlePasteEvent: mocks.handlePasteEvent, - pasteFromClipboard: mocks.pasteFromClipboard + pasteFromClipboard: mocks.pasteFromClipboard, + contains: (node: Node | null) => fieldRef.current?.contains(node) === true })) return