Merge pull request #72288 from NousResearch/bb/composer-undo

Give the composer its own undo stack
This commit is contained in:
brooklyn! 2026-07-26 18:38:11 -05:00 committed by GitHub
commit bd6437d605
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 940 additions and 11 deletions

View file

@ -408,6 +408,7 @@ export function useComposerDraft({
requestMainFocus,
sessionIdRef,
setComposerText,
stashAt
stashAt,
syncDraftFromEditor
}
}

View file

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

View 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 }
}

View file

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

View file

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

View file

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

View file

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

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

View 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'
}

View file

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