-
-
-
-
- {sidebarOpen && flag('cozy.search-conversation.enabled') && (
-
-
-
- )}
-
-
- {sidebarOpen ? (
-
}
- fullWidth
- variant="primary"
- onClick={createNewConversation}
- />
- ) : (
+ <>
+
+
-
+ }
+ />
+
+ {sidebarOpen && flag('cozy.search-conversation.enabled') && (
+
+
+
+ )}
+ {sidebarOpen && isMobile && (
+
+
+
+ )}
+
+
+
+ {sidebarOpen ? (
+ }
+ fullWidth
+ variant="primary"
+ onClick={createNewConversation}
+ />
+ ) : isMobile ? null : (
+
+
+
+ )}
+
+
+ {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}
+
+ ) : (
+
+ )
+
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"