From 8896fc75002af22aa64009fd91ee9a81b007eed6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 16:10:00 -0500 Subject: [PATCH 1/3] fix(desktop): stop a chip inserted after a word from swallowing its space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plainTextInRange` serialized the caret's preceding content through a bare
, but `composerPlainText` appends "\n" to any block element that isn't the editor slot. So `beforeText` always looked like it ended in whitespace and the separating space was never inserted — dragging a file in after a word produced `review@file:...` glued together. Marking the scratch container with RICH_INPUT_SLOT makes it serialize in the same coordinates as the editor. Same fix lands in the new `caretOffsetInEditor`, which measures caret offsets the same way. --- .../src/app/chat/composer/inline-refs.ts | 13 ++- .../src/app/chat/composer/rich-editor.test.ts | 24 +++++ .../src/app/chat/composer/rich-editor.ts | 100 ++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts index 5fd62f4cc94a..5e282f6a040a 100644 --- a/apps/desktop/src/app/chat/composer/inline-refs.ts +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -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
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) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index aa4e80aedb9c..842765388ff8 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -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', () => { diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 738201fc3944..7bf770eda17e 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -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
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) { From bc2ddf5bab59750b0952b469080b55ff03f5abbe Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 16:10:13 -0500 Subject: [PATCH 2/3] fix(desktop): give the composer its own undo stack so Cmd+Z sees a paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rich editor mutates its DOM through `Range` rather than the browser's editing commands, because `execCommand('insertText')` is ~O(n²) on large multiline blobs and froze the composer for seconds on a big paste (#45812). Those mutations never reach Chromium's undo stack, so a paste was invisible to it — Cmd+Z skipped straight past the pasted text and undid whatever edit came before it, leaving the paste stranded and the earlier edit destroyed. Owning the stack outright is the only coherent fix; a half-owned one interleaves our snapshots with Chromium's own typing entries and undoes them out of order. Every edit path now banks its pre-edit state, and the editor claims Cmd+Z / Cmd+Shift+Z (plus Ctrl+Y) instead of letting the native command run. Snapshots are plain text + a caret offset rather than DOM, since the editor already round-trips losslessly through composerPlainText/renderComposerContents. Consecutive keystrokes coalesce inside a 600ms window, so undo steps back by a typing burst the way a native editor does rather than one character at a time. Electron's Edit menu `{ role: 'undo' }` fires the native command without a keystroke the renderer can see, so a capture-phase `beforeinput` listener claims historyUndo/historyRedo too and keeps the menu item and the shortcut in agreement. Switching drafts resets the history — undoing into another conversation's text is worse than having none. Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> --- .../chat/composer/hooks/use-composer-draft.ts | 3 +- .../chat/composer/hooks/use-composer-undo.ts | 124 +++++++++ apps/desktop/src/app/chat/composer/index.tsx | 64 ++++- .../app/chat/composer/undo-history.test.ts | 241 ++++++++++++++++++ .../src/app/chat/composer/undo-history.ts | 126 +++++++++ 5 files changed, 553 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts create mode 100644 apps/desktop/src/app/chat/composer/undo-history.test.ts create mode 100644 apps/desktop/src/app/chat/composer/undo-history.ts 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' +} From 92f62bedd7e98392b789328983aa66c8e452a256 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 16:26:13 -0500 Subject: [PATCH 3/3] fix(desktop): extend the composer undo stack to the message edit composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit composer pastes through the same `insertComposerContentsAtCaret` the main composer uses, so it had the identical bug: the Range-based insert never reaches Chromium's undo stack and Cmd+Z skipped past the paste to destroy the edit before it. Its inline-ref and trigger-chip inserts mutate the DOM directly too, with the same result. Both surfaces now share `useComposerUndo`. The hook already keys its document-level `beforeinput` claim off `document.activeElement`, so the two mounted instances stay independent — only the focused editor's stack responds. Undo/redo is handled ahead of Escape here, since a stray Cmd+Z falling through would cancel the whole edit rather than step back one change. `insertRefStrings` banks through `withUndoPoint` rather than recording after the insert, which would have snapshotted the state it was meant to restore. --- .../composer/hooks/use-composer-undo.test.tsx | 193 ++++++++++++++++++ .../thread/user-edit-composer.tsx | 63 +++++- 2 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx new file mode 100644 index 000000000000..bbffc396ecb6 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx @@ -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, onSync: () => string) { + const api: { current: ReturnType | 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() + + 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() as RefObject + 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() as RefObject + 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() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 0da6e1dea8a8..5168a0d84317 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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) => { + const inputType = (event.nativeEvent as InputEvent).inputType + + if (inputType === 'historyUndo' || inputType === 'historyRedo') { + return + } + + recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' }) + } + const handlePaste = (event: ClipboardEvent) => { const pastedText = sanitizeComposerInput(event.clipboardData.getData('text')) @@ -505,6 +540,7 @@ export const UserEditComposer: FC = ({ 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 = ({ 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 = ({ 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 = ({ cwd, gateway, sess contentEditable data-placeholder={copy.editMessage} data-slot={RICH_INPUT_SLOT} + onBeforeInput={handleBeforeInput} onBlur={() => window.setTimeout(closeTrigger, 80)} onDragOver={handleDragOver} onDrop={handleDrop}