Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ node_modules
!.yarn/releases
!.yarn/sdks
!.yarn/versions

# Worktrees
.worktrees
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ComposerPrimitive } from '@assistant-ui/react'
import cx from 'classnames'
import React, { useRef } from 'react'
import React, { useEffect, useRef, useState } from 'react'

import Button from 'cozy-ui/transpiled/react/Buttons'
import Icon from 'cozy-ui/transpiled/react/Icon'
Expand All @@ -12,6 +12,8 @@ import useEventListener from 'cozy-ui/transpiled/react/hooks/useEventListener'
import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'
import { useI18n } from 'twake-i18n'

import { useFileMention } from './FileMentionContext'
import FileMentionMenu from './FileMentionMenu'
import styles from './styles.styl'

const ConversationBar = ({
Expand All @@ -21,11 +23,29 @@ const ConversationBar = ({
onKeyDown,
onSend,
onCancel,
onChange,
composerRuntime,
...props
}) => {
const { t } = useI18n()
const { isMobile } = useBreakpoints()
const inputRef = useRef()
const containerRef = useRef()
const { handleInputChange, hasMention, mentionSearchTerm, menuKeyDownRef } =
useFileMention()
const [anchorEl, setAnchorEl] = useState(null)

useEffect(() => {
setAnchorEl(hasMention ? containerRef.current : null)
}, [hasMention])

const handleChange = e => {
const newValue = e.target.value
handleInputChange(newValue)
if (onChange) {
onChange(e)
}
}

// to adjust input height for multiline when typing in it
// eslint-disable-next-line react-hooks/refs
Expand All @@ -46,13 +66,17 @@ const ConversationBar = ({
}

const handleKeyDown = e => {
if (hasMention && menuKeyDownRef.current) {
menuKeyDownRef.current(e)
if (e.defaultPrevented) return
}
if (isEmpty) return

onKeyDown(e)
}

return (
<div className="u-w-100 u-maw-7 u-mh-auto">
<div className="u-w-100 u-maw-7 u-mh-auto" ref={containerRef}>
<SearchBar
{...props}
className={cx(styles['conversationBar'], {
Expand All @@ -62,6 +86,7 @@ const ConversationBar = ({
size="auto"
placeholder={t('assistant.search.placeholder')}
value={value}
onChange={handleChange}
disabledClear
componentsProps={{
inputBase: {
Expand Down Expand Up @@ -102,6 +127,14 @@ const ConversationBar = ({
}
}}
/>
{hasMention && (
<FileMentionMenu
anchorEl={anchorEl}
searchTerm={mentionSearchTerm}
composerRuntime={composerRuntime}
inputRef={inputRef}
/>
)}
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ComposerPrimitive,
useComposerRuntime,
useThread,
useThreadRuntime,
useComposer
} from '@assistant-ui/react'
import cx from 'classnames'
Expand All @@ -11,23 +12,50 @@ import flag from 'cozy-flags'
import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'

import ConversationBar from './ConversationBar'
import FileChipsList from './FileChipsList'
import { useFileMention } from './FileMentionContext'
import AssistantSelection from '../Assistant/AssistantSelection'
import { useAssistant } from '../AssistantProvider'
import TwakeKnowledgeSelector from '../TwakeKnowledges/TwakeKnowledgeSelector'

const ConversationComposer = () => {
const { isMobile } = useBreakpoints()
const composerRuntime = useComposerRuntime()
const threadRuntime = useThreadRuntime()
const isRunning = useThread(state => state.isRunning)
const isThreadEmpty = useThread(state => state.messages.length === 0)
const { setOpenedKnowledgePanel } = useAssistant()
const {
selectedFiles,
reset: resetFileMention,
snapshotAttachmentsIDs
} = useFileMention()

const value = useComposer(state => state.text)
const isEmpty = useComposer(state => state.isEmpty)

const handleSend = useCallback(() => {
composerRuntime.send()
}, [composerRuntime])
if (isEmpty || isRunning) return
const text = composerRuntime.getState().text
const attachmentIDs = selectedFiles.map(f => f.id)
snapshotAttachmentsIDs()
const metadata =
attachmentIDs.length > 0 ? { custom: { attachmentIDs } } : undefined
threadRuntime.append({
content: [{ type: 'text', text }],
metadata
})
composerRuntime.setText('')
resetFileMention()
}, [
composerRuntime,
threadRuntime,
selectedFiles,
resetFileMention,
snapshotAttachmentsIDs,
isEmpty,
isRunning
])

const handleCancel = useCallback(() => {
composerRuntime.cancel()
Expand Down Expand Up @@ -64,8 +92,11 @@ const ConversationComposer = () => {
onKeyDown={handleKeyDown}
onCancel={handleCancel}
onSend={handleSend}
composerRuntime={composerRuntime}
/>

<FileChipsList />

<div className="u-flex u-flex-items-center u-flex-justify-between u-mt-1">
{flag('cozy.assistant.create-assistant.enabled') && (
<AssistantSelection disabled={!isThreadEmpty} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react'

import Chip from 'cozy-ui/transpiled/react/Chips'
import Icon from 'cozy-ui/transpiled/react/Icon'

import { useFileMention } from './FileMentionContext'

const FileChipsList = () => {
const { selectedFiles, removeFile } = useFileMention()

if (selectedFiles.length === 0) return null

return (
<div className="u-flex u-flex-wrap u-mt-half">
{selectedFiles.map(file => (
<Chip
key={file.id}
label={file.name}
icon={
file.icon?.component ? (
<Icon icon={file.icon.component} size={16} />
) : undefined
}
onDelete={() => removeFile(file.id)}
className="u-mr-half u-mb-half"
size="small"
/>
))}
</div>
)
}

export default FileChipsList
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React, {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState
} from 'react'

import { MENTION_MATCH_REGEX, MENTION_REPLACE_REGEX } from './mentionConstants'

const FileMentionContext = createContext(null)

export const FileMentionProvider = ({ children }) => {
const [selectedFiles, setSelectedFiles] = useState([])
const [inputValue, setInputValue] = useState('')
const menuKeyDownRef = useRef(null)

const addFile = useCallback(file => {
setSelectedFiles(prev => {
if (prev.some(f => f.id === file.id)) return prev
return [...prev, file]
})
}, [])

const removeFile = useCallback(fileId => {
setSelectedFiles(prev => prev.filter(f => f.id !== fileId))
}, [])

const reset = useCallback(() => {
setSelectedFiles([])
setInputValue('')
}, [])

const removeMentionText = useCallback(() => {
setInputValue(prev => prev.replace(MENTION_REPLACE_REGEX, '$1'))
}, [])

const handleInputChange = useCallback(newValue => {
setInputValue(newValue)
}, [])

const mentionMatch = inputValue.match(MENTION_MATCH_REGEX)
const hasMention = mentionMatch !== null
const mentionSearchTerm = mentionMatch ? mentionMatch[2] : ''

const pendingAttachmentsRef = useRef(null)

const snapshotAttachmentsIDs = useCallback(() => {
pendingAttachmentsRef.current = selectedFiles.map(f => f.id)
}, [selectedFiles])

const getAttachmentsIDs = useCallback(() => {
const ids = pendingAttachmentsRef.current || []
pendingAttachmentsRef.current = null
return ids
}, [])

const value = useMemo(
() => ({
selectedFiles,
inputValue,
addFile,
removeFile,
reset,
removeMentionText,
handleInputChange,
hasMention,
mentionSearchTerm,
snapshotAttachmentsIDs,
getAttachmentsIDs,
menuKeyDownRef
}),
[
selectedFiles,
inputValue,
addFile,
removeFile,
reset,
removeMentionText,
handleInputChange,
hasMention,
mentionSearchTerm,
snapshotAttachmentsIDs,
getAttachmentsIDs
]
)

return (
<FileMentionContext.Provider value={value}>
{children}
</FileMentionContext.Provider>
)
}

export const useFileMention = () => {
const context = useContext(FileMentionContext)
if (!context) {
throw new Error('useFileMention must be used within FileMentionProvider')
}
return context
}
Loading
Loading