apps/chat/src/components/Chatbar/Conversation.tsx (324 lines of code) (raw):

import { IconCheck } from '@tabler/icons-react'; import { DragEvent, MouseEvent, useCallback, useMemo, useRef, useState, } from 'react'; import { useTranslation } from 'next-i18next'; import classNames from 'classnames'; import { hasInvalidNameInPath, isEntityNameInvalid, isEntityNameOrPathInvalid, } from '@/src/utils/app/common'; import { getEntityNameError } from '@/src/utils/app/errors'; import { isEntityIdExternal } from '@/src/utils/app/id'; import { hasParentWithFloatingOverlay } from '@/src/utils/app/modals'; import { MoveType, getDragImage } from '@/src/utils/app/move'; import { AdditionalItemData, FeatureType } from '@/src/types/common'; import { Translation } from '@/src/types/translation'; import { ConversationsActions, ConversationsSelectors, } from '@/src/store/conversations/conversations.reducers'; import { useAppDispatch, useAppSelector } from '@/src/store/hooks'; import { ModelsSelectors } from '@/src/store/models/models.reducers'; import { PublicationActions, PublicationSelectors, } from '@/src/store/publication/publication.reducers'; import { ConversationContextMenu } from '@/src/components/Chat/ConversationContextMenu'; import { PlaybackIcon } from '@/src/components/Chat/Playback/PlaybackIcon'; import { ReplayAsIsIcon } from '@/src/components/Chat/ReplayAsIsIcon'; import ShareIcon from '@/src/components/Common/ShareIcon'; import { ReviewDot } from '../Chat/Publish/ReviewDot'; import Tooltip from '../Common/Tooltip'; import { ModelIcon } from './ModelIcon'; import { ConversationInfo, PublishActions, UploadStatus, } from '@epam/ai-dial-shared'; interface ViewProps { conversation: ConversationInfo; isHighlighted: boolean; isChosen?: boolean; isSelectMode?: boolean; additionalItemData?: AdditionalItemData; isContextMenu: boolean; } export function ConversationView({ conversation, isHighlighted, isChosen = false, isSelectMode, additionalItemData, isContextMenu, }: ViewProps) { const { t } = useTranslation(Translation.Chat); const modelsMap = useAppSelector(ModelsSelectors.selectModelsMap); const selectedPublicationUrl = useAppSelector( PublicationSelectors.selectSelectedPublicationUrl, ); const resourceToReview = useAppSelector((state) => PublicationSelectors.selectResourceToReviewByReviewAndPublicationUrls( state, conversation.id, additionalItemData?.publicationUrl, ), ); const selectedConversationIds = useAppSelector( ConversationsSelectors.selectSelectedConversationsIds, ); const handleToggle = useCallback(() => { ConversationsActions.setChosenConversations({ ids: [conversation.id], }); }, [conversation.id]); const isNameInvalid = isEntityNameInvalid(conversation.name); const isInvalidPath = hasInvalidNameInPath(conversation.folderId); const isNameOrPathInvalid = isNameInvalid || isInvalidPath; const isPartOfSelectedPublication = !additionalItemData?.publicationUrl || selectedPublicationUrl === additionalItemData?.publicationUrl; const iconSize = additionalItemData?.isSidePanelItem ? 24 : 18; const strokeWidth = additionalItemData?.isSidePanelItem ? 1.5 : 2; const isExternal = isEntityIdExternal(conversation); return ( <> <div className={classNames( 'relative', additionalItemData?.isSidePanelItem ? 'size-[24px] items-center justify-center' : 'size-[18px]', isSelectMode && !isExternal && 'shrink-0 group-hover:flex', isSelectMode && isChosen && !isExternal ? 'flex' : 'hidden', )} > <input className={classNames( 'checkbox peer size-[18px] bg-layer-3', additionalItemData?.isSidePanelItem && 'mr-0', )} type="checkbox" checked={isChosen} onChange={handleToggle} data-qa={isChosen ? 'checked' : 'unchecked'} /> <IconCheck size={18} className="pointer-events-none invisible absolute text-accent-primary peer-checked:visible" /> </div> <ShareIcon {...conversation} isHighlighted={isHighlighted} featureType={FeatureType.Chat} containerClassName={classNames( isSelectMode && !isExternal && 'group-hover:hidden', isChosen && !isExternal && 'hidden', )} > {resourceToReview && !resourceToReview.reviewed && ( <ReviewDot className={classNames( 'group-hover:bg-accent-secondary-alpha', (selectedConversationIds.includes(conversation.id) || isContextMenu) && isPartOfSelectedPublication && 'bg-accent-secondary-alpha', )} /> )} {conversation.isReplay && ( <span className="flex shrink-0"> <ReplayAsIsIcon size={iconSize} /> </span> )} {conversation.isPlayback && ( <span className="flex shrink-0"> <PlaybackIcon strokeWidth={strokeWidth} size={iconSize} /> </span> )} {!conversation.isReplay && !conversation.isPlayback && ( <ModelIcon size={iconSize} entityId={conversation.model.id} entity={modelsMap[conversation.model.id]} /> )} </ShareIcon> <div className="relative max-h-5 flex-1 truncate whitespace-pre break-all text-left"> <Tooltip tooltip={t( getEntityNameError(isNameInvalid, isInvalidPath, isExternal), )} hideTooltip={!isNameOrPathInvalid} triggerClassName={classNames( 'block max-h-5 flex-1 truncate whitespace-pre break-all text-left', conversation.publicationInfo?.isNotExist && 'text-secondary', !!additionalItemData?.publicationUrl && conversation.publicationInfo?.action === PublishActions.DELETE && 'text-error', )} dataQa="entity-name" > {conversation.name} </Tooltip> </div> </> ); } interface Props { item: ConversationInfo; level?: number; additionalItemData?: AdditionalItemData; } export const ConversationComponent = ({ item: conversation, level, additionalItemData, }: Props) => { const dispatch = useAppDispatch(); const selectedConversationIds = useAppSelector( ConversationsSelectors.selectSelectedConversationsIds, ); const messageIsStreaming = useAppSelector( ConversationsSelectors.selectIsConversationsStreaming, ); const buttonRef = useRef<HTMLButtonElement>(null); const [isContextMenu, setIsContextMenu] = useState(false); const isSelected = selectedConversationIds.includes(conversation.id); const isSelectMode = useAppSelector( ConversationsSelectors.selectIsSelectMode, ); const isConversationsStreaming = useAppSelector( ConversationsSelectors.selectIsConversationsStreaming, ); const chosenConversationIds = useAppSelector( ConversationsSelectors.selectSelectedItems, ); const isChosen = useMemo( () => chosenConversationIds.includes(conversation.id), [chosenConversationIds, conversation.id], ); const selectedPublicationUrl = useAppSelector( PublicationSelectors.selectSelectedPublicationUrl, ); const isExternal = isEntityIdExternal(conversation); const handleDragStart = useCallback( (e: DragEvent<HTMLButtonElement>, conversation: ConversationInfo) => { if ( e.dataTransfer && !isExternal && !isSelectMode && !isConversationsStreaming ) { e.dataTransfer.setDragImage(getDragImage(), 0, 0); e.dataTransfer.setData( MoveType.Conversation, JSON.stringify(conversation), ); } }, [isConversationsStreaming, isExternal, isSelectMode], ); const handleContextMenuOpen = (e: MouseEvent) => { if (hasParentWithFloatingOverlay(e.target as Element)) { return; } e.preventDefault(); e.stopPropagation(); setIsContextMenu(true); }; const isHighlighted = !isSelectMode ? isSelected && (!additionalItemData?.publicationUrl || selectedPublicationUrl === additionalItemData.publicationUrl) : isChosen; const isNameOrPathInvalid = isEntityNameOrPathInvalid(conversation); return ( <div className={classNames( 'group relative flex items-center rounded border-l-2 pr-3 hover:bg-accent-primary-alpha', !isSelectMode && isHighlighted ? 'border-l-accent-primary' : 'border-l-transparent', (isHighlighted || isContextMenu) && 'bg-accent-primary-alpha', isNameOrPathInvalid && 'text-secondary', additionalItemData?.isSidePanelItem ? 'h-[34px]' : 'h-[30px]', )} style={{ paddingLeft: (level && `${level * 30 + 16}px`) || '0.875rem', }} onContextMenu={handleContextMenuOpen} data-qa="conversation" > <button className={classNames( 'group flex size-full cursor-pointer items-center gap-2 disabled:cursor-not-allowed', isSelectMode ? 'pr-0' : '[&:not(:disabled)]:group-hover:pr-6', )} onClick={() => { if (!isSelectMode || !isExternal) { dispatch( !isSelectMode ? ConversationsActions.selectConversations({ conversationIds: [conversation.id], }) : ConversationsActions.setChosenConversations({ ids: [conversation.id], }), ); if (!isSelectMode) { dispatch( PublicationActions.selectPublication( additionalItemData?.publicationUrl ?? null, ), ); } } }} disabled={messageIsStreaming || (isSelectMode && isExternal)} draggable={ !isExternal && !isNameOrPathInvalid && !isSelectMode && !isConversationsStreaming } onDragStart={(e) => handleDragStart(e, conversation)} ref={buttonRef} data-qa={isSelected ? 'selected' : null} > <ConversationView conversation={conversation} isHighlighted={isHighlighted || isContextMenu} isChosen={isChosen} isSelectMode={isSelectMode} additionalItemData={additionalItemData} isContextMenu={isContextMenu} /> </button> {!isSelectMode && !messageIsStreaming && ( <div className={classNames( 'absolute right-3 z-50 flex cursor-pointer justify-end group-hover:visible', (conversation.status === UploadStatus.LOADED || !isContextMenu) && 'invisible', )} > <ConversationContextMenu conversation={conversation} isOpen={isContextMenu} setIsOpen={setIsContextMenu} publicationUrl={additionalItemData?.publicationUrl} /> </div> )} </div> ); };