Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,43 @@ export interface PlusMenuHandle {
selectActive: () => boolean
}

/**
* Box and typography shared by the textarea and its mirror overlay — both must
* produce identical line wrapping so the overlay text sits exactly over the
* (transparent) textarea text.
*/
const FIELD_MIRROR_CLASSES = cn(
'm-0 box-border min-h-[24px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent',
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]'
)

/**
* The textarea grows to its full content height (`h-auto`, no internal scroll);
* the shared scroller clips and scrolls it. Its text is transparent so the
* mirror overlay shows through; only the caret paints.
*/
export const TEXTAREA_BASE_CLASSES = cn(
'm-0 box-border h-auto min-h-[24px] w-full resize-none',
'overflow-y-auto overflow-x-hidden break-words [overflow-wrap:anywhere] border-0 bg-transparent',
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]',
FIELD_MIRROR_CLASSES,
'block h-auto resize-none overflow-hidden',
'text-transparent caret-[var(--text-primary)] outline-none',
'placeholder:font-[380] placeholder:text-[var(--text-subtle)]',
'focus-visible:ring-0 focus-visible:ring-offset-0',
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
'focus-visible:ring-0 focus-visible:ring-offset-0'
)

/**
* Pinned over the full-height textarea (`inset-0` of the sizer). Both are flow
* children of the same scroller, so they scroll together natively — no JS
* scroll-sync, so the caret and mirrored text never drift apart.
*/
export const OVERLAY_CLASSES = cn(
'pointer-events-none absolute top-0 left-0 m-0 box-border h-auto w-full resize-none',
'overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words [overflow-wrap:anywhere] border-0 bg-transparent',
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]',
'text-[var(--text-primary)] outline-none',
FIELD_MIRROR_CLASSES,
'pointer-events-none absolute inset-0 whitespace-pre-wrap',
'text-[var(--text-primary)]'
)

/** Single scroll container for the textarea + overlay; caps height and hides its scrollbar. */
export const SCROLLER_CLASSES = cn(
'relative overflow-y-auto overflow-x-hidden',
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
)

Expand All @@ -66,15 +88,8 @@ export const SEND_BUTTON_ACTIVE =
'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
export const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'

export const MAX_CHAT_TEXTAREA_HEIGHT = 200
export const SPEECH_RECOGNITION_LANG = 'en-US'

export function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>, maxHeight: number) {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px`
}

/**
* Maps a {@link MothershipResource} (resource-picker domain) to a
* {@link ChatContext} (chat-input domain). Keyed by `MothershipResourceType`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@ export type {
WindowWithSpeech,
} from './constants'
export {
autoResizeTextarea,
MAX_CHAT_TEXTAREA_HEIGHT,
mapResourceToContext,
OVERLAY_CLASSES,
SCROLLER_CLASSES,
SPEECH_RECOGNITION_LANG,
TEXTAREA_BASE_CLASSES,
} from './constants'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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'
Expand All @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Comment thread
waleedlatif1 marked this conversation as resolved.
// 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale selection ref after typing

Medium Severity

lastSelectionRef is only refreshed in handleSelectAdjust on select/mouseup, but caret moves from ordinary typing do not fire those events. The ranged chip snap logic then compares the new selection to a prev from an earlier click or from before submit, so the first Shift+arrow (or similar) grow/shrink after typing can mis-classify the gesture and snap chip edges to the wrong boundary.

Fix in Cursor Fix in Web

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
Comment thread
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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'>
Expand Down
Loading