diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 2d403edef6ef9..f1ea428c3c09a 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -38,8 +38,14 @@ interface InsertRefsDetail { target: ComposerTarget } +interface AttachImagesDetail { + blobs: Blob[] + target: ComposerTarget +} + const FOCUS_EVENT = 'hermes:composer-focus' const INSERT_EVENT = 'hermes:composer-insert' +const ATTACH_IMAGES_EVENT = 'hermes:composer-attach-images' const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' const SUBMIT_EVENT = 'hermes:composer-submit' const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle' @@ -220,6 +226,22 @@ export const onComposerFocusRequest = (handler: (detail: FocusDetail) => void) = export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) => subscribe(INSERT_EVENT, handler) +/** Attach image blobs to a composer's attachment set — the unfocused-paste + * path (paste-to-focus) hands clipboard images over here. The edit composer + * takes no attachments (its own paste path ignores images), so a request + * resolving to `'edit'` is dropped by that surface's target filter. */ +export const requestComposerAttachImages = ( + blobs: Blob[], + { target = 'active' }: { target?: ComposerTarget | 'active' } = {} +) => { + if (blobs.length) { + dispatch(ATTACH_IMAGES_EVENT, { blobs, target: resolve(target) }) + } +} + +export const onComposerAttachImagesRequest = (handler: (detail: AttachImagesDetail) => void) => + subscribe(ATTACH_IMAGES_EVENT, handler) + /** Insert typed ref chips (carrying a display label) into a composer — the * structured cousin of {@link requestComposerInsert}, used for session links. */ export const requestComposerInsertRefs = ( diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index b43f9b90e93e2..5711255d0b4e4 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -36,7 +36,7 @@ import { COMPOSER_AREAS, runComposerMiddleware } from './contrib' import { ComposerControls } from './controls' import { ComposerDirectiveActions } from './directive-actions' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' -import { markActiveComposer } from './focus' +import { markActiveComposer, onComposerAttachImagesRequest } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' import { useComposerBranch } from './hooks/use-composer-branch' @@ -233,6 +233,27 @@ export function ChatBar({ syncDraftFromEditor }) + // Paste-to-focus: clipboard images from an unfocused ⌘V ride the bus (the + // window dispatcher has no handle on this composer's attachment scope). + // Same ingestion as a focused paste's image branch. + useEffect(() => { + if (!onAttachImageBlob) { + return undefined + } + + return onComposerAttachImagesRequest(({ blobs, target }) => { + if (target !== scope.target) { + return + } + + triggerHaptic('selection') + + for (const blob of blobs) { + void onAttachImageBlob(blob) + } + }) + }, [onAttachImageBlob, scope.target]) + // Prior history belongs to the draft that just left — undoing into another // conversation's text is worse than having none. useEffect(() => { diff --git a/apps/desktop/src/app/chat/composer/paste-to-focus.test.ts b/apps/desktop/src/app/chat/composer/paste-to-focus.test.ts new file mode 100644 index 0000000000000..b86c715561dda --- /dev/null +++ b/apps/desktop/src/app/chat/composer/paste-to-focus.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + onComposerAttachImagesRequest, + onComposerFocusRequest, + onComposerInsertRequest +} from './focus' +import { handleWindowPaste, routeClipboardToComposer } from './paste-to-focus' + +/** Minimal DataTransfer stand-in: text/plain + optional image file items. */ +function clipboard({ files = [] as File[], text = '' } = {}): DataTransfer { + return { + files: { item: (i: number) => files[i] ?? null, length: files.length }, + getData: (type: string) => (type === 'text' || type === 'text/plain' ? text : ''), + items: files.map(file => ({ getAsFile: () => file, kind: 'file', type: file.type })) + } as unknown as DataTransfer +} + +const image = (name = 'shot.png') => new File([new Uint8Array(8192)], name, { type: 'image/png' }) + +/** The bus defers dispatch a macrotask; flush it. */ +const flushBus = () => new Promise(resolve => setTimeout(resolve, 1)) + +function pasteEvent(clip: DataTransfer, target: EventTarget = document.body): ClipboardEvent { + const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent + + Object.defineProperty(event, 'clipboardData', { value: clip }) + Object.defineProperty(event, 'target', { value: target }) + + return event +} + +afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('routeClipboardToComposer', () => { + it('routes pasted text to the composer as an insert', async () => { + const inserts: string[] = [] + const off = onComposerInsertRequest(({ text }) => inserts.push(text)) + + expect(routeClipboardToComposer(clipboard({ text: 'hello there' }))).toBe(true) + await flushBus() + off() + + expect(inserts).toEqual(['hello there']) + }) + + it('chips a pasted link the same way a focused paste does', async () => { + const inserts: string[] = [] + const off = onComposerInsertRequest(({ text }) => inserts.push(text)) + + routeClipboardToComposer(clipboard({ text: 'see https://example.com/docs' })) + await flushBus() + off() + + expect(inserts[0]).toContain('@url:') + }) + + it('attaches clipboard images and pulls focus on an image-only paste', async () => { + const attached: Blob[][] = [] + const focused: boolean[] = [] + const offAttach = onComposerAttachImagesRequest(({ blobs }) => attached.push(blobs)) + const offFocus = onComposerFocusRequest(() => focused.push(true)) + + expect(routeClipboardToComposer(clipboard({ files: [image()] }))).toBe(true) + await flushBus() + offAttach() + offFocus() + + expect(attached).toHaveLength(1) + expect(attached[0]).toHaveLength(1) + expect(focused).toHaveLength(1) + }) + + it('takes both from a mixed paste — images attach AND the text inserts', async () => { + const attached: Blob[][] = [] + const inserts: string[] = [] + const offAttach = onComposerAttachImagesRequest(({ blobs }) => attached.push(blobs)) + const offInsert = onComposerInsertRequest(({ text }) => inserts.push(text)) + + routeClipboardToComposer(clipboard({ files: [image()], text: 'look at this' })) + await flushBus() + offAttach() + offInsert() + + expect(attached).toHaveLength(1) + expect(inserts).toEqual(['look at this']) + }) + + it('reports an empty clipboard as unhandled', () => { + expect(routeClipboardToComposer(clipboard())).toBe(false) + }) +}) + +describe('handleWindowPaste', () => { + it('swallows a routed paste on non-editable chrome', () => { + const event = pasteEvent(clipboard({ text: 'hi' })) + + handleWindowPaste(event) + + expect(event.defaultPrevented).toBe(true) + }) + + it('yields to editable targets — they own their own paste', () => { + const input = document.createElement('input') + document.body.append(input) + const event = pasteEvent(clipboard({ text: 'hi' }), input) + + handleWindowPaste(event) + + expect(event.defaultPrevented).toBe(false) + }) + + it('yields while a dialog covers the chat, like type-to-focus', () => { + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + document.body.append(dialog) + const event = pasteEvent(clipboard({ text: 'hi' })) + + handleWindowPaste(event) + + expect(event.defaultPrevented).toBe(false) + }) + + it('leaves an already-handled or empty paste alone', () => { + const handled = pasteEvent(clipboard({ text: 'hi' })) + handled.preventDefault() + const before = handled.defaultPrevented + + handleWindowPaste(handled) + expect(before).toBe(true) + + const empty = pasteEvent(clipboard()) + handleWindowPaste(empty) + expect(empty.defaultPrevented).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/paste-to-focus.ts b/apps/desktop/src/app/chat/composer/paste-to-focus.ts new file mode 100644 index 0000000000000..9cf56228db79d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/paste-to-focus.ts @@ -0,0 +1,69 @@ +/** + * Paste-to-focus: the paste twin of type-to-focus. A ⌘V/Ctrl+V landing on + * non-editable chrome (the transcript, empty chat, the window body) routes the + * clipboard into the active composer instead of dying on the body — images + * attach, text inserts (links/paths chipping exactly like a focused paste), + * and the composer takes focus so the user keeps typing. + * + * Clipboard data is only readable synchronously inside the paste event, so the + * window listener extracts everything here and the payload rides the composer + * bus. Editable targets keep their own paste handlers, and the same surfaces + * that block type-to-focus (dialogs, menus, terminal, full pages) block this. + */ + +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' +import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' +import { isEditableTarget } from '@/lib/keybinds/combo' +import { composerFocusBlockedBySurface } from '@/lib/keybinds/composer-focus-keys' + +import { requestComposerAttachImages, requestComposerFocus, requestComposerInsert } from './focus' +import { pathifyRefs } from './path-refs' +import { extractClipboardImageBlobs } from './text-utils' +import { linkifyUrls } from './url-refs' + +/** Route clipboard contents to the active composer. True when it carried + * something a composer can take (the caller should swallow the event). */ +export function routeClipboardToComposer(clipboard: DataTransfer): boolean { + const blobs = extractClipboardImageBlobs(clipboard) + const text = sanitizeComposerInput(clipboard.getData('text').trim()) + + if (blobs.length > 0) { + requestComposerAttachImages(blobs) + } + + // A bare `data:` URL IS the image attached above — not text to insert. + if (text && !DATA_IMAGE_URL_RE.test(text)) { + // Same chipping the focused paste path applies: links land as `@url:` + // chips, bare `@path` tokens promote. The insert focuses the composer. + requestComposerInsert(pathifyRefs(linkifyUrls(text)), { mode: 'inline' }) + + return true + } + + if (blobs.length > 0) { + // Image-only paste: pull focus so the attach lands somewhere visible. + requestComposerFocus('active') + + return true + } + + return false +} + +/** The window-level `paste` dispatcher (use-keybinds registers it beside the + * keydown listener). Yields to editables — they own their own paste — and to + * every surface that owns its keys per type-to-focus. */ +export function handleWindowPaste(event: ClipboardEvent) { + if ( + event.defaultPrevented || + !event.clipboardData || + isEditableTarget(event.target) || + composerFocusBlockedBySurface() + ) { + return + } + + if (routeClipboardToComposer(event.clipboardData)) { + event.preventDefault() + } +} diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 1e2107f517686..bc9a0ead25c17 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -62,6 +62,7 @@ import { openNewWindow } from '@/store/windows' import { useTheme } from '@/themes/context' import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus' +import { handleWindowPaste } from '../chat/composer/paste-to-focus' import { openSession } from '../open-session' import { $workspaceIsPage, @@ -413,12 +414,17 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { window.addEventListener('keyup', onKeyUp, { capture: true }) window.addEventListener('blur', onBlur) window.addEventListener('contextmenu', onContextMenu, { capture: true }) + // Paste twin of type-to-focus: ⌘V on non-editable chrome routes the + // clipboard (text AND images) into the active composer. Bubble phase so + // editables' own paste handlers run first and mark the event handled. + window.addEventListener('paste', handleWindowPaste) return () => { window.removeEventListener('keydown', onKeyDown, { capture: true }) window.removeEventListener('keyup', onKeyUp, { capture: true }) window.removeEventListener('blur', onBlur) window.removeEventListener('contextmenu', onContextMenu, { capture: true }) + window.removeEventListener('paste', handleWindowPaste) } }, []) }