From 3f27fe24d1ecce99bb35b43f3de689ed39d344ac Mon Sep 17 00:00:00 2001 From: zpyoung Date: Thu, 15 Oct 2026 13:57:27 -0500 Subject: [PATCH] fix(terminal-dock): leave dock pastes to the dock composer Upstream's #23784 (taken in the v1.4.224 sync) narrowed the terminal pane's paste yield from the chat root marker to the chat cover. The dock opts out through that root marker and sits beside the xterm rather than under a cover, so Cmd+V, paste events, and app-menu pastes in the dock composer went to the terminal. The listeners now yield to the cover or the dock's chat root through a one-line import-swap seam. --- config/fork-ownership.json | 12 ++ .../terminal-dock-paste-yield.test.ts | 109 ++++++++++++++++++ .../terminal-dock-paste-yield.ts | 13 +++ .../terminal-pane-paste-listeners.ts | 2 +- 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/terminal-pane/fork-terminal-dock/terminal-dock-paste-yield.test.ts create mode 100644 src/renderer/src/components/terminal-pane/fork-terminal-dock/terminal-dock-paste-yield.ts diff --git a/config/fork-ownership.json b/config/fork-ownership.json index 4f892278839a..9ef2f227ca90 100644 --- a/config/fork-ownership.json +++ b/config/fork-ownership.json @@ -5308,6 +5308,14 @@ "feature": "fork-infra", "kind": "passthrough", "lines": ["const PRE_CHANGE_REF = 'v1.4.216-rc.0.zy01'"] + }, + { + "path": "src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts", + "feature": "terminal-dock", + "kind": "import-swap", + "lines": [ + "import { isTerminalPasteOwnedByChat as isInsideNativeChatCover } from './fork-terminal-dock/terminal-dock-paste-yield'" + ] } ], "exceptions": [ @@ -8710,6 +8718,10 @@ "tests/e2e/native-chat-ssh-delivery.unit.test.ts": { "added": 9, "removed": 8 + }, + "src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts": { + "added": 1, + "removed": 1 } } } diff --git a/src/renderer/src/components/terminal-pane/fork-terminal-dock/terminal-dock-paste-yield.test.ts b/src/renderer/src/components/terminal-pane/fork-terminal-dock/terminal-dock-paste-yield.test.ts new file mode 100644 index 000000000000..61507ac598eb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/fork-terminal-dock/terminal-dock-paste-yield.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste' +import { registerTerminalPanePasteListeners } from '../terminal-pane-paste-listeners' +import type { TerminalPaneCloseController } from '../use-terminal-pane-close-actions' +import type { TerminalPanePasteExecution } from '../terminal-pane-paste-execution' + +const cleanups: (() => void)[] = [] + +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()) + document.body.innerHTML = '' +}) + +// Builds a pane container shaped like a docked pane: the xterm's input and the dock's +// chat root are siblings, with no chat cover over either. +function mountDockedPane() { + const container = document.createElement('div') + const xtermInput = document.createElement('textarea') + const dockRoot = document.createElement('div') + dockRoot.setAttribute('data-native-chat-root', 'true') + dockRoot.setAttribute('data-terminal-dock', '') + const composerInput = document.createElement('textarea') + dockRoot.append(composerInput) + container.append(xtermInput, dockRoot) + document.body.append(container) + + const pane = { id: 1 } + const pasteFromClipboard = vi.fn() + const controller = { + forceBracketedMultilineTextPaste: false, + keybindings: {}, + managerRef: { current: { getActivePane: () => pane, getPanes: () => [pane] } }, + setTerminalError: vi.fn(), + worktreeId: 'wt-1' + } + const execution = { executePanePasteText: vi.fn(), pasteFromClipboard } + cleanups.push( + registerTerminalPanePasteListeners({ + container, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the listeners read only the five controller fields this fixture supplies. + controller: controller as unknown as TerminalPaneCloseController, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the listeners call only executePanePasteText and pasteFromClipboard. + execution: execution as unknown as TerminalPanePasteExecution, + isMac: true, + shortcutPlatform: 'darwin' + }) + ) + return { xtermInput, composerInput, pasteFromClipboard } +} + +function pressCmdV(target: HTMLElement): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key: 'v', + code: 'KeyV', + metaKey: true, + bubbles: true, + cancelable: true + }) + target.dispatchEvent(event) + return event +} + +function firePaste(target: HTMLElement): Event { + const event = new Event('paste', { bubbles: true, cancelable: true }) + target.dispatchEvent(event) + return event +} + +describe('terminal pane paste listeners beside a terminal dock', () => { + it('leave Cmd+V in the dock composer to the dock', () => { + const { composerInput, pasteFromClipboard } = mountDockedPane() + + const event = pressCmdV(composerInput) + + expect(event.defaultPrevented).toBe(false) + expect(pasteFromClipboard).not.toHaveBeenCalled() + }) + + it('leave a paste event in the dock composer to the dock', () => { + const { composerInput, pasteFromClipboard } = mountDockedPane() + + const event = firePaste(composerInput) + + expect(event.defaultPrevented).toBe(false) + expect(pasteFromClipboard).not.toHaveBeenCalled() + }) + + it('leave an app-menu paste with focus in the dock composer to the dock', () => { + const { composerInput, pasteFromClipboard } = mountDockedPane() + composerInput.focus() + + const event = new Event(APP_MENU_PASTE_EVENT, { cancelable: true }) + window.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(false) + expect(pasteFromClipboard).not.toHaveBeenCalled() + }) + + it('still paste Cmd+V on the xterm into the terminal', () => { + const { xtermInput, pasteFromClipboard } = mountDockedPane() + + const event = pressCmdV(xtermInput) + + expect(event.defaultPrevented).toBe(true) + expect(pasteFromClipboard).toHaveBeenCalledWith({ id: 1 }, 'keyboard') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/fork-terminal-dock/terminal-dock-paste-yield.ts b/src/renderer/src/components/terminal-pane/fork-terminal-dock/terminal-dock-paste-yield.ts new file mode 100644 index 000000000000..6785659767e0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/fork-terminal-dock/terminal-dock-paste-yield.ts @@ -0,0 +1,13 @@ +import { NATIVE_CHAT_ROOT_SELECTOR } from '@/lib/native-chat-paste-request' +import { isInsideNativeChatCover } from '../native-chat-covered-pane' + +/** + * True when the terminal pane must leave a paste to a chat: anything under a chat cover, + * or inside the dock's chat root, which sits beside the xterm instead of covering it. + */ +export function isTerminalPasteOwnedByChat(target: EventTarget | null): boolean { + return ( + isInsideNativeChatCover(target) || + (target instanceof Element && target.closest(NATIVE_CHAT_ROOT_SELECTOR) !== null) + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts b/src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts index 77cf5bb020a4..57b228989ec1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts @@ -16,7 +16,7 @@ import { } from '@/lib/app-menu-selection-actions' import { isEditableTarget } from '@/lib/editable-target' import { copyTerminalSelection } from './terminal-selection-copy' -import { isInsideNativeChatCover } from './native-chat-covered-pane' +import { isTerminalPasteOwnedByChat as isInsideNativeChatCover } from './fork-terminal-dock/terminal-dock-paste-yield' import type { TerminalPaneCloseController } from './use-terminal-pane-close-actions' import { formatClipboardImagePasteError,