fix(ui): paint composer caret on first padding click (#2509)

This commit is contained in:
Ibrahim Khan
2026-07-30 19:44:52 +03:00
committed by GitHub
parent 80d939cccd
commit 5792a3d2e2
4 changed files with 128 additions and 11 deletions
@@ -83,6 +83,10 @@ and the send path reading the same grammar.
## Ordering rules worth knowing
- `editor/ComposerEditor.tsx` forwards a click on the composer's padding by
focusing the view *before* setting the selection: CodeMirror reveals its
drawn caret through a class it only writes while applying an update, so the
selection has to be the update that follows the focus.
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
inline comments and context into OpenCode's one-primary-plus-parts shape. The
oldest queued message becomes primary; **inline comments attach to the last
@@ -37,6 +37,7 @@ import type { ComposerLanguageContext } from '../language/tokenize';
import { composerLanguage, setLanguageContext } from './composerLanguage';
import type { ComposerEditorViewStore } from './viewStore';
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
import { handleComposerHostMouseDown } from './hostMouseDown';
export interface ComposerSelection {
start: number;
@@ -417,17 +418,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
* extend into the padding, so the click has to be forwarded.
*/
const handleHostMouseDown = React.useCallback((event: React.MouseEvent) => {
const view = viewRef.current;
if (!view || view.state.readOnly || !view.contentDOM.isContentEditable) return;
// A click that already landed in the text needs no help, and
// forwarding it would break drag-selection.
if (view.contentDOM.contains(event.target as Node)) return;
event.preventDefault();
const position = view.posAtCoords({ x: event.clientX, y: event.clientY })
?? view.state.doc.length;
view.dispatch({ selection: { anchor: position } });
view.focus();
handleComposerHostMouseDown(viewRef.current, event);
}, []);
React.useImperativeHandle(ref, (): ComposerEditorHandle => ({
@@ -0,0 +1,97 @@
import { describe, expect, test } from 'bun:test';
import { EditorState, type TransactionSpec } from '@codemirror/state';
import { drawSelection, type EditorView } from '@codemirror/view';
import { handleComposerHostMouseDown } from '../hostMouseDown';
const padding = {} as Node;
const shell = {} as Node;
const text = {} as Node;
class ComposerViewHarness {
state = EditorState.create({ doc: 'hello', extensions: [drawSelection()] });
contentActive = false;
windowActive = true;
caretPainted = false;
prevented = false;
position: number | null = 2;
readonly contentDOM = {
isContentEditable: true,
contains: (target: Node) => target === text,
};
focus(): void {
this.windowActive = true;
this.contentActive = true;
}
dispatch(spec: TransactionSpec): void {
this.state = this.state.update(spec).state;
this.caretPainted = this.windowActive && this.contentActive;
}
posAtCoords(): number | null {
return this.position;
}
mouseDown(target: Node): void {
handleComposerHostMouseDown(this as unknown as EditorView, {
target,
clientX: 10,
clientY: 20,
preventDefault: () => { this.prevented = true; },
});
}
}
describe('composer host mouse down', () => {
test('focuses before the first padding selection update paints the caret', () => {
const view = new ComposerViewHarness();
view.mouseDown(padding);
expect(view.prevented).toBe(true);
expect(view.contentActive).toBe(true);
expect(view.state.selection.main.head).toBe(2);
expect(view.caretPainted).toBe(true);
});
test('repaints after window reactivation even when focus bookkeeping is stale', () => {
const view = new ComposerViewHarness();
// The content remains active while CodeMirror's last notified focus is
// stale; reactivating the window does not itself repaint drawSelection.
view.contentActive = true;
view.windowActive = false;
view.caretPainted = false;
view.mouseDown(shell);
expect(view.windowActive).toBe(true);
expect(view.caretPainted).toBe(true);
});
test('falls back to the document end when padding has no mapped position', () => {
const view = new ComposerViewHarness();
view.position = null;
view.mouseDown(padding);
expect(view.state.selection.main.head).toBe(5);
});
test('leaves native text selection and read-only editors alone', () => {
const textView = new ComposerViewHarness();
textView.mouseDown(text);
expect(textView.prevented).toBe(false);
expect(textView.contentActive).toBe(false);
const readOnlyView = new ComposerViewHarness();
readOnlyView.state = EditorState.create({
doc: 'hello',
extensions: [EditorState.readOnly.of(true), drawSelection()],
});
readOnlyView.mouseDown(padding);
expect(readOnlyView.prevented).toBe(false);
expect(readOnlyView.contentActive).toBe(false);
});
});
@@ -0,0 +1,25 @@
import type { EditorView } from '@codemirror/view';
import type { MouseEvent } from 'react';
type ComposerHostMouseDownEvent = Pick<
MouseEvent,
'target' | 'clientX' | 'clientY' | 'preventDefault'
>;
export function handleComposerHostMouseDown(
view: EditorView | null,
event: ComposerHostMouseDownEvent,
): void {
if (!view || view.state.readOnly || !view.contentDOM.isContentEditable) return;
// A click that already landed in the text needs no help, and
// forwarding it would break drag-selection.
if (view.contentDOM.contains(event.target as Node)) return;
event.preventDefault();
const position = view.posAtCoords({ x: event.clientX, y: event.clientY })
?? view.state.doc.length;
// Focus before dispatching so CodeMirror updates the drawn caret's
// visibility while applying the selection update.
view.focus();
view.dispatch({ selection: { anchor: position } });
}