@@ -834,7 +834,18 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
834834 }
835835 }
836836
837- if ( selectionLength === 0 && ( e . key === 'ArrowLeft' || e . key === 'ArrowRight' ) ) {
837+ // Hop chips on plain arrows only: Shift/Cmd/Alt/Ctrl variants and IME
838+ // composition keep native handling; the select handler snaps any
839+ // resulting edge inside a chip to a chip boundary.
840+ if (
841+ selectionLength === 0 &&
842+ ( e . key === 'ArrowLeft' || e . key === 'ArrowRight' ) &&
843+ ! e . shiftKey &&
844+ ! e . metaKey &&
845+ ! e . altKey &&
846+ ! e . ctrlKey &&
847+ ! e . nativeEvent . isComposing
848+ ) {
838849 if ( textarea ) {
839850 if ( e . key === 'ArrowLeft' ) {
840851 const nextPos = Math . max ( 0 , selStart - 1 )
@@ -858,7 +869,9 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
858869 }
859870 }
860871
861- if ( e . key . length === 1 || e . key === 'Space' ) {
872+ // Block typing inside a chip (snap to its end instead). Cmd/Ctrl
873+ // shortcuts (Cmd+A, Cmd+C, ...) don't insert text and must pass through.
874+ if ( e . key . length === 1 && ! e . metaKey && ! e . ctrlKey ) {
862875 const blocked =
863876 selectionLength === 0 && ! ! mentionTokensWithContext . findRangeContaining ( selStart )
864877 if ( blocked ) {
@@ -1017,20 +1030,70 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
10171030 ]
10181031 )
10191032
1033+ /** Last observed selection; tells which edge of a range moved, and which way. */
1034+ const lastSelectionRef = useRef < { start : number ; end : number } > ( { start : 0 , end : 0 } )
1035+
1036+ /**
1037+ * Keeps mention chips atomic under every selection gesture. A collapsed
1038+ * caret inside a chip snaps to the nearest edge; a ranged selection edge
1039+ * inside a chip snaps to a chip boundary — never collapsed — so select-all,
1040+ * Shift+arrows, drag, and double-click all select chips whole.
1041+ */
10201042 const handleSelectAdjust = useCallback ( ( ) => {
10211043 const textarea = textareaRef . current
10221044 if ( ! textarea ) return
1023- const pos = textarea . selectionStart ?? 0
1024- const r = mentionTokensWithContext . findRangeContaining ( pos )
1025- if ( r ) {
1026- const snapPos = pos - r . start < r . end - pos ? r . start : r . end
1045+ const start = textarea . selectionStart ?? 0
1046+ const end = textarea . selectionEnd ?? 0
1047+ const prev = lastSelectionRef . current
1048+
1049+ // Deferred so in-flight click/drag processing can't override the write;
1050+ // bails if the selection moved again first (a newer event supersedes it).
1051+ const applySelection = ( nextStart : number , nextEnd : number ) => {
1052+ const direction = textarea . selectionDirection ?? undefined
10271053 setTimeout ( ( ) => {
1028- textarea . setSelectionRange ( snapPos , snapPos )
1054+ if ( textarea . selectionStart !== start || textarea . selectionEnd !== end ) return
1055+ textarea . setSelectionRange ( nextStart , nextEnd , direction )
10291056 } , 0 )
1057+ }
1058+
1059+ if ( start !== end ) {
1060+ const startRange = mentionTokensWithContext . findRangeContaining ( start )
1061+ const endRange = mentionTokensWithContext . findRangeContaining ( end )
1062+ // A lone moved edge (keyboard extend/shrink, drag) snaps in its direction
1063+ // of travel: growing absorbs the chip, shrinking releases it. Fresh
1064+ // selections (double-click, select-all) expand outward.
1065+ const singleEdgeMoved = ( start !== prev . start ) !== ( end !== prev . end )
1066+ let newStart = startRange
1067+ ? singleEdgeMoved && start > prev . start
1068+ ? startRange . end
1069+ : startRange . start
1070+ : start
1071+ const newEnd = endRange
1072+ ? singleEdgeMoved && end < prev . end
1073+ ? endRange . start
1074+ : endRange . end
1075+ : end
1076+ // A selection contained in one chip snaps both edges; don't let it invert.
1077+ if ( newStart > newEnd ) {
1078+ newStart = newEnd
1079+ }
1080+ lastSelectionRef . current = { start : newStart , end : newEnd }
1081+ if ( newStart !== start || newEnd !== end ) {
1082+ applySelection ( newStart , newEnd )
1083+ }
1084+ return
1085+ }
1086+
1087+ const r = mentionTokensWithContext . findRangeContaining ( start )
1088+ if ( r ) {
1089+ const snapPos = start - r . start < r . end - start ? r . start : r . end
1090+ lastSelectionRef . current = { start : snapPos , end : snapPos }
1091+ applySelection ( snapPos , snapPos )
10301092 return
10311093 }
1032- syncMentionState ( textarea , textarea . value , pos )
1033- syncSlashState ( textarea , textarea . value , pos )
1094+ lastSelectionRef . current = { start, end }
1095+ syncMentionState ( textarea , textarea . value , start )
1096+ syncSlashState ( textarea , textarea . value , start )
10341097 } , [ textareaRef , mentionTokensWithContext , syncMentionState , syncSlashState ] )
10351098
10361099 const handleInput = useCallback (
@@ -1230,12 +1293,8 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
12301293 elements . push (
12311294 < span key = { `mention-${ i } -${ range . start } -${ range . end } ` } >
12321295 < span className = 'relative' >
1233- { /* Spacer reserves the real trigger glyph's width so the overlay's
1234- advance matches the transparent textarea char-for-char —
1235- hardcoding a width here would drift the text. For '@' the glyph is
1236- ~1em; skill chips store an EM SPACE sentinel (SKILL_CHIP_TRIGGER)
1237- in place of the narrow '/' so the centered 12px icon fits its slot
1238- exactly like '@' does. */ }
1296+ { /* Invisible trigger glyph keeps the overlay's advance identical to
1297+ the transparent textarea; the icon centers over its slot. */ }
12391298 < span className = 'invisible' > { range . token . charAt ( 0 ) } </ span >
12401299 { mentionIconNode }
12411300 </ span >
@@ -1274,16 +1333,8 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
12741333 onRemoveFile = { handleRemoveFile }
12751334 />
12761335
1277- { /* Clip the absolutely-positioned mirror overlay to the textarea's box.
1278- The overlay is `h-auto`, so its content (e.g. the trailing-newline
1279- sentinel on Shift+Enter) can exceed the textarea height and would
1280- otherwise paint over the toolbar below. */ }
1281- < div className = 'relative overflow-hidden' >
1282- < div
1283- ref = { overlayRef }
1284- className = { cn ( OVERLAY_CLASSES , isInitialView ? 'max-h-[30vh]' : 'max-h-[200px]' ) }
1285- aria-hidden = 'true'
1286- >
1336+ < div className = 'relative' >
1337+ < div ref = { overlayRef } className = { OVERLAY_CLASSES } aria-hidden = 'true' >
12871338 { overlayContent }
12881339 </ div >
12891340
0 commit comments