'use client' import React, { useCallback, useEffect, useRef, useState } from 'react' import TextareaAutosize from 'react-textarea-autosize' import { IconCheck as Check, IconChevronDown as ChevronDown, IconChevronUp as ChevronUp, IconCopy as Copy, IconPencil as Pencil } from '@tabler/icons-react' import { cn } from '@/lib/utils' import { stripMarkdownText } from '@/lib/utils/markdown' import { Button } from './ui/button' import { CollapsibleMessage } from './collapsible-message' import { PastedContentCard, UrlChip } from './pasted-parts' // Legacy: old messages carry pasted material wrapped in tags // (new messages use `data-pastedContent` parts). Split it out so we can render // it as a collapsed card and keep the instruction as the prominent text. const PASTED_RE = /\n?([\s\S]*?)\n?<\/user-content>/g function splitPastedContent(content: string): { cards: string[] rest: string } { const cards: string[] = [] const rest = content .replace(PASTED_RE, (_, body: string) => { cards.push(body) return '' }) .trim() return { cards, rest } } interface UserTextSectionProps { content: string // Pasted attachments (new data parts), placed below the instruction. pastedTexts?: string[] quotedContexts?: string[] noteContexts?: { title?: string; text: string }[] urls?: string[] messageId?: string onUpdateMessage?: (messageId: string, newContent: string) => Promise } export const UserTextSection: React.FC = ({ content, pastedTexts = [], quotedContexts = [], noteContexts = [], urls = [], messageId, onUpdateMessage }) => { // Legacy cards live inside the text; new pasted texts arrive as props. const { cards: legacyCards, rest } = splitPastedContent(content) const cards = [...legacyCards, ...pastedTexts] const [isEditing, setIsEditing] = useState(false) const [editedContent, setEditedContent] = useState(rest) const [isComposing, setIsComposing] = useState(false) const [enterDisabled, setEnterDisabled] = useState(false) const [copied, setCopied] = useState(false) const [isExpanded, setIsExpanded] = useState(false) const [isClamped, setIsClamped] = useState(false) const enterResetTimeoutRef = useRef(null) const contentRef = useCallback((node: HTMLDivElement | null) => { if (node) { setIsClamped(node.scrollHeight > node.clientHeight) } }, []) useEffect(() => { return () => { if (enterResetTimeoutRef.current) { clearTimeout(enterResetTimeoutRef.current) } } }, []) const handleCopyClick = async (e: React.MouseEvent) => { e.stopPropagation() try { await navigator.clipboard.writeText(content) setCopied(true) setTimeout(() => setCopied(false), 2000) } catch { // Clipboard access denied — silently ignore } } const handleEditClick = (e: React.MouseEvent) => { e.stopPropagation() setEditedContent(rest) setIsEditing(true) } const handleCancelClick = () => { setIsEditing(false) } const handleSaveClick = async () => { if (!onUpdateMessage || !messageId) return setIsEditing(false) // Re-wrap only the legacy text-embedded cards; new pasted texts are // separate parts and are not part of the editable text. const wrapped = [ ...legacyCards.map(c => `\n${c}\n`), editedContent ] .filter(s => s && s.trim()) .join('\n\n') try { await onUpdateMessage(messageId, wrapped) } catch (error) { console.error('Failed to save message:', error) } } const handleTextareaKeyDown = ( event: React.KeyboardEvent ) => { if (event.key !== 'Enter') { return } // Any modifier (Shift / Alt / Meta / Ctrl) + Enter → let it insert a // newline instead of submitting the edit. // nativeEvent.isComposing catches the IME candidate-confirm Enter even // after React-level isComposing has flipped. if ( event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || isComposing || (event.nativeEvent as KeyboardEvent).isComposing || enterDisabled ) { // Alt+Enter on macOS does not insert \n by default; do it manually. if (event.altKey && !event.shiftKey && !event.metaKey && !event.ctrlKey) { event.preventDefault() const textarea = event.target as HTMLTextAreaElement const start = textarea.selectionStart ?? editedContent.length const end = textarea.selectionEnd ?? editedContent.length const next = editedContent.slice(0, start) + '\n' + editedContent.slice(end) setEditedContent(next) requestAnimationFrame(() => { textarea.selectionStart = textarea.selectionEnd = start + 1 }) } return } event.preventDefault() void handleSaveClick() } const handleCompositionStart = () => { setIsComposing(true) } const handleCompositionEnd = () => { setIsComposing(false) setEnterDisabled(true) if (enterResetTimeoutRef.current) { clearTimeout(enterResetTimeoutRef.current) } enterResetTimeoutRef.current = setTimeout(() => { setEnterDisabled(false) enterResetTimeoutRef.current = null }, 50) } // Pasted attachments (cards + URL chips), placed below the instruction. const attachments = (cards.length > 0 || quotedContexts.length > 0 || noteContexts.length > 0 || urls.length > 0) && (
{cards.map((c, i) => ( ))} {quotedContexts.map((c, i) => ( ))} {noteContexts.map((note, i) => ( ))} {urls.map((u, i) => ( ))}
) return (
{isEditing ? (
setEditedContent(e.target.value)} autoFocus onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} onKeyDown={handleTextareaKeyDown} className="resize-none flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50" minRows={2} maxRows={10} /> {attachments}
) : (
{rest}
{(isClamped || isExpanded) && ( )} {attachments}
)}
) }