diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/chip-selection.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/chip-selection.test.ts new file mode 100644 index 0000000000..f3f1d90342 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/chip-selection.test.ts @@ -0,0 +1,113 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type ChipBound, + type Selection, + snapSelectionToChips, +} from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection' + +// A chip occupying value indices [5, 12): e.g. " @Gmail " sitting at offset 5. +const CHIP: ChipBound = { start: 5, end: 12 } + +const at = (pos: number): Selection => ({ start: pos, end: pos }) + +describe('snapSelectionToChips', () => { + describe('collapsed caret', () => { + it('leaves a caret outside any chip untouched', () => { + expect(snapSelectionToChips(at(3), at(3), undefined, undefined)).toEqual(at(3)) + }) + + it('snaps a caret in the chip near the start to the start edge', () => { + expect(snapSelectionToChips(at(6), at(6), CHIP, CHIP)).toEqual(at(5)) + }) + + it('snaps a caret in the chip near the end to the end edge', () => { + expect(snapSelectionToChips(at(11), at(11), CHIP, CHIP)).toEqual(at(12)) + }) + + it('snaps the exact midpoint to the start edge (ties favor start)', () => { + // distance to start (8.5-5=3.5) equals distance to end (12-8.5=3.5) only at + // 8.5; integer midpoint 8 is closer to start. + expect(snapSelectionToChips(at(8), at(8), CHIP, CHIP)).toEqual(at(5)) + }) + + it('does not snap a caret resting exactly on an edge (edge is not "inside")', () => { + // findRangeContaining is strict, so an edge caret has no containing chip. + expect(snapSelectionToChips(at(5), at(5), undefined, undefined)).toEqual(at(5)) + expect(snapSelectionToChips(at(12), at(12), undefined, undefined)).toEqual(at(12)) + }) + }) + + describe('ranged — fresh selection (both edges differ from prev)', () => { + it('expands a start edge inside a chip outward to the chip start', () => { + // select-all-like: prev was a caret at 20, new selection 8..30 grew both edges. + const out = snapSelectionToChips({ start: 8, end: 30 }, at(20), CHIP, undefined) + expect(out).toEqual({ start: 5, end: 30 }) + }) + + it('expands an end edge inside a chip outward to the chip end', () => { + const out = snapSelectionToChips({ start: 0, end: 9 }, at(20), undefined, CHIP) + expect(out).toEqual({ start: 0, end: 12 }) + }) + + it('expands both edges when each lands in a (different) chip', () => { + const chipB: ChipBound = { start: 20, end: 27 } + const out = snapSelectionToChips({ start: 8, end: 23 }, at(40), CHIP, chipB) + expect(out).toEqual({ start: 5, end: 27 }) + }) + }) + + describe('ranged — single moved edge (keyboard extend / shrink)', () => { + it('growing the end edge into a chip absorbs the whole chip', () => { + // prev 0..6, end moved 6 -> 9 (grew); start unchanged. + const out = snapSelectionToChips({ start: 0, end: 9 }, { start: 0, end: 6 }, undefined, CHIP) + expect(out).toEqual({ start: 0, end: 12 }) + }) + + it('shrinking the end edge out of a chip releases the whole chip', () => { + // prev 0..14, end moved 14 -> 9 (shrank) into the chip; release to chip start. + const out = snapSelectionToChips({ start: 0, end: 9 }, { start: 0, end: 14 }, undefined, CHIP) + expect(out).toEqual({ start: 0, end: 5 }) + }) + + it('growing the start edge leftward into a chip absorbs the whole chip', () => { + // prev 9..20, start moved 9 -> 6 (grew leftward, start < prev.start). + const out = snapSelectionToChips( + { start: 6, end: 20 }, + { start: 9, end: 20 }, + CHIP, + undefined + ) + expect(out).toEqual({ start: 5, end: 20 }) + }) + + it('shrinking the start edge rightward into a chip releases the whole chip', () => { + // prev 6..20, start moved 6 -> 9 (shrank rightward, start > prev.start) → chip end. + const out = snapSelectionToChips( + { start: 9, end: 20 }, + { start: 6, end: 20 }, + CHIP, + undefined + ) + expect(out).toEqual({ start: 12, end: 20 }) + }) + }) + + describe('selection contained within one chip', () => { + it('clamps to a collapsed caret rather than inverting', () => { + // Both edges inside CHIP via a fresh selection: start→5, end→12 stays ordered. + // Construct an inverting case: a shrink where start snaps to 12 and end to 5. + const out = snapSelectionToChips({ start: 7, end: 9 }, { start: 5, end: 9 }, CHIP, CHIP) + expect(out.start).toBeLessThanOrEqual(out.end) + }) + }) + + describe('no chips', () => { + it('returns the selection unchanged', () => { + const sel = { start: 2, end: 18 } + expect(snapSelectionToChips(sel, at(0), undefined, undefined)).toEqual(sel) + }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/chip-selection.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/chip-selection.ts new file mode 100644 index 0000000000..f6dba9a583 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/chip-selection.ts @@ -0,0 +1,69 @@ +/** + * Pure selection-snapping math for the mention-chip textarea, extracted from the + * `onSelect` handler so it can be unit-tested in isolation from DOM I/O. + * + * A mention chip occupies a contiguous `[start, end)` span of the textarea + * value. The UI treats each chip as atomic: a selection edge may never land + * strictly inside a chip. This function maps an observed selection to the + * nearest valid one. + */ + +/** A half-open chip span `[start, end)` in textarea-value coordinates. */ +export interface ChipBound { + start: number + end: number +} + +/** A selection or caret as reported by `selectionStart`/`selectionEnd`. */ +export interface Selection { + start: number + end: number +} + +/** + * Snaps a selection so no edge sits inside a chip. + * + * - **Collapsed caret inside a chip** → nearest chip edge. + * - **Ranged selection** → each edge inside a chip snaps to a boundary without + * collapsing the range. A lone moved edge (keyboard extend/shrink, drag) snaps + * in its direction of travel — growing absorbs the chip, shrinking releases + * it; a fresh selection (double-click, select-all) expands outward. The two + * paths differ only for a shrinking edge, so the gesture inference is safe + * even when a fresh selection happens to share an edge with `prev`. + * + * @param sel - The observed selection. + * @param prev - The previously observed selection, used to infer which edge moved. + * @param startChip - The chip containing `sel.start`, if any. + * @param endChip - The chip containing `sel.end`, if any. + * @returns The snapped selection (equal to `sel` when no edge is inside a chip). + */ +export function snapSelectionToChips( + sel: Selection, + prev: Selection, + startChip: ChipBound | undefined, + endChip: ChipBound | undefined +): Selection { + const { start, end } = sel + + if (start === end) { + if (!startChip) return sel + const nearest = + start - startChip.start < startChip.end - start ? startChip.start : startChip.end + return { start: nearest, end: nearest } + } + + const singleEdgeMoved = (start !== prev.start) !== (end !== prev.end) + + let newStart = startChip + ? singleEdgeMoved && start > prev.start + ? startChip.end + : startChip.start + : start + const newEnd = endChip ? (singleEdgeMoved && end < prev.end ? endChip.start : endChip.end) : end + + // A selection contained within a single chip snaps both edges; clamp so it + // collapses to a caret rather than inverting. + if (newStart > newEnd) newStart = newEnd + + return { start: newStart, end: newEnd } +} 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..3d62bcdc11 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 @@ -20,6 +20,7 @@ import { cn } from '@/lib/core/utils/cn' import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' +import { snapSelectionToChips } from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection' import type { PlusMenuHandle, SkillsMenuHandle, @@ -27,16 +28,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 +171,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 +479,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 +739,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 +826,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 +842,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 +877,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 +1038,70 @@ export const UserInput = forwardRef(function Us ] ) + // Selection one change ago, used to infer which edge of a range moved. Kept + // current by the `selectionchange` listener below — which fires on EVERY + // caret/selection change (typing, arrows, clicks, programmatic), unlike + // `select`/`mouseup` — so the inference is never fed a stale `prev`. + const prevSelectionRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 }) + + useEffect(() => { + const textarea = textareaRef.current + if (!textarea) return + let last = { start: 0, end: 0 } + const onSelectionChange = () => { + if (document.activeElement !== textarea) return + prevSelectionRef.current = last + last = { start: textarea.selectionStart ?? 0, end: textarea.selectionEnd ?? 0 } + } + document.addEventListener('selectionchange', onSelectionChange) + return () => document.removeEventListener('selectionchange', onSelectionChange) + }, [textareaRef]) + + /** + * 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 = prevSelectionRef.current + + // Adopt value changes that bypassed React's change tracking (browser + // autofill, password managers, grammar extensions — see facebook/react#2125) + // so state never drifts from the DOM. The render rebuilds the overlay and + // selection logic resumes on the next event. + if (textarea.value !== valueRef.current) { + adoptDomValue(textarea) + return + } + + const startChip = mentionTokensWithContext.findRangeContaining(start) + const endChip = start === end ? startChip : mentionTokensWithContext.findRangeContaining(end) + const snapped = snapSelectionToChips({ start, end }, prev, startChip, endChip) + + if (snapped.start !== start || snapped.end !== end) { + // Deferred so in-flight click/drag processing can't override the write, + // and bailed if the selection moved again first. The write re-fires this + // handler, which then syncs the menus. setTimeout(() => { - textarea.setSelectionRange(snapPos, snapPos) + if (textarea.selectionStart !== start || textarea.selectionEnd !== end) return + textarea.setSelectionRange( + snapped.start, + snapped.end, + 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 +1187,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 +1282,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 +1322,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. */} -
-