mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Merge pull request #72288 from NousResearch/bb/composer-undo
Give the composer its own undo stack
This commit is contained in:
commit
bd6437d605
10 changed files with 940 additions and 11 deletions
|
|
@ -408,6 +408,7 @@ export function useComposerDraft({
|
|||
requestMainFocus,
|
||||
sessionIdRef,
|
||||
setComposerText,
|
||||
stashAt
|
||||
stashAt,
|
||||
syncDraftFromEditor
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import { createRef, type RefObject } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComposerUndo } from './use-composer-undo'
|
||||
|
||||
/** Mount the hook against a real contentEditable, exposing its API. */
|
||||
function mountUndo(editorRef: RefObject<HTMLDivElement | null>, onSync: () => string) {
|
||||
const api: { current: ReturnType<typeof useComposerUndo> | null } = { current: null }
|
||||
|
||||
const Harness = () => {
|
||||
// Assigned during render on purpose: the tests drive the API imperatively
|
||||
// right after mount, and this is a harness, not app state.
|
||||
api.current = useComposerUndo({ editorRef, syncDraftFromEditor: onSync })
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const view = render(<Harness />)
|
||||
|
||||
return { api, view }
|
||||
}
|
||||
|
||||
function makeEditor(text: string) {
|
||||
const editor = document.createElement('div')
|
||||
editor.contentEditable = 'true'
|
||||
// jsdom only focuses a contentEditable div when it's explicitly focusable;
|
||||
// the real editor is reachable via the composer's focus bus.
|
||||
editor.tabIndex = 0
|
||||
editor.append(document.createTextNode(text))
|
||||
document.body.append(editor)
|
||||
|
||||
const ref = createRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>
|
||||
ref.current = editor
|
||||
|
||||
return { editor, ref }
|
||||
}
|
||||
|
||||
const caretAtEnd = (editor: HTMLElement) => {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()!
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
describe('useComposerUndo', () => {
|
||||
it('restores the pre-edit text, which is what a paste destroyed', () => {
|
||||
const { editor, ref } = makeEditor('before')
|
||||
caretAtEnd(editor)
|
||||
|
||||
const { api, view } = mountUndo(ref, () => editor.textContent || '')
|
||||
|
||||
// Bank, then simulate the Range-based paste that Chromium never records.
|
||||
api.current!.recordUndoPoint()
|
||||
editor.append(document.createTextNode(' PASTED'))
|
||||
expect(editor.textContent).toBe('before PASTED')
|
||||
|
||||
api.current!.undo()
|
||||
expect(editor.textContent).toBe('before')
|
||||
|
||||
api.current!.redo()
|
||||
expect(editor.textContent).toBe('before PASTED')
|
||||
|
||||
view.unmount()
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('withUndoPoint banks only when the edit actually ran', () => {
|
||||
const { editor, ref } = makeEditor('text')
|
||||
caretAtEnd(editor)
|
||||
|
||||
const { api, view } = mountUndo(ref, () => editor.textContent || '')
|
||||
|
||||
// A guard that declines must not consume an undo slot.
|
||||
expect(api.current!.withUndoPoint(() => false)).toBe(false)
|
||||
expect(api.current!.undo()).toBe(false)
|
||||
|
||||
expect(
|
||||
api.current!.withUndoPoint(() => {
|
||||
editor.append(document.createTextNode('!'))
|
||||
|
||||
return true
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
api.current!.undo()
|
||||
expect(editor.textContent).toBe('text')
|
||||
|
||||
view.unmount()
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('claims a native historyUndo aimed at the focused editor', () => {
|
||||
const { editor, ref } = makeEditor('kept')
|
||||
editor.focus()
|
||||
caretAtEnd(editor)
|
||||
|
||||
const { api, view } = mountUndo(ref, () => editor.textContent || '')
|
||||
|
||||
api.current!.recordUndoPoint()
|
||||
editor.append(document.createTextNode(' extra'))
|
||||
|
||||
// What Electron's Edit menu `{ role: 'undo' }` produces.
|
||||
const event = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'historyUndo' })
|
||||
editor.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(editor.textContent).toBe('kept')
|
||||
|
||||
view.unmount()
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('ignores a historyUndo while another editor holds focus', () => {
|
||||
const { editor, ref } = makeEditor('mine')
|
||||
const { editor: other } = makeEditor('theirs')
|
||||
|
||||
other.focus()
|
||||
|
||||
const { api, view } = mountUndo(ref, () => editor.textContent || '')
|
||||
|
||||
api.current!.recordUndoPoint()
|
||||
editor.append(document.createTextNode(' changed'))
|
||||
|
||||
const event = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'historyUndo' })
|
||||
other.dispatchEvent(event)
|
||||
|
||||
// Not ours to claim — the other surface keeps its native behavior.
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(editor.textContent).toBe('mine changed')
|
||||
|
||||
view.unmount()
|
||||
editor.remove()
|
||||
other.remove()
|
||||
})
|
||||
|
||||
it('keeps two mounted composers independent', () => {
|
||||
const { editor: main, ref: mainRef } = makeEditor('main')
|
||||
const { editor: edit, ref: editRef } = makeEditor('edit')
|
||||
|
||||
const mainUndo = mountUndo(mainRef, () => main.textContent || '')
|
||||
const editUndo = mountUndo(editRef, () => edit.textContent || '')
|
||||
|
||||
mainUndo.api.current!.recordUndoPoint()
|
||||
main.append(document.createTextNode(' typed'))
|
||||
|
||||
// Undoing in the edit composer must not touch the main composer's text.
|
||||
editUndo.api.current!.undo()
|
||||
expect(main.textContent).toBe('main typed')
|
||||
|
||||
mainUndo.api.current!.undo()
|
||||
expect(main.textContent).toBe('main')
|
||||
expect(edit.textContent).toBe('edit')
|
||||
|
||||
mainUndo.view.unmount()
|
||||
editUndo.view.unmount()
|
||||
main.remove()
|
||||
edit.remove()
|
||||
})
|
||||
|
||||
it('reset drops history so undo cannot cross a draft swap', () => {
|
||||
const { editor, ref } = makeEditor('session A')
|
||||
caretAtEnd(editor)
|
||||
|
||||
const { api, view } = mountUndo(ref, () => editor.textContent || '')
|
||||
|
||||
api.current!.recordUndoPoint()
|
||||
editor.append(document.createTextNode(' edited'))
|
||||
api.current!.resetUndoHistory()
|
||||
|
||||
expect(api.current!.undo()).toBe(false)
|
||||
expect(editor.textContent).toBe('session A edited')
|
||||
|
||||
view.unmount()
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('is inert when the editor ref is empty', () => {
|
||||
const ref = createRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>
|
||||
const sync = vi.fn(() => '')
|
||||
|
||||
const { api, view } = mountUndo(ref, sync)
|
||||
|
||||
api.current!.recordUndoPoint()
|
||||
|
||||
expect(api.current!.undo()).toBe(false)
|
||||
expect(sync).not.toHaveBeenCalled()
|
||||
|
||||
view.unmount()
|
||||
})
|
||||
})
|
||||
124
apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts
Normal file
124
apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { type RefObject, useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import {
|
||||
caretOffsetInEditor,
|
||||
composerPlainText,
|
||||
placeCaretAtOffset,
|
||||
renderComposerContents
|
||||
} from '../rich-editor'
|
||||
import { type ComposerSnapshot, createComposerUndoHistory } from '../undo-history'
|
||||
|
||||
interface UseComposerUndoArgs {
|
||||
editorRef: RefObject<HTMLDivElement | null>
|
||||
/** Push a restored snapshot back into draftRef + composer state. */
|
||||
syncDraftFromEditor: () => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo/redo for the rich composer.
|
||||
*
|
||||
* The editor mutates its DOM through `Range` to dodge Chromium's O(n²) editing
|
||||
* pipeline (#45812), which also dodges Chromium's undo stack — so a paste was
|
||||
* invisible to ⌘Z and the keystroke undid whatever edit came before it instead.
|
||||
* We own the stack outright rather than half of it: every edit path records the
|
||||
* pre-edit state here, and the editor claims ⌘Z / ⌘⇧Z itself.
|
||||
*/
|
||||
export function useComposerUndo({ editorRef, syncDraftFromEditor }: UseComposerUndoArgs) {
|
||||
const history = useMemo(() => createComposerUndoHistory(), [])
|
||||
|
||||
const snapshot = useCallback((): ComposerSnapshot => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
return { caret: 0, text: '' }
|
||||
}
|
||||
|
||||
return { caret: caretOffsetInEditor(editor), text: composerPlainText(editor) }
|
||||
}, [editorRef])
|
||||
|
||||
/** Bank the current state before mutating the editor. `coalesce` marks a
|
||||
* keystroke, so a run of typing collapses into one undo step. */
|
||||
const recordUndoPoint = useCallback(
|
||||
(options?: { coalesce?: boolean }) => {
|
||||
if (editorRef.current) {
|
||||
history.record(snapshot(), options)
|
||||
}
|
||||
},
|
||||
[editorRef, history, snapshot]
|
||||
)
|
||||
|
||||
const applySnapshot = useCallback(
|
||||
(next: ComposerSnapshot | null) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!next || !editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
renderComposerContents(editor, next.text)
|
||||
placeCaretAtOffset(editor, next.caret)
|
||||
syncDraftFromEditor()
|
||||
|
||||
return true
|
||||
},
|
||||
[editorRef, syncDraftFromEditor]
|
||||
)
|
||||
|
||||
/** Run a conditional edit, banking its pre-edit state only if it actually
|
||||
* ran. The snapshot has to be taken first (the edit destroys the state we'd
|
||||
* be saving), but recording unconditionally would clear the redo stack on
|
||||
* every Backspace that falls through to the native path. */
|
||||
const withUndoPoint = useCallback(
|
||||
(edit: () => boolean) => {
|
||||
const before = snapshot()
|
||||
const ran = edit()
|
||||
|
||||
if (ran) {
|
||||
history.record(before)
|
||||
}
|
||||
|
||||
return ran
|
||||
},
|
||||
[history, snapshot]
|
||||
)
|
||||
|
||||
const undo = useCallback(() => applySnapshot(history.undo(snapshot())), [applySnapshot, history, snapshot])
|
||||
const redo = useCallback(() => applySnapshot(history.redo(snapshot())), [applySnapshot, history, snapshot])
|
||||
|
||||
// A session/draft swap makes prior history meaningless — undoing into another
|
||||
// conversation's text is worse than having no history at all.
|
||||
const resetUndoHistory = useCallback(() => history.reset(), [history])
|
||||
|
||||
// Electron's Edit menu ships `{ role: 'undo' }`, whose accelerator the macOS
|
||||
// menu bar consumes before the web contents sees the keystroke (the same
|
||||
// hazard main.ts documents for ⌘W). It fires the native editing command,
|
||||
// which knows nothing about our stack. Claim it at the document level while
|
||||
// the composer holds focus, so the menu item and the keystroke agree.
|
||||
useEffect(() => {
|
||||
const onBeforeInput = (event: Event) => {
|
||||
const inputType = (event as InputEvent).inputType
|
||||
|
||||
if (inputType !== 'historyUndo' && inputType !== 'historyRedo') {
|
||||
return
|
||||
}
|
||||
|
||||
if (document.activeElement !== editorRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (inputType === 'historyUndo') {
|
||||
undo()
|
||||
} else {
|
||||
redo()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('beforeinput', onBeforeInput, true)
|
||||
|
||||
return () => document.removeEventListener('beforeinput', onBeforeInput, true)
|
||||
}, [editorRef, redo, undo])
|
||||
|
||||
return { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint }
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ import { useComposerPopout } from './hooks/use-composer-popout'
|
|||
import { useComposerQueue } from './hooks/use-composer-queue'
|
||||
import { useComposerSubmit } from './hooks/use-composer-submit'
|
||||
import { useComposerTrigger } from './hooks/use-composer-trigger'
|
||||
import { useComposerUndo } from './hooks/use-composer-undo'
|
||||
import { useComposerUrlDialog } from './hooks/use-composer-url-dialog'
|
||||
import { useComposerVoice } from './hooks/use-composer-voice'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
|
|
@ -59,6 +60,7 @@ import { CodingStatusRow } from './status-stack/coding-row'
|
|||
import { extractClipboardImageBlobs } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
import { isRedoShortcut, isUndoShortcut } from './undo-history'
|
||||
import { UrlDialog } from './url-dialog'
|
||||
import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs'
|
||||
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
|
|
@ -173,9 +175,24 @@ export function ChatBar({
|
|||
requestMainFocus,
|
||||
sessionIdRef,
|
||||
setComposerText,
|
||||
stashAt
|
||||
stashAt,
|
||||
syncDraftFromEditor
|
||||
} = useComposerDraft({ activeQueueSessionKey, focusKey, inputDisabled, queueEditRef, sessionId })
|
||||
|
||||
// Undo/redo. The rich editor bypasses Chromium's editing pipeline for speed,
|
||||
// which also bypasses its undo stack — so we own the stack and every edit
|
||||
// path below banks its pre-edit state through `recordUndoPoint`.
|
||||
const { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint } = useComposerUndo({
|
||||
editorRef,
|
||||
syncDraftFromEditor
|
||||
})
|
||||
|
||||
// Prior history belongs to the draft that just left — undoing into another
|
||||
// conversation's text is worse than having none.
|
||||
useEffect(() => {
|
||||
resetUndoHistory()
|
||||
}, [activeQueueSessionKey, resetUndoHistory])
|
||||
|
||||
// "Add URL" dialog — open/value state, autofocus, and submit (host onAddUrl or
|
||||
// an @url: directive into the draft).
|
||||
const { openUrlDialog, setUrlOpen, setUrlValue, submitUrl, urlInputRef, urlOpen, urlValue } = useComposerUrlDialog({
|
||||
|
|
@ -357,6 +374,23 @@ export function ChatBar({
|
|||
scheduleFlushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
// Native typing/deleting mutates the DOM through Chromium's editing pipeline,
|
||||
// whose undo stack we've taken over — so bank the pre-edit state here, before
|
||||
// the change lands. `beforeinput` is the only hook that still sees the old
|
||||
// text. Consecutive keystrokes coalesce into one entry, so ⌘Z steps back by a
|
||||
// burst rather than a character.
|
||||
const handleEditorBeforeInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const inputType = (event.nativeEvent as InputEvent).inputType
|
||||
|
||||
// Undo/redo are ours (handled in useComposerUndo + keydown), and IME preedit
|
||||
// is not a committed edit — compositionend is where that text becomes real.
|
||||
if (inputType === 'historyUndo' || inputType === 'historyRedo' || composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' })
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
|
|
@ -407,6 +441,7 @@ export function ChatBar({
|
|||
// Links in the paste land as `@url:` chips rather than a wall of URL text —
|
||||
// the same reference the "Add URL" dialog inserts, parsed in place so a link
|
||||
// mid-sentence keeps its position.
|
||||
recordUndoPoint()
|
||||
insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText))
|
||||
scheduleFlushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
|
@ -421,6 +456,23 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
// Undo/redo before anything else — we own the stack (see useComposerUndo),
|
||||
// so these never reach Chromium's native history, which has no record of
|
||||
// the Range-based edits the rich editor makes.
|
||||
if (isUndoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
undo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isRedoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Plain Backspace right after a directive chip: remove the chip + its
|
||||
// auto-inserted trailing space as one unit, so deleting a directive never
|
||||
// leaves an orphaned space. (Modified backspaces stay native.)
|
||||
|
|
@ -429,7 +481,7 @@ export function ChatBar({
|
|||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
deleteChipBeforeCaret(event.currentTarget)
|
||||
withUndoPoint(() => deleteChipBeforeCaret(event.currentTarget))
|
||||
) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
|
@ -439,7 +491,10 @@ export function ChatBar({
|
|||
|
||||
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
|
||||
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
|
||||
if ((event.key === 'Backspace' || event.key === 'Delete') && deleteSelectionInEditor(event.currentTarget)) {
|
||||
if (
|
||||
(event.key === 'Backspace' || event.key === 'Delete') &&
|
||||
withUndoPoint(() => deleteSelectionInEditor(event.currentTarget))
|
||||
) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
|
|
@ -448,7 +503,7 @@ export function ChatBar({
|
|||
|
||||
// A typed link finished with a space chips like a pasted one — the space
|
||||
// itself rides along inside the insert.
|
||||
if (chipTypedUrlOnSpace(event)) {
|
||||
if (withUndoPoint(() => chipTypedUrlOnSpace(event))) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
|
|
@ -791,6 +846,7 @@ export function ChatBar({
|
|||
contentEditable={!inputDisabled}
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBeforeInput={handleEditorBeforeInput}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onCompositionEnd={event => {
|
||||
composingRef.current = false
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ import { contextPath } from '@/lib/chat-runtime'
|
|||
|
||||
import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
|
||||
import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor'
|
||||
import {
|
||||
composerPlainText,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
|
||||
export type InlineRefInput = string | { kind: string; label?: string; value: string }
|
||||
|
|
@ -92,7 +98,12 @@ function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' |
|
|||
slice.setStart(range.endContainer, range.endOffset)
|
||||
}
|
||||
|
||||
// Carry the editor's slot marker: composerPlainText appends a trailing "\n"
|
||||
// to any other block element, so a bare <div> made `beforeText` always look
|
||||
// like it ended in whitespace and the separating space was never inserted —
|
||||
// a chip dropped after a word came out glued to it (`review@file:...`).
|
||||
const container = document.createElement('div')
|
||||
container.dataset.slot = RICH_INPUT_SLOT
|
||||
container.appendChild(slice.cloneContents())
|
||||
|
||||
return composerPlainText(container)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,30 @@ describe('insertInlineRefsIntoEditor', () => {
|
|||
expect(editor.querySelector(':scope > div')).toBeNull()
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
|
||||
})
|
||||
|
||||
it('separates a chip from the word the caret sits after', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.append(document.createTextNode('review'))
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('does not double the space when one is already there', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.append(document.createTextNode('review '))
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertComposerContentsAtCaret', () => {
|
||||
|
|
|
|||
|
|
@ -332,6 +332,106 @@ export function placeCaretEnd(element: HTMLElement) {
|
|||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
/** The caret's offset in `composerPlainText` coordinates, so it can be restored
|
||||
* after the editor is re-rendered from text (undo/redo). A chip counts as its
|
||||
* whole `@kind:value` text — the same units the snapshot measures. */
|
||||
export function caretOffsetInEditor(editor: HTMLElement): number {
|
||||
const selection = window.getSelection()
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
|
||||
|
||||
if (!range || !editor.contains(range.commonAncestorContainer)) {
|
||||
return composerPlainText(editor).length
|
||||
}
|
||||
|
||||
const before = range.cloneRange()
|
||||
before.selectNodeContents(editor)
|
||||
before.setEnd(range.startContainer, range.startOffset)
|
||||
|
||||
// The scratch container must carry the editor's slot marker: composerPlainText
|
||||
// appends a trailing "\n" to any other block element, which would inflate
|
||||
// every offset by one and land the restored caret a character late.
|
||||
const container = document.createElement('div')
|
||||
container.dataset.slot = RICH_INPUT_SLOT
|
||||
container.append(before.cloneContents())
|
||||
|
||||
return composerPlainText(container).length
|
||||
}
|
||||
|
||||
/** Place the caret `offset` characters into the editor, in the same
|
||||
* `composerPlainText` coordinates `caretOffsetInEditor` reports. Lands after a
|
||||
* chip it would otherwise split, since a chip is a single atomic unit. */
|
||||
export function placeCaretAtOffset(editor: HTMLElement, offset: number) {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
|
||||
let remaining = offset
|
||||
|
||||
const walk = (node: Node): Range | null => {
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const length = (child.textContent || '').length
|
||||
|
||||
if (remaining <= length) {
|
||||
const range = document.createRange()
|
||||
range.setStart(child, remaining)
|
||||
range.collapse(true)
|
||||
|
||||
return range
|
||||
}
|
||||
|
||||
remaining -= length
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) {
|
||||
continue
|
||||
}
|
||||
|
||||
const el = child as HTMLElement
|
||||
|
||||
// Chips and <br> are atomic: consume their serialized length whole.
|
||||
if (el.dataset.refText || el.tagName === 'BR') {
|
||||
const length = el.dataset.refText ? el.dataset.refText.length : 1
|
||||
|
||||
if (remaining < length) {
|
||||
const range = document.createRange()
|
||||
range.setStartBefore(el)
|
||||
range.collapse(true)
|
||||
|
||||
return range
|
||||
}
|
||||
|
||||
remaining -= length
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const hit = walk(el)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const range = walk(editor)
|
||||
|
||||
if (range) {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
/** Nothing but a break / whitespace (recursively) — i.e. no real text or chip. */
|
||||
function isBlankNode(node: ChildNode | null): boolean {
|
||||
if (!node) {
|
||||
|
|
|
|||
241
apps/desktop/src/app/chat/composer/undo-history.test.ts
Normal file
241
apps/desktop/src/app/chat/composer/undo-history.test.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
caretOffsetInEditor,
|
||||
composerPlainText,
|
||||
placeCaretAtOffset,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { createComposerUndoHistory, isRedoShortcut, isUndoShortcut } from './undo-history'
|
||||
|
||||
const key = (over: Partial<KeyboardEvent> = {}) =>
|
||||
({ altKey: false, ctrlKey: false, key: 'z', metaKey: false, shiftKey: false, ...over }) as KeyboardEvent
|
||||
|
||||
describe('undo/redo shortcut recognition', () => {
|
||||
it('claims Cmd+Z and Ctrl+Z as undo, but not with Shift or Alt', () => {
|
||||
expect(isUndoShortcut(key({ metaKey: true }))).toBe(true)
|
||||
expect(isUndoShortcut(key({ ctrlKey: true }))).toBe(true)
|
||||
expect(isUndoShortcut(key({ metaKey: true, shiftKey: true }))).toBe(false)
|
||||
expect(isUndoShortcut(key({ altKey: true, metaKey: true }))).toBe(false)
|
||||
expect(isUndoShortcut(key({ key: 'a', metaKey: true }))).toBe(false)
|
||||
expect(isUndoShortcut(key())).toBe(false)
|
||||
})
|
||||
|
||||
it('claims Cmd+Shift+Z everywhere and Ctrl+Y as redo', () => {
|
||||
expect(isRedoShortcut(key({ metaKey: true, shiftKey: true }))).toBe(true)
|
||||
expect(isRedoShortcut(key({ ctrlKey: true, shiftKey: true }))).toBe(true)
|
||||
expect(isRedoShortcut(key({ ctrlKey: true, key: 'y' }))).toBe(true)
|
||||
expect(isRedoShortcut(key({ metaKey: true }))).toBe(false)
|
||||
expect(isRedoShortcut(key({ altKey: true, ctrlKey: true, key: 'y' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('never treats one keystroke as both undo and redo', () => {
|
||||
const chords = [
|
||||
key({ metaKey: true }),
|
||||
key({ ctrlKey: true }),
|
||||
key({ metaKey: true, shiftKey: true }),
|
||||
key({ ctrlKey: true, shiftKey: true }),
|
||||
key({ ctrlKey: true, key: 'y' })
|
||||
]
|
||||
|
||||
for (const chord of chords) {
|
||||
expect(isUndoShortcut(chord) && isRedoShortcut(chord)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('composer undo history', () => {
|
||||
const snap = (text: string, caret = text.length) => ({ caret, text })
|
||||
|
||||
it('steps back through discrete edits, newest first', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap(''))
|
||||
history.record(snap('a'))
|
||||
history.record(snap('ab'))
|
||||
|
||||
expect(history.undo(snap('abc'))?.text).toBe('ab')
|
||||
expect(history.undo(snap('ab'))?.text).toBe('a')
|
||||
expect(history.undo(snap('a'))?.text).toBe('')
|
||||
expect(history.undo(snap(''))).toBeNull()
|
||||
})
|
||||
|
||||
it('redoes back to the state undo left, and stops at the newest', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap('before'))
|
||||
|
||||
const undone = history.undo(snap('before + pasted'))
|
||||
|
||||
expect(undone?.text).toBe('before')
|
||||
expect(history.redo(snap('before'))?.text).toBe('before + pasted')
|
||||
expect(history.redo(snap('before + pasted'))).toBeNull()
|
||||
})
|
||||
|
||||
it('restores the caret along with the text', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record({ caret: 3, text: 'hello world' })
|
||||
|
||||
expect(history.undo({ caret: 0, text: 'changed' })).toEqual({ caret: 3, text: 'hello world' })
|
||||
})
|
||||
|
||||
it('collapses a run of typing into one entry, so undo steps back by a burst', () => {
|
||||
let now = 1_000
|
||||
const history = createComposerUndoHistory(200, () => now)
|
||||
|
||||
history.record(snap(''), { coalesce: true })
|
||||
now += 50
|
||||
history.record(snap('h'), { coalesce: true })
|
||||
now += 50
|
||||
history.record(snap('he'), { coalesce: true })
|
||||
|
||||
// One burst → one entry, back to the state before the burst started.
|
||||
expect(history.undo(snap('hel'))?.text).toBe('')
|
||||
expect(history.undo(snap(''))).toBeNull()
|
||||
})
|
||||
|
||||
it('starts a new entry once the typing pause exceeds the coalesce window', () => {
|
||||
let now = 1_000
|
||||
const history = createComposerUndoHistory(200, () => now)
|
||||
|
||||
history.record(snap(''), { coalesce: true })
|
||||
now += 5_000
|
||||
history.record(snap('word one'), { coalesce: true })
|
||||
|
||||
expect(history.undo(snap('word one two'))?.text).toBe('word one')
|
||||
expect(history.undo(snap('word one'))?.text).toBe('')
|
||||
})
|
||||
|
||||
it('does not coalesce a paste into the typing burst that preceded it', () => {
|
||||
let now = 1_000
|
||||
const history = createComposerUndoHistory(200, () => now)
|
||||
|
||||
history.record(snap(''), { coalesce: true })
|
||||
now += 20
|
||||
// A paste is a discrete edit — no coalesce flag.
|
||||
history.record(snap('typed '))
|
||||
|
||||
expect(history.undo(snap('typed PASTED'))?.text).toBe('typed ')
|
||||
expect(history.undo(snap('typed '))?.text).toBe('')
|
||||
})
|
||||
|
||||
it('drops the redo stack once a new edit lands', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap('one'))
|
||||
history.undo(snap('one two'))
|
||||
history.record(snap('one'))
|
||||
|
||||
expect(history.redo(snap('one three'))).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a no-op edit so undo never looks stuck for a press', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap('same'))
|
||||
history.record(snap('same'))
|
||||
|
||||
expect(history.undo(snap('same'))?.text).toBe('same')
|
||||
expect(history.undo(snap('same'))).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds the stack, discarding the oldest entries', () => {
|
||||
const history = createComposerUndoHistory(3)
|
||||
|
||||
for (const text of ['a', 'b', 'c', 'd', 'e']) {
|
||||
history.record(snap(text))
|
||||
}
|
||||
|
||||
expect(history.undo(snap('f'))?.text).toBe('e')
|
||||
expect(history.undo(snap('e'))?.text).toBe('d')
|
||||
expect(history.undo(snap('d'))?.text).toBe('c')
|
||||
expect(history.undo(snap('c'))).toBeNull()
|
||||
})
|
||||
|
||||
it('reset clears both directions', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap('a'))
|
||||
history.undo(snap('ab'))
|
||||
history.reset()
|
||||
|
||||
expect(history.undo(snap('ab'))).toBeNull()
|
||||
expect(history.redo(snap('ab'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('caret offsets in composerPlainText coordinates', () => {
|
||||
let editor: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
})
|
||||
|
||||
const caretAfter = (node: Node, offset: number) => {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()!
|
||||
range.setStart(node, offset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
it('round-trips a caret in plain text', () => {
|
||||
renderComposerContents(editor, 'hello world')
|
||||
placeCaretAtOffset(editor, 5)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(5)
|
||||
})
|
||||
|
||||
it('counts a chip as its whole @kind:value text', () => {
|
||||
editor.append(document.createTextNode('see '), refChipElement('file', '`src/a.ts`'), document.createTextNode(' now'))
|
||||
|
||||
const chipText = '@file:`src/a.ts`'
|
||||
// Caret at the very end = everything before it.
|
||||
placeCaretAtOffset(editor, composerPlainText(editor).length)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(4 + chipText.length + 4)
|
||||
})
|
||||
|
||||
it('lands the caret before a chip rather than splitting it', () => {
|
||||
editor.append(document.createTextNode('a '), refChipElement('file', '`x.ts`'))
|
||||
|
||||
// An offset that falls midway through the chip's serialized text.
|
||||
placeCaretAtOffset(editor, 2 + 3)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(2)
|
||||
})
|
||||
|
||||
it('counts a line break as one character', () => {
|
||||
renderComposerContents(editor, 'one\ntwo')
|
||||
placeCaretAtOffset(editor, 5)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(5)
|
||||
expect(composerPlainText(editor)).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('clamps past-the-end offsets to the end instead of throwing', () => {
|
||||
renderComposerContents(editor, 'short')
|
||||
placeCaretAtOffset(editor, 999)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(5)
|
||||
})
|
||||
|
||||
it('reports the end when the selection is outside the editor', () => {
|
||||
renderComposerContents(editor, 'hello')
|
||||
|
||||
const outside = document.createElement('div')
|
||||
outside.textContent = 'elsewhere'
|
||||
document.body.append(outside)
|
||||
caretAfter(outside.firstChild!, 3)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(5)
|
||||
|
||||
outside.remove()
|
||||
})
|
||||
})
|
||||
126
apps/desktop/src/app/chat/composer/undo-history.ts
Normal file
126
apps/desktop/src/app/chat/composer/undo-history.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* The composer's own undo stack.
|
||||
*
|
||||
* The rich editor mutates its DOM through `Range` rather than the browser's
|
||||
* editing commands — `execCommand('insertText')` is ~O(n²) on large multiline
|
||||
* blobs and froze the composer for seconds on a big paste (#45812). The cost of
|
||||
* that bypass is that those mutations never reach Chromium's undo stack, so
|
||||
* ⌘Z skipped straight past a paste and undid whatever came *before* it, leaving
|
||||
* the pasted text stranded.
|
||||
*
|
||||
* Owning the whole stack is the only coherent fix: a half-owned one interleaves
|
||||
* our snapshots with Chromium's own typing entries and undoes them out of order.
|
||||
* So every composer edit — typed or programmatic — records here, and the editor
|
||||
* intercepts ⌘Z / ⌘⇧Z instead of letting the native command run.
|
||||
*
|
||||
* Snapshots are plain text + a caret offset, not DOM: the editor already
|
||||
* round-trips losslessly through `composerPlainText`/`renderComposerContents`,
|
||||
* so text is the smallest thing that fully restores a state.
|
||||
*/
|
||||
|
||||
export interface ComposerSnapshot {
|
||||
caret: number
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Consecutive typing inside this window collapses into one undo entry, so ⌘Z
|
||||
* steps back by a burst the way a native editor does — not one character. */
|
||||
const COALESCE_WINDOW_MS = 600
|
||||
|
||||
const DEFAULT_LIMIT = 200
|
||||
|
||||
export interface ComposerUndoHistory {
|
||||
/** Drop all history and start over from `snapshot`'s state (session swap). */
|
||||
reset: () => void
|
||||
/** Redo one step. `current` is the live state, banked for a subsequent undo. */
|
||||
redo: (current: ComposerSnapshot) => ComposerSnapshot | null
|
||||
/** Bank the state that existed *before* an edit. `coalesce` merges this into
|
||||
* the previous entry when it lands inside the typing window. */
|
||||
record: (previous: ComposerSnapshot, options?: { coalesce?: boolean }) => void
|
||||
/** Undo one step. `current` is the live state, banked for a subsequent redo. */
|
||||
undo: (current: ComposerSnapshot) => ComposerSnapshot | null
|
||||
}
|
||||
|
||||
export function createComposerUndoHistory(
|
||||
limit = DEFAULT_LIMIT,
|
||||
now: () => number = () => Date.now()
|
||||
): ComposerUndoHistory {
|
||||
let past: ComposerSnapshot[] = []
|
||||
let future: ComposerSnapshot[] = []
|
||||
let lastRecordedAt = 0
|
||||
let lastWasCoalescable = false
|
||||
|
||||
const record: ComposerUndoHistory['record'] = (previous, options) => {
|
||||
const coalesce = options?.coalesce ?? false
|
||||
const at = now()
|
||||
const merges = coalesce && lastWasCoalescable && past.length > 0 && at - lastRecordedAt < COALESCE_WINDOW_MS
|
||||
|
||||
lastRecordedAt = at
|
||||
lastWasCoalescable = coalesce
|
||||
// A fresh edit invalidates anything the user had redone past.
|
||||
future = []
|
||||
|
||||
// Merging keeps the OLDER snapshot — the entry already holds the state from
|
||||
// the start of the burst, which is what ⌘Z should step back to.
|
||||
if (merges) {
|
||||
return
|
||||
}
|
||||
|
||||
// A no-op edit (same text) would make ⌘Z look broken for one press.
|
||||
if (past[past.length - 1]?.text === previous.text) {
|
||||
return
|
||||
}
|
||||
|
||||
past.push(previous)
|
||||
|
||||
if (past.length > limit) {
|
||||
past = past.slice(past.length - limit)
|
||||
}
|
||||
}
|
||||
|
||||
const step = (from: ComposerSnapshot[], to: ComposerSnapshot[], current: ComposerSnapshot) => {
|
||||
const next = from.pop()
|
||||
|
||||
if (!next) {
|
||||
return null
|
||||
}
|
||||
|
||||
to.push(current)
|
||||
// Any traversal ends the typing burst, so the next keystroke opens a new entry.
|
||||
lastWasCoalescable = false
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
return {
|
||||
record,
|
||||
redo: current => step(future, past, current),
|
||||
reset: () => {
|
||||
past = []
|
||||
future = []
|
||||
lastRecordedAt = 0
|
||||
lastWasCoalescable = false
|
||||
},
|
||||
undo: current => step(past, future, current)
|
||||
}
|
||||
}
|
||||
|
||||
/** True for the keystroke that means "undo" (⌘Z / Ctrl+Z, without Shift). */
|
||||
export function isUndoShortcut(event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>) {
|
||||
return (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'z'
|
||||
}
|
||||
|
||||
/** True for "redo" — ⌘⇧Z everywhere, plus Ctrl+Y on Windows/Linux. */
|
||||
export function isRedoShortcut(event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>) {
|
||||
if (event.altKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey && key === 'z') {
|
||||
return true
|
||||
}
|
||||
|
||||
return event.ctrlKey && !event.metaKey && !event.shiftKey && key === 'y'
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
onComposerInsertRequest
|
||||
} from '@/app/chat/composer/focus'
|
||||
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
||||
import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo'
|
||||
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
|
|
@ -39,6 +40,7 @@ import {
|
|||
} from '@/app/chat/composer/rich-editor'
|
||||
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { isRedoShortcut, isUndoShortcut } from '@/app/chat/composer/undo-history'
|
||||
import { chipTypedUrlOnSpace, linkifyUrls } from '@/app/chat/composer/url-refs'
|
||||
import {
|
||||
extractDroppedFiles,
|
||||
|
|
@ -215,6 +217,22 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
[aui]
|
||||
)
|
||||
|
||||
// Same stack the main composer owns, for the same reason: the editor mutates
|
||||
// through `Range` to dodge Chromium's O(n²) editing pipeline, which also
|
||||
// dodges its undo stack, so a paste was invisible to Cmd+Z. `rememberInitialDraft`
|
||||
// already marks every mutation site (it's the dirty-edit guard), so the undo
|
||||
// points ride along with it.
|
||||
const syncFromEditorRef = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
return editor ? syncDraftFromEditor(editor) : draftRef.current
|
||||
}, [syncDraftFromEditor])
|
||||
|
||||
const { recordUndoPoint, redo, undo, withUndoPoint } = useComposerUndo({
|
||||
editorRef,
|
||||
syncDraftFromEditor: syncFromEditorRef
|
||||
})
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
|
|
@ -278,6 +296,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
}
|
||||
|
||||
rememberInitialDraft()
|
||||
recordUndoPoint()
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
|
|
@ -327,7 +346,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
document.execCommand('insertText', false, text)
|
||||
finish()
|
||||
},
|
||||
[aui, closeTrigger, refreshTrigger, rememberInitialDraft, requestEditFocus, trigger]
|
||||
[aui, closeTrigger, recordUndoPoint, refreshTrigger, rememberInitialDraft, requestEditFocus, trigger]
|
||||
)
|
||||
|
||||
const insertRefStrings = useCallback(
|
||||
|
|
@ -338,20 +357,23 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
return false
|
||||
}
|
||||
|
||||
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
|
||||
// Bank BEFORE the insert — insertInlineRefsIntoEditor mutates in place, so
|
||||
// recording after it would snapshot the state we're trying to undo to.
|
||||
const undone = withUndoPoint(() => insertInlineRefsIntoEditor(editor, refs) !== null)
|
||||
|
||||
if (nextDraft === null) {
|
||||
if (!undone) {
|
||||
return false
|
||||
}
|
||||
|
||||
rememberInitialDraft()
|
||||
const nextDraft = composerPlainText(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
requestEditFocus()
|
||||
|
||||
return true
|
||||
},
|
||||
[aui, rememberInitialDraft, requestEditFocus]
|
||||
[aui, rememberInitialDraft, requestEditFocus, withUndoPoint]
|
||||
)
|
||||
|
||||
const insertDroppedRefs = useCallback(
|
||||
|
|
@ -494,6 +516,19 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
// Native typing/deleting still goes through Chromium's editing pipeline, whose
|
||||
// undo stack we've taken over — bank the pre-edit state here, while
|
||||
// `beforeinput` can still see the old text.
|
||||
const handleBeforeInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const inputType = (event.nativeEvent as InputEvent).inputType
|
||||
|
||||
if (inputType === 'historyUndo' || inputType === 'historyRedo') {
|
||||
return
|
||||
}
|
||||
|
||||
recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' })
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const pastedText = sanitizeComposerInput(event.clipboardData.getData('text'))
|
||||
|
||||
|
|
@ -505,6 +540,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
|
||||
event.preventDefault()
|
||||
rememberInitialDraft()
|
||||
recordUndoPoint()
|
||||
|
||||
// Links land as `@url:` chips, same as the main composer.
|
||||
insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText))
|
||||
|
|
@ -599,6 +635,22 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
}
|
||||
}
|
||||
|
||||
// Undo/redo before Escape — we own the stack, and a stray Cmd+Z must never
|
||||
// fall through to something that cancels the edit outright.
|
||||
if (isUndoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
undo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isRedoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
aui.composer().cancel()
|
||||
|
|
@ -607,7 +659,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
}
|
||||
|
||||
// A typed link finished with a space chips like a pasted one.
|
||||
if (chipTypedUrlOnSpace(event)) {
|
||||
if (withUndoPoint(() => chipTypedUrlOnSpace(event))) {
|
||||
event.preventDefault()
|
||||
rememberInitialDraft()
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
|
|
@ -682,6 +734,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
contentEditable
|
||||
data-placeholder={copy.editMessage}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue