Skip to content

Commit 2e0aacd

Browse files
committed
fix(user-input): atomic chip selection, modifier-key handling, and stale overlay ghost
1 parent c90a1eb commit 2e0aacd

2 files changed

Lines changed: 97 additions & 33 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,22 +43,35 @@ export interface PlusMenuHandle {
4343
selectActive: () => boolean
4444
}
4545

46+
/**
47+
* Box and typography shared by the textarea and its mirror overlay — both must
48+
* produce identical line wrapping so the overlay text sits exactly over the
49+
* (transparent) textarea text.
50+
*/
51+
const FIELD_MIRROR_CLASSES = cn(
52+
'm-0 box-border min-h-[24px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent',
53+
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]'
54+
)
55+
4656
export const TEXTAREA_BASE_CLASSES = cn(
47-
'm-0 box-border h-auto min-h-[24px] w-full resize-none',
48-
'overflow-y-auto overflow-x-hidden break-words [overflow-wrap:anywhere] border-0 bg-transparent',
49-
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]',
57+
FIELD_MIRROR_CLASSES,
58+
'h-auto resize-none overflow-y-auto overflow-x-hidden',
5059
'text-transparent caret-[var(--text-primary)] outline-none',
5160
'placeholder:font-[380] placeholder:text-[var(--text-subtle)]',
5261
'focus-visible:ring-0 focus-visible:ring-offset-0',
5362
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
5463
)
5564

65+
/**
66+
* Pinned to the textarea's box (`inset-0`) and clipped (`overflow-hidden`) so
67+
* stale paints can never escape the input. Not a scroll container — it mirrors
68+
* the textarea's scroll position via programmatic `scrollTop`, which works on
69+
* `overflow: hidden` boxes.
70+
*/
5671
export const OVERLAY_CLASSES = cn(
57-
'pointer-events-none absolute top-0 left-0 m-0 box-border h-auto w-full resize-none',
58-
'overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words [overflow-wrap:anywhere] border-0 bg-transparent',
59-
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]',
60-
'text-[var(--text-primary)] outline-none',
61-
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
72+
FIELD_MIRROR_CLASSES,
73+
'pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap',
74+
'text-[var(--text-primary)]'
6275
)
6376

6477
export const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors'

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)