diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 3cf2bcc2a4..76cb3079af 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -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' ) @@ -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, 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` diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts index 9b1c2d4d3e..9ea5a44d42 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index baa7b2683c..53e62c05a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -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(function Us }) const valueRef = useRef(value) valueRef.current = value - const overlayRef = useRef(null) const plusMenuRef = useRef(null) const skillsMenuRef = useRef(null) @@ -480,13 +478,11 @@ export const UserInput = forwardRef(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(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) => { if (mentionRangeRef.current && !e.nativeEvent.isComposing) { @@ -819,8 +825,9 @@ export const UserInput = forwardRef(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(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(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(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 + // 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 + 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) => { - 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) => { const textarea = e.currentTarget @@ -1129,12 +1196,6 @@ export const UserInput = forwardRef(function Us filesRef.current.processFiles(dt.files) }, []) - const handleScroll = useCallback((e: React.UIEvent) => { - 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(function Us elements.push( - {/* 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. */} {range.token.charAt(0)} {mentionIconNode} @@ -1274,35 +1331,30 @@ export const UserInput = forwardRef(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. */} -
-