feat(desktop): paste into the composer without focusing it first

This commit is contained in:
Brooklyn Nicholson 2026-08-12 20:45:53 -05:00
parent b088535c78
commit 34e4ca14e8
5 changed files with 258 additions and 1 deletions

View File

@ -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<InsertDetail>(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<AttachImagesDetail>(ATTACH_IMAGES_EVENT, { blobs, target: resolve(target) })
}
}
export const onComposerAttachImagesRequest = (handler: (detail: AttachImagesDetail) => void) =>
subscribe<AttachImagesDetail>(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 = (

View File

@ -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(() => {

View File

@ -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)
})
})

View File

@ -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()
}
}

View File

@ -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)
}
}, [])
}