import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type RefObject, } from 'react' import Block from '../../Primitives/Block/Block' import TooltipV2 from '../../TooltipV2/TooltipV2' import type { ChatInputV2AttachedFile } from './ChatInputV2.types' import { useClickOutside } from '../../../hooks/useClickOutside' import { useResizeObserver } from '../../../hooks/useResizeObserver' import type { CSSObject } from 'styled-components' import AttachmentDropdownV2 from './AttachmentDropdown' import type { ChatInputV2TokensType } from './ChatInputV2.tokens' import ChatInputTagV2 from './ChatInputTagV2' import { ATTACHMENT_ROW_GAP_PX, computeAttachmentRowCutoff, isOuterWidthExpanding, isSignificantOuterWidthChange, OVERFLOW_MENU_TRIGGER_CLASS, reduceCutoffForFileCountChange, removePxFromValue, shouldExpandCutoffToMeasureAllChips, sliceOverflowChatInputV2AttachedFiles, sliceVisibleChatInputV2AttachedFiles, truncateFileNameForTag, } from './utils' import { ButtonV2 } from '../../ButtonV2' import { ButtonV2Size, ButtonV2Type, ButtonV2SubType, } from '../../ButtonV2/buttonV2.types' import { PlusIcon } from '@phosphor-icons/react' export type ChatInputV2AttachmentRowProps = { attachedFiles: ChatInputV2AttachedFile[] onFileRemove: (fileId: string) => void onFileClick: (file: ChatInputV2AttachedFile) => void outerContainerRef: RefObject gap: NonNullable tokens: ChatInputV2TokensType } export default function ChatInputV2AttachmentRow({ tokens, attachedFiles, onFileRemove, onFileClick, outerContainerRef, gap, }: ChatInputV2AttachmentRowProps) { const [cutOffIndex, setCutOffIndex] = useState(attachedFiles.length) const [overflowMenuOpen, setOverflowMenuOpen] = useState(false) const isExpanding = useRef(false) const filesContainerRef = useRef(null) const lastWidthRef = useRef(0) const prevAttachedCountRef = useRef(attachedFiles.length) const layoutResizeRafRef = useRef(undefined) const generatedId = useId() const filesRegionId = `chat-input-v2-files-${generatedId}` const overflowListId = `${filesRegionId}-overflow-list` const overflowMenuContainerRef = useRef(null) const visibleFiles = useMemo( () => sliceVisibleChatInputV2AttachedFiles(attachedFiles, cutOffIndex), [attachedFiles, cutOffIndex] ) const hiddenFiles = useMemo( () => sliceOverflowChatInputV2AttachedFiles(attachedFiles, cutOffIndex), [attachedFiles, cutOffIndex] ) const hasOverflow = hiddenFiles.length > 0 const rowGapPx = useMemo(() => { const n = removePxFromValue(gap as string | number) return Number.isFinite(n) && n >= 0 ? n : ATTACHMENT_ROW_GAP_PX }, [gap]) const handleResize = useCallback(() => { const container = filesContainerRef.current if (!container || attachedFiles.length === 0) { isExpanding.current = false return } const fileChipCount = Array.from(container.children).filter( (c) => !c.classList.contains(OVERFLOW_MENU_TRIGGER_CLASS) ).length if ( shouldExpandCutoffToMeasureAllChips( isExpanding.current, fileChipCount, attachedFiles.length ) ) { setCutOffIndex(attachedFiles.length) isExpanding.current = false requestAnimationFrame(() => handleResize()) return } isExpanding.current = false setCutOffIndex( computeAttachmentRowCutoff({ filesContainer: container, attachedFileCount: attachedFiles.length, rowGapPx, }) ) }, [attachedFiles.length, rowGapPx]) const scheduleHandleResize = useCallback(() => { if (layoutResizeRafRef.current !== undefined) return layoutResizeRafRef.current = requestAnimationFrame(() => { layoutResizeRafRef.current = undefined handleResize() }) }, [handleResize]) useLayoutEffect(() => { const prev = prevAttachedCountRef.current const next = attachedFiles.length prevAttachedCountRef.current = next const updater = reduceCutoffForFileCountChange(prev, next) setCutOffIndex((c) => updater(c)) // After a removal, rendered chips can still be fewer than `attachedFiles.length` // until we expand — `computeAttachmentRowCutoff` only measures existing nodes. // Reuse the same “show all, then re-measure” pass as for widening the viewport. isExpanding.current = next < prev && next > 0 const id = requestAnimationFrame(() => handleResize()) return () => cancelAnimationFrame(id) }, [attachedFiles.length, handleResize]) const closeOverflowMenu = useCallback(() => { setOverflowMenuOpen(false) }, []) useClickOutside(overflowMenuContainerRef, closeOverflowMenu) useEffect(() => { if (!overflowMenuOpen) return const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() setOverflowMenuOpen(false) } } document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) }, [overflowMenuOpen]) useEffect(() => { if (!hasOverflow) setOverflowMenuOpen(false) }, [hasOverflow]) const onOuterResize = useCallback( ({ width }: DOMRectReadOnly) => { const prev = lastWidthRef.current if (!isSignificantOuterWidthChange(prev, width)) return if (isOuterWidthExpanding(prev, width)) { isExpanding.current = true } lastWidthRef.current = width scheduleHandleResize() }, [scheduleHandleResize] ) useResizeObserver( outerContainerRef as RefObject, onOuterResize ) useEffect(() => { return () => { if (layoutResizeRafRef.current !== undefined) { cancelAnimationFrame(layoutResizeRafRef.current) } } }, []) if (attachedFiles.length === 0) return null return ( {visibleFiles.map((file) => ( onFileRemove(file.id)} onFileClick={() => onFileClick(file)} /> ))} {hasOverflow && ( }} onClick={() => setOverflowMenuOpen((prevOpen) => !prevOpen) } /> {overflowMenuOpen && ( )} )} ) }