fix(composer): keep the caret inside the normalized document

CodeMirror collapses a CRLF pair into one line break, so the document is
shorter than the string it was given. The composer derived the caret from
the JS string length, which put it past the end of the document and made
dispatch throw `RangeError: Selection points outside of document`.

Because the exception fires before the transaction applies, the document
never updates, the un-normalized text stays in React state, and the draft
persists as-is: every later visit to the session restores it and crashes
again, with no way out from the UI.

Derive the caret from the change set instead, in the controlled writeback
and in the imperative insert/replace handles.

fixes #3013

# Conflicts:
#	CHANGELOG.md
#	packages/vscode/CHANGELOG.md
This commit is contained in:
Iuliia Ivashko
2026-08-28 19:14:02 +03:00
parent 7b5b431d22
commit eca9353382
7 changed files with 117 additions and 17 deletions
+1
View File
@@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
- Files: opening a file over 5,000 lines is no longer blocked — the line-count guard now allows up to 20,000 lines, letting large files reach the virtualized full-file preview instead of being rejected at the open step (thanks @gaojunran).
- Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss).
- Updates: "Update OpenCode" no longer fails with a bare "Bad Request". OpenChamber now names the release to install, which recent OpenCode versions require, and when an update is refused the reason from OpenCode is shown instead of the HTTP status. This affects setups where OpenChamber runs an OpenCode you installed yourself; the desktop app bundles OpenCode and never offered the button.
- Chat: a saved draft or recalled message containing Windows line endings no longer replaces the chat with a "Selection points outside of document" error — text like this reached the input from reverted messages, message history and plugin output, and once it was saved as a draft the error came back on every visit to that session.
- Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour).
- Git: picking a remote branch such as `origin/main` in the branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name.
- Desktop: "Restart to Update" no longer looks dead when the update cannot be installed — the update window now shows the reason, including when the running copy was not installed from an official signed release, and the button stays available to retry.
@@ -60,6 +60,15 @@ copy.
exactly what gets sent, so nothing downstream serializes a rich document model
back into a prompt.
The document is not, however, the string it was given: CodeMirror normalizes
line endings, so a `\r\n` pair becomes one break and the document ends up
shorter than the inserted string. **Never derive a caret position from the
length of text you are inserting** — a caret past the end makes `dispatch`
throw, the transaction never applies, and the un-normalized text stays in React
state to crash again on the next restore. Every edit that moves the caret goes
through `replaceWithCaret` (`editor/documentEdits.ts`), which measures the
change instead of the string.
The composer previously painted a transparent `<textarea>` over a mirror
`<div>`. That restricted highlighting to styles which do not change glyph
advance width — colour, background, underline — because anything else made the
@@ -36,6 +36,7 @@ import { cn } from '@/lib/utils';
import type { ComposerLanguageContext } from '../language/tokenize';
import type { ComposerAutoCorrect } from './autocorrect';
import { composerLanguage, setLanguageContext } from './composerLanguage';
import { replaceWithCaret } from './documentEdits';
import type { ComposerEditorViewStore } from './viewStore';
import { composerEditorTheme, composerSelectionExtension } from './theme';
import { handleComposerHostMouseDown } from './hostMouseDown';
@@ -351,17 +352,14 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
// A stale value echo can differ from CodeMirror's newer document,
// and replacing it would interrupt the IME session and move the caret.
if (view.compositionStarted) return;
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
// An external rewrite (draft restore, history navigation,
// "add to chat", dictation insert) lands the caret at the END,
// matching what a plain textarea did when its value was
// replaced. Every rewrite that reaches here appends or
// replaces wholesale; keeping the old caret instead left it
// stranded before the inserted text, and the next insertion
// or keystroke landed inside the previous one.
selection: { anchor: value.length },
});
// An external rewrite (draft restore, history navigation,
// "add to chat", dictation insert) lands the caret at the END,
// matching what a plain textarea did when its value was replaced.
// Every rewrite that reaches here appends or replaces wholesale;
// keeping the old caret instead left it stranded before the
// inserted text, and the next insertion or keystroke landed inside
// the previous one.
view.dispatch(replaceWithCaret(view.state, 0, current.length, value));
// A large insert can push the caret below the fold, and a
// transaction-time `scrollIntoView` cannot reach it: wrapped-line
// heights are still estimates during the update, and the
@@ -515,18 +513,18 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view || !text) return;
const { from, to } = view.state.selection.main;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: from + text.length },
...replaceWithCaret(view.state, from, to, text),
userEvent: 'input.type',
});
},
replaceRange(from, to, text, selectionStart, selectionEnd = selectionStart) {
const view = viewRef.current;
if (!view) return;
const anchor = selectionStart ?? from + text.length;
const caret = selectionStart === undefined
? undefined
: { anchor: selectionStart, head: selectionEnd ?? selectionStart };
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor, head: selectionEnd ?? anchor },
...replaceWithCaret(view.state, from, to, text, caret),
userEvent: 'input.type',
});
},
@@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test';
import { EditorState } from '@codemirror/state';
import { replaceWithCaret } from '../documentEdits';
const apply = (doc: string, from: number, to: number, insert: string, caret?: { anchor: number; head: number }) => {
const state = EditorState.create({ doc });
const next = state.update(replaceWithCaret(state, from, to, insert, caret)).state;
return { text: next.doc.toString(), selection: next.selection.main };
};
describe('replaceWithCaret', () => {
test('puts the caret at the end of a wholesale replacement', () => {
const { text, selection } = apply('old', 0, 3, 'a new draft');
expect(text).toBe('a new draft');
expect(selection.anchor).toBe(11);
expect(selection.head).toBe(11);
});
// Issue #3013: CodeMirror collapses `\r\n` into one line break, so a caret
// taken from the JS string length falls outside the document and dispatch
// throws `RangeError: Selection points outside of document`.
test('keeps the caret inside the document when CRLF is normalized away', () => {
const { text, selection } = apply('a', 0, 1, 'x\r\ny');
expect(text).toBe('x\ny');
expect(selection.anchor).toBe(3);
});
test('survives a draft made only of CRLF breaks', () => {
const { text, selection } = apply('a', 0, 1, '\r\n\r\n\r\n');
expect(text).toBe('\n\n\n');
expect(selection.anchor).toBe(3);
});
test('places the caret after text inserted at the selection', () => {
const { text, selection } = apply('hello world', 5, 5, ',\r\n there');
expect(text).toBe('hello,\n there world');
expect(selection.anchor).toBe(13);
});
test('honours an explicit caret', () => {
const { selection } = apply('hello', 0, 5, 'goodbye', { anchor: 2, head: 4 });
expect(selection.anchor).toBe(2);
expect(selection.head).toBe(4);
});
test('clamps an explicit caret that the normalized document cannot hold', () => {
const { text, selection } = apply('a', 0, 1, 'x\r\ny', { anchor: 4, head: 4 });
expect(text).toBe('x\ny');
expect(selection.anchor).toBe(3);
});
});
@@ -19,7 +19,7 @@ describe('composer value writeback composition guard (issue #2527)', () => {
const effect = writebackEffect();
const equalityCheck = effect.indexOf('if (current === value) return;');
const compositionGuard = effect.indexOf('if (view.compositionStarted) return;');
const dispatch = effect.indexOf('view.dispatch({');
const dispatch = effect.indexOf('view.dispatch(');
expect(equalityCheck).toBeGreaterThan(-1);
expect(compositionGuard).toBeGreaterThan(equalityCheck);
@@ -0,0 +1,33 @@
import type { EditorState, TransactionSpec } from '@codemirror/state';
/**
* Replace a document range and leave the caret inside the resulting document.
*
* CodeMirror normalizes line endings on the way in: a `\r\n` pair becomes one
* line break, so the inserted string is longer than the text it produces. A
* caret derived from the JavaScript string therefore lands past the end of the
* document and `dispatch` throws `RangeError: Selection points outside of
* document`. The transaction never applies, so the un-normalized text stays in
* React state, gets persisted as a draft, and crashes the chat again on every
* restore (issue #3013).
*
* Deriving the caret from the change set instead keeps it correct for whatever
* CodeMirror actually inserted, without this module having to know the
* normalization rules.
*/
export const replaceWithCaret = (
state: EditorState,
from: number,
to: number,
insert: string,
caret?: { anchor: number; head: number },
): TransactionSpec => {
const changes = state.changes({ from, to, insert });
const clamp = (position: number): number => Math.min(Math.max(position, 0), changes.newLength);
// What CodeMirror inserted, measured on the document rather than on the
// string: the new length minus everything the change left untouched.
const insertedLength = changes.newLength - (state.doc.length - (to - from));
const anchor = caret ? clamp(caret.anchor) : from + insertedLength;
const head = caret ? clamp(caret.head) : anchor;
return { changes, selection: { anchor, head } };
};
+1
View File
@@ -4,6 +4,7 @@
- GitHub Copilot usage now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss).
- The context usage readout now reports the session cost including everything its subagents spent, matching the work status panel instead of showing a lower figure.
- Updating OpenCode no longer fails with a bare "Bad Request": the extension names the release to install, which recent OpenCode versions require, and shows OpenCode's own reason when an update is refused.
- A saved draft or recalled message containing Windows line endings no longer replaces the chat with a "Selection points outside of document" error, and no longer brings the error back every time you reopen that session.
## [1.21.0] - 2026-08-26