|
1 | | -import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' |
| 1 | +import { |
| 2 | + useCallback, |
| 3 | + useEffect, |
| 4 | + useImperativeHandle, |
| 5 | + useLayoutEffect, |
| 6 | + useMemo, |
| 7 | + useRef, |
| 8 | + useState, |
| 9 | +} from 'react' |
2 | 10 | import { isEqual } from 'lodash' |
3 | 11 | import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react' |
4 | 12 | import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn' |
@@ -382,93 +390,138 @@ export function MessagesInput({ |
382 | 390 | textareaRefs.current[fieldId]?.focus() |
383 | 391 | }, []) |
384 | 392 |
|
385 | | - const autoResizeTextarea = useCallback((fieldId: string) => { |
| 393 | + const syncOverlay = useCallback((fieldId: string) => { |
386 | 394 | const textarea = textareaRefs.current[fieldId] |
387 | | - if (!textarea) return |
388 | 395 | const overlay = overlayRefs.current[fieldId] |
| 396 | + if (!textarea || !overlay) return |
389 | 397 |
|
390 | | - // If user has manually resized, respect their chosen height and only sync overlay. |
391 | | - if (userResizedRef.current[fieldId]) { |
392 | | - const currentHeight = |
393 | | - textarea.offsetHeight || Number.parseFloat(textarea.style.height) || MIN_TEXTAREA_HEIGHT_PX |
394 | | - const clampedHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, currentHeight) |
395 | | - textarea.style.height = `${clampedHeight}px` |
| 398 | + overlay.style.width = `${textarea.clientWidth}px` |
| 399 | + overlay.scrollTop = textarea.scrollTop |
| 400 | + overlay.scrollLeft = textarea.scrollLeft |
| 401 | + }, []) |
| 402 | + |
| 403 | + const autoResizeTextarea = useCallback( |
| 404 | + (fieldId: string) => { |
| 405 | + const textarea = textareaRefs.current[fieldId] |
| 406 | + const overlay = overlayRefs.current[fieldId] |
| 407 | + if (!textarea) return |
| 408 | + |
| 409 | + if (!textarea.value.trim()) { |
| 410 | + userResizedRef.current[fieldId] = false |
| 411 | + } |
| 412 | + |
| 413 | + if (userResizedRef.current[fieldId]) { |
| 414 | + if (overlay) { |
| 415 | + overlay.style.height = `${textarea.offsetHeight}px` |
| 416 | + } |
| 417 | + syncOverlay(fieldId) |
| 418 | + return |
| 419 | + } |
| 420 | + |
| 421 | + textarea.style.height = 'auto' |
| 422 | + const scrollHeight = textarea.scrollHeight |
| 423 | + const height = Math.min( |
| 424 | + MAX_TEXTAREA_HEIGHT_PX, |
| 425 | + Math.max(MIN_TEXTAREA_HEIGHT_PX, scrollHeight) |
| 426 | + ) |
| 427 | + |
| 428 | + textarea.style.height = `${height}px` |
396 | 429 | if (overlay) { |
397 | | - overlay.style.height = `${clampedHeight}px` |
| 430 | + overlay.style.height = `${height}px` |
398 | 431 | } |
399 | | - return |
400 | | - } |
401 | 432 |
|
402 | | - textarea.style.height = 'auto' |
403 | | - const naturalHeight = textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX |
404 | | - const nextHeight = Math.min( |
405 | | - MAX_TEXTAREA_HEIGHT_PX, |
406 | | - Math.max(MIN_TEXTAREA_HEIGHT_PX, naturalHeight) |
407 | | - ) |
408 | | - textarea.style.height = `${nextHeight}px` |
| 433 | + syncOverlay(fieldId) |
| 434 | + }, |
| 435 | + [syncOverlay] |
| 436 | + ) |
409 | 437 |
|
410 | | - if (overlay) { |
411 | | - overlay.style.height = `${nextHeight}px` |
412 | | - } |
413 | | - }, []) |
| 438 | + const handleResizeStart = useCallback( |
| 439 | + (fieldId: string, e: React.MouseEvent<HTMLDivElement>) => { |
| 440 | + e.preventDefault() |
| 441 | + e.stopPropagation() |
414 | 442 |
|
415 | | - const handleResizeStart = useCallback((fieldId: string, e: React.MouseEvent<HTMLDivElement>) => { |
416 | | - e.preventDefault() |
417 | | - e.stopPropagation() |
| 443 | + const textarea = textareaRefs.current[fieldId] |
| 444 | + if (!textarea) return |
418 | 445 |
|
419 | | - const textarea = textareaRefs.current[fieldId] |
420 | | - if (!textarea) return |
| 446 | + const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX |
421 | 447 |
|
422 | | - const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX |
| 448 | + isResizingRef.current = true |
| 449 | + resizeStateRef.current = { |
| 450 | + fieldId, |
| 451 | + startY: e.clientY, |
| 452 | + startHeight, |
| 453 | + } |
423 | 454 |
|
424 | | - isResizingRef.current = true |
425 | | - resizeStateRef.current = { |
426 | | - fieldId, |
427 | | - startY: e.clientY, |
428 | | - startHeight, |
429 | | - } |
| 455 | + const handleMouseMove = (moveEvent: MouseEvent) => { |
| 456 | + if (!isResizingRef.current || !resizeStateRef.current) return |
430 | 457 |
|
431 | | - const handleMouseMove = (moveEvent: MouseEvent) => { |
432 | | - if (!isResizingRef.current || !resizeStateRef.current) return |
| 458 | + const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current |
| 459 | + const deltaY = moveEvent.clientY - startY |
| 460 | + const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY) |
433 | 461 |
|
434 | | - const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current |
435 | | - const deltaY = moveEvent.clientY - startY |
436 | | - const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY) |
| 462 | + const activeTextarea = textareaRefs.current[activeFieldId] |
| 463 | + const overlay = overlayRefs.current[activeFieldId] |
437 | 464 |
|
438 | | - const activeTextarea = textareaRefs.current[activeFieldId] |
439 | | - if (activeTextarea) { |
440 | | - activeTextarea.style.height = `${nextHeight}px` |
441 | | - } |
| 465 | + if (activeTextarea) { |
| 466 | + activeTextarea.style.height = `${nextHeight}px` |
| 467 | + } |
442 | 468 |
|
443 | | - const overlay = overlayRefs.current[activeFieldId] |
444 | | - if (overlay) { |
445 | | - overlay.style.height = `${nextHeight}px` |
| 469 | + if (overlay) { |
| 470 | + overlay.style.height = `${nextHeight}px` |
| 471 | + if (activeTextarea) { |
| 472 | + overlay.scrollTop = activeTextarea.scrollTop |
| 473 | + overlay.scrollLeft = activeTextarea.scrollLeft |
| 474 | + } |
| 475 | + } |
446 | 476 | } |
447 | | - } |
448 | 477 |
|
449 | | - const handleMouseUp = () => { |
450 | | - if (resizeStateRef.current) { |
451 | | - const { fieldId: activeFieldId } = resizeStateRef.current |
452 | | - userResizedRef.current[activeFieldId] = true |
453 | | - } |
| 478 | + const handleMouseUp = () => { |
| 479 | + if (resizeStateRef.current) { |
| 480 | + const { fieldId: activeFieldId } = resizeStateRef.current |
| 481 | + userResizedRef.current[activeFieldId] = true |
| 482 | + syncOverlay(activeFieldId) |
| 483 | + } |
454 | 484 |
|
455 | | - isResizingRef.current = false |
456 | | - resizeStateRef.current = null |
457 | | - document.removeEventListener('mousemove', handleMouseMove) |
458 | | - document.removeEventListener('mouseup', handleMouseUp) |
459 | | - } |
| 485 | + isResizingRef.current = false |
| 486 | + resizeStateRef.current = null |
| 487 | + document.removeEventListener('mousemove', handleMouseMove) |
| 488 | + document.removeEventListener('mouseup', handleMouseUp) |
| 489 | + } |
460 | 490 |
|
461 | | - document.addEventListener('mousemove', handleMouseMove) |
462 | | - document.addEventListener('mouseup', handleMouseUp) |
463 | | - }, []) |
| 491 | + document.addEventListener('mousemove', handleMouseMove) |
| 492 | + document.addEventListener('mouseup', handleMouseUp) |
| 493 | + }, |
| 494 | + [syncOverlay] |
| 495 | + ) |
464 | 496 |
|
465 | | - useEffect(() => { |
| 497 | + useLayoutEffect(() => { |
466 | 498 | currentMessages.forEach((_, index) => { |
467 | | - const fieldId = `message-${index}` |
468 | | - autoResizeTextarea(fieldId) |
| 499 | + autoResizeTextarea(`message-${index}`) |
469 | 500 | }) |
470 | 501 | }, [currentMessages, autoResizeTextarea]) |
471 | 502 |
|
| 503 | + useEffect(() => { |
| 504 | + const observers: ResizeObserver[] = [] |
| 505 | + |
| 506 | + for (let i = 0; i < currentMessages.length; i++) { |
| 507 | + const fieldId = `message-${i}` |
| 508 | + const textarea = textareaRefs.current[fieldId] |
| 509 | + const overlay = overlayRefs.current[fieldId] |
| 510 | + |
| 511 | + if (textarea && overlay) { |
| 512 | + const observer = new ResizeObserver(() => { |
| 513 | + overlay.style.width = `${textarea.clientWidth}px` |
| 514 | + }) |
| 515 | + observer.observe(textarea) |
| 516 | + observers.push(observer) |
| 517 | + } |
| 518 | + } |
| 519 | + |
| 520 | + return () => { |
| 521 | + observers.forEach((observer) => observer.disconnect()) |
| 522 | + } |
| 523 | + }, [currentMessages.length]) |
| 524 | + |
472 | 525 | return ( |
473 | 526 | <div className='flex w-full flex-col gap-[10px]'> |
474 | 527 | {currentMessages.map((message, index) => ( |
@@ -621,19 +674,15 @@ export function MessagesInput({ |
621 | 674 | </div> |
622 | 675 |
|
623 | 676 | {/* Content Input with overlay for variable highlighting */} |
624 | | - <div className='relative w-full'> |
| 677 | + <div className='relative w-full overflow-hidden'> |
625 | 678 | <textarea |
626 | 679 | ref={(el) => { |
627 | 680 | textareaRefs.current[fieldId] = el |
628 | 681 | }} |
629 | | - className='allow-scroll box-border min-h-[80px] w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-sm text-transparent leading-[inherit] caret-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed' |
630 | | - rows={3} |
| 682 | + className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden' |
631 | 683 | placeholder='Enter message content...' |
632 | 684 | value={message.content} |
633 | | - onChange={(e) => { |
634 | | - fieldHandlers.onChange(e) |
635 | | - autoResizeTextarea(fieldId) |
636 | | - }} |
| 685 | + onChange={fieldHandlers.onChange} |
637 | 686 | onKeyDown={(e) => { |
638 | 687 | if (e.key === 'Tab' && !isPreview && !disabled) { |
639 | 688 | e.preventDefault() |
@@ -670,12 +719,13 @@ export function MessagesInput({ |
670 | 719 | ref={(el) => { |
671 | 720 | overlayRefs.current[fieldId] = el |
672 | 721 | }} |
673 | | - className='scrollbar-none pointer-events-none absolute top-0 left-0 box-border w-full overflow-auto whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-[var(--text-primary)] text-sm leading-[inherit]' |
| 722 | + className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' |
674 | 723 | > |
675 | 724 | {formatDisplayText(message.content, { |
676 | 725 | accessiblePrefixes, |
677 | 726 | highlightAll: !accessiblePrefixes, |
678 | 727 | })} |
| 728 | + {message.content.endsWith('\n') && '\u200B'} |
679 | 729 | </div> |
680 | 730 |
|
681 | 731 | {/* Env var dropdown for this message */} |
@@ -705,7 +755,7 @@ export function MessagesInput({ |
705 | 755 |
|
706 | 756 | {!isPreview && !disabled && ( |
707 | 757 | <div |
708 | | - className='absolute right-1 bottom-1 flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]' |
| 758 | + className='absolute right-1 bottom-1 z-[3] flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]' |
709 | 759 | onMouseDown={(e) => handleResizeStart(fieldId, e)} |
710 | 760 | onDragStart={(e) => { |
711 | 761 | e.preventDefault() |
|
0 commit comments