diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 11aa35dfaf79..43b8514cc35f 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -408,6 +408,7 @@ export function useComposerDraft({ requestMainFocus, sessionIdRef, setComposerText, - stashAt + stashAt, + syncDraftFromEditor } } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts new file mode 100644 index 000000000000..d55f75dcd2b5 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts @@ -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 + /** 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 } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index a29dcb1cd7e3..5988d6103707 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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) => { + 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) => { 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 diff --git a/apps/desktop/src/app/chat/composer/undo-history.test.ts b/apps/desktop/src/app/chat/composer/undo-history.test.ts new file mode 100644 index 000000000000..e59935c73cc3 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/undo-history.test.ts @@ -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 = {}) => + ({ 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() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/undo-history.ts b/apps/desktop/src/app/chat/composer/undo-history.ts new file mode 100644 index 000000000000..62bed71be835 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/undo-history.ts @@ -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) { + 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) { + 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' +}