From 5792a3d2e219f05379a38bcbb2bcd7085bebc4e6 Mon Sep 17 00:00:00 2001 From: Ibrahim Khan <2005ibrahimkhan@gmail.com> Date: Thu, 30 Jul 2026 09:44:52 -0700 Subject: [PATCH] fix(ui): paint composer caret on first padding click (#2509) --- .../components/chat/composer/DOCUMENTATION.md | 4 + .../chat/composer/editor/ComposerEditor.tsx | 13 +-- .../editor/__tests__/hostMouseDown.test.ts | 97 +++++++++++++++++++ .../chat/composer/editor/hostMouseDown.ts | 25 +++++ 4 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/components/chat/composer/editor/__tests__/hostMouseDown.test.ts create mode 100644 packages/ui/src/components/chat/composer/editor/hostMouseDown.ts diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index a5644136..3544932a 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -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 diff --git a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx index 898dd2f7..f3715dd7 100644 --- a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx +++ b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx @@ -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 { - 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 => ({ diff --git a/packages/ui/src/components/chat/composer/editor/__tests__/hostMouseDown.test.ts b/packages/ui/src/components/chat/composer/editor/__tests__/hostMouseDown.test.ts new file mode 100644 index 00000000..acdcd281 --- /dev/null +++ b/packages/ui/src/components/chat/composer/editor/__tests__/hostMouseDown.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/components/chat/composer/editor/hostMouseDown.ts b/packages/ui/src/components/chat/composer/editor/hostMouseDown.ts new file mode 100644 index 00000000..bb5e550d --- /dev/null +++ b/packages/ui/src/components/chat/composer/editor/hostMouseDown.ts @@ -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 } }); +}