diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ceb02..3c2845d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - ✨(back) add projects with custom LLM instructions +- ✨(front) projects management UI ## [0.0.14] - 2026-03-11 diff --git a/src/frontend/apps/conversations/cunningham.ts b/src/frontend/apps/conversations/cunningham.ts index 4689604..9f0332f 100644 --- a/src/frontend/apps/conversations/cunningham.ts +++ b/src/frontend/apps/conversations/cunningham.ts @@ -846,8 +846,6 @@ if (dsfrThemes.dark) { dsfrThemes.dark.globals.colors['logo-2'] = '#E78087'; } -console.log(dsfrThemes); - const themes = { themes: { default: whiteLabelThemes.light, diff --git a/src/frontend/apps/conversations/package.json b/src/frontend/apps/conversations/package.json index affd06a..2c88363 100644 --- a/src/frontend/apps/conversations/package.json +++ b/src/frontend/apps/conversations/package.json @@ -18,13 +18,11 @@ "dependencies": { "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", - "@emoji-mart/data": "1.2.1", - "@emoji-mart/react": "1.1.1", "@fontsource/material-icons": "5.2.5", + "@gouvfr-lasuite/cunningham-react": "^4.2.0", "@gouvfr-lasuite/cunningham-tokens": "^3.1.0", "@gouvfr-lasuite/integration": "1.0.3", - "@gouvfr-lasuite/ui-kit": "0.18.7", - "@openfun/cunningham-react": "4.0.0", + "@gouvfr-lasuite/ui-kit": "0.19.10", "@sentry/nextjs": "9.26.0", "@shikijs/rehype": "^3.21.0", "@tanstack/react-query": "5.80.5", @@ -32,7 +30,6 @@ "clsx": "2.1.1", "cmdk": "1.1.1", "crisp-sdk-web": "1.0.25", - "emoji-mart": "5.6.0", "i18next": "25.2.1", "i18next-browser-languagedetector": "8.1.0", "idb": "8.0.3", diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/book-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/book-filled.svg index d1bb202..c4a1147 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/book-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/book-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/bookmark-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/bookmark-filled.svg index fe03cc0..f53a868 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/bookmark-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/bookmark-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/car-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/car-filled.svg index a19cada..cbbd150 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/car-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/car-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/chart-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/chart-filled.svg index 6341070..fbf582f 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/chart-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/chart-filled.svg @@ -1,4 +1,4 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/checkmark-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/checkmark-filled.svg index 683ddab..40cd486 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/checkmark-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/checkmark-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/euro-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/euro-filled.svg index ff85424..0027273 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/euro-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/euro-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/file-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/file-filled.svg index 3b511b4..6f856e3 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/file-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/file-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/folder-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/folder-filled.svg index 4727ee1..200c661 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/folder-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/folder-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/folder-plus.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/folder-plus.svg index a70d3ba..0a0b329 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/folder-plus.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/folder-plus.svg @@ -1,4 +1,4 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/gear-rounded-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/gear-rounded-filled.svg index 70b5d71..51e6a6c 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/gear-rounded-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/gear-rounded-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/justice-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/justice-filled.svg index 6bf57e1..247986e 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/justice-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/justice-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/key-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/key-filled.svg index cf72d3d..6428ca9 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/key-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/key-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/lasuite-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/lasuite-filled.svg index 6b83204..f150a42 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/lasuite-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/lasuite-filled.svg @@ -1,4 +1,4 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/megaphone-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/megaphone-filled.svg index b8d9d61..a4fb3b6 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/megaphone-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/megaphone-filled.svg @@ -1,4 +1,4 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/music-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/music-filled.svg index 81dc34c..db4ee4d 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/music-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/music-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/palette-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/palette-filled.svg index 23ea132..c2eca4c 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/palette-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/palette-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/perso-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/perso-filled.svg index 8fbfec8..d8c5034 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/perso-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/perso-filled.svg @@ -1,4 +1,4 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/picture-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/picture-filled.svg index 7f8f6a2..3c6050a 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/picture-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/picture-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/picture.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/picture.svg deleted file mode 100644 index 59e567b..0000000 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/picture.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/puzzle-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/puzzle-filled.svg index 8569cec..00eb204 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/puzzle-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/puzzle-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/star-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/star-filled.svg index 9e12cc0..9a87ac8 100644 --- a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/star-filled.svg +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/star-filled.svg @@ -1,3 +1,3 @@ - + diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/terminal-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/terminal-filled.svg new file mode 100644 index 0000000..cefb124 --- /dev/null +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/terminal-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/conversations/src/components/InfiniteScroll.tsx b/src/frontend/apps/conversations/src/components/InfiniteScroll.tsx index 497ec5b..e9fbade 100644 --- a/src/frontend/apps/conversations/src/components/InfiniteScroll.tsx +++ b/src/frontend/apps/conversations/src/components/InfiniteScroll.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { PropsWithChildren } from 'react'; import { useTranslation } from 'react-i18next'; import { InView } from 'react-intersection-observer'; diff --git a/src/frontend/apps/conversations/src/components/SideModal.tsx b/src/frontend/apps/conversations/src/components/SideModal.tsx index db6f417..7f69f72 100644 --- a/src/frontend/apps/conversations/src/components/SideModal.tsx +++ b/src/frontend/apps/conversations/src/components/SideModal.tsx @@ -1,4 +1,4 @@ -import { Modal, ModalSize } from '@openfun/cunningham-react'; +import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { ComponentPropsWithRef, PropsWithChildren } from 'react'; import { createGlobalStyle } from 'styled-components'; diff --git a/src/frontend/apps/conversations/src/components/TextErrors.tsx b/src/frontend/apps/conversations/src/components/TextErrors.tsx index 095984c..4d2cc3f 100644 --- a/src/frontend/apps/conversations/src/components/TextErrors.tsx +++ b/src/frontend/apps/conversations/src/components/TextErrors.tsx @@ -1,4 +1,4 @@ -import { Alert, VariantType } from '@openfun/cunningham-react'; +import { Alert, VariantType } from '@gouvfr-lasuite/cunningham-react'; import { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; diff --git a/src/frontend/apps/conversations/src/components/quick-search/QuickSearchResultItem.tsx b/src/frontend/apps/conversations/src/components/quick-search/QuickSearchResultItem.tsx index 3fe51c5..3fbe3a5 100644 --- a/src/frontend/apps/conversations/src/components/quick-search/QuickSearchResultItem.tsx +++ b/src/frontend/apps/conversations/src/components/quick-search/QuickSearchResultItem.tsx @@ -1,17 +1,17 @@ -import { memo, useMemo } from 'react'; +import { ComponentType, SVGProps, memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import { Box, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { ChatConversation } from '@/features/chat/types'; +import { PROJECT_ICONS } from '@/features/left-panel/components/projects/project-constants'; import { getRelativeTime } from '@/utils'; import ArrowForwardIcon from './assets/arrow-forward.svg'; import BubbleIcon from './assets/bubble.svg'; const descriptionCss = css` - color: var(--c--contextuals--content--semantic--neutral--tertiary); font-weight: 400; `; @@ -23,14 +23,19 @@ export const QuickSearchResultItem = memo(function QuickSearchResultItem({ conversation, }: QuickSearchResultItemProps) { const { t, i18n } = useTranslation(); - const { spacingsTokens } = useCunninghamTheme(); + const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const title = conversation.title || t('Untitled conversation'); + const project = conversation.project; + const projectTitle = project?.title?.trim(); const updatedAtLabel = useMemo( () => getRelativeTime(conversation.updated_at, i18n.language), [conversation.updated_at, i18n.language], ); + const ProjectIcon: ComponentType> | undefined = + project?.icon ? (PROJECT_ICONS[project.icon] ?? undefined) : undefined; + return ( {title} - {updatedAtLabel && ( - - {updatedAtLabel} - - )} + + + {projectTitle && ( + <> + {ProjectIcon && ( + + )} + {projectTitle} + {updatedAtLabel && } + + )} + {updatedAtLabel && {updatedAtLabel}} + + ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), +})); +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +const renderWithProviders = (component: React.ReactNode) => + render({component}); + +const makeConversation = ( + overrides?: Partial, +): ChatConversation => ({ + id: 'conv-1', + title: 'Test conversation', + messages: [], + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-05T00:00:00Z', + ...overrides, +}); + +describe('QuickSearchResultItem', () => { + it('renders project title, bullet separator, and relative date when conversation has a project', () => { + const conversation = makeConversation({ + project: { id: 'proj-1', title: 'My Project', icon: 'folder' }, + }); + + renderWithProviders(); + + expect(screen.getByText('Test conversation')).toBeInTheDocument(); + expect(screen.getByText('My Project')).toBeInTheDocument(); + // Bullet separator between project name and date + expect(screen.getByText('\u2022')).toBeInTheDocument(); + }); + + it('renders only relative date when conversation has no project', () => { + const conversation = makeConversation({ project: null }); + + renderWithProviders(); + + expect(screen.getByText('Test conversation')).toBeInTheDocument(); + expect(screen.queryByText('\u2022')).not.toBeInTheDocument(); + }); + + it('shows "Untitled conversation" when title is empty', () => { + const conversation = makeConversation({ title: '' }); + + renderWithProviders(); + + expect(screen.getByText('Untitled conversation')).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/apps/conversations/src/core/AppProvider.tsx b/src/frontend/apps/conversations/src/core/AppProvider.tsx index a3d837d..f28f3a9 100644 --- a/src/frontend/apps/conversations/src/core/AppProvider.tsx +++ b/src/frontend/apps/conversations/src/core/AppProvider.tsx @@ -16,7 +16,7 @@ const ToastProviderNoSSR = dynamic( const CunninghamProviderNoSSR = dynamic( () => - import('@openfun/cunningham-react').then((mod) => ({ + import('@gouvfr-lasuite/cunningham-react').then((mod) => ({ default: mod.CunninghamProvider, })), { ssr: false }, diff --git a/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx b/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx index 6694f6b..743e960 100644 --- a/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx +++ b/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx @@ -1,4 +1,4 @@ -import { Loader } from '@openfun/cunningham-react'; +import { Loader } from '@gouvfr-lasuite/cunningham-react'; import Head from 'next/head'; import { PropsWithChildren, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx b/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx index 7c6cb6f..5e2c801 100644 --- a/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { Resource } from 'i18next'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Theme } from '@/cunningham/'; +import { BaseTheme } from '@/cunningham/'; import { FooterType } from '@/features/footer'; import { PostHogConf } from '@/services'; @@ -28,7 +28,7 @@ export interface ConfigResponse { FEATURE_FLAGS: FeatureFlags; FRONTEND_CSS_URL?: string; FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean; - FRONTEND_THEME?: Theme; + FRONTEND_THEME?: BaseTheme; LANGUAGES: [string, string][]; LANGUAGE_CODE: string; MEDIA_BASE_URL?: string; diff --git a/src/frontend/apps/conversations/src/cunningham/useCunninghamTheme.tsx b/src/frontend/apps/conversations/src/cunningham/useCunninghamTheme.tsx index b28876b..aa44e74 100644 --- a/src/frontend/apps/conversations/src/cunningham/useCunninghamTheme.tsx +++ b/src/frontend/apps/conversations/src/cunningham/useCunninghamTheme.tsx @@ -17,6 +17,7 @@ type ComponentTokens = Partial< Record; type ContextualTokens = Tokens['contextuals']; export type Theme = keyof typeof tokens.themes; +export type BaseTheme = Exclude; interface ThemeStore { colorsTokens: Partial; @@ -24,10 +25,10 @@ interface ThemeStore { contextualTokens: ContextualTokens; currentTokens: Partial; fontSizesTokens: Partial; - setTheme: (theme: Theme) => void; + setTheme: (theme: BaseTheme) => void; spacingsTokens: Partial; theme: Theme; - baseTheme: Theme; // 'default' or 'dsfr' (not persisted) + baseTheme: BaseTheme; themeTokens: Partial; toggleDarkMode: () => void; } @@ -47,7 +48,7 @@ const getComponentTokens = ( ); }; -const DEFAULT_THEME: Theme = 'default'; +const DEFAULT_THEME: BaseTheme = 'default'; const defaultTokens = getMergedTokens(DEFAULT_THEME); const initialState: ThemeStore = { @@ -65,12 +66,16 @@ const initialState: ThemeStore = { }; const getIsDarkMode = () => - useChatPreferencesStore.getState().isDarkModePreference; + useChatPreferencesStore.getState?.()?.isDarkModePreference ?? false; -const resolveTheme = (baseTheme: Theme, isDarkMode: boolean): Theme => - isDarkMode ? (baseTheme === 'dsfr' ? 'dsfr-dark' : 'dark') : baseTheme; +const resolveTheme = (baseTheme: BaseTheme, isDarkMode: boolean): Theme => { + if (!isDarkMode) { + return baseTheme; + } + return baseTheme === 'dsfr' ? 'dsfr-dark' : 'dark'; +}; -const computeThemeState = (baseTheme: Theme, isDarkMode: boolean) => { +const computeThemeState = (baseTheme: BaseTheme, isDarkMode: boolean) => { const theme = resolveTheme(baseTheme, isDarkMode); const mergedTokens = getMergedTokens(theme); return { @@ -88,19 +93,21 @@ const computeThemeState = (baseTheme: Theme, isDarkMode: boolean) => { export const useCunninghamTheme = create()((set) => ({ ...initialState, - setTheme: (theme: Theme) => { - const baseTheme: Theme = - theme === 'dark' || theme === 'dsfr-dark' - ? theme === 'dark' - ? 'default' - : 'dsfr' - : theme; - + ...computeThemeState(DEFAULT_THEME, getIsDarkMode()), + setTheme: (baseTheme: BaseTheme) => { set(computeThemeState(baseTheme, getIsDarkMode())); }, toggleDarkMode: () => { useChatPreferencesStore.getState().toggleDarkModePreferences(); - - set((state) => computeThemeState(state.baseTheme, getIsDarkMode())); }, })); + +// Sync theme when isDarkModePreference changes (e.g. persist rehydration) +useChatPreferencesStore.subscribe?.((state, prev) => { + if (state.isDarkModePreference !== prev.isDarkModePreference) { + const { baseTheme } = useCunninghamTheme.getState(); + useCunninghamTheme.setState( + computeThemeState(baseTheme, state.isDarkModePreference), + ); + } +}); diff --git a/src/frontend/apps/conversations/src/features/auth/components/ActivationPage.tsx b/src/frontend/apps/conversations/src/features/auth/components/ActivationPage.tsx index d8db9c8..360f2cc 100644 --- a/src/frontend/apps/conversations/src/features/auth/components/ActivationPage.tsx +++ b/src/frontend/apps/conversations/src/features/auth/components/ActivationPage.tsx @@ -1,4 +1,4 @@ -import { Button, Modal, ModalSize } from '@openfun/cunningham-react'; +import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/auth/components/ButtonLogin.tsx b/src/frontend/apps/conversations/src/features/auth/components/ButtonLogin.tsx index f3db015..57dd524 100644 --- a/src/frontend/apps/conversations/src/features/auth/components/ButtonLogin.tsx +++ b/src/frontend/apps/conversations/src/features/auth/components/ButtonLogin.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; diff --git a/src/frontend/apps/conversations/src/features/chat/api/__tests__/useConversations.test.tsx b/src/frontend/apps/conversations/src/features/chat/api/__tests__/useConversations.test.tsx new file mode 100644 index 0000000..6f140c0 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/api/__tests__/useConversations.test.tsx @@ -0,0 +1,37 @@ +import fetchMock from 'fetch-mock'; + +import { getConversations } from '../useConversations'; + +const API_BASE = 'http://test.jest/api/v1.0/'; + +describe('getConversations', () => { + beforeEach(() => { + fetchMock.restore(); + }); + + it('sends project=none when title is not provided', async () => { + fetchMock.get(`begin:${API_BASE}chats/`, { + status: 200, + body: { count: 0, results: [], next: null, previous: null }, + }); + + await getConversations({ page: 1 }); + + const url = fetchMock.lastUrl()!; + expect(url).toContain('project=none'); + expect(url).not.toContain('title='); + }); + + it('omits project param when title is provided', async () => { + fetchMock.get(`begin:${API_BASE}chats/`, { + status: 200, + body: { count: 0, results: [], next: null, previous: null }, + }); + + await getConversations({ page: 1, title: 'search term' }); + + const url = fetchMock.lastUrl()!; + expect(url).not.toContain('project='); + expect(url).toContain('title=search+term'); + }); +}); diff --git a/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx b/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx index 081511a..6ce0063 100644 --- a/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react'; import { fetchAPI } from '@/api'; import { KEY_LIST_CONVERSATION } from '@/features/chat/api/useConversations'; +import { KEY_LIST_PROJECT } from '@/features/chat/api/useProjects'; import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore'; const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => { @@ -76,6 +77,9 @@ export function useChat(options: Omit) { void queryClient.invalidateQueries({ queryKey: [KEY_LIST_CONVERSATION], }); + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_PROJECT], + }); } } } diff --git a/src/frontend/apps/conversations/src/features/chat/api/useConversation.tsx b/src/frontend/apps/conversations/src/features/chat/api/useConversation.tsx index 0c208d9..e61a117 100644 --- a/src/frontend/apps/conversations/src/features/chat/api/useConversation.tsx +++ b/src/frontend/apps/conversations/src/features/chat/api/useConversation.tsx @@ -1,5 +1,3 @@ -import { UseQueryOptions, useQuery } from '@tanstack/react-query'; - import { APIError, errorCauses, fetchAPI } from '@/api'; import { ChatConversation } from '@/features/chat/types'; @@ -25,18 +23,3 @@ export const getConversation = async ({ }; export const KEY_CONVERSATION = 'conversation'; - -export function useConversations( - param: ConversationsParams, - queryConfig?: UseQueryOptions< - ConversationResponse, - APIError, - ConversationResponse - >, -) { - return useQuery({ - queryKey: [KEY_CONVERSATION, param], - queryFn: () => getConversation(param), - ...queryConfig, - }); -} diff --git a/src/frontend/apps/conversations/src/features/chat/api/useConversations.tsx b/src/frontend/apps/conversations/src/features/chat/api/useConversations.tsx index 415a9f2..01f935e 100644 --- a/src/frontend/apps/conversations/src/features/chat/api/useConversations.tsx +++ b/src/frontend/apps/conversations/src/features/chat/api/useConversations.tsx @@ -1,5 +1,3 @@ -import { UseQueryOptions, useQuery } from '@tanstack/react-query'; - import { APIError, APIList, @@ -43,7 +41,9 @@ export const getConversations = async ( searchParams.set('title', params.title); } - searchParams.set("project", "none") + if (!params.title) { + searchParams.set('project', 'none'); + } const response = await fetchAPI(`chats/?${searchParams.toString()}`); @@ -59,21 +59,6 @@ export const getConversations = async ( export const KEY_LIST_CONVERSATION = 'conversations'; -export function useConversations( - param: ConversationsParams, - queryConfig?: UseQueryOptions< - ConversationsResponse, - APIError, - ConversationsResponse - >, -) { - return useQuery({ - queryKey: [KEY_LIST_CONVERSATION, param], - queryFn: () => getConversations(param), - ...queryConfig, - }); -} - export const useInfiniteConversations = ( params: ConversationsParams, queryConfig?: Partial< diff --git a/src/frontend/apps/conversations/src/features/chat/api/useCreateConversation.tsx b/src/frontend/apps/conversations/src/features/chat/api/useCreateConversation.tsx index 306cee6..f4a08ed 100644 --- a/src/frontend/apps/conversations/src/features/chat/api/useCreateConversation.tsx +++ b/src/frontend/apps/conversations/src/features/chat/api/useCreateConversation.tsx @@ -5,19 +5,25 @@ import { APIError, errorCauses, fetchAPI } from '@/api'; import { ChatConversation } from '../types'; import { KEY_LIST_CONVERSATION } from './useConversations'; +import { KEY_LIST_PROJECT } from './useProjects'; interface ChatConversationParams { title: string; + project?: string; } export const createChatConversation = async ({ title, + project, }: ChatConversationParams): Promise => { + const body: Record = { title }; + if (project) { + body.project = project; + } + const response = await fetchAPI(`chats/`, { method: 'POST', - body: JSON.stringify({ - title, - }), + body: JSON.stringify(body), }); if (!response.ok) { @@ -34,10 +40,15 @@ export function useCreateChatConversation() { const queryClient = useQueryClient(); return useMutation({ mutationFn: createChatConversation, - onSuccess: () => { + onSuccess: (_data, variables) => { void queryClient.invalidateQueries({ queryKey: [KEY_LIST_CONVERSATION], }); + if (variables.project) { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_PROJECT], + }); + } }, }); } diff --git a/src/frontend/apps/conversations/src/features/chat/components/AttachmentList.tsx b/src/frontend/apps/conversations/src/features/chat/components/AttachmentList.tsx index eaaba04..d0d01bb 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/AttachmentList.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/AttachmentList.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import { Box, Icon, Text } from '@/components'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx index e1c4869..a4f8e22 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx @@ -1,5 +1,6 @@ import { Message, SourceUIPart } from '@ai-sdk/ui-utils'; -import { Modal, ModalSize } from '@openfun/cunningham-react'; +import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; +import { InfiniteData, useQueryClient } from '@tanstack/react-query'; import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you import { useRouter } from 'next/router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -16,6 +17,10 @@ import { LLMModel, useLLMConfiguration, } from '@/features/chat/api/useLLMConfiguration'; +import { + KEY_LIST_PROJECT, + ProjectsResponse, +} from '@/features/chat/api/useProjects'; import { ChatError } from '@/features/chat/components/ChatError'; import { InputChat } from '@/features/chat/components/InputChat'; import { MessageItem } from '@/features/chat/components/MessageItem'; @@ -159,13 +164,20 @@ export const Chat = ({ } | null>(null); const { mutate: createChatConversation } = useCreateChatConversation(); + const queryClient = useQueryClient(); + const [isReadingInstructions, setIsReadingInstructions] = useState(false); + const readingInstructionsStartRef = useRef(0); // Zustand store for pending chat state const { input: pendingInput, files: pendingFiles, + projectId: pendingProjectId, + hasProjectInstructions, setPendingChat, - clearPendingChat, + setProjectId, + setHasProjectInstructions, + clearPendingInput, } = usePendingChatStore(); const scrollToBottom = useCallback(() => { @@ -332,6 +344,23 @@ export const Chat = ({ } }, [messages]); + // Clear "reading instructions" once streaming begins or on error, with minimum display time + useEffect(() => { + if (isReadingInstructions) { + if (status === 'error') { + setIsReadingInstructions(false); + } else if (status === 'streaming') { + const elapsed = Date.now() - readingInstructionsStartRef.current; + const remaining = Math.max(0, 1500 - elapsed); + const timer = setTimeout( + () => setIsReadingInstructions(false), + remaining, + ); + return () => clearTimeout(timer); + } + } + }, [status, isReadingInstructions]); + // Détecter l'arrivée d'un nouveau message user et retirer la hauteur de l'ancien useEffect(() => { if (status === 'streaming') { @@ -399,6 +428,10 @@ export const Chat = ({ (pendingInput && pendingInput.trim()) || (pendingFiles && pendingFiles.length > 0) ) { + if (hasProjectInstructions) { + readingInstructionsStartRef.current = Date.now(); + setIsReadingInstructions(true); + } if (pendingInput) { const syntheticEvent = { target: { value: pendingInput }, @@ -409,10 +442,9 @@ export const Chat = ({ setFiles(pendingFiles); } setShouldAutoSubmit(true); - clearPendingChat(); - } else { - clearPendingChat(); } + // Clear input/files but keep projectId alive until conversation is created + clearPendingInput(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -502,6 +534,18 @@ export const Chat = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasInitialized, messages.length]); + // Check if the current project has custom LLM instructions + const checkProjectHasInstructions = useCallback(() => { + if (!pendingProjectId) return false; + const projectsData = queryClient.getQueryData< + InfiniteData + >([KEY_LIST_PROJECT, { page: 1 }]); + const project = projectsData?.pages + .flatMap((page) => page.results) + .find((p) => p.id === pendingProjectId); + return !!project?.llm_instructions?.trim(); + }, [pendingProjectId, queryClient]); + // Custom handleSubmit to include attachments and handle chat creation const handleSubmit = async (event: FormEvent) => { event.preventDefault(); @@ -538,10 +582,17 @@ export const Chat = ({ setPendingFirstMessage({ event, attachments, forceWebSearch }); // Save input and files to Zustand store before navigation setPendingChat(input, files); + if (checkProjectHasInstructions()) { + setHasProjectInstructions(true); + } void createChatConversation( - { title: input.length > 100 ? `${input.slice(0, 97)}...` : input }, + { + title: input.length > 100 ? `${input.slice(0, 97)}...` : input, + ...(pendingProjectId && { project: pendingProjectId }), + }, { onSuccess: (data) => { + setProjectId(null); setConversationId(data.id); // Update the URL to /chat/[id]/ void router.push(`/chat/${data.id}/`); @@ -671,7 +722,12 @@ export const Chat = ({ > - {isUploadingFiles ? t('Uploading files...') : t('Thinking...')} + {(() => { + if (isUploadingFiles) return t('Uploading files...'); + if (isReadingInstructions) + return t('Reading project instructions...'); + return t('Thinking...'); + })()} ) : null} diff --git a/src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx b/src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx index 3421fb1..1063e5a 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useRouter } from 'next/router'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/FeedbackButtons.tsx b/src/frontend/apps/conversations/src/features/chat/components/FeedbackButtons.tsx index 6d3b89f..6b65e6b 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/FeedbackButtons.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/FeedbackButtons.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx b/src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx index 7da90f9..72e9c71 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx @@ -12,6 +12,7 @@ import { useToast } from '@/components/ToastProvider'; import { FeatureFlagState, useConfig } from '@/core'; import { LLMModel } from '@/features/chat/api/useLLMConfiguration'; import { InputChatActions } from '@/features/chat/components/InputChatAction'; +import { ProjectWelcomeMessage } from '@/features/chat/components/ProjectWelcomeMessage'; import { SuggestionCarousel } from '@/features/chat/components/SuggestionCarousel'; import { WelcomeMessage } from '@/features/chat/components/WelcomeMessage'; import { useFileDragDrop } from '@/features/chat/hooks/useFileDragDrop'; @@ -415,7 +416,9 @@ export const InputChat = ({ )} {/* Message de bienvenue */} - {messagesLength === 0 && } + {messagesLength === 0 && ( + } /> + )}
diff --git a/src/frontend/apps/conversations/src/features/chat/components/InputChatAction.tsx b/src/frontend/apps/conversations/src/features/chat/components/InputChatAction.tsx index ac0aff8..c54a24d 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/InputChatAction.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/InputChatAction.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/ModelSelector.tsx b/src/frontend/apps/conversations/src/features/chat/components/ModelSelector.tsx index a029963..2ee28db 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/ModelSelector.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/ModelSelector.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import Image from 'next/image'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/ProjectWelcomeMessage.tsx b/src/frontend/apps/conversations/src/features/chat/components/ProjectWelcomeMessage.tsx new file mode 100644 index 0000000..e919a5f --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/components/ProjectWelcomeMessage.tsx @@ -0,0 +1,62 @@ +import { type ReactNode, memo, useMemo } from 'react'; + +import { Box } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { useInfiniteProjects } from '@/features/chat/api/useProjects'; +import { usePendingChatStore } from '@/features/chat/stores/usePendingChatStore'; +import { + PROJECT_COLORS, + PROJECT_ICONS, +} from '@/features/left-panel/components/projects/project-constants'; + +import { WelcomeLayout } from './WelcomeLayout'; + +interface ProjectWelcomeMessageProps { + fallback: ReactNode; +} + +export const ProjectWelcomeMessage = memo(function ProjectWelcomeMessage({ + fallback, +}: ProjectWelcomeMessageProps) { + const projectId = usePendingChatStore((s) => s.projectId); + const { colorsTokens } = useCunninghamTheme(); + const projects = useInfiniteProjects({ page: 1 }); + + const project = useMemo(() => { + if (!projectId) return undefined; + return projects.data?.pages + .flatMap((page) => page.results) + .find((p) => p.id === projectId); + }, [projectId, projects.data]); + + if (!project) { + return <>{fallback}; + } + + const IconComp = PROJECT_ICONS[project.icon] ?? PROJECT_ICONS.folder; + const iconColor = + colorsTokens[PROJECT_COLORS[project.color] as keyof typeof colorsTokens] ?? + undefined; + + return ( + + + + } + /> + ); +}); diff --git a/src/frontend/apps/conversations/src/features/chat/components/ScrollDown.tsx b/src/frontend/apps/conversations/src/features/chat/components/ScrollDown.tsx index cd88c6f..30d0b25 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/ScrollDown.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/ScrollDown.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import React, { useEffect, useState } from 'react'; import { Box, Icon } from '@/components'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/SendButton.tsx b/src/frontend/apps/conversations/src/features/chat/components/SendButton.tsx index 20cbb53..933300d 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/SendButton.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/SendButton.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import { Icon, Text } from '@/components'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/WelcomeLayout.tsx b/src/frontend/apps/conversations/src/features/chat/components/WelcomeLayout.tsx new file mode 100644 index 0000000..9f857ad --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/components/WelcomeLayout.tsx @@ -0,0 +1,32 @@ +import { type ReactNode, memo } from 'react'; + +import { Box, Text } from '@/components'; + +const WELCOME_PADDING = { all: 'base', bottom: 'md' } as const; +const WELCOME_MARGIN = { + horizontal: 'base', + bottom: 'md', + top: '-105px', +} as const; +const WELCOME_TEXT_MARGIN = { all: '0' } as const; + +interface WelcomeLayoutProps { + title: string; + icon?: ReactNode; +} + +export const WelcomeLayout = memo(function WelcomeLayout({ + title, + icon, +}: WelcomeLayoutProps) { + return ( + + + {icon} + + {title} + + + + ); +}); diff --git a/src/frontend/apps/conversations/src/features/chat/components/WelcomeMessage.tsx b/src/frontend/apps/conversations/src/features/chat/components/WelcomeMessage.tsx index 322eeff..f13a127 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/WelcomeMessage.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/WelcomeMessage.tsx @@ -1,25 +1,10 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Box, Text } from '@/components'; +import { WelcomeLayout } from './WelcomeLayout'; -const WELCOME_PADDING = { all: 'base', bottom: 'md' } as const; -const WELCOME_MARGIN = { - horizontal: 'base', - bottom: 'md', - top: '-105px', -} as const; -const WELCOME_TEXT_MARGIN = { all: '0' } as const; - -export const WelcomeMessage = memo(() => { +export const WelcomeMessage = memo(function WelcomeMessage() { const { t } = useTranslation(); - return ( - - - {t('What is on your mind?')} - - - ); + return ; }); -WelcomeMessage.displayName = 'WelcomeMessage'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/InputChat.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/InputChat.test.tsx index a676ed2..45b8858 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/InputChat.test.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/InputChat.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@/i18n/initI18n'; +import { AppWrapper } from '@/tests/utils'; import { InputChat } from '../InputChat'; @@ -94,7 +95,7 @@ describe('InputChat', () => { }); it('should render the textarea', () => { - render(); + render(, { wrapper: AppWrapper }); expect( screen.getByRole('textbox', { name: 'Enter your message or a question' }), @@ -102,31 +103,37 @@ describe('InputChat', () => { }); it('should render welcome message when messagesLength is 0', () => { - render(); + render(, { + wrapper: AppWrapper, + }); expect(screen.getByTestId('welcome-message')).toBeInTheDocument(); }); it('should not render welcome message when messagesLength > 0', () => { - render(); + render(, { + wrapper: AppWrapper, + }); expect(screen.queryByTestId('welcome-message')).not.toBeInTheDocument(); }); it('should render suggestion carousel when input is empty', () => { - render(); + render(, { wrapper: AppWrapper }); expect(screen.getByTestId('suggestion-carousel')).toBeInTheDocument(); }); it('should not render suggestion carousel when input has content', () => { - render(); + render(, { + wrapper: AppWrapper, + }); expect(screen.queryByTestId('suggestion-carousel')).not.toBeInTheDocument(); }); it('should render input chat actions', () => { - render(); + render(, { wrapper: AppWrapper }); expect(screen.getByTestId('input-chat-actions')).toBeInTheDocument(); }); @@ -136,6 +143,7 @@ describe('InputChat', () => { const handleInputChange = jest.fn(); render( , + { wrapper: AppWrapper }, ); const textarea = screen.getByRole('textbox', { @@ -147,7 +155,9 @@ describe('InputChat', () => { }); it('should disable textarea when status is not ready', () => { - render(); + render(, { + wrapper: AppWrapper, + }); expect( screen.getByRole('textbox', { name: 'Enter your message or a question' }), @@ -155,7 +165,9 @@ describe('InputChat', () => { }); it('should disable textarea when isUploadingFiles is true', () => { - render(); + render(, { + wrapper: AppWrapper, + }); expect( screen.getByRole('textbox', { name: 'Enter your message or a question' }), @@ -165,7 +177,9 @@ describe('InputChat', () => { it('should submit form when pressing Enter', async () => { const user = userEvent.setup(); const handleSubmit = jest.fn((e) => e.preventDefault()); - render(); + render(, { + wrapper: AppWrapper, + }); const textarea = screen.getByRole('textbox', { name: 'Enter your message or a question', @@ -178,7 +192,9 @@ describe('InputChat', () => { it('should not submit form when pressing Shift+Enter', async () => { const user = userEvent.setup(); const handleSubmit = jest.fn((e) => e.preventDefault()); - render(); + render(, { + wrapper: AppWrapper, + }); const textarea = screen.getByRole('textbox', { name: 'Enter your message or a question', diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageBlock.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageBlock.test.tsx index 4a8afa0..21d373b 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageBlock.test.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageBlock.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable testing-library/no-unnecessary-act, @typescript-eslint/require-await */ -import { CunninghamProvider } from '@openfun/cunningham-react'; +import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; import { act, render, screen } from '@testing-library/react'; import { Suspense } from 'react'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx index 5ed218a..47eaf30 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable testing-library/no-unnecessary-act, @typescript-eslint/require-await */ -import { CunninghamProvider } from '@openfun/cunningham-react'; +import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Suspense } from 'react'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/ProjectWelcomeMessage.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/ProjectWelcomeMessage.test.tsx new file mode 100644 index 0000000..81ea5a0 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/ProjectWelcomeMessage.test.tsx @@ -0,0 +1,102 @@ +import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; +import { + InfiniteData, + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; + +import { + KEY_LIST_PROJECT, + ProjectsResponse, +} from '@/features/chat/api/useProjects'; +import { usePendingChatStore } from '@/features/chat/stores/usePendingChatStore'; +import { ChatProject } from '@/features/chat/types'; + +import { ProjectWelcomeMessage } from '../ProjectWelcomeMessage'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +const makeProject = (overrides?: Partial): ChatProject => ({ + id: 'proj-1', + title: 'Design System', + icon: 'folder', + color: 'color_6', + llm_instructions: '', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + conversations: [], + ...overrides, +}); + +const renderWithCache = ( + projectId: string | null, + cachedProjects: ChatProject[], +) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + if (cachedProjects.length > 0) { + const cacheData: InfiniteData = { + pages: [ + { + count: cachedProjects.length, + results: cachedProjects, + next: null, + previous: null, + }, + ], + pageParams: [1], + }; + queryClient.setQueryData([KEY_LIST_PROJECT, { page: 1 }], cacheData); + } + + usePendingChatStore.setState({ projectId }); + + return render( + + + Welcome fallback} /> + + , + ); +}; + +describe('ProjectWelcomeMessage', () => { + beforeEach(() => { + usePendingChatStore.setState({ + projectId: null, + input: '', + files: null, + hasProjectInstructions: false, + }); + }); + + it('renders project title when projectId matches a cached project', () => { + renderWithCache('proj-1', [makeProject()]); + + expect(screen.getByText('Design System')).toBeInTheDocument(); + expect(screen.queryByText('Welcome fallback')).not.toBeInTheDocument(); + }); + + it('renders fallback when no project found in cache', () => { + renderWithCache('proj-unknown', [makeProject()]); + + expect(screen.getByText('Welcome fallback')).toBeInTheDocument(); + expect(screen.queryByText('Design System')).not.toBeInTheDocument(); + }); + + it('renders fallback when projectId is null', () => { + renderWithCache(null, [makeProject()]); + + expect(screen.getByText('Welcome fallback')).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/apps/conversations/src/features/chat/stores/usePendingChatStore.ts b/src/frontend/apps/conversations/src/features/chat/stores/usePendingChatStore.ts index bcfe7a6..1606f22 100644 --- a/src/frontend/apps/conversations/src/features/chat/stores/usePendingChatStore.ts +++ b/src/frontend/apps/conversations/src/features/chat/stores/usePendingChatStore.ts @@ -3,13 +3,34 @@ import { create } from 'zustand'; interface PendingChatState { input: string; files: FileList | null; + projectId: string | null; + hasProjectInstructions: boolean; setPendingChat: (input: string, files: FileList | null) => void; + setProjectId: (id: string | null) => void; + setHasProjectInstructions: (value: boolean) => void; + clearPendingInput: () => void; clearPendingChat: () => void; } export const usePendingChatStore = create((set) => ({ input: '', files: null, + projectId: null, + hasProjectInstructions: false, setPendingChat: (input, files) => set({ input, files }), - clearPendingChat: () => set({ input: '', files: null }), + setProjectId: (id) => set({ projectId: id }), + setHasProjectInstructions: (value) => set({ hasProjectInstructions: value }), + clearPendingInput: () => + set({ + input: '', + files: null, + hasProjectInstructions: false, + }), + clearPendingChat: () => + set({ + input: '', + files: null, + projectId: null, + hasProjectInstructions: false, + }), })); diff --git a/src/frontend/apps/conversations/src/features/chat/types.tsx b/src/frontend/apps/conversations/src/features/chat/types.tsx index 587f474..10eb862 100644 --- a/src/frontend/apps/conversations/src/features/chat/types.tsx +++ b/src/frontend/apps/conversations/src/features/chat/types.tsx @@ -2,12 +2,19 @@ import { Message } from '@ai-sdk/react'; export type ChatMessage = Message; +export interface ChatConversationProject { + id: string; + title: string; + icon: string; +} + export interface ChatConversation { id: string; messages: ChatMessage[]; created_at: string; updated_at: string; title?: string; + project?: ChatConversationProject | null; } export interface ChatProjectConversation { id: string; diff --git a/src/frontend/apps/conversations/src/features/feedback/Feedback.tsx b/src/frontend/apps/conversations/src/features/feedback/Feedback.tsx index ed867db..609f276 100644 --- a/src/frontend/apps/conversations/src/features/feedback/Feedback.tsx +++ b/src/frontend/apps/conversations/src/features/feedback/Feedback.tsx @@ -4,7 +4,7 @@ import { Modal, ModalSize, useModal, -} from '@openfun/cunningham-react'; +} from '@gouvfr-lasuite/cunningham-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/header/components/ButtonToggleLeftPanel.tsx b/src/frontend/apps/conversations/src/features/header/components/ButtonToggleLeftPanel.tsx index 09be548..3da9270 100644 --- a/src/frontend/apps/conversations/src/features/header/components/ButtonToggleLeftPanel.tsx +++ b/src/frontend/apps/conversations/src/features/header/components/ButtonToggleLeftPanel.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import LeftPanelIcon from '@/assets/icons/left-panel-bold.svg'; diff --git a/src/frontend/apps/conversations/src/features/header/components/ButtonTogglePanel.tsx b/src/frontend/apps/conversations/src/features/header/components/ButtonTogglePanel.tsx index 25a4ced..cb81d03 100644 --- a/src/frontend/apps/conversations/src/features/header/components/ButtonTogglePanel.tsx +++ b/src/frontend/apps/conversations/src/features/header/components/ButtonTogglePanel.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import { Icon } from '@/components/'; diff --git a/src/frontend/apps/conversations/src/features/header/components/Header.tsx b/src/frontend/apps/conversations/src/features/header/components/Header.tsx index 52b1b02..e88bdb9 100644 --- a/src/frontend/apps/conversations/src/features/header/components/Header.tsx +++ b/src/frontend/apps/conversations/src/features/header/components/Header.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useRouter } from 'next/navigation'; import { useEffect as _useEffect, useState as _useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/header/components/LaGaufre.tsx b/src/frontend/apps/conversations/src/features/header/components/LaGaufre.tsx index dc8e9e8..21e131b 100644 --- a/src/frontend/apps/conversations/src/features/header/components/LaGaufre.tsx +++ b/src/frontend/apps/conversations/src/features/header/components/LaGaufre.tsx @@ -1,6 +1,5 @@ -// import { Gaufre } from '@gouvfr-lasuite/integration'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import '@gouvfr-lasuite/integration/dist/css/gaufre.css'; -import { Button } from '@openfun/cunningham-react'; import Script from 'next/script'; import { useEffect } from 'react'; diff --git a/src/frontend/apps/conversations/src/features/home/components/HomeBanner.tsx b/src/frontend/apps/conversations/src/features/home/components/HomeBanner.tsx index 044c576..e24fb6d 100644 --- a/src/frontend/apps/conversations/src/features/home/components/HomeBanner.tsx +++ b/src/frontend/apps/conversations/src/features/home/components/HomeBanner.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import _Image from 'next/image'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -7,7 +7,6 @@ import IconAssistant from '@/assets/logo/logo-beta.svg'; import { Box, Icon, Text } from '@/components'; import { productName } from '@/core'; import { useCunninghamTheme } from '@/cunningham'; -import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore'; import { gotoLogin } from '@/features/auth'; import { useResponsiveStore } from '@/stores'; @@ -18,11 +17,8 @@ import { getHeaderHeight } from './HomeHeader'; export default function HomeBanner() { const { t } = useTranslation(); - const { spacingsTokens, colorsTokens, componentTokens } = + const { theme, spacingsTokens, colorsTokens, componentTokens } = useCunninghamTheme(); - const isDarkMode = useChatPreferencesStore( - (state) => state.isDarkModePreference, - ); const { isMobile, isSmallMobile } = useResponsiveStore(); const withProConnect = Boolean( (componentTokens as Record)['home-proconnect'], @@ -110,7 +106,8 @@ export default function HomeBanner() { - {!isMobile && (isDarkMode ? : )} + {!isMobile && + (theme.includes('dark') ? : )} ); diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ConversationRow.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ConversationRow.tsx index 8c30038..0328d44 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ConversationRow.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ConversationRow.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, type ReactNode } from 'react'; +import { type ReactNode, memo, useCallback, useMemo } from 'react'; import { css } from 'styled-components'; import { Box, StyledLink } from '@/components'; @@ -24,7 +24,7 @@ const getBoxStyles = (isActive: boolean, isDesktop: boolean) => css` margin-bottom: 1px; background-color: ${isActive ? 'var(--c--contextuals--background--semantic--overlay--primary)' - : ''}; + : 'transparent'}; font-weight: ${isActive ? '700' : '500'}; transition: background-color 0.2s cubic-bezier(1, 0, 0, 1); .pinned-actions { diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelConversations.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelConversations.tsx deleted file mode 100644 index 63824d5..0000000 --- a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelConversations.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useRouter } from 'next/router'; -import { useTranslation } from 'react-i18next'; - -import { Box, InfiniteScroll, Text } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { useInfiniteConversations } from '@/features/chat/api/useConversations'; -import { LeftPanelConversationItem } from '@/features/left-panel/components/LeftPanelConversationItem'; - -export const LeftPanelConversations = () => { - const { t } = useTranslation(); - const router = useRouter(); - const { id } = router.query; - - const { spacingsTokens } = useCunninghamTheme(); - - const conversations = useInfiniteConversations({ - page: 1, - ordering: '-updated_at', - }); - - const favoriteConversations = - conversations.data?.pages.flatMap((page) => page.results) || []; - - if (favoriteConversations.length === 0) { - return null; - } - - return ( - - - - {t('Your chats')} - - void conversations.fetchNextPage()} - > - {favoriteConversations.map((conversation) => ( - - ))} - - - - ); -}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjects.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjects.tsx deleted file mode 100644 index 3f5e9c0..0000000 --- a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjects.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useRouter } from 'next/router'; -import { useTranslation } from 'react-i18next'; - -import { Box, InfiniteScroll, Text } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { useInfiniteProjects } from '@/features/chat/api/useProjects'; -import { LeftPanelProjectItem } from '@/features/left-panel/components/LeftPanelProjectItem'; - -export const LeftPanelProjects = () => { - const { t } = useTranslation(); - const router = useRouter(); - const { id } = router.query; - - const { spacingsTokens } = useCunninghamTheme(); - - const projects = useInfiniteProjects({ - page: 1, - }); - - const mainProjects = - projects.data?.pages.flatMap((page) => page.results) || []; - - if (mainProjects.length === 0) { - return null; - } - - return ( - - - - {t('Your projects')} - - -
- {mainProjects.map((prj) => ( - - ))} -
-
-
- ); -}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ModalCreateProject.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ModalCreateProject.tsx deleted file mode 100644 index 3984626..0000000 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ModalCreateProject.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { css } from 'styled-components'; - -import { Box, Text, useToast } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { useCreateProject } from '@/features/chat/api/useCreateProject'; -import { useOwnModal } from '@/features/left-panel/hooks/useModalHook'; - -import { PROJECT_COLORS, PROJECT_ICONS } from './project-constants'; -import { ModalIconColorPicker } from './ModalIconColorPicker'; - -const textareaCss = css` - width: 100%; - min-height: 120px; - padding: var(--c--globals--spacings--xs); - border: 1px solid var(--c--contextuals--border--semantic--neutral--default); - border-radius: 4px; - font-family: var(--c--globals--font--families--base); - font-size: 0.875rem; - resize: vertical; - - &:focus { - outline: none; - border-color: var(--c--contextuals--border--semantic--info--default); - } -`; - -const avatarButtonCss = css` - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - border: 1px solid var(--c--contextuals--border--semantic--neutral--default); - border-radius: 8px; - background-color: transparent; - cursor: pointer; - flex-shrink: 0; - transition: border-color 0.15s ease; - - &:hover { - border-color: var(--c--contextuals--border--semantic--info--default); - } -`; - -interface ModalCreateProjectProps { - onClose: () => void; -} - -export const ModalCreateProject = ({ onClose }: ModalCreateProjectProps) => { - const { showToast } = useToast(); - const { t } = useTranslation(); - - const { colorsTokens } = useCunninghamTheme(); - const iconColorModal = useOwnModal(); - - const [name, setName] = useState(''); - const [icon, setIcon] = useState('folder'); - const [color, setColor] = useState('color_1'); - const [instructions, setInstructions] = useState(''); - - const IconComp = PROJECT_ICONS[icon] ?? PROJECT_ICONS.folder; - const iconColor = - colorsTokens[PROJECT_COLORS[color] as keyof typeof colorsTokens] ?? - undefined; - - const { mutate: createProject, isPending } = useCreateProject({ - onSuccess: () => { - showToast( - 'success', - t('The project has been created.'), - undefined, - 4000, - ); - onClose(); - }, - onError: (error) => { - const errorMessage = - error.cause?.[0] || - error.message || - t('An error occurred while creating the project'); - showToast('error', errorMessage, undefined, 4000); - }, - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const trimmedName = name.trim(); - if (!trimmedName) return; - - createProject({ - title: trimmedName, - icon, - color, - llm_instructions: instructions.trim() || undefined, - }); - }; - - return ( - <> - - - - - } - size={ModalSize.MEDIUM} - title={ - - {t('New project')} - - } - > - - - - - - {t('Project avatar and name')} - - - - - - - - - ) => - setName(e.target.value) - } - required - /> - - - - - - - {t('Instructions (experimental)')} - - ) => - setInstructions(e.target.value) - } - placeholder={t( - 'Example: Be concise and maintain a professional tone.', - )} - /> - - {t( - 'Use this field to provide any additional context the Assistant may need. This feature is being tested. It may not work as expected.', - )} - - - - - - - {iconColorModal.isOpen && ( - - )} - - ); -}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ModalProjectSettings.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ModalProjectSettings.tsx deleted file mode 100644 index a243c95..0000000 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ModalProjectSettings.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { css } from 'styled-components'; - -import { Box, Text, useToast } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { useUpdateProject } from '@/features/chat/api/useUpdateProject'; -import { ChatProject } from '@/features/chat/types'; -import { useOwnModal } from '@/features/left-panel/hooks/useModalHook'; - -import { PROJECT_COLORS, PROJECT_ICONS } from './project-constants'; -import { ModalIconColorPicker } from './ModalIconColorPicker'; - -const textareaCss = css` - width: 100%; - min-height: 120px; - padding: var(--c--globals--spacings--xs); - border: 1px solid var(--c--contextuals--border--semantic--neutral--default); - border-radius: 4px; - font-family: var(--c--globals--font--families--base); - font-size: 0.875rem; - resize: vertical; - - &:focus { - outline: none; - border-color: var(--c--contextuals--border--semantic--info--default); - } -`; - -const avatarButtonCss = css` - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - border: 1px solid var(--c--contextuals--border--semantic--neutral--default); - border-radius: 8px; - background-color: transparent; - cursor: pointer; - flex-shrink: 0; - transition: border-color 0.15s ease; - - &:hover { - border-color: var(--c--contextuals--border--semantic--info--default); - } -`; - -interface ModalProjectSettingsProps { - project: ChatProject; - onClose: () => void; -} - -export const ModalProjectSettings = ({ - project, - onClose, -}: ModalProjectSettingsProps) => { - const { showToast } = useToast(); - const { t } = useTranslation(); - - const { colorsTokens } = useCunninghamTheme(); - const iconColorModal = useOwnModal(); - - const [name, setName] = useState(project.title); - const [icon, setIcon] = useState(project.icon || 'folder'); - const [color, setColor] = useState(project.color || 'color_1'); - const [instructions, setInstructions] = useState( - project.llm_instructions || '', - ); - - const IconComp = PROJECT_ICONS[icon] ?? PROJECT_ICONS.folder; - const iconColor = - colorsTokens[PROJECT_COLORS[color] as keyof typeof colorsTokens] ?? - undefined; - - const { mutate: updateProject, isPending } = useUpdateProject({ - onSuccess: () => { - showToast( - 'success', - t('The project has been updated.'), - undefined, - 4000, - ); - onClose(); - }, - onError: (error) => { - const errorMessage = - error.cause?.[0] || - error.message || - t('An error occurred while updating the project'); - showToast('error', errorMessage, undefined, 4000); - }, - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const trimmedName = name.trim(); - if (!trimmedName) return; - - updateProject({ - projectId: project.id, - title: trimmedName, - icon, - color, - llm_instructions: instructions.trim() || undefined, - }); - }; - - return ( - <> - - - - - } - size={ModalSize.MEDIUM} - title={ - - {t('Project settings')} - - } - > - -
- - - - {t('Project avatar and name')} - - - - - - - - - ) => - setName(e.target.value) - } - required - /> - - - - - - - {t('Instructions (experimental)')} - - ) => - setInstructions(e.target.value) - } - placeholder={t( - 'Example: Be concise and maintain a professional tone.', - )} - /> - - {t( - 'Use this field to provide any additional context the Assistant may need. This feature is being tested. It may not work as expected.', - )} - - - - -
-
- {iconColorModal.isOpen && ( - - )} - - ); -}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveConversation.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveConversation.tsx index 7127114..a3f8575 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveConversation.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveConversation.tsx @@ -1,6 +1,5 @@ -import { Button, Modal, ModalSize } from '@openfun/cunningham-react'; +import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { t } from 'i18next'; -import { usePathname } from 'next/navigation'; import { useRouter } from 'next/router'; import { Box, Text, useToast } from '@/components'; @@ -16,8 +15,7 @@ export const ModalRemoveConversation = ({ conversation, }: ModalRemoveConversationProps) => { const { showToast } = useToast(); - const { push } = useRouter(); - const pathname = usePathname(); + const { push, pathname } = useRouter(); const { mutate: removeDoc } = useRemoveConversation({ onSuccess: () => { diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ModalRenameConversation.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ModalRenameConversation.tsx index dc1c8e0..1eaf093 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ModalRenameConversation.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ModalRenameConversation.tsx @@ -1,4 +1,9 @@ -import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react'; +import { + Button, + Input, + Modal, + ModalSize, +} from '@gouvfr-lasuite/cunningham-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/LeftPanelConversationItem.test.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/LeftPanelConversationItem.test.tsx index 78204ed..1f3f087 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/LeftPanelConversationItem.test.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/LeftPanelConversationItem.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import '@/i18n/initI18n'; -import { LeftPanelConversationItem } from '../LeftPanelConversationItem'; +import { LeftPanelConversationItem } from '../left-panel/LeftPanelConversationItem'; const mockSetPanelOpen = jest.fn(); const mockPush = jest.fn(); @@ -24,7 +24,7 @@ jest.mock('@/features/chat/stores/useChatPreferencesStore', () => ({ ), })); -jest.mock('../SimpleConversationItem', () => ({ +jest.mock('@/features/left-panel/components/SimpleConversationItem', () => ({ SimpleConversationItem: ({ conversation, }: { @@ -32,7 +32,7 @@ jest.mock('../SimpleConversationItem', () => ({ }) =>
{conversation.title}
, })); -jest.mock('../ConversationItemActions', () => ({ +jest.mock('@/features/left-panel/components/ConversationItemActions', () => ({ ConversationItemActions: ({ conversation, }: { diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ModalRenameConversation.test.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ModalRenameConversation.test.tsx index 2d5fa21..a9cfd99 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ModalRenameConversation.test.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ModalRenameConversation.test.tsx @@ -1,4 +1,4 @@ -import { CunninghamProvider } from '@openfun/cunningham-react'; +import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/index.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/index.tsx index aedb0f9..4ec4281 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/index.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/index.tsx @@ -1 +1 @@ -export * from './LeftPanel'; +export * from './left-panel/LeftPanel'; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanel.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanel.tsx similarity index 100% rename from src/frontend/apps/conversations/src/features/left-panel/components/LeftPanel.tsx rename to src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanel.tsx diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelContent.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelContent.tsx similarity index 90% rename from src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelContent.tsx rename to src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelContent.tsx index f4c8c33..5c4a5da 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelContent.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelContent.tsx @@ -1,8 +1,7 @@ import { Box } from '@/components'; import { useAuth } from '@/features/auth/hooks'; -import { LeftPanelConversations } from '@/features/left-panel/components/LeftPanelConversations'; -import { LeftPanelProjects } from '@/features/left-panel/components/LeftPanelProjects'; - +import { LeftPanelConversations } from '@/features/left-panel/components/left-panel/LeftPanelConversations'; +import { LeftPanelProjects } from '@/features/left-panel/components/left-panel/LeftPanelProjects'; import { useResponsiveStore } from '@/stores'; export const LeftPanelContent = () => { @@ -21,7 +20,6 @@ export const LeftPanelContent = () => { > {authenticated && ( <> - {' '} )} diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelConversationItem.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversationItem.tsx similarity index 92% rename from src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelConversationItem.tsx rename to src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversationItem.tsx index 8d9c304..c81e08d 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelConversationItem.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversationItem.tsx @@ -8,7 +8,7 @@ import { SimpleConversationItem } from '@/features/left-panel/components/SimpleC type LeftPanelConversationItemProps = { conversation: ChatConversation; isCurrentConversation: boolean; - /** Affiche le temps depuis le dernier message (ex. dans la modale de recherche) */ + /** Displays the time since the last message (e.g., in the search modal) */ showUpdatedAt?: boolean; }; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversations.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversations.tsx new file mode 100644 index 0000000..bec62ac --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversations.tsx @@ -0,0 +1,60 @@ +import { useRouter } from 'next/router'; +import { useTranslation } from 'react-i18next'; + +import { Box, InfiniteScroll, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { useInfiniteConversations } from '@/features/chat/api/useConversations'; +import { LeftPanelConversationItem } from '@/features/left-panel/components/left-panel/LeftPanelConversationItem'; + +export const LeftPanelConversations = () => { + const { t } = useTranslation(); + const router = useRouter(); + const { id } = router.query; + + const { spacingsTokens } = useCunninghamTheme(); + + const conversations = useInfiniteConversations({ + page: 1, + ordering: '-updated_at', + }); + + const favoriteConversations = + conversations.data?.pages.flatMap((page) => page.results) || []; + + if (favoriteConversations.length === 0) { + return null; + } + + return ( + + + {t('Your chats')} + + void conversations.fetchNextPage()} + > + {favoriteConversations.map((conversation) => ( + + ))} + + + ); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelHeader.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeader.tsx similarity index 91% rename from src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelHeader.tsx rename to src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeader.tsx index c3909a1..e6ebcbc 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelHeader.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeader.tsx @@ -1,4 +1,4 @@ -import { useRouter } from 'next/navigation'; +import { useRouter } from 'next/router'; import { PropsWithChildren, useEffect, useState } from 'react'; import { Box } from '@/components'; @@ -7,9 +7,10 @@ import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferenc import { useOwnModal } from '@/features/left-panel/hooks/useModalHook'; import { useResponsiveStore } from '@/stores'; +import { ModalProjectForm } from '../projects/ModalProjectForm'; + import { LeftPanelHeaderActions } from './LeftPanelHeaderActions'; import { LeftPanelSearchModal } from './LeftPanelSearchModal'; -import { ModalCreateProject } from './ModalCreateProject'; export const LeftPanelHeader = ({ children }: PropsWithChildren) => { const router = useRouter(); @@ -32,7 +33,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { }, []); const goToHome = () => { - router.push('/'); + void router.push('/'); if (!isDesktop) { setPanelOpen(false); } @@ -60,7 +61,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { setIsSearchModalOpen(false)} /> )} {authenticated && createProjectModal.isOpen && ( - + )} ); diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelHeaderActions.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeaderActions.tsx similarity index 91% rename from src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelHeaderActions.tsx rename to src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeaderActions.tsx index cdeb793..dd6ec26 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelHeaderActions.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeaderActions.tsx @@ -1,8 +1,8 @@ import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; -import FolderPlusIcon from '@/assets/icons/uikit-custom/folder-plus.svg'; import NewChatIcon from '@/assets/icons/new-message-bold.svg'; +import FolderPlusIcon from '@/assets/icons/uikit-custom/folder-plus.svg'; import { Box, Icon } from '@/components'; import { LeftPanelMenuItem } from './LeftPanelMenuItem'; @@ -36,7 +36,7 @@ export const LeftPanelHeaderActions = ({ $direction="column" $gap="2px" $align="center" - $margin={{ vertical: 'base' }} + $margin={{ top: 'base' }} $padding={{ horizontal: 'sm' }} > - + } - label={t('Create project')} + label={t('New project')} onClick={onCreateProject} - aria-label={t('Create project')} + aria-label={t('New project')} /> ); diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelMenuItem.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelMenuItem.tsx similarity index 100% rename from src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelMenuItem.tsx rename to src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelMenuItem.tsx diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjectItem.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelProjectItem.tsx similarity index 56% rename from src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjectItem.tsx rename to src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelProjectItem.tsx index e0da015..1c72ba7 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjectItem.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelProjectItem.tsx @@ -1,14 +1,20 @@ -import { memo, useCallback, useState } from 'react'; -import { css } from 'styled-components'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; +import { useRouter } from 'next/router'; +import { memo, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; import { Box, Icon, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; +import { usePendingChatStore } from '@/features/chat/stores/usePendingChatStore'; import { ChatProject } from '@/features/chat/types'; import { ConversationItemActions } from '@/features/left-panel/components/ConversationItemActions'; import { ConversationRow } from '@/features/left-panel/components/ConversationRow'; -import { PROJECT_COLORS, PROJECT_ICONS } from '@/features/left-panel/components/project-constants'; -import { ProjectItemActions } from '@/features/left-panel/components/ProjectItemActions'; +import { ProjectItemActions } from '@/features/left-panel/components/projects/ProjectItemActions'; +import { + PROJECT_COLORS, + PROJECT_ICONS, +} from '@/features/left-panel/components/projects/project-constants'; type LeftPanelProjectItemProps = { project: ChatProject; currentConversationId?: string; @@ -51,9 +57,25 @@ export const LeftPanelProjectItem = memo(function LeftPanelProjectItem({ }: LeftPanelProjectItemProps) { const { t } = useTranslation(); const { colorsTokens } = useCunninghamTheme(); + const router = useRouter(); + const pendingProjectId = usePendingChatStore((s) => s.projectId); + const setProjectId = usePendingChatStore((s) => s.setProjectId); const [isOpen, setIsOpen] = useState(false); + // Auto-open when current conversation belongs to this project or creating a new one in it + const belongsToProject = + currentConversationId && + project.conversations.some((c) => c.id === currentConversationId); + const isTargetProject = belongsToProject || pendingProjectId === project.id; + + useEffect(() => { + if (isTargetProject) { + setIsOpen(true); + } + }, [isTargetProject]); + + const IconComponent = PROJECT_ICONS[project.icon] ?? PROJECT_ICONS.folder; const iconColor = colorsTokens[PROJECT_COLORS[project.color] as keyof typeof colorsTokens] ?? undefined; @@ -62,9 +84,14 @@ export const LeftPanelProjectItem = memo(function LeftPanelProjectItem({ setIsOpen((prev) => !prev); }, []); + const handleNewConversation = useCallback(() => { + setProjectId(project.id); + void router.push('/chat/'); + }, [router, project.id, setProjectId]); + return ( - {/* Project header - click to toggle */} + {/* Project header */} { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleOpen(); - } - }} > - + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleOpen(); + } + }} + > - {(() => { - const IconComp = PROJECT_ICONS[project.icon] ?? PROJECT_ICONS.folder; - return ( - - - - ); - })()} + + + - e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} + role="presentation" + style={{ display: 'flex', alignItems: 'center', gap: 0 }} > - + + + + } + size={ModalSize.MEDIUM} + title={ + + {isEditing ? t('Project settings') : t('New project')} + + } + > + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + e.stopPropagation()} + > +
+ + + + {t('Project avatar and name')} + + + + + + } + label={t('Choose icon and color')} + > + + dispatch({ type: 'SET_ICON', value }) + } + onColorChange={(value) => + dispatch({ type: 'SET_COLOR', value }) + } + /> + + + ) => + dispatch({ type: 'SET_NAME', value: e.target.value }) + } + required + /> + + + + +