Merge pull request #84955 from NousResearch/bb/paste-to-focus
feat(desktop): paste into the composer without focusing it first
This commit is contained in:
commit
79ad5bc353
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue