-
Notifications
You must be signed in to change notification settings - Fork 3.7k
fix(user-input): atomic chip selection, modifier-key handling, and stale overlay ghost #4902
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from all commits
2e0aacd
9567adf
d1b3562
1a2e63f
6997d3e
35a2591
7c3e298
5b02798
31c66fa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,16 +27,15 @@ import type { | |
| import { | ||
| AnimatedPlaceholderEffect, | ||
| AttachedFilesList, | ||
| autoResizeTextarea, | ||
| chipDisplayToken, | ||
| chipLinkToContext, | ||
| DropOverlay, | ||
| MAX_CHAT_TEXTAREA_HEIGHT, | ||
| MicButton, | ||
| mapResourceToContext, | ||
| OVERLAY_CLASSES, | ||
| PlusMenuDropdown, | ||
| parseChipLinks, | ||
| SCROLLER_CLASSES, | ||
| SendButton, | ||
| SkillsMenuDropdown, | ||
| serializeSelectionForClipboard, | ||
|
|
@@ -171,7 +170,6 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us | |
| }) | ||
| const valueRef = useRef(value) | ||
| valueRef.current = value | ||
| const overlayRef = useRef<HTMLDivElement>(null) | ||
| const plusMenuRef = useRef<PlusMenuHandle>(null) | ||
| const skillsMenuRef = useRef<SkillsMenuHandle>(null) | ||
|
|
||
|
|
@@ -480,13 +478,11 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us | |
| useLayoutEffect(() => { | ||
| const textarea = textareaRef.current | ||
| if (!textarea) return | ||
| const maxHeight = isInitialView ? window.innerHeight * 0.3 : MAX_CHAT_TEXTAREA_HEIGHT | ||
| // Grow the textarea to its full content height; the scroller caps the | ||
| // visible height and scrolls textarea + overlay together natively. | ||
| textarea.style.height = 'auto' | ||
| textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px` | ||
| if (overlayRef.current) { | ||
| overlayRef.current.scrollTop = textarea.scrollTop | ||
| } | ||
| }, [value, isInitialView, textareaRef]) | ||
| textarea.style.height = `${textarea.scrollHeight}px` | ||
| }, [value, textareaRef]) | ||
|
|
||
| const handleResourceSelect = useCallback( | ||
| (resource: MothershipResource) => { | ||
|
|
@@ -742,6 +738,16 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us | |
| } | ||
| }, [onSubmit, textareaRef, resetTranscript]) | ||
|
|
||
| /** | ||
| * Adopts the textarea's DOM value into state. State and DOM can only drift | ||
| * when an edit's input event is lost (programmatic edits pair setValue | ||
| * synchronously) — the DOM is the user's intent. | ||
| */ | ||
| const adoptDomValue = useCallback((textarea: HTMLTextAreaElement) => { | ||
| valueRef.current = textarea.value | ||
| setValue(textarea.value) | ||
| }, []) | ||
|
|
||
| const handleKeyDown = useCallback( | ||
| (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | ||
| if (mentionRangeRef.current && !e.nativeEvent.isComposing) { | ||
|
|
@@ -819,8 +825,9 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us | |
|
|
||
| // Single-chip delete: remove the token's text atomically. A selection | ||
| // delete falls through to the native edit; either way the context-sync | ||
| // effect prunes contexts whose last token is gone. | ||
| if ((e.key === 'Backspace' || e.key === 'Delete') && selectionLength === 0) { | ||
| // effect prunes contexts whose last token is gone. Cmd+Backspace | ||
| // (delete to line start) stays native — it spans more than one chip. | ||
| if ((e.key === 'Backspace' || e.key === 'Delete') && selectionLength === 0 && !e.metaKey) { | ||
| const ranges = mentionTokensWithContext.mentionRanges | ||
| const target = | ||
| e.key === 'Backspace' | ||
|
|
@@ -834,7 +841,18 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us | |
| } | ||
| } | ||
|
|
||
| if (selectionLength === 0 && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { | ||
| // Hop chips on plain arrows only: Shift/Cmd/Alt/Ctrl variants and IME | ||
| // composition keep native handling; the select handler snaps any | ||
| // resulting edge inside a chip to a chip boundary. | ||
| if ( | ||
| selectionLength === 0 && | ||
| (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && | ||
| !e.shiftKey && | ||
| !e.metaKey && | ||
| !e.altKey && | ||
| !e.ctrlKey && | ||
| !e.nativeEvent.isComposing | ||
| ) { | ||
| if (textarea) { | ||
| if (e.key === 'ArrowLeft') { | ||
| const nextPos = Math.max(0, selStart - 1) | ||
|
|
@@ -858,7 +876,9 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us | |
| } | ||
| } | ||
|
|
||
| if (e.key.length === 1 || e.key === 'Space') { | ||
| // Block typing inside a chip (snap to its end instead). Cmd/Ctrl | ||
| // shortcuts (Cmd+A, Cmd+C, ...) don't insert text and must pass through. | ||
| if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) { | ||
| const blocked = | ||
| selectionLength === 0 && !!mentionTokensWithContext.findRangeContaining(selStart) | ||
| if (blocked) { | ||
|
|
@@ -1017,33 +1037,80 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us | |
| ] | ||
| ) | ||
|
|
||
| /** Last selection reported by the DOM; tells which edge of a range moved, and which way. */ | ||
| const lastSelectionRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 }) | ||
|
|
||
| /** | ||
| * Keeps mention chips atomic under every selection gesture. A collapsed | ||
| * caret inside a chip snaps to the nearest edge; a ranged selection edge | ||
| * inside a chip snaps to a chip boundary — never collapsed — so select-all, | ||
| * Shift+arrows, drag, and double-click all select chips whole. | ||
| */ | ||
| const handleSelectAdjust = useCallback(() => { | ||
| const textarea = textareaRef.current | ||
| if (!textarea) return | ||
| const pos = textarea.selectionStart ?? 0 | ||
| const r = mentionTokensWithContext.findRangeContaining(pos) | ||
| if (r) { | ||
| const snapPos = pos - r.start < r.end - pos ? r.start : r.end | ||
| const start = textarea.selectionStart ?? 0 | ||
| const end = textarea.selectionEnd ?? 0 | ||
| const prev = lastSelectionRef.current | ||
| // Always track the raw observed selection — never an intended write that | ||
| // may get superseded — so edge-movement inference stays true to the DOM. | ||
| lastSelectionRef.current = { start, end } | ||
|
|
||
| // Reconciliation backstop for non-keyboard mutations; the render rebuilds | ||
| // the overlay and selection logic resumes on the next event. | ||
| if (textarea.value !== valueRef.current) { | ||
| adoptDomValue(textarea) | ||
| return | ||
| } | ||
|
|
||
| let newStart = start | ||
| let newEnd = end | ||
| if (start !== end) { | ||
| const startRange = mentionTokensWithContext.findRangeContaining(start) | ||
| const endRange = mentionTokensWithContext.findRangeContaining(end) | ||
| // A lone moved edge (keyboard extend/shrink, drag) snaps in its direction | ||
| // of travel: growing absorbs the chip, shrinking releases it. Fresh | ||
| // selections (double-click, select-all) expand outward. A fresh selection | ||
| // sharing one edge with `prev` (e.g. select-all from a caret at 0) takes | ||
| // the single-edge path, but a grown edge snaps outward there too — the | ||
| // two paths only differ for a shrinking edge, which implies a real | ||
| // single-edge gesture. | ||
| const singleEdgeMoved = (start !== prev.start) !== (end !== prev.end) | ||
| newStart = startRange | ||
| ? singleEdgeMoved && start > prev.start | ||
| ? startRange.end | ||
| : startRange.start | ||
| : start | ||
| newEnd = endRange ? (singleEdgeMoved && end < prev.end ? endRange.start : endRange.end) : end | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale selection ref after typingMedium Severity
Reviewed by Cursor Bugbot for commit 31c66fa. Configure here. |
||
| // A selection contained in one chip snaps both edges; don't let it invert. | ||
| if (newStart > newEnd) { | ||
| newStart = newEnd | ||
| } | ||
| } else { | ||
| const r = mentionTokensWithContext.findRangeContaining(start) | ||
| if (r) { | ||
| const snapPos = start - r.start < r.end - start ? r.start : r.end | ||
| newStart = snapPos | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| newEnd = snapPos | ||
| } | ||
| } | ||
|
|
||
| if (newStart !== start || newEnd !== end) { | ||
| // Deferred so in-flight click/drag processing can't override the write; | ||
| // bails if the selection moved again first (a newer event supersedes it). | ||
| // The write re-fires this handler, which then syncs the menus below. | ||
| // Direction is read at apply time so it's never stale. | ||
| setTimeout(() => { | ||
| textarea.setSelectionRange(snapPos, snapPos) | ||
| if (textarea.selectionStart !== start || textarea.selectionEnd !== end) return | ||
| textarea.setSelectionRange(newStart, newEnd, textarea.selectionDirection ?? undefined) | ||
| }, 0) | ||
| return | ||
| } | ||
| syncMentionState(textarea, textarea.value, pos) | ||
| syncSlashState(textarea, textarea.value, pos) | ||
| }, [textareaRef, mentionTokensWithContext, syncMentionState, syncSlashState]) | ||
|
|
||
| const handleInput = useCallback( | ||
| (e: React.FormEvent<HTMLTextAreaElement>) => { | ||
| const maxHeight = isInitialView ? window.innerHeight * 0.3 : MAX_CHAT_TEXTAREA_HEIGHT | ||
| autoResizeTextarea(e, maxHeight) | ||
|
|
||
| if (overlayRef.current) { | ||
| overlayRef.current.scrollTop = (e.target as HTMLTextAreaElement).scrollTop | ||
| } | ||
| }, | ||
| [isInitialView] | ||
| ) | ||
| const focusPos = textarea.selectionDirection === 'backward' ? start : end | ||
| syncMentionState(textarea, textarea.value, focusPos) | ||
| syncSlashState(textarea, textarea.value, focusPos) | ||
| }, [textareaRef, mentionTokensWithContext, adoptDomValue, syncMentionState, syncSlashState]) | ||
|
|
||
| const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => { | ||
| const textarea = e.currentTarget | ||
|
|
@@ -1129,12 +1196,6 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us | |
| filesRef.current.processFiles(dt.files) | ||
| }, []) | ||
|
|
||
| const handleScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => { | ||
| if (overlayRef.current) { | ||
| overlayRef.current.scrollTop = e.currentTarget.scrollTop | ||
| } | ||
| }, []) | ||
|
|
||
| /** | ||
| * On copy/cut, write a portable representation of the selection to the | ||
| * clipboard. Portable resource chips (table/file/folder/etc.) become | ||
|
|
@@ -1230,12 +1291,8 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us | |
| elements.push( | ||
| <span key={`mention-${i}-${range.start}-${range.end}`}> | ||
| <span className='relative'> | ||
| {/* Spacer reserves the real trigger glyph's width so the overlay's | ||
| advance matches the transparent textarea char-for-char — | ||
| hardcoding a width here would drift the text. For '@' the glyph is | ||
| ~1em; skill chips store an EM SPACE sentinel (SKILL_CHIP_TRIGGER) | ||
| in place of the narrow '/' so the centered 12px icon fits its slot | ||
| exactly like '@' does. */} | ||
| {/* Invisible trigger glyph keeps the overlay's advance identical to | ||
| the transparent textarea; the icon centers over its slot. */} | ||
| <span className='invisible'>{range.token.charAt(0)}</span> | ||
| {mentionIconNode} | ||
| </span> | ||
|
|
@@ -1274,35 +1331,30 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us | |
| onRemoveFile={handleRemoveFile} | ||
| /> | ||
|
|
||
| {/* Clip the absolutely-positioned mirror overlay to the textarea's box. | ||
| The overlay is `h-auto`, so its content (e.g. the trailing-newline | ||
| sentinel on Shift+Enter) can exceed the textarea height and would | ||
| otherwise paint over the toolbar below. */} | ||
| <div className='relative overflow-hidden'> | ||
| <div | ||
| ref={overlayRef} | ||
| className={cn(OVERLAY_CLASSES, isInitialView ? 'max-h-[30vh]' : 'max-h-[200px]')} | ||
| aria-hidden='true' | ||
| > | ||
| {overlayContent} | ||
| {/* Single scroller for textarea + overlay so they co-scroll natively; | ||
| the sizer is sized by the full-height textarea, and the overlay fills | ||
| it via `inset-0`. */} | ||
| <div className={cn(SCROLLER_CLASSES, isInitialView ? 'max-h-[30vh]' : 'max-h-[200px]')}> | ||
| <div className='relative'> | ||
| <div className={OVERLAY_CLASSES} aria-hidden='true'> | ||
| {overlayContent} | ||
| </div> | ||
|
|
||
| <textarea | ||
| ref={textareaRef} | ||
| value={value} | ||
| onChange={handleInputChange} | ||
| onKeyDown={handleKeyDown} | ||
| onPaste={handlePaste} | ||
| onCopy={handleCopy} | ||
| onCut={handleCut} | ||
| onSelect={handleSelectAdjust} | ||
| onMouseUp={handleSelectAdjust} | ||
| placeholder='Ask Sim to ' | ||
| rows={1} | ||
| className={TEXTAREA_BASE_CLASSES} | ||
| /> | ||
| </div> | ||
|
|
||
| <textarea | ||
| ref={textareaRef} | ||
| value={value} | ||
| onChange={handleInputChange} | ||
| onKeyDown={handleKeyDown} | ||
| onInput={handleInput} | ||
| onPaste={handlePaste} | ||
| onCopy={handleCopy} | ||
| onCut={handleCut} | ||
| onSelect={handleSelectAdjust} | ||
| onMouseUp={handleSelectAdjust} | ||
| onScroll={handleScroll} | ||
| placeholder='Ask Sim to ' | ||
| rows={1} | ||
| className={cn(TEXTAREA_BASE_CLASSES, isInitialView ? 'max-h-[30vh]' : 'max-h-[200px]')} | ||
| /> | ||
| </div> | ||
|
|
||
| <div className='flex items-center justify-between'> | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.