diff --git a/packages/cozy-search/package.json b/packages/cozy-search/package.json index 2f063e15e5..eef45a9e98 100644 --- a/packages/cozy-search/package.json +++ b/packages/cozy-search/package.json @@ -11,11 +11,8 @@ }, "dependencies": { "@assistant-ui/react": "^0.12.5", - "class-variance-authority": "^0.7.1", "classnames": "^2.5.1", - "clsx": "^2.1.1", "lodash": "^4.17.21", - "lucide-react": "^0.563.0", "mime-types": "2.1.35", "react-type-animation": "3.2.0", "rooks": "7.14.1" @@ -86,7 +83,7 @@ "directory": "packages/cozy-search" }, "scripts": { - "build": "yarn build:clean && yarn build:types && babel --extensions .ts,.tsx,.js,.jsx --ignore '**/*.spec.tsx','**/*.spec.ts','**/*.d.ts' ./src -d ./dist --copy-files", + "build": "yarn build:clean && yarn build:types && babel --extensions .ts,.tsx,.js,.jsx --ignore '**/*.spec.tsx','**/*.spec.ts','**/*.spec.js','**/*.spec.jsx','**/*.d.ts' ./src -d ./dist --copy-files", "build:clean": "rm -rf ./dist", "build:types": "tsc -p tsconfig-build.json", "build:watch": "yarn build --watch", diff --git a/packages/cozy-search/src/actions/delete.jsx b/packages/cozy-search/src/actions/delete.jsx index b93d32a022..624977d84f 100644 --- a/packages/cozy-search/src/actions/delete.jsx +++ b/packages/cozy-search/src/actions/delete.jsx @@ -34,7 +34,7 @@ export const remove = ({ t }) => { Component: makeComponent(label, icon), displayCondition: () => true, action: () => { - // TO DO: Add action to remove + // TO DO: Add action to remove due to this action does not exist yet in backend, we will implement it later } } } diff --git a/packages/cozy-search/src/actions/rename.jsx b/packages/cozy-search/src/actions/rename.jsx index e828fe68c5..2fb831a711 100644 --- a/packages/cozy-search/src/actions/rename.jsx +++ b/packages/cozy-search/src/actions/rename.jsx @@ -34,7 +34,7 @@ export const rename = ({ t }) => { Component: makeComponent(label, icon), displayCondition: () => true, action: () => { - // TO DO: Add action to remove + // TO DO: Add action to rename due to this action does not exist yet in backend, we will implement it later } } } diff --git a/packages/cozy-search/src/actions/share.jsx b/packages/cozy-search/src/actions/share.jsx index 409f6db032..5970d029c9 100644 --- a/packages/cozy-search/src/actions/share.jsx +++ b/packages/cozy-search/src/actions/share.jsx @@ -34,7 +34,7 @@ export const share = ({ t }) => { Component: makeComponent(label, icon), displayCondition: () => true, action: () => { - // TO DO: Add action to remove + // TO DO: Add action to share due to this action does not exist yet in backend, we will implement it later } } } diff --git a/packages/cozy-search/src/components/Assistant/AssistantAvatar.jsx b/packages/cozy-search/src/components/Assistant/AssistantAvatar.jsx index 14a5194876..ba625cd779 100644 --- a/packages/cozy-search/src/components/Assistant/AssistantAvatar.jsx +++ b/packages/cozy-search/src/components/Assistant/AssistantAvatar.jsx @@ -9,7 +9,7 @@ import styles from './styles.styl' import { DEFAULT_ASSISTANT } from '../constants' const AssistantAvatar = ({ assistant, isSmall, className }) => { - if (!assistant) return + if (!assistant) return null if (assistant.id !== DEFAULT_ASSISTANT.id && !assistant.icon) { return ( diff --git a/packages/cozy-search/src/components/Assistant/AssistantContainer.jsx b/packages/cozy-search/src/components/Assistant/AssistantContainer.jsx index 98691be725..a0c620784c 100644 --- a/packages/cozy-search/src/components/Assistant/AssistantContainer.jsx +++ b/packages/cozy-search/src/components/Assistant/AssistantContainer.jsx @@ -2,13 +2,13 @@ import cx from 'classnames' import React from 'react' import flag from 'cozy-flags' -import Divider from 'cozy-ui/transpiled/react/Divider' +import { useCozyTheme } from 'cozy-ui-plus/dist/providers/CozyTheme' import { useAssistant } from '../AssistantProvider' import styles from './styles.styl' import PrettyScrollbar from '../Containers/PrettyScrollbar' import Conversation from '../Conversations/Conversation' -import CozyAssistantRuntimeProvider from '../CozyAssistantRuntimeProvider' +import CozyAssistantRuntimeProviderWithErrorBoundary from '../CozyAssistantRuntimeProvider' import SearchConversation from '../Search/SearchConversation' import Sidebar from '../Sidebar' import TwakeKnowledgePanel from '../TwakeKnowledges/TwakeKnowledgePanel' @@ -19,6 +19,7 @@ const AssistantContainer = () => { openedKnowledgePanel, setOpenedKnowledgePanel } = useAssistant() + const { type: theme } = useCozyTheme() return (
{ styles['assistant-container'] )} > - + - - - + {isOpenSearchConversation && flag('cozy.search-conversation.enabled') ? ( ) : ( - + - + )} {openedKnowledgePanel && flag('cozy.source-knowledge.enabled') && ( -
+
setOpenedKnowledgePanel(undefined)} /> diff --git a/packages/cozy-search/src/components/Assistant/AssistantSelection.jsx b/packages/cozy-search/src/components/Assistant/AssistantSelection.jsx index faa34f0c98..6c9cf9f7ee 100644 --- a/packages/cozy-search/src/components/Assistant/AssistantSelection.jsx +++ b/packages/cozy-search/src/components/Assistant/AssistantSelection.jsx @@ -1,13 +1,16 @@ import cx from 'classnames' -import React, { useState, useRef } from 'react' +import React, { useState, useRef, useEffect } from 'react' +import { useI18n } from 'twake-i18n' import { useQuery } from 'cozy-client' import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Chips from 'cozy-ui/transpiled/react/Chips' import Icon from 'cozy-ui/transpiled/react/Icon' +import DropdownIcon from 'cozy-ui/transpiled/react/Icons/Dropdown' import PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus' import Typography from 'cozy-ui/transpiled/react/Typography' +import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import styles from './styles.styl' import { useAssistant } from '../AssistantProvider' @@ -16,7 +19,9 @@ import { buildAssistantsQuery } from '../queries' import AssistantAvatar from './AssistantAvatar' import AssistantSelectionItem from './AssistantSelectionItem' -const AssistantSelection = ({ className }) => { +const AssistantSelection = ({ className, disabled }) => { + const { t } = useI18n() + const { isMobile } = useBreakpoints() const buttonRef = useRef(null) const [open, setOpen] = useState(false) const { @@ -28,11 +33,18 @@ const AssistantSelection = ({ className }) => { setSelectedAssistantId } = useAssistant() + useEffect(() => { + if (disabled) { + setOpen(false) + } + }, [disabled]) + const assistantsQuery = buildAssistantsQuery() const assistants = useQuery(assistantsQuery.definition, assistantsQuery.options)?.data || [] const handleClick = () => { + if (disabled) return setOpen(true) } @@ -60,9 +72,16 @@ const AssistantSelection = ({ className }) => { assistant={selectedAssistant} /> } - label={selectedAssistant.name} + label={ + isMobile ? ( + + ) : ( + selectedAssistant.name + ) + } clickable onClick={handleClick} + disabled={disabled} />
{open && ( @@ -101,7 +120,9 @@ const AssistantSelection = ({ className }) => { >
- Create Assistant + + {t('assistant_create.title')} +
diff --git a/packages/cozy-search/src/components/Assistant/AssistantSelectionItem.jsx b/packages/cozy-search/src/components/Assistant/AssistantSelectionItem.jsx index 877016e281..c437b0e38f 100644 --- a/packages/cozy-search/src/components/Assistant/AssistantSelectionItem.jsx +++ b/packages/cozy-search/src/components/Assistant/AssistantSelectionItem.jsx @@ -1,12 +1,15 @@ -import React from 'react' +import cx from 'classnames' +import React, { useMemo } from 'react' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' +import Badge from 'cozy-ui/transpiled/react/Badge' import Icon from 'cozy-ui/transpiled/react/Icon' import IconButton from 'cozy-ui/transpiled/react/IconButton' import CheckIcon from 'cozy-ui/transpiled/react/Icons/Check' import PenIcon from 'cozy-ui/transpiled/react/Icons/Pen' import TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash' import Typography from 'cozy-ui/transpiled/react/Typography' +import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import styles from './styles.styl' import AssistantAvatar from '../Assistant/AssistantAvatar' @@ -21,6 +24,8 @@ const AssistantSelectionItem = ({ setIsOpenEditAssistant, disableActions = false }) => { + const { isMobile } = useBreakpoints() + const handleSelect = assistant => { onSelect(assistant) onClose() @@ -40,17 +45,43 @@ const AssistantSelectionItem = ({ e.stopPropagation() } + const isSelected = useMemo( + () => selectedAssistant?.id === assistant.id, + [selectedAssistant?.id, assistant.id] + ) + return ( handleSelect(assistant)} className={styles['menu-item']} >
- + {isMobile && isSelected ? ( + + } + variant="standard" + size="small" + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right' + }} + overlap="circular" + > + + + ) : ( + + )} {assistant.name}
- {selectedAssistant?.id === assistant.id && ( + {isSelected && !isMobile && ( )} {!disableActions && ( @@ -58,7 +89,11 @@ const AssistantSelectionItem = ({ @@ -66,7 +101,11 @@ const AssistantSelectionItem = ({ diff --git a/packages/cozy-search/src/components/Assistant/styles.styl b/packages/cozy-search/src/components/Assistant/styles.styl index 7e6efedb85..ad7072d5ca 100644 --- a/packages/cozy-search/src/components/Assistant/styles.styl +++ b/packages/cozy-search/src/components/Assistant/styles.styl @@ -1,6 +1,13 @@ .assistant-container z-index 1 + .knowledge-panel + &--light + background-color var(--primaryBackgroundLight) + + &--dark + background-color var(--greyA400) + .trigger-button border-radius 20px padding 4px 12px @@ -36,4 +43,13 @@ display none !important .create-icon - margin-right 8px + margin-right 0.5rem + +.dropdown-icon + margin-top 0.25rem + +.selected-item--mobile + padding 1px + border-radius 100% + color var(--white) + background-color var(--malachite) diff --git a/packages/cozy-search/src/components/AssistantProvider.d.ts b/packages/cozy-search/src/components/AssistantProvider.d.ts new file mode 100644 index 0000000000..2fa4653be1 --- /dev/null +++ b/packages/cozy-search/src/components/AssistantProvider.d.ts @@ -0,0 +1,43 @@ +import React from 'react' + +export interface AssistantState { + message: Record + status: 'idle' | 'writing' | 'pending' + messagesId: string[] +} + +export interface TwakeKnowledgeState { + drive: { id: string; name: string }[] + mail: { id: string; name: string }[] + chat: { id: string; name: string }[] +} + +export interface AssistantContextValue { + assistantState: AssistantState + isOpenCreateAssistant: boolean + isOpenDeleteAssistant: boolean + isOpenEditAssistant: boolean + assistantIdInAction: string | null + selectedAssistantId: string + isOpenSearchConversation: boolean + openedKnowledgePanel: string | null + selectedTwakeKnowledge: TwakeKnowledgeState + setAssistantIdInAction: (id: string | null) => void + setIsOpenDeleteAssistant: (isOpen: boolean) => void + setAssistantState: React.Dispatch> + clearAssistant: () => void + onAssistantExecute: ( + params: { value: string; conversationId: string }, + callback?: () => void + ) => Promise + setIsOpenCreateAssistant: (isOpen: boolean) => void + setIsOpenEditAssistant: (isOpen: boolean) => void + setSelectedAssistantId: (id: string) => void + setIsOpenSearchConversation: (isOpen: boolean) => void + setOpenedKnowledgePanel: (panel: string | null) => void + setSelectedTwakeKnowledge: React.Dispatch< + React.SetStateAction + > +} + +export function useAssistant(): AssistantContextValue diff --git a/packages/cozy-search/src/components/AssistantProvider.jsx b/packages/cozy-search/src/components/AssistantProvider.jsx index 525db3f8b8..d13663e70d 100644 --- a/packages/cozy-search/src/components/AssistantProvider.jsx +++ b/packages/cozy-search/src/components/AssistantProvider.jsx @@ -11,6 +11,9 @@ import { CHAT_EVENTS_DOCTYPE, CHAT_CONVERSATIONS_DOCTYPE } from './queries' export const AssistantContext = React.createContext() +/** + * @returns {import('./AssistantProvider').AssistantContextValue} + */ export const useAssistant = () => { const context = useContext(AssistantContext) diff --git a/packages/cozy-search/src/components/Conversations/ConversationBar.jsx b/packages/cozy-search/src/components/Conversations/ConversationBar.jsx index 6be732dc40..b894a71975 100644 --- a/packages/cozy-search/src/components/Conversations/ConversationBar.jsx +++ b/packages/cozy-search/src/components/Conversations/ConversationBar.jsx @@ -1,4 +1,5 @@ import { ComposerPrimitive } from '@assistant-ui/react' +import cx from 'classnames' import React, { useRef } from 'react' import { useI18n } from 'twake-i18n' @@ -19,7 +20,8 @@ const ConversationBar = ({ isRunning, onKeyDown, onSend, - onCancel + onCancel, + ...props }) => { const { t } = useI18n() const { isMobile } = useBreakpoints() @@ -51,7 +53,10 @@ const ConversationBar = ({ return (
{ const { isMobile } = useBreakpoints() const composerRuntime = useComposerRuntime() const isRunning = useThread(state => state.isRunning) + const isThreadEmpty = useThread(state => state.messages.length === 0) const { setOpenedKnowledgePanel } = useAssistant() const value = useComposer(state => state.text) @@ -48,8 +50,14 @@ const ConversationComposer = () => { ) return ( - + { />
- {flag('cozy.create-assistant.enabled') && } + {flag('cozy.create-assistant.enabled') && ( + + )} {flag('cozy.source-knowledge.enabled') && ( { const { t, lang } = useI18n() + const { type: theme } = useCozyTheme() return ( - + {formatConversationDate( conversation.cozyMetadata?.updatedAt, t, diff --git a/packages/cozy-search/src/components/Conversations/ConversationListItemWider.jsx b/packages/cozy-search/src/components/Conversations/ConversationListItemWider.jsx index b330c60896..7d79745882 100644 --- a/packages/cozy-search/src/components/Conversations/ConversationListItemWider.jsx +++ b/packages/cozy-search/src/components/Conversations/ConversationListItemWider.jsx @@ -3,15 +3,15 @@ import React from 'react' import { useI18n } from 'twake-i18n' import flag from 'cozy-flags' -import Icon from 'cozy-ui/transpiled/react/Icon' -import ImageIcon from 'cozy-ui/transpiled/react/Icons/Image' import ListItem from 'cozy-ui/transpiled/react/ListItem' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import Typography from 'cozy-ui/transpiled/react/Typography' +import { useCozyTheme } from 'cozy-ui-plus/dist/providers/CozyTheme' import ConversationActions from './ConversationActions' import styles from './styles.styl' +import AssistantAvatar from '../Assistant/AssistantAvatar' import { formatConversationDate, getDescriptionOfConversation, @@ -26,6 +26,7 @@ const ConversationListItemWider = ({ onOpenConversation }) => { const { t, lang } = useI18n() + const { type: theme } = useCozyTheme() return ( onOpenConversation(conversation._id)} className={cx( 'u-bdrs-0 u-ov-hidden u-flex u-flex-items-center u-flex-justify-between u-w-100', - styles['conversation-list-item'] + styles['conversation-list-item'], + { + [styles[`conversation-list-item--selected--${theme}`]]: selected + } )} selected={selected} > - + { + const { setSelectedAssistantId } = useAssistant() const conversationQuery = buildChatConversationQueryById(conversationId) const queryResult = useQuery( conversationQuery.definition, @@ -86,6 +101,15 @@ const ConversationLoader = ({ [conversation?.messages] ) + useEffect(() => { + setSelectedAssistantId( + conversation?.relationships?.assistant?.data?._id || DEFAULT_ASSISTANT.id + ) + }, [ + conversation?.relationships?.assistant?.data?._id, + setSelectedAssistantId + ]) + if (isLoading) { return (
@@ -119,6 +143,7 @@ const CozyAssistantRuntimeProviderInner = ({ const messagesIdRef = useRef([]) const cancelledMessageIdsRef = useRef>(new Set()) const currentStreamingMessageIdRef = useRef(null) + const { selectedAssistantId } = useAssistant() useEffect(() => { messagesIdRef.current = initialMessages @@ -128,35 +153,45 @@ const CozyAssistantRuntimeProviderInner = ({ useEffect(() => { streamBridgeRef.current.setCleanupCallback(() => { - if (currentStreamingMessageIdRef.current) { - cancelledMessageIdsRef.current.add(currentStreamingMessageIdRef.current) - currentStreamingMessageIdRef.current = null + try { + if (currentStreamingMessageIdRef.current) { + cancelledMessageIdsRef.current.add( + currentStreamingMessageIdRef.current + ) + currentStreamingMessageIdRef.current = null + } + } catch (error) { + log.error('Error during StreamBridge cleanup callback:', error) } }) }, []) const handleConversationChange = useCallback( (res: Conversation) => { - if (res._id === conversationId && res.messages) { - const newIds = res.messages.map(m => m.id) - const lastAssistantMsg = res.messages - .filter(m => m.role === 'assistant') - .pop() - if ( - lastAssistantMsg && - !messagesIdRef.current.includes(lastAssistantMsg.id) - ) { + try { + if (res._id === conversationId && res.messages) { + const newIds = res.messages.map(m => m.id) + const lastAssistantMsg = res.messages + .filter(m => m.role === 'assistant') + .pop() if ( - currentStreamingMessageIdRef.current && - currentStreamingMessageIdRef.current !== lastAssistantMsg.id + lastAssistantMsg && + !messagesIdRef.current.includes(lastAssistantMsg.id) ) { - cancelledMessageIdsRef.current.add( - currentStreamingMessageIdRef.current - ) + if ( + currentStreamingMessageIdRef.current && + currentStreamingMessageIdRef.current !== lastAssistantMsg.id + ) { + cancelledMessageIdsRef.current.add( + currentStreamingMessageIdRef.current + ) + } + currentStreamingMessageIdRef.current = lastAssistantMsg.id } - currentStreamingMessageIdRef.current = lastAssistantMsg.id + messagesIdRef.current = newIds } - messagesIdRef.current = newIds + } catch (error) { + log.error('Error handling conversation change:', error) } }, [conversationId] @@ -184,6 +219,9 @@ const CozyAssistantRuntimeProviderInner = ({ content?: string }) => { if (cancelledMessageIdsRef.current.has(res._id)) { + if (res.object === 'done') { + cancelledMessageIdsRef.current.delete(res._id) + } return } @@ -201,13 +239,17 @@ const CozyAssistantRuntimeProviderInner = ({ currentStreamingMessageIdRef.current = res._id } - if (res.object === 'delta' && res.content !== undefined) { - streamBridgeRef.current.onDelta(conversationId, res.content) - } + try { + if (res.object === 'delta' && res.content !== undefined) { + streamBridgeRef.current.onDelta(conversationId, res.content) + } - if (res.object === 'done') { - streamBridgeRef.current.onDone(conversationId) - currentStreamingMessageIdRef.current = null + if (res.object === 'done') { + streamBridgeRef.current.onDone(conversationId) + currentStreamingMessageIdRef.current = null + } + } catch (error) { + log.error('Error handling chat real-time event:', error) } } } @@ -223,11 +265,12 @@ const CozyAssistantRuntimeProviderInner = ({ typeof createCozyRealtimeChatAdapter >[0]['client'], conversationId, - streamBridge: streamBridgeRef.current + streamBridge: streamBridgeRef.current, + assistantId: selectedAssistantId }, t ), - [client, conversationId, t] + [client, conversationId, selectedAssistantId, t] ) const runtime = useLocalRuntime(adapter, { @@ -237,7 +280,11 @@ const CozyAssistantRuntimeProviderInner = ({ useEffect(() => { const streamBridge = streamBridgeRef.current return () => { - streamBridge.cleanup(conversationId) + try { + streamBridge.cleanup(conversationId) + } catch (error) { + log.error('Error cleaning up StreamBridge on unmount:', error) + } } }, [conversationId]) @@ -268,4 +315,59 @@ const CozyAssistantRuntimeProvider = ({ ) } -export default CozyAssistantRuntimeProvider +class CozyAssistantErrorBoundary extends React.Component< + { children: ReactNode; t: (key: string) => string }, + { hasError: boolean; error: Error | null } +> { + constructor(props: { children: ReactNode; t: (key: string) => string }) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): { + hasError: boolean + error: Error + } { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + log.error('Assistant Runtime UI crashed:', error, errorInfo) + } + + handleRetry = (): void => { + this.setState({ hasError: false, error: null }) + } + + render(): ReactNode { + if (this.state.hasError) { + return ( +
+ + {this.props.t('assistant.default_error')} + +
+ ) + } + + return this.props.children + } +} + +const CozyAssistantRuntimeProviderWithErrorBoundary = ( + props: CozyAssistantRuntimeProviderProps +): JSX.Element | null => { + const { t } = useI18n() + return ( + + + + ) +} + +export default CozyAssistantRuntimeProviderWithErrorBoundary diff --git a/packages/cozy-search/src/components/CreateAssistantSteps/ProviderSelectionStep.jsx b/packages/cozy-search/src/components/CreateAssistantSteps/ProviderSelectionStep.jsx index 3eea08ba8f..70f9680d32 100644 --- a/packages/cozy-search/src/components/CreateAssistantSteps/ProviderSelectionStep.jsx +++ b/packages/cozy-search/src/components/CreateAssistantSteps/ProviderSelectionStep.jsx @@ -1,8 +1,10 @@ +import cx from 'classnames' import React from 'react' import { useI18n } from 'twake-i18n' import Alert from 'cozy-ui/transpiled/react/Alert' import Typography from 'cozy-ui/transpiled/react/Typography' +import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import Provider from './Provider' import providers from './providers.json' @@ -10,12 +12,17 @@ import styles from './styles.styl' const ProviderSelectionStep = ({ selectedProvider, onSelect }) => { const { t } = useI18n() + const { isMobile } = useBreakpoints() return ( -
+
{t('assistant_create.steps.provider_selection.description')} -
+
{providers.map(provider => ( { const { t } = useI18n() - const isRunning = useMessage(s => s.status?.type === 'running') + const isThinking = useMessage(s => s.status?.type === 'requires-action') return ( - {isRunning && ( + {isThinking && ( { + const { type: theme } = useCozyTheme() + return ( { + const { t } = useI18n() + + return ( +
+
+ +
+ + {t('assistant.search_conversation.not_found_title')} + + + {t('assistant.search_conversation.not_found_desc')} + +
+ ) +} + +export default NotFoundConversation diff --git a/packages/cozy-search/src/components/Search/SearchConversation.jsx b/packages/cozy-search/src/components/Search/SearchConversation.jsx index c023d0f17b..2faa06ddf9 100644 --- a/packages/cozy-search/src/components/Search/SearchConversation.jsx +++ b/packages/cozy-search/src/components/Search/SearchConversation.jsx @@ -1,59 +1,133 @@ -import React, { useMemo, useState } from 'react' +import cx from 'classnames' +import debounce from 'lodash/debounce' +import escapeRegExp from 'lodash/escapeRegExp' +import React, { useEffect, useMemo, useState } from 'react' import { useI18n } from 'twake-i18n' -import { useQuery } from 'cozy-client' import Button from 'cozy-ui/transpiled/react/Buttons' +import Dialog from 'cozy-ui/transpiled/react/Dialog' +import Divider from 'cozy-ui/transpiled/react/Divider' import Icon from 'cozy-ui/transpiled/react/Icon' +import IconButton from 'cozy-ui/transpiled/react/IconButton' +import CrossIcon from 'cozy-ui/transpiled/react/Icons/Cross' import PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus' import SearchBar from 'cozy-ui/transpiled/react/SearchBar' +import Spinner from 'cozy-ui/transpiled/react/Spinner' import Typography from 'cozy-ui/transpiled/react/Typography' +import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' +import NotFoundConversation from './NotFoundConversation' import { groupConversationsByDate } from './helpers' +import styles from './styles.styl' import useConversation from '../../hooks/useConversation' +import useFetchConversations from '../../hooks/useFetchConversations' +import { useAssistant } from '../AssistantProvider' import ConversationList from '../Conversations/ConversationList' import ConversationListItemWider from '../Conversations/ConversationListItemWider' -import { buildChatConversationsQuery } from '../queries' + +const SearchConversationContainer = ({ children, isMobile }) => + !isMobile ? ( +
+ {children} +
+ ) : ( + + {children} + + ) const SearchConversation = () => { const { t } = useI18n() + const { isMobile } = useBreakpoints() + const [query, setQuery] = useState(undefined) + const [searchStr, setSearchStr] = useState('') + const { createNewConversation, goToConversation } = useConversation() + const { setIsOpenSearchConversation } = useAssistant() + const { conversations, isLoading } = useFetchConversations({ query }) - const conversationsQuery = useMemo(() => buildChatConversationsQuery(), []) - const { data: conversations } = useQuery( - conversationsQuery.definition, - conversationsQuery.options + const groupedConversations = useMemo( + () => groupConversationsByDate(conversations || []), + [conversations] ) - const [query, setQuery] = useState('') + const debouncedFetchConversations = useMemo( + () => + debounce(async value => { + // FIXME: This fallback query is highly inefficient. + // It bypasses index usage, forcing CouchDB to scan the entire database, + // deserialize every document, and then evaluate the regex via `$elemMatch`. + // Furthermore, it restricts us from doing fuzzy-search. + // We need a dedicated task to migrate this to an efficient client-side search approach. + const fetchQuery = value + ? { + messages: { + $elemMatch: { + content: { + $regex: escapeRegExp(value) + } + } + } + } + : undefined + setQuery(fetchQuery) + }, 300), + [setQuery] + ) - const filteredConversations = useMemo(() => { - if (!conversations) return [] - if (!query) return conversations + useEffect(() => { + return () => { + debouncedFetchConversations.cancel() + } + }, [debouncedFetchConversations]) - const lowerQuery = query.toLowerCase() - return conversations.filter(conversation => - conversation.messages?.some(msg => - msg.content?.toLowerCase().includes(lowerQuery) - ) - ) - }, [conversations, query]) - - const groupedConversations = useMemo( - () => groupConversationsByDate(filteredConversations), - [filteredConversations] - ) + const handleSearchChange = e => { + const newQuery = e.target.value + setSearchStr(newQuery) + debouncedFetchConversations(newQuery) + } return ( -
-
-
- setQuery(e.target.value)} - /> + +
+
+
+ + + {isMobile && ( + setIsOpenSearchConversation(false)} + aria-label={t('assistant.search_conversation.close')} + > + + + )} +
+ + {isMobile && }
-
- {groupedConversations.today?.length > 0 && ( +
+ {isLoading ? ( +
+ +
+ ) : searchStr ? ( + + ) : ( <> - - {t('assistant.search_conversation.recent')} - - + {groupedConversations.today?.length > 0 && ( + <> + + {t('assistant.search_conversation.recent')} + + + + )} + + {groupedConversations.older?.length > 0 && ( + <> + + {t('assistant.search_conversation.older')} + + + + )} )} - {groupedConversations.older?.length > 0 && ( - <> - - {t('assistant.search_conversation.older')} - - - + {!isLoading && conversations?.length === 0 && ( + )}
-
+
) } diff --git a/packages/cozy-search/src/components/Search/helpers.spec.js b/packages/cozy-search/src/components/Search/helpers.spec.js new file mode 100644 index 0000000000..5503ec84de --- /dev/null +++ b/packages/cozy-search/src/components/Search/helpers.spec.js @@ -0,0 +1,71 @@ +import { groupConversationsByDate } from './helpers' + +describe('groupConversationsByDate', () => { + let OriginalDate + + beforeEach(() => { + OriginalDate = global.Date + global.Date = class extends OriginalDate { + constructor(...args) { + if (args.length) return new OriginalDate(...args) + return new OriginalDate('2023-11-20T12:00:00Z') + } + } + global.Date.now = jest.fn(() => + new OriginalDate('2023-11-20T12:00:00Z').getTime() + ) + }) + + afterEach(() => { + global.Date = OriginalDate + }) + + it('returns empty groups for null or undefined input', () => { + expect(groupConversationsByDate(null)).toEqual({ today: [], older: [] }) + expect(groupConversationsByDate(undefined)).toEqual({ + today: [], + older: [] + }) + }) + + it('groups conversations into today and older', () => { + const mockConversations = [ + { + id: '1', + cozyMetadata: { + updatedAt: new OriginalDate(2023, 10, 20, 14, 0).toISOString() + } + }, // Today + { + id: '2', + cozyMetadata: { + updatedAt: new OriginalDate(2023, 10, 20, 8, 0).toISOString() + } + }, // Today + { + id: '3', + cozyMetadata: { + updatedAt: new OriginalDate(2023, 10, 19, 23, 59).toISOString() + } + }, // Older + { + id: '4', + cozyMetadata: { + updatedAt: new OriginalDate(2022, 0, 1, 12, 0).toISOString() + } + }, // Older + { id: '5' } // Missing cozyMetadata (Date.now() fallback -> Today) + ] + + const result = groupConversationsByDate(mockConversations) + + expect(result.today).toHaveLength(3) + expect(result.today[0].id).toBe('1') + expect(result.today[1].id).toBe('2') + expect(result.today[2].id).toBe('5') + + expect(result.older).toHaveLength(2) + expect(result.older[0].id).toBe('3') + expect(result.older[1].id).toBe('4') + }) +}) diff --git a/packages/cozy-search/src/components/Search/styles.styl b/packages/cozy-search/src/components/Search/styles.styl index fb32312c76..c04ae664c2 100644 --- a/packages/cozy-search/src/components/Search/styles.styl +++ b/packages/cozy-search/src/components/Search/styles.styl @@ -8,3 +8,8 @@ .search-bar-icon opacity 0.42 + +.search-bar--mobile + border 0 + background-color transparent !important + border-color transparent !important diff --git a/packages/cozy-search/src/components/Sidebar/index.jsx b/packages/cozy-search/src/components/Sidebar/index.jsx index 85eb3ea36c..712196602a 100644 --- a/packages/cozy-search/src/components/Sidebar/index.jsx +++ b/packages/cozy-search/src/components/Sidebar/index.jsx @@ -3,21 +3,25 @@ import React, { useState } from 'react' import { useParams } from 'react-router-dom' import { useI18n } from 'twake-i18n' -import { useQuery } from 'cozy-client' import flag from 'cozy-flags' import Button from 'cozy-ui/transpiled/react/Buttons' +import Divider from 'cozy-ui/transpiled/react/Divider' import Icon from 'cozy-ui/transpiled/react/Icon' import IconButton from 'cozy-ui/transpiled/react/IconButton' import BurgerIcon from 'cozy-ui/transpiled/react/Icons/Burger' +import CrossSmallIcon from 'cozy-ui/transpiled/react/Icons/CrossSmall' import SearchIcon from 'cozy-ui/transpiled/react/Icons/Magnifier' import PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus' +import LoadMore from 'cozy-ui/transpiled/react/LoadMore' import Typography from 'cozy-ui/transpiled/react/Typography' +import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' +import styles from './styles.styl' import useConversation from '../../hooks/useConversation' +import useFetchConversations from '../../hooks/useFetchConversations' import { useAssistant } from '../AssistantProvider' import PrettyScrollbar from '../Containers/PrettyScrollbar' import ConversationList from '../Conversations/ConversationList' -import { buildChatConversationsQuery } from '../queries' const Sidebar = ({ className }) => { const { t } = useI18n() @@ -25,13 +29,10 @@ const Sidebar = ({ className }) => { const { createNewConversation, goToConversation } = useConversation() const { isOpenSearchConversation, setIsOpenSearchConversation } = useAssistant() - const [sidebarOpen, setSidebarOpen] = useState(true) + const { isMobile } = useBreakpoints() + const [sidebarOpen, setSidebarOpen] = useState(!isMobile) - const conversationsQuery = buildChatConversationsQuery() - const { data: conversations } = useQuery( - conversationsQuery.definition, - conversationsQuery.options - ) + const { conversations, hasMore, fetchMore } = useFetchConversations() const onToggleSidebar = () => { setSidebarOpen(!sidebarOpen) @@ -39,70 +40,114 @@ const Sidebar = ({ className }) => { const onToggleSearch = () => { setIsOpenSearchConversation(!isOpenSearchConversation) + if (isMobile) { + setSidebarOpen(false) + } } return ( -
-
- - - - {sidebarOpen && flag('cozy.search-conversation.enabled') && ( - - - - )} -
-
- {sidebarOpen ? ( -
+
+ {sidebarOpen ? ( +
+ + {sidebarOpen && ( + <> + + {t('assistant.sidebar.recent_chats')} + + + + {hasMore && ( +
+ +
+ )} +
+ )}
- - {sidebarOpen && ( - <> - - {t('assistant.sidebar.recent_chats')} - - - - - + {isMobile && sidebarOpen && ( + )} -
+ {sidebarOpen && !isMobile && } + ) } diff --git a/packages/cozy-search/src/components/Sidebar/styles.styl b/packages/cozy-search/src/components/Sidebar/styles.styl new file mode 100644 index 0000000000..61ebb5725f --- /dev/null +++ b/packages/cozy-search/src/components/Sidebar/styles.styl @@ -0,0 +1,12 @@ +.sidebar-container + background-color var(--paperBackgroundColor) + z-index calc(var(--zIndex-modal) + 30) + +.sidebar-overlay--mobile + background-color var(--actionColorActive) + position fixed + top 0 + right 0 + left 0 + bottom 0 + z-index calc(var(--zIndex-modal) + 20) diff --git a/packages/cozy-search/src/components/TwakeKnowledges/TwakeKnowledgePanel.jsx b/packages/cozy-search/src/components/TwakeKnowledges/TwakeKnowledgePanel.jsx index c109338778..c26c3713a1 100644 --- a/packages/cozy-search/src/components/TwakeKnowledges/TwakeKnowledgePanel.jsx +++ b/packages/cozy-search/src/components/TwakeKnowledges/TwakeKnowledgePanel.jsx @@ -3,11 +3,14 @@ import React, { useState, useEffect } from 'react' import { useI18n } from 'twake-i18n' import Button from 'cozy-ui/transpiled/react/Buttons' +import Dialog from 'cozy-ui/transpiled/react/Dialog' import Icon from 'cozy-ui/transpiled/react/Icon' import IconButton from 'cozy-ui/transpiled/react/IconButton' import CrossIcon from 'cozy-ui/transpiled/react/Icons/Cross' +import Paper from 'cozy-ui/transpiled/react/Paper' import SearchBar from 'cozy-ui/transpiled/react/SearchBar' import Typography from 'cozy-ui/transpiled/react/Typography' +import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import { useAssistant } from '../AssistantProvider' import ChatKnowledge from './ChatKnowledge' @@ -18,8 +21,48 @@ import TChat from '../../assets/tchat.png' import TDrive from '../../assets/tdrive.png' import TMail from '../../assets/tmail.png' +const PANEL_CONFIG = { + drive: { + title: 'assistant.twake_knowledges.title_drive', + desc: 'assistant.twake_knowledges.desc_drive', + icon: TDrive, + Component: DriveKnowledge, + actionLabel: 'assistant.twake_knowledges.select_folders' + }, + mail: { + title: 'assistant.twake_knowledges.title_mail', + desc: 'assistant.twake_knowledges.desc_mail', + icon: TMail, + Component: MailKnowledge, + actionLabel: 'assistant.twake_knowledges.select_emails' + }, + chat: { + title: 'assistant.twake_knowledges.title_chat', + desc: 'assistant.twake_knowledges.desc_chat', + icon: TChat, + Component: ChatKnowledge, + actionLabel: 'assistant.twake_knowledges.select_messages' + } +} + +const TwakeKnowledgePanelContainer = ({ children, isMobile }) => + !isMobile ? ( + + {children} + + ) : ( + + {children} + + ) + const TwakeKnowledgePanel = ({ onClose }) => { const { t } = useI18n() + const { isMobile } = useBreakpoints() const { openedKnowledgePanel, selectedTwakeKnowledge, @@ -56,84 +99,20 @@ const TwakeKnowledgePanel = ({ onClose }) => { onClose() } - const renderContent = () => { - switch (openedKnowledgePanel) { - case 'drive': - return ( - - ) - case 'mail': - return ( - - ) - case 'chat': - return ( - - ) - default: - return null - } - } + const config = PANEL_CONFIG[openedKnowledgePanel] - const getTitle = () => { - switch (openedKnowledgePanel) { - case 'drive': - return t('assistant.twake_knowledges.title_drive') - case 'mail': - return t('assistant.twake_knowledges.title_mail') - case 'chat': - return t('assistant.twake_knowledges.title_chat') - default: - return t('assistant.twake_knowledges.title_default') - } - } + if (!openedKnowledgePanel || !config) return null - const getDescription = () => { - switch (openedKnowledgePanel) { - case 'drive': - return t('assistant.twake_knowledges.desc_drive') - case 'mail': - return t('assistant.twake_knowledges.desc_mail') - case 'chat': - return t('assistant.twake_knowledges.desc_chat') - default: - return '' - } - } - - const getIcon = () => { - switch (openedKnowledgePanel) { - case 'drive': - return TDrive - case 'mail': - return TMail - case 'chat': - return TChat - default: - return null - } - } + const { title, desc, icon: IconImg, Component, actionLabel } = config if (!openedKnowledgePanel) return null return ( -
+
- - {getTitle()} + + {t(title)} @@ -142,7 +121,7 @@ const TwakeKnowledgePanel = ({ onClose }) => {
- {getDescription()} + {t(desc)}
@@ -153,7 +132,13 @@ const TwakeKnowledgePanel = ({ onClose }) => { />
-
{renderContent()}
+
+ +
@@ -173,19 +158,13 @@ const TwakeKnowledgePanel = ({ onClose }) => { />
-
+ ) } diff --git a/packages/cozy-search/src/components/TwakeKnowledges/TwakeKnowledgeSelector.jsx b/packages/cozy-search/src/components/TwakeKnowledges/TwakeKnowledgeSelector.jsx index a8b75b9d96..e3e9bb8b75 100644 --- a/packages/cozy-search/src/components/TwakeKnowledges/TwakeKnowledgeSelector.jsx +++ b/packages/cozy-search/src/components/TwakeKnowledges/TwakeKnowledgeSelector.jsx @@ -4,6 +4,8 @@ import { useI18n } from 'twake-i18n' import flag from 'cozy-flags' import Chip from 'cozy-ui/transpiled/react/Chips' +import Typography from 'cozy-ui/transpiled/react/Typography' +import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import styles from './styles.styl' import TChat from '../../assets/tchat.png' @@ -13,6 +15,7 @@ import { useAssistant } from '../AssistantProvider' const TwakeKnowledgeSelector = ({ onSelectTwakeKnowledge }) => { const { t } = useI18n() + const { isMobile } = useBreakpoints() const { openedKnowledgePanel, selectedTwakeKnowledge } = useAssistant() const twakeKnowledges = [ { @@ -37,6 +40,9 @@ const TwakeKnowledgeSelector = ({ onSelectTwakeKnowledge }) => { return (
+ + {t('assistant.twake_knowledges.search_in')} + {twakeKnowledges .filter(twakeKnowledge => twakeKnowledge.display) .map((twakeKnowledge, index) => { @@ -45,9 +51,15 @@ const TwakeKnowledgeSelector = ({ onSelectTwakeKnowledge }) => { selectedTwakeKnowledge[twakeKnowledge.id].length return ( + } deleteIcon={ numberOfSelectedItems > 0 ? ( @@ -62,7 +74,7 @@ const TwakeKnowledgeSelector = ({ onSelectTwakeKnowledge }) => { ) : null } onDelete={numberOfSelectedItems > 0 ? () => {} : null} - label={twakeKnowledge.label} + label={isMobile ? '' : twakeKnowledge.label} clickable variant={ isSelected || numberOfSelectedItems > 0 ? 'ghost' : 'default' diff --git a/packages/cozy-search/src/components/TwakeKnowledges/styles.styl b/packages/cozy-search/src/components/TwakeKnowledges/styles.styl index 68816568e9..34587eb527 100644 --- a/packages/cozy-search/src/components/TwakeKnowledges/styles.styl +++ b/packages/cozy-search/src/components/TwakeKnowledges/styles.styl @@ -3,14 +3,12 @@ border-radius 0 display flex flex-direction column - background-color var(--paperBackgroundColor) .source-panel-header display flex justify-content space-between align-items center padding 16px 20px 0 - background-color var(--paperBackgroundColor) flex-shrink 0 .source-panel-description @@ -37,7 +35,6 @@ .source-panel-footer padding 16px 20px border-top 1px solid var(--dividerColor) - background-color var(--paperBackgroundColor) flex-shrink 0 .section-header diff --git a/packages/cozy-search/src/components/Views/AssistantView.jsx b/packages/cozy-search/src/components/Views/AssistantView.jsx index e9414fe4da..b2baf546f9 100644 --- a/packages/cozy-search/src/components/Views/AssistantView.jsx +++ b/packages/cozy-search/src/components/Views/AssistantView.jsx @@ -1,6 +1,7 @@ import cx from 'classnames' import React from 'react' +import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import CozyTheme from 'cozy-ui-plus/dist/providers/CozyTheme' import AssistantProvider, { useAssistant } from '../AssistantProvider' @@ -19,12 +20,16 @@ const AssistantView = () => { isOpenDeleteAssistant, setIsOpenDeleteAssistant } = useAssistant() + const { isMobile } = useBreakpoints() return (
diff --git a/packages/cozy-search/src/components/Views/CreateAssistantDialog.jsx b/packages/cozy-search/src/components/Views/CreateAssistantDialog.jsx index a2338bcd2e..edf0f81b31 100644 --- a/packages/cozy-search/src/components/Views/CreateAssistantDialog.jsx +++ b/packages/cozy-search/src/components/Views/CreateAssistantDialog.jsx @@ -14,6 +14,7 @@ import Icon from 'cozy-ui/transpiled/react/Icon' import IconButton from 'cozy-ui/transpiled/react/IconButton' import CrossIcon from 'cozy-ui/transpiled/react/Icons/Cross' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' +import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import { locales } from '../../locales' import AssistantDialogContent from '../CreateAssistantSteps/AssistantDialogContent' @@ -28,6 +29,7 @@ const CreateAssistantDialog = ({ open, onClose }) => { const { t } = useI18n() const client = useClient() const { showAlert } = useAlert() + const { isMobile } = useBreakpoints() const { step, @@ -67,6 +69,7 @@ const CreateAssistantDialog = ({ open, onClose }) => { open={open} onClose={onClose} maxWidth="lg" + fullScreen={!!isMobile} className={styles.CreateAssistantDialog} > {getTitle()} diff --git a/packages/cozy-search/src/components/Views/EditAssistantDialog.jsx b/packages/cozy-search/src/components/Views/EditAssistantDialog.jsx index 4046cad194..50a624fa98 100644 --- a/packages/cozy-search/src/components/Views/EditAssistantDialog.jsx +++ b/packages/cozy-search/src/components/Views/EditAssistantDialog.jsx @@ -14,6 +14,7 @@ import Icon from 'cozy-ui/transpiled/react/Icon' import IconButton from 'cozy-ui/transpiled/react/IconButton' import CrossIcon from 'cozy-ui/transpiled/react/Icons/Cross' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' +import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import { locales } from '../../locales' import { useAssistant } from '../AssistantProvider' @@ -31,6 +32,7 @@ const EditAssistantDialog = ({ open, onClose }) => { const client = useClient() const { assistantIdInAction } = useAssistant() const { showAlert } = useAlert() + const { isMobile } = useBreakpoints() const { step, @@ -105,6 +107,7 @@ const EditAssistantDialog = ({ open, onClose }) => { open={open} onClose={onClose} maxWidth="lg" + fullScreen={!!isMobile} className={styles.CreateAssistantDialog} > {getTitle()} diff --git a/packages/cozy-search/src/components/adapters/CozyRealtimeChatAdapter.ts b/packages/cozy-search/src/components/adapters/CozyRealtimeChatAdapter.ts index 90365575a9..fb0791b081 100644 --- a/packages/cozy-search/src/components/adapters/CozyRealtimeChatAdapter.ts +++ b/packages/cozy-search/src/components/adapters/CozyRealtimeChatAdapter.ts @@ -31,6 +31,7 @@ export interface CozyRealtimeChatAdapterOptions { client: CozyClient conversationId: string streamBridge: StreamBridge + assistantId?: string } /** @@ -66,7 +67,7 @@ export const createCozyRealtimeChatAdapter = ( messages, abortSignal }: ChatModelRunOptions): AsyncGenerator { - const { client, conversationId, streamBridge } = options + const { client, conversationId, streamBridge, assistantId } = options const userQuery = findUserQuery(messages) if (!userQuery) { @@ -78,10 +79,14 @@ export const createCozyRealtimeChatAdapter = ( try { // Note: For reload, this sends the same query again to regenerate + yield { + content: [{ type: 'text', text: '' }], + status: { type: 'requires-action', reason: 'tool-calls' } + } await client.stackClient.fetchJSON( 'POST', `/ai/chat/conversations/${conversationId}`, - { q: userQuery } + { q: userQuery, assistantId } ) let fullText = '' diff --git a/packages/cozy-search/src/components/adapters/StreamBridge.spec.ts b/packages/cozy-search/src/components/adapters/StreamBridge.spec.ts new file mode 100644 index 0000000000..d987ab635f --- /dev/null +++ b/packages/cozy-search/src/components/adapters/StreamBridge.spec.ts @@ -0,0 +1,82 @@ +import { StreamBridge } from './StreamBridge' + +describe('StreamBridge', () => { + let bridge: StreamBridge + + beforeEach(() => { + bridge = new StreamBridge() + }) + + it('should create an async iterable iterator', () => { + const iterator = bridge.createStream('convo_1') + expect(typeof iterator.next).toBe('function') + expect(iterator[Symbol.asyncIterator]).toBeDefined() + }) + + it('should push deltas and yield them from the iterator', async () => { + const iterator = bridge.createStream('convo_1') + + bridge.onDelta('convo_1', 'Hello ') + bridge.onDelta('convo_1', 'world!') + + const first = await iterator.next() + expect(first).toEqual({ value: 'Hello ', done: false }) + + const second = await iterator.next() + expect(second).toEqual({ value: 'world!', done: false }) + }) + + it('should mark the stream as done when complete is called', async () => { + const iterator = bridge.createStream('convo_1') + + bridge.onDelta('convo_1', 'done chunk') + bridge.onDone('convo_1') + + const first = await iterator.next() + expect(first).toEqual({ value: 'done chunk', done: false }) + + const second = await iterator.next() + expect(second.done).toBe(true) + }) + + it('should reject the iterator when an error occurs', async () => { + const iterator = bridge.createStream('convo_1') + const error = new Error('Socket disconnected') + + bridge.onError('convo_1', error) + + await expect(iterator.next()).rejects.toThrow('Socket disconnected') + }) + + it('should call the cleanup callback and mark the stream complete on cleanup', async () => { + const cleanupSpy = jest.fn() + bridge.setCleanupCallback(cleanupSpy) + + const iterator = bridge.createStream('convo_1') + bridge.cleanup('convo_1') + + expect(cleanupSpy).toHaveBeenCalledTimes(1) + expect(bridge.hasStream('convo_1')).toBe(false) + + // The iterator should be marked done + const result = await iterator.next() + expect(result.done).toBe(true) + }) + + it('multiple unresolved next calls should reject to prevent concurrency issues', async () => { + const iterator = bridge.createStream('convo_1') + + // Call next twice concurrently + const p1 = iterator.next() + const p2 = iterator.next() + + await expect(p2).rejects.toThrow( + 'StreamBridge: concurrent next() calls are not supported' + ) + + // Fulfill the first one + bridge.onDelta('convo_1', 'ok') + const res1 = await p1 + expect(res1).toEqual({ value: 'ok', done: false }) + }) +}) diff --git a/packages/cozy-search/src/components/constants.js b/packages/cozy-search/src/components/constants.js index 591e85d931..2a6771b456 100644 --- a/packages/cozy-search/src/components/constants.js +++ b/packages/cozy-search/src/components/constants.js @@ -2,3 +2,5 @@ export const DEFAULT_ASSISTANT = { id: 'ai_assistant', name: 'AI Assistant' } + +export const FETCH_CONVERSATIONS_LIMIT = 50 diff --git a/packages/cozy-search/src/components/helpers.spec.js b/packages/cozy-search/src/components/helpers.spec.js index 2a450dcff8..0050fb1926 100644 --- a/packages/cozy-search/src/components/helpers.spec.js +++ b/packages/cozy-search/src/components/helpers.spec.js @@ -1,4 +1,11 @@ -import { sanitizeChatContent } from './helpers' +import { + sanitizeChatContent, + formatConversationDate, + getNameOfConversation, + getDescriptionOfConversation +} from './helpers' + +jest.mock('cozy-flags', () => jest.fn(() => true), { virtual: true }) describe('sanitizeChatContent', () => { it('should return empty string for empty content', () => { @@ -41,3 +48,92 @@ describe('sanitizeChatContent', () => { expect(sanitizeChatContent(text)).toBe(text) }) }) + +describe('formatConversationDate', () => { + const mockT = jest.fn(key => key) + const mockDate = new Date('2023-11-20T12:00:00Z') + let OriginalDate + let dateSpy + + beforeEach(() => { + OriginalDate = global.Date + dateSpy = jest.spyOn(global, 'Date').mockImplementation(function (...args) { + if (args.length) { + return new OriginalDate(...args) + } + return mockDate + }) + dateSpy.now = jest.fn(() => mockDate.getTime()) + }) + + afterEach(() => { + dateSpy.mockRestore() + mockT.mockClear() + }) + + it('returns empty string for invalid dates', () => { + expect(formatConversationDate(null, mockT, 'en-US')).toBe('') + expect(formatConversationDate('not date', mockT, 'en-US')).toBe('') + }) + + it('formats today as "Today, HH:mm"', () => { + const today = new Date('2023-11-20T08:30:00Z').toISOString() + const result = formatConversationDate(today, mockT, 'en-US') + + expect(result).toMatch(/assistant\.time\.today/) + expect(result).toMatch(/\d{1,2}:\d{2}/) + }) + + it('formats yesterday as "Yesterday, HH:mm"', () => { + const yesterday = new Date('2023-11-19T14:45:00Z').toISOString() + const result = formatConversationDate(yesterday, mockT, 'en-US') + + expect(result).toMatch(/assistant\.time\.yesterday/) + expect(result).toMatch(/\d{1,2}:\d{2}/) + }) + + it('formats older dates as formatted short date strings', () => { + const older = new Date('2022-01-05T10:00:00Z').toISOString() + const result = formatConversationDate(older, mockT, 'en-US') + + expect(result).toContain('2022') + expect(result).toContain('Jan') + }) +}) + +describe('getNameOfConversation', () => { + it('returns undefined if messages array is empty or missing', () => { + expect(getNameOfConversation({})).toBeUndefined() + expect(getNameOfConversation({ messages: [] })).toBeUndefined() + expect( + getNameOfConversation({ messages: [{ content: 'Hi' }] }) + ).toBeUndefined() + }) + + it('returns the content of the second to last message', () => { + const convo = { + messages: [ + { role: 'user', content: 'What is the sum?' }, + { role: 'assistant', content: 'It is 4' } + ] + } + expect(getNameOfConversation(convo)).toBe('What is the sum?') + }) +}) + +describe('getDescriptionOfConversation', () => { + it('returns undefined if messages array is empty or missing', () => { + expect(getDescriptionOfConversation({})).toBeUndefined() + expect(getDescriptionOfConversation({ messages: [] })).toBeUndefined() + }) + + it('returns the content of the last message', () => { + const convo = { + messages: [ + { role: 'user', content: 'What is the sum?' }, + { role: 'assistant', content: 'It is 4' } + ] + } + expect(getDescriptionOfConversation(convo)).toBe('It is 4') + }) +}) diff --git a/packages/cozy-search/src/components/queries.js b/packages/cozy-search/src/components/queries.js index 1c0b7729b8..83972b8d78 100644 --- a/packages/cozy-search/src/components/queries.js +++ b/packages/cozy-search/src/components/queries.js @@ -1,5 +1,7 @@ import { Q, fetchPolicies } from 'cozy-client' +import { FETCH_CONVERSATIONS_LIMIT } from './constants' + const CONTACTS_DOCTYPE = 'io.cozy.contacts' export const CHAT_CONVERSATIONS_DOCTYPE = 'io.cozy.ai.chat.conversations' export const CHAT_EVENTS_DOCTYPE = 'io.cozy.ai.chat.events' @@ -66,15 +68,17 @@ export const buildAssistantByIdQuery = id => ({ export const buildChatConversationsQuery = () => { return { - definition: () => + definition: ({ bookmark, query = {} }) => Q(CHAT_CONVERSATIONS_DOCTYPE) - .where({}) + .where(query) .indexFields(['cozyMetadata.updatedAt']) .sortBy([{ 'cozyMetadata.updatedAt': 'desc' }]) - .limitBy(50), - options: { - as: CHAT_CONVERSATIONS_DOCTYPE + '/recent', + .include(['assistant']) + .offsetBookmark(bookmark) + .limitBy(FETCH_CONVERSATIONS_LIMIT), + options: ({ query = {} }) => ({ + as: `${CHAT_CONVERSATIONS_DOCTYPE}/recent-${JSON.stringify(query)}`, fetchPolicy: defaultFetchPolicy - } + }) } } diff --git a/packages/cozy-search/src/components/styles.styl b/packages/cozy-search/src/components/styles.styl index d053d690ee..eb8f61cfbb 100644 --- a/packages/cozy-search/src/components/styles.styl +++ b/packages/cozy-search/src/components/styles.styl @@ -18,4 +18,4 @@ padding-bottom calc(1rem + var(--flagship-bottom-height)) .assistantWrapper - height calc(100vh - 48px) + height calc(100dvh - 3rem - 1px) // 3rem is topbar height, 1px is divider height diff --git a/packages/cozy-search/src/hooks/useConversation.jsx b/packages/cozy-search/src/hooks/useConversation.jsx index dee4cd036a..a28d5796c9 100644 --- a/packages/cozy-search/src/hooks/useConversation.jsx +++ b/packages/cozy-search/src/hooks/useConversation.jsx @@ -9,17 +9,10 @@ const useConversation = () => { const { setIsOpenSearchConversation } = useAssistant() const goToConversation = conversationId => { - const parts = location.pathname.split('/') - const assistantIndex = parts.findIndex(part => part === 'assistant') - - if (assistantIndex === -1) { - parts.push('assistant', conversationId) - } else if (parts.length > assistantIndex + 1) { - parts[assistantIndex + 1] = conversationId - } else { - parts.push(conversationId) - } - const newPathname = parts.join('/') + // Extract base path safely by identifying the start of '/assistant' if it exists. + const match = location.pathname.match(/^(.*?)(\/assistant(\/|$).*|$)/) + const basePath = (match?.[1] || location.pathname).replace(/\/$/, '') + const newPathname = `${basePath}/assistant/${conversationId}` setIsOpenSearchConversation(false) diff --git a/packages/cozy-search/src/hooks/useConversation.spec.jsx b/packages/cozy-search/src/hooks/useConversation.spec.jsx new file mode 100644 index 0000000000..fe461d0ca8 --- /dev/null +++ b/packages/cozy-search/src/hooks/useConversation.spec.jsx @@ -0,0 +1,121 @@ +import { renderHook, act } from '@testing-library/react-hooks' + +import useConversation from './useConversation' + +const mockNavigate = jest.fn() +let mockLocation = { + pathname: '/', + search: '', + hash: '' +} + +jest.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, + useLocation: () => mockLocation +})) + +const mockSetIsOpenSearchConversation = jest.fn() +jest.mock('../components/AssistantProvider', () => ({ + useAssistant: () => ({ + setIsOpenSearchConversation: mockSetIsOpenSearchConversation + }) +})) + +jest.mock('../components/helpers', () => ({ + makeConversationId: () => 'mock-id-123' +})) + +describe('useConversation', () => { + beforeEach(() => { + jest.clearAllMocks() + mockLocation = { + pathname: '/', + search: '', + hash: '' + } + }) + + describe('goToConversation', () => { + it('appends /assistant/id to a base url', () => { + mockLocation.pathname = '/drive/folders/123' + const { result } = renderHook(() => useConversation()) + + act(() => { + result.current.goToConversation('convo-456') + }) + + expect(mockSetIsOpenSearchConversation).toHaveBeenCalledWith(false) + expect(mockNavigate).toHaveBeenCalledWith({ + pathname: '/drive/folders/123/assistant/convo-456', + search: '', + hash: '' + }) + }) + + it('replaces existing /assistant/... path intelligently', () => { + mockLocation.pathname = '/drive/folders/123/assistant/old-convo-789' + const { result } = renderHook(() => useConversation()) + + act(() => { + result.current.goToConversation('new-convo-000') + }) + + expect(mockNavigate).toHaveBeenCalledWith({ + pathname: '/drive/folders/123/assistant/new-convo-000', + search: '', + hash: '' + }) + }) + + it('handles trailing slashes on base path correctly', () => { + mockLocation.pathname = '/drive/folders/123/' + const { result } = renderHook(() => useConversation()) + + act(() => { + result.current.goToConversation('convo-456') + }) + + expect(mockNavigate).toHaveBeenCalledWith({ + pathname: '/drive/folders/123/assistant/convo-456', + search: '', + hash: '' + }) + }) + + it('preserves search query and hash fragments', () => { + mockLocation = { + pathname: '/base', + search: '?foo=bar', + hash: '#section1' + } + const { result } = renderHook(() => useConversation()) + + act(() => { + result.current.goToConversation('convo-1') + }) + + expect(mockNavigate).toHaveBeenCalledWith({ + pathname: '/base/assistant/convo-1', + search: '?foo=bar', + hash: '#section1' + }) + }) + }) + + describe('createNewConversation', () => { + it('creates a new conversation using makeConversationId', () => { + mockLocation.pathname = '/docs' + const { result } = renderHook(() => useConversation()) + + act(() => { + result.current.createNewConversation() + }) + + expect(mockNavigate).toHaveBeenCalledWith({ + pathname: '/docs/assistant/mock-id-123', + search: '', + hash: '' + }) + }) + }) +}) diff --git a/packages/cozy-search/src/hooks/useFetchConversations.js b/packages/cozy-search/src/hooks/useFetchConversations.js new file mode 100644 index 0000000000..0ed9d4d45e --- /dev/null +++ b/packages/cozy-search/src/hooks/useFetchConversations.js @@ -0,0 +1,119 @@ +import isEqual from 'lodash/isEqual' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { useClient } from 'cozy-client' +import Minilog from 'cozy-minilog' + +import { DEFAULT_ASSISTANT } from '../components/constants' +import { buildChatConversationsQuery } from '../components/queries' + +const log = Minilog('[useFetchConversations]') + +/** + * We use `client.query` manually instead of the `useQuery` hook from cozy-client + * because `useQuery` currently drops the `included` array from its output state. + * Without `included`, we cannot easily map the `assistant` relationship to each conversation. + * + * For more details on the cozy-client issue, see: + * https://github.com/linagora/cozy-client/issues/1083 + * + * @typedef {Object} Assistant + * @property {string} _id + * @property {string} name + * @property {string} [icon] + * + * @typedef {Object} ConversationWithAssistant + * @property {string} _id + * @property {Array} messages + * @property {Assistant} assistant - The assistant object bolted on from the query's `included` relationships, or the DEFAULT_ASSISTANT fallback. + * + * @param {Object} [props={}] + * @param {Object} [props.query={}] - Optional query filters to pass to `where()` + * + * @returns {{ + * conversations: ConversationWithAssistant[], + * hasMore: boolean, + * bookmark: string|null, + * isLoading: boolean, + * fetchMore: function(): Promise, + * fetchConversations: function(string|null, Object): Promise + * }} + */ +const useFetchConversations = ({ query = {} } = {}) => { + const client = useClient() + const [conversations, setConversations] = useState([]) + const [hasMore, setHasMore] = useState(false) + const [bookmark, setBookmark] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const previousQueryRef = useRef() + const latestRequestIdRef = useRef(0) + + const conversationsQuery = useMemo(() => buildChatConversationsQuery(), []) + + const fetchConversations = useCallback( + async (bookmark = null, fetchQuery) => { + const requestId = ++latestRequestIdRef.current + setIsLoading(true) + try { + const response = await client.query( + conversationsQuery.definition({ + bookmark, + query: fetchQuery + }) + ) + if (requestId !== latestRequestIdRef.current) return + + const combinedData = + response.data?.map(conversation => ({ + ...conversation, + assistant: + response.included?.find( + included => + included._id === + conversation.relationships?.assistant?.data?._id + ) || DEFAULT_ASSISTANT + })) || [] + + setConversations(prev => + !bookmark ? combinedData : [...prev, ...combinedData] + ) + setHasMore(response.next) + setBookmark(response.bookmark) + } catch (error) { + log.error('Error fetching conversations:', error) + } finally { + if (requestId === latestRequestIdRef.current) { + setIsLoading(false) + } + } + }, + [client, conversationsQuery] + ) + + useEffect(() => { + if (!isEqual(previousQueryRef.current, query)) { + setConversations([]) + setHasMore(false) + setBookmark(null) + previousQueryRef.current = query + fetchConversations(null, query) + } + }, [query, fetchConversations]) + + const fetchMore = useCallback(async () => { + if (hasMore) { + await fetchConversations(bookmark, previousQueryRef.current) + } + }, [hasMore, bookmark, fetchConversations]) + + return { + conversations, + hasMore, + bookmark, + isLoading, + fetchMore, + fetchConversations + } +} + +export default useFetchConversations diff --git a/packages/cozy-search/src/hooks/useFetchConversations.spec.js b/packages/cozy-search/src/hooks/useFetchConversations.spec.js new file mode 100644 index 0000000000..011e021249 --- /dev/null +++ b/packages/cozy-search/src/hooks/useFetchConversations.spec.js @@ -0,0 +1,256 @@ +import { render, act } from '@testing-library/react' +import React from 'react' + +import { useClient } from 'cozy-client' + +import useFetchConversations from './useFetchConversations' + +jest.mock('cozy-client', () => ({ + useClient: jest.fn(), + Q: jest.fn(() => ({ + getByIds: jest.fn(), + getById: jest.fn(), + where: jest.fn().mockReturnThis(), + include: jest.fn().mockReturnThis(), + indexFields: jest.fn().mockReturnThis(), + sortBy: jest.fn().mockReturnThis(), + offsetBookmark: jest.fn().mockReturnThis(), + limitBy: jest.fn().mockReturnThis() + })), + fetchPolicies: { + olderThan: jest.fn() + } +})) + +jest.mock( + 'cozy-minilog', + () => () => ({ + error: jest.fn(), + info: jest.fn() + }), + { virtual: true } +) + +const mockClient = { + query: jest.fn() +} + +describe('useFetchConversations', () => { + beforeEach(() => { + jest.clearAllMocks() + mockClient.query.mockReset() + useClient.mockReturnValue(mockClient) + }) + + it('initializes with empty conversations and not loading', () => { + let hookResult = {} + const TestComponent = () => { + hookResult.current = useFetchConversations() + return null + } + act(() => { + render() + }) + + expect(hookResult.current.conversations).toEqual([]) + expect(hookResult.current.hasMore).toBe(false) + expect(hookResult.current.isLoading).toBe(true) // Starts loading immediately due to useEffect + }) + + it('fetches conversations on mount and resolves relationships', async () => { + const mockResponse = { + data: [ + { + _id: 'conv1', + relationships: { assistant: { data: { _id: 'ast1' } } } + }, + { _id: 'conv2', relationships: {} } + ], + included: [{ _id: 'ast1', name: 'Mock Assistant', icon: 'mock.png' }], + next: true, + bookmark: 'bmk123' + } + + mockClient.query.mockResolvedValueOnce(mockResponse) + + let hookResult = {} + const TestComponent = () => { + hookResult.current = useFetchConversations() + return null + } + act(() => { + render() + }) + + expect(hookResult.current.isLoading).toBe(true) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + expect(mockClient.query).toHaveBeenCalledTimes(1) + expect(hookResult.current.isLoading).toBe(false) + expect(hookResult.current.hasMore).toBe(true) + expect(hookResult.current.bookmark).toBe('bmk123') + + // Check relationship mapping + expect(hookResult.current.conversations).toHaveLength(2) + expect(hookResult.current.conversations[0].assistant.name).toBe( + 'Mock Assistant' + ) + // Fallback DEFAULT_ASSISTANT + expect(hookResult.current.conversations[1].assistant.id).toBe( + 'ai_assistant' + ) + }) + + it('fetchMore loads next bookmark and concatenates results', async () => { + const firstPage = { + data: [{ _id: 'c1' }], + next: true, + bookmark: 'bmk1' + } + const secondPage = { + data: [{ _id: 'c2' }], + next: false, + bookmark: null + } + + mockClient.query + .mockResolvedValueOnce(firstPage) + .mockResolvedValueOnce(secondPage) + + let hookResult = {} + const TestComponent = () => { + hookResult.current = useFetchConversations() + return null + } + act(() => { + render() + }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + }) // wait for initial load + + expect(hookResult.current.conversations).toHaveLength(1) + expect(hookResult.current.bookmark).toBe('bmk1') + expect(hookResult.current.hasMore).toBe(true) + + // Trigger fetchMore + act(() => { + hookResult.current.fetchMore() + }) + + expect(hookResult.current.isLoading).toBe(true) + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + expect(hookResult.current.conversations).toHaveLength(2) + expect(hookResult.current.conversations[1]._id).toBe('c2') + expect(hookResult.current.hasMore).toBe(false) + expect(hookResult.current.bookmark).toBe(null) + }) + + it('handles query parameter changes gracefully by resetting state', async () => { + const initialQuery = { title: 'First' } + const newQuery = { title: 'Second' } + + mockClient.query.mockResolvedValue({ + data: [{ _id: 'c1' }], + next: true, + bookmark: 'bmk1' + }) + + let hookResult + const TestComponent = ({ query }) => { + hookResult = useFetchConversations({ query }) + return null + } + + let rerender + act(() => { + const utils = render() + rerender = utils.rerender + }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + }) + expect(hookResult.conversations).toHaveLength(1) + + // Render with new query + mockClient.query.mockResolvedValueOnce({ + data: [{ _id: 'c2' }], + next: false, + bookmark: null + }) + + act(() => { + rerender() + }) + + // It should immediately be loading and wipe state + expect(hookResult.isLoading).toBe(true) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + // Now populated with new query results + expect(hookResult.conversations[0]._id).toBe('c2') + expect(mockClient.query).toHaveBeenCalledTimes(2) + }) + + it('ignores stale responses using request IDs', async () => { + // We simulate a slow response that resolves AFTER a fast response + // First request is slow + const slowResponsePromise = new Promise(resolve => { + setTimeout(() => resolve({ data: [{ _id: 'stale' }], next: false }), 50) + }) + + // Second request is fast + const fastResponsePromise = Promise.resolve({ + data: [{ _id: 'fresh' }], + next: false + }) + + mockClient.query + .mockReturnValueOnce(slowResponsePromise) + .mockReturnValueOnce(fastResponsePromise) + + let hookResult + const TestComponent = ({ query }) => { + hookResult = useFetchConversations({ query }) + return null + } + + let rerender + act(() => { + const utils = render() + rerender = utils.rerender + }) + + // Immediately trigger a new query + act(() => { + rerender() + }) + + // Wait for the fast response to resolve + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + // Ensure it's the fresh data + expect(hookResult.conversations[0]._id).toBe('fresh') + + // Wait a bit longer for the slow response to resolve + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 60)) + }) + + // Data should not have been overwritten by the stale response + expect(hookResult.conversations[0]._id).toBe('fresh') + }) +}) diff --git a/packages/cozy-search/src/locales/en.json b/packages/cozy-search/src/locales/en.json index e63b43012c..d26e48b7f0 100644 --- a/packages/cozy-search/src/locales/en.json +++ b/packages/cozy-search/src/locales/en.json @@ -26,12 +26,16 @@ }, "sidebar": { "create_new": "New Chat", + "toggle_sidebar": "Toggle sidebar", + "toggle_search": "Toggle search", + "close_sidebar": "Close sidebar", "recent_chats": "Recent Chats", "conversation": { "actions": { "delete": "Delete", "rename": "Rename", - "share": "Share" + "share": "Share", + "load_more": "Load more" } } }, @@ -46,7 +50,10 @@ "placeholder": "Search in your conversations...", "new_chat": "New Chat", "recent": "Recent Conversations", - "older": "Older" + "older": "Older", + "not_found_title": "No conversations found", + "not_found_desc": "Click \"New Chat\" to begin a conversation or select from your recent chats", + "close": "Close" }, "message": { "welcome": "How can I help you today?", @@ -77,7 +84,8 @@ "sent": "Sent", "draft": "Draft", "outbox": "Outbox", - "spam": "Spam" + "spam": "Spam", + "search_in": "Search in" } }, "assistant_create": { diff --git a/packages/cozy-search/src/locales/fr.json b/packages/cozy-search/src/locales/fr.json index 2658a4d41f..8fbf7ac6ed 100644 --- a/packages/cozy-search/src/locales/fr.json +++ b/packages/cozy-search/src/locales/fr.json @@ -26,12 +26,16 @@ }, "sidebar": { "create_new": "Nouveau chat", + "toggle_sidebar": "Basculer la barre latérale", + "toggle_search": "Basculer la recherche", + "close_sidebar": "Fermer la barre latérale", "recent_chats": "Conversations récentes", "conversation": { "actions": { "delete": "Supprimer", "rename": "Renommer", - "share": "Partager" + "share": "Partager", + "load_more": "Charger plus" } } }, @@ -46,7 +50,10 @@ "placeholder": "Rechercher dans vos conversations...", "new_chat": "Nouvelle conversation", "recent": "Conversations récentes", - "older": "Plus ancien" + "older": "Plus ancien", + "not_found_title": "Aucune conversation trouvée", + "not_found_desc": "Cliquez sur \"Nouvelle conversation\" pour commencer une conversation ou sélectionnez parmi vos discussions récentes", + "close": "Fermer" }, "message": { "welcome": "Comment puis-je vous aider aujourd'hui ?", @@ -77,7 +84,8 @@ "sent": "Envoyé", "draft": "Brouillon", "outbox": "Boîte d'envoi", - "spam": "Spam" + "spam": "Spam", + "search_in": "Rechercher dans" } }, "assistant_create": { diff --git a/packages/cozy-search/src/locales/ru.json b/packages/cozy-search/src/locales/ru.json index aa9b066e19..ab716943c6 100644 --- a/packages/cozy-search/src/locales/ru.json +++ b/packages/cozy-search/src/locales/ru.json @@ -19,12 +19,16 @@ }, "sidebar": { "create_new": "Новый чат", + "toggle_sidebar": "Переключить боковую панель", + "toggle_search": "Переключить поиск", + "close_sidebar": "Закрыть боковую панель", "recent_chats": "Недавние переписки", "conversation": { "actions": { "delete": "Удалить", "rename": "Переименовать", - "share": "Поделиться" + "share": "Поделиться", + "load_more": "Загрузить еще" } } }, @@ -39,7 +43,10 @@ "placeholder": "Поиск в переписках...", "new_chat": "Новый чат", "recent": "Недавние переписки", - "older": "Старые" + "older": "Старые", + "not_found_title": "Переписок не найдено", + "not_found_desc": "Нажмите \"Новый чат\", чтобы начать переписку, или выберите из ваших недавних переписок", + "close": "Закрыть" }, "actions": { "copy": "Копировать", @@ -77,7 +84,8 @@ "sent": "Отправленные", "draft": "Черновики", "outbox": "Исходящие", - "spam": "Спам" + "spam": "Спам", + "search_in": "Поиск в" } }, "assistant_create": { diff --git a/packages/cozy-search/src/locales/vi.json b/packages/cozy-search/src/locales/vi.json index 2318e35910..22ff6b3233 100644 --- a/packages/cozy-search/src/locales/vi.json +++ b/packages/cozy-search/src/locales/vi.json @@ -26,12 +26,16 @@ }, "sidebar": { "create_new": "Cuộc trò chuyện mới", + "toggle_sidebar": "Bật/tắt thanh bên", + "toggle_search": "Bật/tắt tìm kiếm", + "close_sidebar": "Đóng thanh bên", "recent_chats": "Cuộc trò chuyện gần đây", "conversation": { "actions": { "delete": "Xóa", "rename": "Đổi tên", - "share": "Chia sẻ" + "share": "Chia sẻ", + "load_more": "Tải thêm" } } }, @@ -50,7 +54,10 @@ "placeholder": "Tìm kiếm trong các cuộc trò chuyện...", "new_chat": "Cuộc trò chuyện mới", "recent": "Cuộc trò chuyện gần đây", - "older": "Cũ hơn" + "older": "Cũ hơn", + "not_found_title": "Không tìm thấy cuộc trò chuyện nào", + "not_found_desc": "Nhấp vào \"Cuộc trò chuyện mới\" để bắt đầu một cuộc trò chuyện hoặc chọn từ các cuộc trò chuyện gần đây của bạn", + "close": "Đóng" }, "twake_knowledges": { "chat": "Chat", @@ -77,7 +84,8 @@ "sent": "Đã gửi", "draft": "Nháp", "outbox": "Hộp thư đi", - "spam": "Thư rác" + "spam": "Thư rác", + "search_in": "Tìm kiếm trong" } }, "assistant_create": { diff --git a/packages/cozy-search/tests/jest.config.js b/packages/cozy-search/tests/jest.config.js index 99ae05bde4..f14548379a 100644 --- a/packages/cozy-search/tests/jest.config.js +++ b/packages/cozy-search/tests/jest.config.js @@ -9,6 +9,7 @@ const config = { coveragePathIgnorePatterns: ['./tests'], rootDir: '../', testMatch: ['./**/*.spec.{ts,tsx,js}'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], coverageThreshold: { global: { branches: 80, diff --git a/yarn.lock b/yarn.lock index 9f9d529409..f7f81fa4ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15705,15 +15705,6 @@ __metadata: languageName: node linkType: hard -"class-variance-authority@npm:^0.7.1": - version: 0.7.1 - resolution: "class-variance-authority@npm:0.7.1" - dependencies: - clsx: "npm:^2.1.1" - checksum: 10c0/0f438cea22131808b99272de0fa933c2532d5659773bfec0c583de7b3f038378996d3350683426b8e9c74a6286699382106d71fbec52f0dd5fbb191792cccb5b - languageName: node - linkType: hard - "classnames@npm:^2.2.5, classnames@npm:^2.2.6, classnames@npm:^2.5.1": version: 2.5.1 resolution: "classnames@npm:2.5.1" @@ -15902,13 +15893,6 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^2.1.1": - version: 2.1.1 - resolution: "clsx@npm:2.1.1" - checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 - languageName: node - linkType: hard - "cmd-shim@npm:6.0.3, cmd-shim@npm:^6.0.0": version: 6.0.3 resolution: "cmd-shim@npm:6.0.3" @@ -17994,9 +17978,7 @@ __metadata: babel-plugin-module-resolver: "npm:^4.0.0" babel-plugin-tsconfig-paths: "npm:^1.0.3" babel-preset-cozy-app: "npm:^2.8.4" - class-variance-authority: "npm:^0.7.1" classnames: "npm:^2.5.1" - clsx: "npm:^2.1.1" cozy-bar: "npm:^29.0.0" cozy-client: "npm:^60.21.0" cozy-device-helper: "npm:^4.0.3" @@ -18011,7 +17993,6 @@ __metadata: cross-fetch: "npm:^4.0.0" jest: "npm:27.5.1" lodash: "npm:^4.17.21" - lucide-react: "npm:^0.563.0" mime-types: "npm:2.1.35" react: "npm:18.2.0" react-dom: "npm:18.2.0" @@ -29438,15 +29419,6 @@ __metadata: languageName: node linkType: hard -"lucide-react@npm:^0.563.0": - version: 0.563.0 - resolution: "lucide-react@npm:0.563.0" - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/4a2027be255755d64ce18040062426dec07a5da920c000d9cd330e383fa10716a584c927dc03e91daccc6e05f6f486d4f49d1bd009e5f7763aa6fcf704806554 - languageName: node - linkType: hard - "lunr@npm:^2.3.6": version: 2.3.6 resolution: "lunr@npm:2.3.6"