diff --git a/CHANGELOG.md b/CHANGELOG.md
index bed9b311..7267d2fe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [unreleased next]
+
+### Changed
+
+- 🏗️(front) replace Next.js with Vite and React Router
+
## [unreleased]
### Added
diff --git a/README.md b/README.md
index a0122d2e..a70037e4 100644
--- a/README.md
+++ b/README.md
@@ -214,7 +214,7 @@ docs
### Stack
-Conversations is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [Vercel‘s AI SDK](https://ai-sdk.dev/) and [Pydantic AI](https://ai.pydantic.dev). We thank the contributors of all these projects for their awesome work!
+Conversations is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Vite](https://vite.dev/), [React Router](https://reactrouter.com/), [Vercel‘s AI SDK](https://ai-sdk.dev/) and [Pydantic AI](https://ai.pydantic.dev). We thank the contributors of all these projects for their awesome work!
### Gov ❤️ open source
diff --git a/docs/env.md b/docs/env.md
index 3574cf66..684ef4de 100644
--- a/docs/env.md
+++ b/docs/env.md
@@ -148,13 +148,13 @@ Example:
docker build -f src/frontend/Dockerfile --target frontend-production --build-arg API_ORIGIN=https://mybackend.example.com conversations-frontend:latest
```
-If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/conversations/.env` with the `NODE_ENV=production` environment variable and modify it. Alternatively, you can use the listed environment variables with the prefix `NEXT_PUBLIC_`.
+If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/conversations/.env` and modify it. Alternatively, you can use the listed environment variables with the prefix `VITE_`.
Example:
```
cd src/frontend/apps/conversations
-NODE_ENV=production NEXT_PUBLIC_API_ORIGIN=https://mybackend.example.com yarn build
+VITE_API_ORIGIN=https://mybackend.example.com yarn build
```
| Option | Description | default |
diff --git a/docs/examples/conversations.values.yaml b/docs/examples/conversations.values.yaml
index 10158eea..ba3da66f 100644
--- a/docs/examples/conversations.values.yaml
+++ b/docs/examples/conversations.values.yaml
@@ -94,7 +94,7 @@ backend:
frontend:
envVars:
PORT: 8080
- NEXT_PUBLIC_API_ORIGIN: https://conversations.127.0.0.1.nip.io
+ VITE_API_ORIGIN: https://conversations.127.0.0.1.nip.io
replicas: 1
diff --git a/docs/system-requirements.md b/docs/system-requirements.md
index 9e1b819f..e335cb93 100644
--- a/docs/system-requirements.md
+++ b/docs/system-requirements.md
@@ -8,7 +8,7 @@
| **Team QA** | 16 GB | 6 | 30 GB | Runs integration tests |
| **Prod ≤ 100 live users** | 32 GB | 8 + | 50 GB + | Scale linearly above this |
-Memory is the first bottleneck; CPU matters only when Celery or the Next.js build is saturated.
+Memory is the first bottleneck; CPU matters only when Celery or the Vite build is saturated.
> **Note:** Memory consumption varies by operating system. Windows tends to be more memory-hungry than Linux, so consider adding 10-20% extra RAM when running on Windows compared to Linux-based systems.
@@ -21,7 +21,7 @@ Memory is the first bottleneck; CPU matters only when Celery or the Next.js buil
| Redis | **≤ 256 MB** | Empty instance ≈ 3 MB; budget 256 MB to allow small datasets ([stackoverflow.com][3]) |
| MinIO | **2 GB (dev) / 32 GB (prod)** | Pre-allocates 1–2 GiB; docs recommend 32 GB per host for ≤ 100 Ti storage ([min.io][4]) |
| Django API | **0.8 – 1.5 GB** | Empirical in-house metrics |
-| Next.js frontend | **0.5 – 1 GB** | Dev build chain |
+| Vite frontend | **0.5 – 1 GB** | Dev build chain |
| Nginx | **< 100 MB** | Static reverse-proxy footprint |
[1]: https://www.postgresql.org/docs/9.1/runtime-config-resource.html "PostgreSQL: Documentation: 9.1: Resource Consumption"
@@ -42,12 +42,12 @@ Production deployments differ significantly from development environments. The t
| Redis | **256 MB – 2 GB** | Session storage and caching; scales with active user sessions |
| Object Storage (optional) | **External or self-hosted** | Can use AWS S3, Azure Blob, Google Cloud Storage, or self-hosted MinIO |
| Django API (+ Celery) | **1 – 3 GB** | Production workloads with background tasks and higher concurrency |
-| Static Files (Nginx) | **< 200 MB** | Serves Next.js build output and static assets; no development overhead |
+| Static Files (Nginx) | **< 200 MB** | Serves Vite build output and static assets; no development overhead |
| Nginx (Load Balancer) | **< 200 MB** | Reverse proxy, SSL termination, static file serving |
### Production Architecture Notes
-- **Frontend**: Uses pre-built Next.js static assets served by Nginx (no Node.js runtime needed)
+- **Frontend**: Uses pre-built Vite static assets served by Nginx (no Node.js runtime needed)
- **Authentication**: Any OIDC-compatible provider can be used instead of self-hosted Keycloak
- **Object Storage**: External services (S3, Azure Blob) or self-hosted solutions (MinIO) are both viable
- **Database**: Consider PostgreSQL clustering or managed database services for high availability
@@ -83,7 +83,7 @@ Production deployments differ significantly from development environments. The t
| Port | Service |
|-----------|----------------------------|
-| 3000 | Next.js |
+| 3000 | Vite |
| 8071 | Django |
| 8080 | Keycloak |
| 8083 | Nginx proxy |
@@ -98,7 +98,7 @@ Production deployments differ significantly from development environments. The t
> **OS considerations:** Windows systems typically require 10-20% more RAM than Linux due to higher OS overhead. Docker Desktop on Windows also uses additional memory compared to native Linux Docker.
-**CPU** – budget one vCPU per busy container until Celery or Next.js builds saturate.
+**CPU** – budget one vCPU per busy container until Celery or Vite builds saturate.
**Disk** – SSD; add 10 GB extra for the Docker layer cache.
diff --git a/src/backend/core/authentication/views.py b/src/backend/core/authentication/views.py
index b66f62a1..cb16eed9 100644
--- a/src/backend/core/authentication/views.py
+++ b/src/backend/core/authentication/views.py
@@ -9,10 +9,9 @@
from core.authentication.backends import OIDCRoleAccessDenied
# Frontend path shown to users whose account is not allowed to access the app.
-# Keep the trailing slash: the frontend is a Next.js static export with
-# trailingSlash=true, so the canonical URL is /unauthorized/. Redirecting to the
-# bare path makes the frontend nginx issue a directory redirect that leaks its
-# internal listen port (e.g. :8080) and breaks behind the TLS ingress.
+# The frontend is a client-routed single-page app: nginx serves index.html for
+# this path and the router renders the access-denied page. The trailing slash is
+# kept so links minted before the SPA migration keep resolving to the same URL.
ACCESS_DENIED_PATH = "/unauthorized/"
diff --git a/src/backend/core/tests/authentication/test_views.py b/src/backend/core/tests/authentication/test_views.py
index a5d5c990..460cad7c 100644
--- a/src/backend/core/tests/authentication/test_views.py
+++ b/src/backend/core/tests/authentication/test_views.py
@@ -12,10 +12,8 @@
def test_callback_redirects_denied_user_to_unauthorized_page(rf, monkeypatch):
"""A role-denied user is redirected to the frontend access-denied page.
- The target must keep its trailing slash: the frontend is a Next.js static
- export with trailingSlash=true, so /unauthorized/ is the canonical URL. A
- bare /unauthorized would make the frontend nginx issue a directory redirect
- that leaks its internal port and breaks behind the TLS ingress.
+ The target keeps its trailing slash so links minted before the frontend
+ became a single-page app keep resolving to the same URL.
"""
def raise_denied(self, request): # pylint: disable=unused-argument
diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile
index a11b51b1..a2e343a4 100644
--- a/src/frontend/Dockerfile
+++ b/src/frontend/Dockerfile
@@ -29,6 +29,11 @@ FROM frontend-deps AS conversations-dev
WORKDIR /home/frontend/apps/conversations
+# The container runs as the host user (DOCKER_USER) while node_modules is
+# root-owned from the build above. Vite writes its dependency-optimization cache
+# to node_modules/.vite, so that directory has to stay writable whatever the uid.
+RUN rm -rf node_modules/.vite && chmod a+w node_modules
+
EXPOSE 3000
CMD [ "yarn", "dev"]
@@ -40,10 +45,10 @@ FROM conversations AS conversations-builder
WORKDIR /home/frontend/apps/conversations
ARG API_ORIGIN
-ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
+ENV VITE_API_ORIGIN=${API_ORIGIN}
ARG PRODUCT_NAME
-ENV NEXT_PUBLIC_PRODUCT_NAME=${PRODUCT_NAME}
+ENV VITE_PRODUCT_NAME=${PRODUCT_NAME}
RUN yarn build
@@ -62,7 +67,7 @@ ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=conversations-builder \
- /home/frontend/apps/conversations/out \
+ /home/frontend/apps/conversations/dist \
/usr/share/nginx/html
COPY ./src/frontend/apps/conversations/conf/default.conf /etc/nginx/conf.d
diff --git a/src/frontend/apps/conversations/.env b/src/frontend/apps/conversations/.env
index f89a6e6f..965dc7c1 100644
--- a/src/frontend/apps/conversations/.env
+++ b/src/frontend/apps/conversations/.env
@@ -1,2 +1,2 @@
-NEXT_PUBLIC_API_ORIGIN=
-NEXT_PUBLIC_PRODUCT_NAME=
+VITE_API_ORIGIN=
+VITE_PRODUCT_NAME=
diff --git a/src/frontend/apps/conversations/.env.development b/src/frontend/apps/conversations/.env.development
index 99a452e5..cea42efc 100644
--- a/src/frontend/apps/conversations/.env.development
+++ b/src/frontend/apps/conversations/.env.development
@@ -1,2 +1,2 @@
-NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
-NEXT_PUBLIC_PRODUCT_NAME=
+VITE_API_ORIGIN=http://localhost:8071
+VITE_PRODUCT_NAME=
diff --git a/src/frontend/apps/conversations/.env.test b/src/frontend/apps/conversations/.env.test
index 9a4d5142..eed05678 100644
--- a/src/frontend/apps/conversations/.env.test
+++ b/src/frontend/apps/conversations/.env.test
@@ -1 +1 @@
-NEXT_PUBLIC_API_ORIGIN=http://test.jest
+VITE_API_ORIGIN=http://test.jest
diff --git a/src/frontend/apps/conversations/.gitignore b/src/frontend/apps/conversations/.gitignore
index 0abcbb52..8987a527 100644
--- a/src/frontend/apps/conversations/.gitignore
+++ b/src/frontend/apps/conversations/.gitignore
@@ -9,9 +9,8 @@
# testing
/coverage
-# next.js
-/.next/
-/out/
+# vite
+/dist/
# production
/build
diff --git a/src/frontend/apps/conversations/.prettierignore b/src/frontend/apps/conversations/.prettierignore
index 15b20443..d6e87ad2 100644
--- a/src/frontend/apps/conversations/.prettierignore
+++ b/src/frontend/apps/conversations/.prettierignore
@@ -1,2 +1,2 @@
-next-env.d.ts
+dist
service-worker.js
diff --git a/src/frontend/apps/conversations/README.md b/src/frontend/apps/conversations/README.md
index c4033664..52f06d98 100644
--- a/src/frontend/apps/conversations/README.md
+++ b/src/frontend/apps/conversations/README.md
@@ -1,36 +1,33 @@
-This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
+This is the Conversations front-end: a [React](https://react.dev/) single-page app built with [Vite](https://vite.dev/) and routed with [React Router](https://reactrouter.com/) in declarative mode.
## Getting Started
-First, run the development server:
+Run the development server:
```bash
-npm run dev
-# or
yarn dev
-# or
-pnpm dev
-# or
-bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+Routes are declared in `src/App.tsx`; their page components live in `src/pages/`.
-This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
+## Commands
-## Learn More
-
-To learn more about Next.js, take a look at the following resources:
-
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
-
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
+```bash
+yarn dev # development server (Vite)
+yarn build # format/style/type checks, then a production build into dist/
+yarn start # serve the production build
+yarn lint # tsc --noEmit + eslint
+yarn test # Vitest
+```
-## Deploy on Vercel
+## Environment variables
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+Build-time variables must use the `VITE_` prefix and are read through
+`import.meta.env`. They are set in `.env`, `.env.development` and `.env.test`:
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
+| Variable | Description |
+| ------------------- | ---------------------------------------------------------------- |
+| `VITE_API_ORIGIN` | Backend origin; falls back to the current origin when left empty |
+| `VITE_PRODUCT_NAME` | Product name shown in the UI |
diff --git a/src/frontend/apps/conversations/conf/default.conf b/src/frontend/apps/conversations/conf/default.conf
index ff047abf..33bd02ea 100644
--- a/src/frontend/apps/conversations/conf/default.conf
+++ b/src/frontend/apps/conversations/conf/default.conf
@@ -10,16 +10,10 @@ server {
root /usr/share/nginx/html;
+ # Single-page app: every unknown path falls back to index.html so the
+ # client-side router can handle it. Unknown routes therefore return HTTP 200
+ # and the app renders its own 404 page.
location / {
- try_files $uri index.html $uri/ =404;
- }
-
- location ~ "^/chat/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$" {
- try_files $uri /chat/[id]/index.html;
- }
-
- error_page 404 /404.html;
- location = /404.html {
- internal;
+ try_files $uri $uri/ /index.html;
}
}
diff --git a/src/frontend/apps/conversations/eslint.config.mjs b/src/frontend/apps/conversations/eslint.config.mjs
index 8202741a..db8a9a00 100644
--- a/src/frontend/apps/conversations/eslint.config.mjs
+++ b/src/frontend/apps/conversations/eslint.config.mjs
@@ -1,6 +1,5 @@
-import { nextConfig } from 'eslint-config-conversations/next.mjs';
+import { viteConfig } from 'eslint-config-conversations/vite.mjs';
-export default nextConfig({
+export default viteConfig({
tsconfigRootDir: import.meta.dirname,
- nextRootDir: import.meta.dirname,
});
diff --git a/src/frontend/apps/conversations/index.html b/src/frontend/apps/conversations/index.html
new file mode 100644
index 00000000..da948956
--- /dev/null
+++ b/src/frontend/apps/conversations/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ L'Assistant
+
+
+
+
+
+
diff --git a/src/frontend/apps/conversations/jest.config.ts b/src/frontend/apps/conversations/jest.config.ts
deleted file mode 100644
index 05bfb0f3..00000000
--- a/src/frontend/apps/conversations/jest.config.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import type { Config } from 'jest';
-import nextJest from 'next/jest.js';
-
-const createJestConfig = nextJest({
- dir: './',
-});
-
-// Add any custom config to be passed to Jest
-const config: Config = {
- coverageProvider: 'v8',
- moduleNameMapper: {
- '^@/docs/(.*)$': '/src/features/docs/$1',
- '^@/(.*)$': '/src/$1',
- },
- setupFilesAfterEnv: ['/jest.setup.ts'],
- testEnvironment: 'jsdom',
-};
-
-const jestConfig = async () => {
- const nextJestConfig = await createJestConfig(config)();
- return {
- ...nextJestConfig,
- moduleNameMapper: {
- '\\.svg$': '/jest/mocks/svg.js',
- '^.+\\.svg\\?url$': `/jest/mocks/fileMock.js`,
- BlockNoteEditor: `/jest/mocks/ComponentMock.js`,
- 'custom-blocks': `/jest/mocks/ComponentMock.js`,
- ...nextJestConfig.moduleNameMapper,
- },
- };
-};
-
-export default jestConfig;
diff --git a/src/frontend/apps/conversations/jest.setup.ts b/src/frontend/apps/conversations/jest.setup.ts
deleted file mode 100644
index 564d8a6d..00000000
--- a/src/frontend/apps/conversations/jest.setup.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import '@testing-library/jest-dom';
-import * as dotenv from 'dotenv';
-
-dotenv.config({ path: './.env.test' });
diff --git a/src/frontend/apps/conversations/jest/mocks/ComponentMock.js b/src/frontend/apps/conversations/jest/mocks/ComponentMock.js
deleted file mode 100644
index 812a08b1..00000000
--- a/src/frontend/apps/conversations/jest/mocks/ComponentMock.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import React from 'react';
-
-export const ComponentMock = () => {
- return My component mocked
;
-};
diff --git a/src/frontend/apps/conversations/jest/mocks/fileMock.js b/src/frontend/apps/conversations/jest/mocks/fileMock.js
deleted file mode 100644
index 28a24984..00000000
--- a/src/frontend/apps/conversations/jest/mocks/fileMock.js
+++ /dev/null
@@ -1,16 +0,0 @@
-module.exports = {
- src: '/img.jpg',
- height: 40,
- width: 40,
- blurDataURL: 'data:image/png;base64,imagedata',
-};
-
-if (
- (typeof exports.default === 'function' ||
- (typeof exports.default === 'object' && exports.default !== null)) &&
- typeof exports.default.__esModule === 'undefined'
-) {
- Object.defineProperty(exports.default, '__esModule', { value: true });
- Object.assign(exports.default, exports);
- module.exports = exports.default;
-}
diff --git a/src/frontend/apps/conversations/jest/mocks/svg.js b/src/frontend/apps/conversations/jest/mocks/svg.js
deleted file mode 100644
index 0b7fc5b8..00000000
--- a/src/frontend/apps/conversations/jest/mocks/svg.js
+++ /dev/null
@@ -1,3 +0,0 @@
-const nameMock = 'svg';
-export default nameMock;
-export const ReactComponent = 'svg';
diff --git a/src/frontend/apps/conversations/next-env.d.ts b/src/frontend/apps/conversations/next-env.d.ts
deleted file mode 100644
index 7996d352..00000000
--- a/src/frontend/apps/conversations/next-env.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-///
-///
-import "./.next/dev/types/routes.d.ts";
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
diff --git a/src/frontend/apps/conversations/next.config.js b/src/frontend/apps/conversations/next.config.js
deleted file mode 100644
index a9a4e806..00000000
--- a/src/frontend/apps/conversations/next.config.js
+++ /dev/null
@@ -1,52 +0,0 @@
-const crypto = require('crypto');
-const path = require('path');
-
-const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
-
-/** @type {import('next').NextConfig} */
-const nextConfig = {
- output: 'export',
- trailingSlash: true,
- images: {
- unoptimized: true,
- },
- compiler: {
- styledComponents: true,
- },
- generateBuildId: () => buildId,
- env: {
- NEXT_PUBLIC_BUILD_ID: buildId,
- },
- turbopack: {
- root: path.resolve(__dirname, '../..'),
- rules: {
- '*.svg': {
- loaders: ['@svgr/webpack'],
- as: '*.js',
- },
- },
- resolveAlias: {
- 'micromark-extension-math': 'micromark-extension-llm-math',
- '@gouvfr-lasuite/cunningham-react': '@gouvfr-lasuite/cunningham-react',
- '@gouvfr-lasuite/ui-kit/node_modules/@gouvfr-lasuite/cunningham-react':
- '@gouvfr-lasuite/cunningham-react',
- },
- },
- webpack: (config) => {
- config.resolve.alias = {
- ...(config.resolve.alias || {}),
- '@gouvfr-lasuite/cunningham-react': path.resolve(
- __dirname,
- '../../node_modules/@gouvfr-lasuite/cunningham-react',
- ),
- '@gouvfr-lasuite/ui-kit/node_modules/@gouvfr-lasuite/cunningham-react':
- path.resolve(
- __dirname,
- '../../node_modules/@gouvfr-lasuite/cunningham-react',
- ),
- };
- return config;
- },
-};
-
-module.exports = nextConfig;
diff --git a/src/frontend/apps/conversations/package.json b/src/frontend/apps/conversations/package.json
index 5514c61b..7447f90b 100644
--- a/src/frontend/apps/conversations/package.json
+++ b/src/frontend/apps/conversations/package.json
@@ -3,17 +3,17 @@
"version": "0.0.19",
"private": true,
"scripts": {
- "dev": "next dev",
- "build": "prettier --check . && yarn stylelint && next build",
+ "dev": "vite",
+ "build": "prettier --check . && yarn stylelint && tsc --noEmit && vite build",
"build:ci": "cp .env.development .env.local && yarn build",
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier && yarn stylelint --fix",
- "start": "npx -y serve@latest out",
+ "start": "vite preview",
"lint": "tsc --noEmit && eslint src/",
"lint:fix": "tsc --noEmit && eslint src/ --fix",
"prettier": "prettier --write .",
"stylelint": "stylelint \"**/*.css\"",
- "test": "jest",
- "test:watch": "jest --watch"
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@ai-sdk/react": "1.2.12",
@@ -25,7 +25,7 @@
"@gouvfr-lasuite/cunningham-tokens": "^3.1.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.23.2",
- "@sentry/nextjs": "10.58.0",
+ "@sentry/react": "10.58.0",
"@shikijs/rehype": "^4.2.0",
"@tanstack/react-query": "5.101.0",
"clsx": "2.1.1",
@@ -36,7 +36,6 @@
"lottie-react": "^2.4.1",
"luxon": "3.7.2",
"micromark-extension-llm-math": "3.1.1-20250610",
- "next": "16.2.7",
"posthog-js": "1.399.1",
"react": "19.2.6",
"react-aria-components": "1.18.0",
@@ -44,6 +43,7 @@
"react-i18next": "17.0.8",
"react-intersection-observer": "10.0.3",
"react-markdown": "10.1.0",
+ "react-router": "7.18.1",
"rehype-katex": "7.0.1",
"remark-gfm": "4.0.1",
"remark-math": "6.0.0",
@@ -53,30 +53,30 @@
"zustand": "5.0.14"
},
"devDependencies": {
- "@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.101.0",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
- "@types/jest": "30.0.0",
"@types/lodash": "4.17.24",
"@types/luxon": "3.7.1",
"@types/node": "*",
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
- "dotenv": "17.4.2",
+ "@vitejs/plugin-react": "6.0.3",
+ "@vitest/coverage-v8": "4.1.10",
"eslint": "^9",
"eslint-config-conversations": "*",
- "eslint-config-next": "16.2.6",
"fetch-mock": "9.11.0",
- "jest": "30.4.2",
- "jest-environment-jsdom": "30.4.1",
+ "jsdom": "29.1.1",
"prettier": "3.8.4",
"stylelint": "17.13.0",
"stylelint-config-standard": "40.0.0",
"stylelint-prettier": "5.0.3",
- "typescript": "*"
+ "typescript": "*",
+ "vite": "8.1.5",
+ "vite-plugin-svgr": "5.2.0",
+ "vitest": "4.1.10"
},
"resolutions": {
"@types/react": "19.2.17",
diff --git a/src/frontend/apps/conversations/src/App.tsx b/src/frontend/apps/conversations/src/App.tsx
new file mode 100644
index 00000000..6a7e8f60
--- /dev/null
+++ b/src/frontend/apps/conversations/src/App.tsx
@@ -0,0 +1,97 @@
+import { useTranslation } from 'react-i18next';
+import { BrowserRouter, Outlet, Route, Routes } from 'react-router';
+
+import { AppProvider, productName } from '@/core/';
+import { useCunninghamTheme } from '@/cunningham';
+import { MainLayout, PageLayout } from '@/layouts';
+
+import Page401 from './pages/401';
+import Page403 from './pages/403';
+import Page404 from './pages/404';
+import ActivationPage from './pages/activation';
+import ChatPage from './pages/chat';
+import ChatConversationPage from './pages/chat/conversation';
+import HomePage from './pages/home';
+import LoginPage from './pages/login';
+import UnauthorizedPage from './pages/unauthorized';
+
+const MainLayoutRoute = () => (
+
+
+
+);
+
+const PageLayoutRoute = () => (
+
+
+
+);
+
+const AppHead = () => {
+ const { t } = useTranslation();
+ const { componentTokens } = useCunninghamTheme();
+ const favicon = (componentTokens as Record).favicon as
+ | { 'png-light': string; 'png-dark': string }
+ | undefined;
+
+ return (
+ <>
+ {productName}
+
+
+ {favicon && (
+ <>
+
+
+
+ >
+ )}
+ >
+ );
+};
+
+export function App() {
+ return (
+
+
+
+
+ }>
+ } />
+ } />
+ } />
+
+ } />
+ } />
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+ );
+}
diff --git a/src/frontend/apps/conversations/src/api/__tests__/config.test.ts b/src/frontend/apps/conversations/src/api/__tests__/config.test.ts
index cb9bf268..aa410624 100644
--- a/src/frontend/apps/conversations/src/api/__tests__/config.test.ts
+++ b/src/frontend/apps/conversations/src/api/__tests__/config.test.ts
@@ -10,7 +10,8 @@ describe('config', () => {
});
it('uses env origin if available', () => {
- process.env.NEXT_PUBLIC_API_ORIGIN = 'https://env.example.com';
+ vi.stubEnv('VITE_API_ORIGIN', 'https://env.example.com');
expect(baseApiUrl('3.0')).toBe('https://env.example.com/api/v3.0/');
+ vi.unstubAllEnvs();
});
});
diff --git a/src/frontend/apps/conversations/src/api/__tests__/helpers.test.tsx b/src/frontend/apps/conversations/src/api/__tests__/helpers.test.tsx
index e4706367..0ea1c3f2 100644
--- a/src/frontend/apps/conversations/src/api/__tests__/helpers.test.tsx
+++ b/src/frontend/apps/conversations/src/api/__tests__/helpers.test.tsx
@@ -21,8 +21,8 @@ const createWrapper = () => {
describe('helpers', () => {
it('fetches and paginates correctly', async () => {
- const mockAPI = jest
- .fn, [{ page: number; query: string }]>()
+ const mockAPI = vi
+ .fn<(params: { page: number; query: string }) => Promise>()
.mockResolvedValueOnce({
results: [{ id: 1 }],
next: 'url?page=2',
diff --git a/src/frontend/apps/conversations/src/api/config.ts b/src/frontend/apps/conversations/src/api/config.ts
index 916585e6..b1926c41 100644
--- a/src/frontend/apps/conversations/src/api/config.ts
+++ b/src/frontend/apps/conversations/src/api/config.ts
@@ -2,14 +2,14 @@
* Returns the base URL for the backend API.
*
* Priority:
- * 1. Uses NEXT_PUBLIC_API_ORIGIN from environment variables if defined.
+ * 1. Uses VITE_API_ORIGIN from environment variables if defined.
* 2. Falls back to the browser's window.location.origin if in a browser environment.
* 3. Defaults to an empty string if executed in a non-browser environment without the env variable.
*
* @returns The backend base URL as a string.
*/
export const backendUrl = () =>
- process.env.NEXT_PUBLIC_API_ORIGIN ||
+ import.meta.env.VITE_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
/**
diff --git a/src/frontend/apps/conversations/src/components/Link.tsx b/src/frontend/apps/conversations/src/components/Link.tsx
index 73fa86a3..63ed836b 100644
--- a/src/frontend/apps/conversations/src/components/Link.tsx
+++ b/src/frontend/apps/conversations/src/components/Link.tsx
@@ -1,5 +1,5 @@
-import { useRouter } from 'next/navigation';
import { memo, useCallback, useEffect, useRef } from 'react';
+import { useNavigate } from 'react-router';
import styled, { RuleSet } from 'styled-components';
interface StyledLinkProps {
@@ -20,26 +20,24 @@ interface Props extends React.AnchorHTMLAttributes {
}
/**
- * Link that avoids re-renders from Next.js router context.
+ * Link that avoids re-renders from the router context.
*
- * Use instead of Next.js `Link` in large lists (sidebars, tables) where
+ * Use instead of the router `Link` in large lists (sidebars, tables) where
* router-triggered re-renders cause performance issues.
*
- * Warning: No automatic prefetching.
- *
*/
export const StyledLink = memo(function StyledLink({
href,
onClick,
...props
}: Props) {
- const router = useRouter();
- const routerRef = useRef(router);
+ const navigate = useNavigate();
+ const navigateRef = useRef(navigate);
// avoid rerenders
useEffect(() => {
- routerRef.current = router;
- }, [router]);
+ navigateRef.current = navigate;
+ }, [navigate]);
// Memoized click handler to maintain stable reference across re-renders.
// Necessary for memo() to work correctly
@@ -56,7 +54,7 @@ export const StyledLink = memo(function StyledLink({
e.preventDefault();
onClick?.(e);
- routerRef.current.push(href);
+ void navigateRef.current(href);
},
[href, onClick, props.target],
);
diff --git a/src/frontend/apps/conversations/src/components/Loader.tsx b/src/frontend/apps/conversations/src/components/Loader.tsx
index 609d0d07..1af65a56 100644
--- a/src/frontend/apps/conversations/src/components/Loader.tsx
+++ b/src/frontend/apps/conversations/src/components/Loader.tsx
@@ -1,17 +1,20 @@
-import dynamic from 'next/dynamic';
+import { Suspense, lazy } from 'react';
-const Lottie = dynamic(() => import('lottie-react'), { ssr: false });
import searchingAnimation from '@/assets/lotties/searching';
+const Lottie = lazy(() => import('lottie-react'));
+
export function Loader() {
return (
-
+
+
+
);
}
diff --git a/src/frontend/apps/conversations/src/components/__tests__/Link.test.tsx b/src/frontend/apps/conversations/src/components/__tests__/Link.test.tsx
index fed9dc98..6e60d301 100644
--- a/src/frontend/apps/conversations/src/components/__tests__/Link.test.tsx
+++ b/src/frontend/apps/conversations/src/components/__tests__/Link.test.tsx
@@ -3,17 +3,16 @@ import userEvent from '@testing-library/user-event';
import { StyledLink } from '../Link';
-const mockPush = jest.fn();
+const mockNavigate = vi.hoisted(() => vi.fn());
-jest.mock('next/navigation', () => ({
- useRouter: () => ({
- push: mockPush,
- }),
+vi.mock('react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useNavigate: () => mockNavigate,
}));
describe('StyledLink', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
});
it('should render a link with the correct href', () => {
@@ -23,18 +22,18 @@ describe('StyledLink', () => {
expect(link).toHaveAttribute('href', '/test-path');
});
- it('should navigate using router.push on click', async () => {
+ it('should navigate on click', async () => {
const user = userEvent.setup();
render(Test Link );
const link = screen.getByRole('link', { name: 'Test Link' });
await user.click(link);
- expect(mockPush).toHaveBeenCalledWith('/test-path');
+ expect(mockNavigate).toHaveBeenCalledWith('/test-path');
});
it('should call onClick prop when clicked', async () => {
- const handleClick = jest.fn();
+ const handleClick = vi.fn();
const user = userEvent.setup();
render(
@@ -54,7 +53,7 @@ describe('StyledLink', () => {
const link = screen.getByRole('link', { name: 'Test Link' });
fireEvent.click(link, { metaKey: true });
- expect(mockPush).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
});
it('should allow default behavior when ctrl key is pressed', () => {
@@ -63,7 +62,7 @@ describe('StyledLink', () => {
const link = screen.getByRole('link', { name: 'Test Link' });
fireEvent.click(link, { ctrlKey: true });
- expect(mockPush).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
});
it('should allow default behavior when shift key is pressed', () => {
@@ -72,7 +71,7 @@ describe('StyledLink', () => {
const link = screen.getByRole('link', { name: 'Test Link' });
fireEvent.click(link, { shiftKey: true });
- expect(mockPush).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
});
it('should allow default behavior when alt key is pressed', () => {
@@ -81,7 +80,7 @@ describe('StyledLink', () => {
const link = screen.getByRole('link', { name: 'Test Link' });
fireEvent.click(link, { altKey: true });
- expect(mockPush).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
});
it('should pass additional props to the anchor element', () => {
diff --git a/src/frontend/apps/conversations/src/components/quick-search/__tests__/QuickSearchResultItem.test.tsx b/src/frontend/apps/conversations/src/components/quick-search/__tests__/QuickSearchResultItem.test.tsx
index 27995bb6..5d73a810 100644
--- a/src/frontend/apps/conversations/src/components/quick-search/__tests__/QuickSearchResultItem.test.tsx
+++ b/src/frontend/apps/conversations/src/components/quick-search/__tests__/QuickSearchResultItem.test.tsx
@@ -5,13 +5,13 @@ import { ChatConversation } from '@/features/chat/types';
import { QuickSearchResultItem } from '../QuickSearchResultItem';
-jest.mock('react-i18next', () => ({
+vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en' },
}),
}));
-jest.mock('i18next', () => ({
+vi.mock('i18next', () => ({
t: (key: string) => key,
}));
diff --git a/src/frontend/apps/conversations/src/core/AppProvider.tsx b/src/frontend/apps/conversations/src/core/AppProvider.tsx
index 98b55c20..31f8447d 100644
--- a/src/frontend/apps/conversations/src/core/AppProvider.tsx
+++ b/src/frontend/apps/conversations/src/core/AppProvider.tsx
@@ -1,12 +1,13 @@
+import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
-import dynamic from 'next/dynamic';
import { useEffect } from 'react';
import { isAPIError } from '@/api';
+import { ToastProvider } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
@@ -14,20 +15,6 @@ import { useResponsiveStore } from '@/stores';
import { ConfigProvider } from './config';
import { KEY_CONFIG } from './config/api/useConfig';
-// Client-only providers
-const ToastProviderNoSSR = dynamic(
- () => import('@/components').then((mod) => ({ default: mod.ToastProvider })),
- { ssr: false, loading: () => null },
-);
-
-const CunninghamProviderNoSSR = dynamic(
- () =>
- import('@gouvfr-lasuite/cunningham-react').then((mod) => ({
- default: mod.CunninghamProvider,
- })),
- { ssr: false },
-);
-
const isMaintenanceError = (error: unknown): boolean =>
isAPIError(error) &&
error.status === 503 &&
@@ -78,13 +65,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return (
-
+
-
+
{children}
-
+
-
+
);
}
diff --git a/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx b/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx
index 5192e9f2..e9cbb44e 100644
--- a/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx
+++ b/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx
@@ -1,5 +1,4 @@
import { Loader } from '@gouvfr-lasuite/cunningham-react';
-import Head from 'next/head';
import { PropsWithChildren, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -102,9 +101,11 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
return (
<>
{conf?.FRONTEND_CSS_URL && (
-
-
-
+
)}
{children}
>
diff --git a/src/frontend/apps/conversations/src/core/config/__tests__/ConfigProvider.test.tsx b/src/frontend/apps/conversations/src/core/config/__tests__/ConfigProvider.test.tsx
index 989ee037..7256b323 100644
--- a/src/frontend/apps/conversations/src/core/config/__tests__/ConfigProvider.test.tsx
+++ b/src/frontend/apps/conversations/src/core/config/__tests__/ConfigProvider.test.tsx
@@ -1,22 +1,23 @@
import { render, waitFor } from '@testing-library/react';
import i18n from 'i18next';
+import type { Mock } from 'vitest';
import { AppWrapper } from '@/tests/utils';
import { ConfigProvider } from '../ConfigProvider';
import { useConfig } from '../api/useConfig';
-jest.mock('../api/useConfig', () => ({
- ...jest.requireActual('../api/useConfig'),
- useConfig: jest.fn(),
+vi.mock('../api/useConfig', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useConfig: vi.fn(),
}));
-jest.mock('@/features/auth', () => ({
- ...jest.requireActual('@/features/auth'),
- useAuthQuery: jest.fn(() => ({ data: undefined })),
+vi.mock('@/features/auth', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useAuthQuery: vi.fn(() => ({ data: undefined })),
}));
-const mockUseConfig = useConfig as jest.Mock;
+const mockUseConfig = useConfig as Mock;
const makeConfig = (overrides: Record = {}) => ({
data: {
@@ -49,7 +50,7 @@ describe('ConfigProvider - initial language', () => {
it('follows a non-French browser language over the instance default', async () => {
await i18n.changeLanguage('de');
- const changeLanguageSpy = jest.spyOn(i18n, 'changeLanguage');
+ const changeLanguageSpy = vi.spyOn(i18n, 'changeLanguage');
mockUseConfig.mockReturnValue(makeConfig({ LANGUAGE_CODE: 'fr-fr' }));
render(app , { wrapper: AppWrapper });
diff --git a/src/frontend/apps/conversations/src/core/settings.ts b/src/frontend/apps/conversations/src/core/settings.ts
index bbe16e6a..a95a0ee5 100644
--- a/src/frontend/apps/conversations/src/core/settings.ts
+++ b/src/frontend/apps/conversations/src/core/settings.ts
@@ -1,2 +1 @@
-export const productName =
- process.env.NEXT_PUBLIC_PRODUCT_NAME || "L'Assistant";
+export const productName = import.meta.env.VITE_PRODUCT_NAME || "L'Assistant";
diff --git a/src/frontend/apps/conversations/src/custom-next.d.ts b/src/frontend/apps/conversations/src/custom-next.d.ts
deleted file mode 100644
index 097aba3d..00000000
--- a/src/frontend/apps/conversations/src/custom-next.d.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-declare module '*.svg' {
- import * as React from 'react';
-
- const ReactComponent: React.FunctionComponent<
- React.SVGProps & {
- title?: string;
- }
- >;
-
- export default ReactComponent;
-}
-
-declare module '*.svg?url' {
- const content: {
- src: string;
- width: number;
- height: number;
- blurWidth: number;
- blurHeight: number;
- };
- export default content;
-}
-
-namespace NodeJS {
- interface ProcessEnv {
- NEXT_PUBLIC_PRODUCT_NAME?: string;
- NEXT_PUBLIC_API_ORIGIN?: string;
- }
-}
diff --git a/src/frontend/apps/conversations/src/features/auth/__tests__/silentLogin.test.ts b/src/frontend/apps/conversations/src/features/auth/__tests__/silentLogin.test.ts
index 4a17488c..91b31df5 100644
--- a/src/frontend/apps/conversations/src/features/auth/__tests__/silentLogin.test.ts
+++ b/src/frontend/apps/conversations/src/features/auth/__tests__/silentLogin.test.ts
@@ -2,24 +2,24 @@ import { navigate } from '@/utils/system';
import { attemptSilentLogin, canAttemptSilentLogin } from '../silentLogin';
-jest.mock('@/utils/system', () => ({
- ...jest.requireActual('@/utils/system'),
- navigate: jest.fn(),
+vi.mock('@/utils/system', async (importOriginal) => ({
+ ...(await importOriginal()),
+ navigate: vi.fn(),
}));
-const mockNavigate = jest.mocked(navigate);
+const mockNavigate = vi.mocked(navigate);
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry';
describe('silentLogin', () => {
beforeEach(() => {
localStorage.clear();
- jest.useFakeTimers();
+ vi.useFakeTimers();
mockNavigate.mockClear();
});
afterEach(() => {
- jest.useRealTimers();
+ vi.useRealTimers();
});
describe('canAttemptSilentLogin', () => {
@@ -28,25 +28,25 @@ describe('silentLogin', () => {
});
it('returns false within the retry interval', () => {
- jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
attemptSilentLogin(30);
- jest.setSystemTime(new Date('2026-01-01T00:00:15Z'));
+ vi.setSystemTime(new Date('2026-01-01T00:00:15Z'));
expect(canAttemptSilentLogin()).toBe(false);
});
it('returns true after the retry interval expires', () => {
- jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
attemptSilentLogin(30);
- jest.setSystemTime(new Date('2026-01-01T00:00:31Z'));
+ vi.setSystemTime(new Date('2026-01-01T00:00:31Z'));
expect(canAttemptSilentLogin()).toBe(true);
});
});
describe('attemptSilentLogin', () => {
it('sets the retry throttle in localStorage', () => {
- jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
attemptSilentLogin(30);
const stored = localStorage.getItem(SILENT_LOGIN_RETRY_KEY);
@@ -64,11 +64,11 @@ describe('silentLogin', () => {
});
it('does nothing if retry is not allowed', () => {
- jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
attemptSilentLogin(30);
mockNavigate.mockClear();
- jest.setSystemTime(new Date('2026-01-01T00:00:10Z'));
+ vi.setSystemTime(new Date('2026-01-01T00:00:10Z'));
attemptSilentLogin(30);
expect(mockNavigate).not.toHaveBeenCalled();
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 27e97a65..b7ec1d09 100644
--- a/src/frontend/apps/conversations/src/features/auth/components/ActivationPage.tsx
+++ b/src/frontend/apps/conversations/src/features/auth/components/ActivationPage.tsx
@@ -1,7 +1,7 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
-import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router';
import { css } from 'styled-components';
import { fetchAPI } from '@/api';
@@ -35,7 +35,7 @@ export const ActivationPage = () => {
const { data: activationStatus, refetch } = useActivationStatus();
const { mutate: registerNotification } = useRegisterNotification();
const { isSmallMobile, isDesktop } = useResponsiveStore();
- const router = useRouter();
+ const navigate = useNavigate();
const { authenticated, user } = useAuth();
const IconKey = () => {
@@ -80,9 +80,9 @@ export const ActivationPage = () => {
useEffect(() => {
// Redirect to home if user is authenticated and already activated
if (authenticated && activationStatus?.is_activated) {
- void router.push('/');
+ void navigate('/');
}
- }, [authenticated, activationStatus, router]);
+ }, [authenticated, activationStatus, navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -90,7 +90,7 @@ export const ActivationPage = () => {
// Verify user is still authenticated before submitting
if (!authenticated) {
- void router.push('/');
+ void navigate('/');
return;
}
@@ -144,7 +144,7 @@ export const ActivationPage = () => {
const handleNotificationRegister = () => {
// Verify user is still authenticated before registering
if (!authenticated) {
- void router.push('/');
+ void navigate('/');
return;
}
diff --git a/src/frontend/apps/conversations/src/features/auth/components/Auth.tsx b/src/frontend/apps/conversations/src/features/auth/components/Auth.tsx
index e761fd9c..6ee869cb 100644
--- a/src/frontend/apps/conversations/src/features/auth/components/Auth.tsx
+++ b/src/frontend/apps/conversations/src/features/auth/components/Auth.tsx
@@ -1,5 +1,5 @@
-import { useRouter } from 'next/router';
import { PropsWithChildren } from 'react';
+import { Navigate, useLocation } from 'react-router';
import { Box, Loader } from '@/components';
import { useConfig } from '@/core';
@@ -13,7 +13,10 @@ import { getAuthUrl, gotoLogin } from '../utils';
export const Auth = ({ children }: PropsWithChildren) => {
const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } =
useAuth();
- const { replace, pathname } = useRouter();
+ const location = useLocation();
+ // URLs may carry a trailing slash (the previous static export produced them),
+ // so normalise before comparing against the route paths below.
+ const pathname = location.pathname.replace(/(.)\/$/, '$1');
const { data: config, isLoading: isConfigLoading } = useConfig();
const { data: activationStatus, isLoading: isActivationLoading } =
useActivationStatus();
@@ -33,12 +36,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
if (authenticated) {
const authUrl = getAuthUrl();
if (authUrl) {
- void replace(authUrl);
- return (
-
-
-
- );
+ return ;
}
}
@@ -57,7 +55,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
if (config?.FRONTEND_SILENT_LOGIN_ENABLED && canAttemptSilentLogin()) {
attemptSilentLogin(30);
} else if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) {
- void replace(HOME_URL);
+ return ;
} else {
gotoLogin();
}
@@ -72,12 +70,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
* If the user is authenticated and the path is the home page, we redirect to the index.
*/
if (pathname === HOME_URL && authenticated) {
- void replace('/');
- return (
-
-
-
- );
+ return ;
}
/**
@@ -100,12 +93,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
// If activation is required but user is not activated, redirect to activation page
if (activationStatus && !activationStatus.is_activated) {
- void replace('/activation');
- return (
-
-
-
- );
+ return ;
}
}
diff --git a/src/frontend/apps/conversations/src/features/auth/hooks/__tests__/useAuth.test.tsx b/src/frontend/apps/conversations/src/features/auth/hooks/__tests__/useAuth.test.tsx
index e41166a2..751888e9 100644
--- a/src/frontend/apps/conversations/src/features/auth/hooks/__tests__/useAuth.test.tsx
+++ b/src/frontend/apps/conversations/src/features/auth/hooks/__tests__/useAuth.test.tsx
@@ -7,7 +7,7 @@ import { AppWrapper } from '@/tests/utils';
import { useAuth } from '../useAuth';
-const trackEventMock = jest.fn();
+const trackEventMock = vi.fn();
const flag = true;
class TestAnalytic extends AbstractAnalytic {
public constructor() {
@@ -31,19 +31,16 @@ class TestAnalytic extends AbstractAnalytic {
}
}
-jest.mock('next/router', () => ({
- ...jest.requireActual('next/router'),
- useRouter: () => ({
- pathname: '/dashboard',
- replace: jest.fn(),
- }),
+vi.mock('react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useLocation: () => ({ pathname: '/dashboard' }),
}));
const dummyUser = { id: '123', email: 'test@example.com', sub: 'test-sub' };
describe('useAuth hook - trackEvent effect', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
fetchMock.restore();
});
diff --git a/src/frontend/apps/conversations/src/features/auth/hooks/useAuth.tsx b/src/frontend/apps/conversations/src/features/auth/hooks/useAuth.tsx
index 8f24362b..7c66ca67 100644
--- a/src/frontend/apps/conversations/src/features/auth/hooks/useAuth.tsx
+++ b/src/frontend/apps/conversations/src/features/auth/hooks/useAuth.tsx
@@ -1,5 +1,5 @@
-import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
+import { useLocation } from 'react-router';
import { useAnalytics } from '@/libs';
@@ -14,7 +14,7 @@ const regexpUrlsAuth = [
export const useAuth = () => {
const { data: user, ...authStates } = useAuthQuery();
- const { pathname } = useRouter();
+ const { pathname } = useLocation();
const { trackEvent } = useAnalytics();
const [hasTracked, setHasTracked] = useState(authStates.isFetched);
const [pathAllowed, setPathAllowed] = useState(
diff --git a/src/frontend/apps/conversations/src/features/banner/__tests__/StatusBanner.test.tsx b/src/frontend/apps/conversations/src/features/banner/__tests__/StatusBanner.test.tsx
index 99a5d5a1..84e0982e 100644
--- a/src/frontend/apps/conversations/src/features/banner/__tests__/StatusBanner.test.tsx
+++ b/src/frontend/apps/conversations/src/features/banner/__tests__/StatusBanner.test.tsx
@@ -4,9 +4,9 @@ import { AppWrapper } from '@/tests/utils';
import { StatusBanner } from '../StatusBanner';
-const mockUseConfig = jest.fn();
+const mockUseConfig = vi.fn();
-jest.mock('@/core/config', () => ({
+vi.mock('@/core/config', () => ({
useConfig: () => mockUseConfig(),
}));
diff --git a/src/frontend/apps/conversations/src/features/chat/api/__tests__/useAssistantHealth.test.tsx b/src/frontend/apps/conversations/src/features/chat/api/__tests__/useAssistantHealth.test.tsx
index 6f50b751..bbfacf05 100644
--- a/src/frontend/apps/conversations/src/features/chat/api/__tests__/useAssistantHealth.test.tsx
+++ b/src/frontend/apps/conversations/src/features/chat/api/__tests__/useAssistantHealth.test.tsx
@@ -95,7 +95,7 @@ describe('useAssistantHealth', () => {
});
it('polls the endpoint every 60 seconds', async () => {
- jest.useFakeTimers();
+ vi.useFakeTimers();
fetchMock.get(`${API_BASE}assistant-health/`, {
status: 200,
@@ -115,7 +115,7 @@ describe('useAssistantHealth', () => {
expect(callsBefore).toBeGreaterThanOrEqual(1);
await act(async () => {
- jest.advanceTimersByTime(60_000);
+ vi.advanceTimersByTime(60_000);
await Promise.resolve();
});
@@ -123,6 +123,6 @@ describe('useAssistantHealth', () => {
expect(fetchMock.calls().length).toBeGreaterThan(callsBefore),
);
- jest.useRealTimers();
+ vi.useRealTimers();
});
});
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 a8bc2fe0..918b413f 100644
--- a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx
+++ b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx
@@ -2,7 +2,6 @@ import { Message, SourceUIPart } from '@ai-sdk/ui-utils';
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 React, {
useCallback,
useEffect,
@@ -12,6 +11,7 @@ import React, {
} from 'react';
import type { ChangeEvent, FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Box, Icon, Loader, Text } from '@/components';
@@ -141,7 +141,7 @@ export const Chat = ({
setSelectedModelHrid(model.hrid);
};
- const router = useRouter();
+ const navigate = useNavigate();
const [files, setFiles] = useState(null);
const [isUploadingFiles, setIsUploadingFiles] = useState(false);
// Project of an already-loaded conversation (new chats use pendingProjectId).
@@ -865,7 +865,7 @@ export const Chat = ({
setProjectId(null);
setConversationId(data.id);
// Update the URL to /chat/[id]/
- void router.push(`/chat/${data.id}/`);
+ void navigate(`/chat/${data.id}/`);
// After setting the conversationId, submit the pending message
setTimeout(() => {
if (pendingFirstMessage) {
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 526e7920..95d386f5 100644
--- a/src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx
+++ b/src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx
@@ -1,6 +1,6 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
-import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router';
import { Box, Icon, Text } from '@/components';
import { useConfig } from '@/core';
@@ -28,7 +28,7 @@ export const ChatError = ({
onRetry,
}: ChatErrorProps) => {
const { t } = useTranslation();
- const router = useRouter();
+ const navigate = useNavigate();
const { data: config } = useConfig();
const statusPageUrl = config?.STATUS_PAGE_URL;
const isProviderError = errorType !== 'generic';
@@ -111,7 +111,7 @@ export const ChatError = ({
color="brand"
variant="bordered"
onClick={() => {
- void router.push('/');
+ void navigate('/');
}}
icon={ }
>
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 a0700baf..dd2f3fff 100644
--- a/src/frontend/apps/conversations/src/features/chat/components/ModelSelector.tsx
+++ b/src/frontend/apps/conversations/src/features/chat/components/ModelSelector.tsx
@@ -1,5 +1,4 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
-import Image from 'next/image';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -57,11 +56,14 @@ export const ModelSelector = ({
position: relative;
`}
>
-
diff --git a/src/frontend/apps/conversations/src/features/chat/components/SourceItem.tsx b/src/frontend/apps/conversations/src/features/chat/components/SourceItem.tsx
index 3e7f7e27..443546d0 100644
--- a/src/frontend/apps/conversations/src/features/chat/components/SourceItem.tsx
+++ b/src/frontend/apps/conversations/src/features/chat/components/SourceItem.tsx
@@ -1,4 +1,3 @@
-import Image from 'next/image';
import React, { useEffect, useState } from 'react';
import { Box, StyledLink } from '@/components';
@@ -136,7 +135,7 @@ export const SourceItem: React.FC = ({ url, metadata }) => {
return (
- ({
- useRouter: () => ({ push: jest.fn() }),
-}));
-
-jest.mock('@/core', () => ({
+vi.mock('@/core', () => ({
useConfig: () => ({
data: { STATUS_PAGE_URL: mockStatusPageUrl },
}),
@@ -26,7 +22,7 @@ describe('ChatError', () => {
,
{ wrapper: AppWrapper },
);
@@ -42,7 +38,7 @@ describe('ChatError', () => {
,
{ wrapper: AppWrapper },
);
@@ -60,7 +56,7 @@ describe('ChatError', () => {
,
{ wrapper: AppWrapper },
);
@@ -80,7 +76,7 @@ describe('ChatError', () => {
,
{ wrapper: AppWrapper },
);
@@ -100,7 +96,7 @@ describe('ChatError', () => {
,
{ wrapper: AppWrapper },
);
@@ -121,7 +117,7 @@ describe('ChatError', () => {
,
{ wrapper: AppWrapper },
);
diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/ImageProcessingUnavailableBanner.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/ImageProcessingUnavailableBanner.test.tsx
index f53b7a37..22244047 100644
--- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/ImageProcessingUnavailableBanner.test.tsx
+++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/ImageProcessingUnavailableBanner.test.tsx
@@ -9,7 +9,7 @@ import { ImageProcessingUnavailableBanner } from '../ImageProcessingUnavailableB
describe('ImageProcessingUnavailableBanner', () => {
it('renders the headline and calls onDismiss when the user clicks the close button', async () => {
const user = userEvent.setup();
- const onDismiss = jest.fn();
+ const onDismiss = vi.fn();
render( , {
wrapper: AppWrapper,
@@ -29,7 +29,7 @@ describe('ImageProcessingUnavailableBanner', () => {
it('opens a modal with the explanation when "More info" is clicked', async () => {
const user = userEvent.setup();
- render( , {
+ render( , {
wrapper: AppWrapper,
});
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 96caf7bf..6b58dac8 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
@@ -7,42 +7,42 @@ import { AppWrapper } from '@/tests/utils';
import { InputChat } from '../InputChat';
// Mock stores and hooks
-jest.mock('@/stores', () => ({
+vi.mock('@/stores', () => ({
useResponsiveStore: () => ({
isDesktop: true,
isMobile: false,
}),
}));
-const mockUseConfig = jest.fn();
+const mockUseConfig = vi.fn();
-jest.mock('@/core', () => ({
+vi.mock('@/core', () => ({
useConfig: () => mockUseConfig(),
useFeatureEnabled: () => true,
}));
-jest.mock('@/components/ToastProvider', () => ({
+vi.mock('@/components/ToastProvider', () => ({
useToast: () => ({
- showToast: jest.fn(),
+ showToast: vi.fn(),
}),
}));
-jest.mock('@/features/chat/hooks/useFileDragDrop', () => ({
+vi.mock('@/features/chat/hooks/useFileDragDrop', () => ({
useFileDragDrop: () => ({
isDragActive: false,
}),
}));
-jest.mock('@/features/chat/hooks/useFileUrls', () => ({
+vi.mock('@/features/chat/hooks/useFileUrls', () => ({
useFileUrls: () => new Map(),
}));
// Mock child components
-jest.mock('../InputChatAction', () => ({
+vi.mock('../InputChatAction', () => ({
InputChatActions: () => Actions
,
}));
-jest.mock('../SuggestionCarousel', () => ({
+vi.mock('../SuggestionCarousel', () => ({
SuggestionCarousel: ({
blocked,
banners,
@@ -59,41 +59,41 @@ jest.mock('../SuggestionCarousel', () => ({
),
}));
-jest.mock('../WelcomeMessage', () => ({
+vi.mock('../WelcomeMessage', () => ({
WelcomeMessage: () => Welcome
,
}));
-jest.mock('../AttachmentList', () => ({
+vi.mock('../AttachmentList', () => ({
AttachmentList: () => Attachments
,
}));
-jest.mock('../ScrollDown', () => ({
+vi.mock('../ScrollDown', () => ({
ScrollDown: () => Scroll Down
,
}));
-jest.mock('../../assets/files.svg', () => () => (
-
-));
+vi.mock('../../assets/files.svg', () => ({
+ default: () => ,
+}));
-const mockUseAssistantHealth = jest.fn();
+const mockUseAssistantHealth = vi.fn();
-jest.mock('@/features/chat/api/useAssistantHealth', () => ({
+vi.mock('@/features/chat/api/useAssistantHealth', () => ({
useAssistantHealth: () => mockUseAssistantHealth(),
}));
const defaultProps = {
messagesLength: 0,
input: '',
- handleInputChange: jest.fn(),
- handleSubmit: jest.fn(),
+ handleInputChange: vi.fn(),
+ handleSubmit: vi.fn(),
status: 'ready' as const,
files: null,
- setFiles: jest.fn(),
+ setFiles: vi.fn(),
};
describe('InputChat', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
mockUseAssistantHealth.mockReturnValue({
data: {
banners: [],
@@ -157,7 +157,7 @@ describe('InputChat', () => {
it('should call handleInputChange when typing', async () => {
const user = userEvent.setup();
- const handleInputChange = jest.fn();
+ const handleInputChange = vi.fn();
render(
,
{ wrapper: AppWrapper },
@@ -203,7 +203,7 @@ describe('InputChat', () => {
it('should not submit form when pressing Enter and status is streaming', async () => {
const user = userEvent.setup();
- const handleSubmit = jest.fn((e) => e.preventDefault());
+ const handleSubmit = vi.fn((e) => e.preventDefault());
render(
{
it('should not submit form when pressing Enter and status is submitted', async () => {
const user = userEvent.setup();
- const handleSubmit = jest.fn((e) => e.preventDefault());
+ const handleSubmit = vi.fn((e) => e.preventDefault());
render(
{
it('should submit form when pressing Enter', async () => {
const user = userEvent.setup();
- const handleSubmit = jest.fn((e) => e.preventDefault());
+ const handleSubmit = vi.fn((e) => e.preventDefault());
render( , {
wrapper: AppWrapper,
});
@@ -268,7 +268,7 @@ describe('InputChat', () => {
it('should not submit form when pressing Shift+Enter', async () => {
const user = userEvent.setup();
- const handleSubmit = jest.fn((e) => e.preventDefault());
+ const handleSubmit = vi.fn((e) => e.preventDefault());
render( , {
wrapper: AppWrapper,
});
@@ -306,7 +306,7 @@ describe('InputChat', () => {
it('should not submit form when pressing Enter during an active cooldown', async () => {
const user = userEvent.setup();
- const handleSubmit = jest.fn((e) => e.preventDefault());
+ const handleSubmit = vi.fn((e) => e.preventDefault());
render(
{
it('should submit form when pressing Enter once the cooldown is past', async () => {
const user = userEvent.setup();
- const handleSubmit = jest.fn((e) => e.preventDefault());
+ const handleSubmit = vi.fn((e) => e.preventDefault());
render(
({
+vi.mock('../ModelSelector', () => ({
ModelSelector: ({ onModelSelect }: { onModelSelect: () => void }) => (
Model Selector
@@ -13,7 +13,7 @@ jest.mock('../ModelSelector', () => ({
),
}));
-jest.mock('../SendButton', () => ({
+vi.mock('../SendButton', () => ({
SendButton: ({
onClick,
disabled,
@@ -40,7 +40,7 @@ const defaultProps = {
isUploadingFiles: false,
isMobile: false,
forceWebSearch: false,
- onAttachClick: jest.fn(),
+ onAttachClick: vi.fn(),
selectedModel: null,
status: null,
inputHasContent: true,
@@ -48,7 +48,7 @@ const defaultProps = {
describe('InputChatActions', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
});
it('should render attach file button', () => {
@@ -62,7 +62,7 @@ describe('InputChatActions', () => {
it('should call onAttachClick when attach button is clicked', async () => {
const user = userEvent.setup();
- const onAttachClick = jest.fn();
+ const onAttachClick = vi.fn();
render(
,
);
@@ -95,7 +95,7 @@ describe('InputChatActions', () => {
});
it('should render web search button when onWebSearchToggle is provided', () => {
- const onWebSearchToggle = jest.fn();
+ const onWebSearchToggle = vi.fn();
render(
{
it('should call onWebSearchToggle when web search button is clicked', async () => {
const user = userEvent.setup();
- const onWebSearchToggle = jest.fn();
+ const onWebSearchToggle = vi.fn();
render(
{
,
);
@@ -150,7 +150,7 @@ describe('InputChatActions', () => {
});
it('should render model selector when onModelSelect is provided', () => {
- const onModelSelect = jest.fn();
+ const onModelSelect = vi.fn();
render(
,
);
@@ -194,7 +194,7 @@ describe('InputChatActions', () => {
{...defaultProps}
isMobile={true}
forceWebSearch={true}
- onWebSearchToggle={jest.fn()}
+ onWebSearchToggle={vi.fn()}
/>,
);
@@ -207,7 +207,7 @@ describe('InputChatActions', () => {
{...defaultProps}
isMobile={false}
forceWebSearch={true}
- onWebSearchToggle={jest.fn()}
+ onWebSearchToggle={vi.fn()}
/>,
);
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 21d373b3..6a3ee243 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
@@ -5,8 +5,8 @@ import { Suspense } from 'react';
import { CompletedMarkdownBlock, RawTextBlock } from '../MessageBlock';
-// Mock react-markdown (ESM module not compatible with Jest)
-jest.mock('react-markdown', () => ({
+// Mock react-markdown to keep the assertions on the component, not the parser
+vi.mock('react-markdown', () => ({
MarkdownHooks: ({ children }: { children: string }) => {
// Simple mock that renders markdown-like content
// This tests the component integration, not the markdown parsing itself
@@ -15,15 +15,15 @@ jest.mock('react-markdown', () => ({
}));
// Mock rehype/remark plugins
-jest.mock('@shikijs/rehype/core', () => () => {});
-jest.mock('../../utils/shiki', () => ({
+vi.mock('@shikijs/rehype/core', () => ({ default: () => {} }));
+vi.mock('../../utils/shiki', () => ({
getHighlighter: () => Promise.resolve({}),
}));
-jest.mock('rehype-katex', () => () => {});
-jest.mock('remark-gfm', () => () => {});
-jest.mock('remark-math', () => () => {});
+vi.mock('rehype-katex', () => ({ default: () => {} }));
+vi.mock('remark-gfm', () => ({ default: () => {} }));
+vi.mock('remark-math', () => ({ default: () => {} }));
-jest.mock('react-i18next', () => ({
+vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageEnergyIndicator.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageEnergyIndicator.test.tsx
index cd843a5b..3d40790d 100644
--- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageEnergyIndicator.test.tsx
+++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageEnergyIndicator.test.tsx
@@ -9,7 +9,7 @@ import { MessageEnergyIndicator } from '../MessageEnergyIndicator';
const TEST_CO2_IMPACT_KG = 0.00002191613089507352;
-jest.mock('react-i18next', () => ({
+vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { co2?: string }) =>
opts?.co2 ? `${key}:${opts.co2}` : key,
@@ -17,11 +17,11 @@ jest.mock('react-i18next', () => ({
}),
}));
-jest.mock('@/stores', () => ({
- useResponsiveStore: jest.fn(),
+vi.mock('@/stores', () => ({
+ useResponsiveStore: vi.fn(),
}));
-const mockUseResponsiveStore = jest.mocked(useResponsiveStore);
+const mockUseResponsiveStore = vi.mocked(useResponsiveStore);
const setResponsive = (isMobile: boolean) =>
mockUseResponsiveStore.mockReturnValue({
@@ -31,8 +31,8 @@ const setResponsive = (isMobile: boolean) =>
isSmallMobile: false,
screenSize: isMobile ? 'mobile' : 'desktop',
screenWidth: isMobile ? 600 : 1024,
- setScreenSize: jest.fn(),
- initializeResizeListener: jest.fn(() => () => {}),
+ setScreenSize: vi.fn(),
+ initializeResizeListener: vi.fn(() => () => {}),
});
const renderIndicator = () =>
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 00ae7020..dd194f76 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
@@ -14,54 +14,54 @@ import {
const TEST_CO2_IMPACT_KG = 0.00002191613089507352;
-// Mock react-markdown (ESM module)
-jest.mock('react-markdown', () => ({
+// Mock react-markdown to keep the assertions on the component, not the parser
+vi.mock('react-markdown', () => ({
MarkdownHooks: ({ children }: { children: string }) => (
{children}
),
}));
-jest.mock('@shikijs/rehype/core', () => () => {});
-jest.mock('../../utils/shiki', () => ({
+vi.mock('@shikijs/rehype/core', () => ({ default: () => {} }));
+vi.mock('../../utils/shiki', () => ({
getHighlighter: () => Promise.resolve({}),
}));
-jest.mock('rehype-katex', () => () => {});
-jest.mock('remark-gfm', () => () => {});
-jest.mock('remark-math', () => () => {});
+vi.mock('rehype-katex', () => ({ default: () => {} }));
+vi.mock('remark-gfm', () => ({ default: () => {} }));
+vi.mock('remark-math', () => ({ default: () => {} }));
-jest.mock('react-i18next', () => ({
+vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const mockConfig: { DOCS_BASE_URL?: string } = { DOCS_BASE_URL: undefined };
-jest.mock('@/core/config', () => ({
+vi.mock('@/core/config', () => ({
useConfig: () => ({ data: mockConfig }),
}));
-jest.mock('../MoreActionsButton', () => ({
+vi.mock('../MoreActionsButton', () => ({
MoreActionsButton: () =>
,
}));
// Mock child components
-jest.mock('../AttachmentList', () => ({
+vi.mock('../AttachmentList', () => ({
AttachmentList: () =>
,
}));
-jest.mock('../FeedbackButtons', () => ({
+vi.mock('../FeedbackButtons', () => ({
FeedbackButtons: () =>
,
}));
-jest.mock('../MessageEnergyIndicator', () => ({
+vi.mock('../MessageEnergyIndicator', () => ({
MessageEnergyIndicator: () =>
,
}));
-jest.mock('../SourceItemList', () => ({
+vi.mock('../SourceItemList', () => ({
SourceItemList: () =>
,
}));
-jest.mock('../ToolInvocationItem', () => ({
+vi.mock('../ToolInvocationItem', () => ({
ToolInvocationItem: () =>
,
}));
@@ -376,9 +376,9 @@ describe('MessageItem', () => {
conversationId: 'conv-1',
isSourceOpen: null,
isMobile: false,
- onCopyToClipboard: jest.fn(),
- onOpenSources: jest.fn(),
- getMetadata: jest.fn(),
+ onCopyToClipboard: vi.fn(),
+ onOpenSources: vi.fn(),
+ getMetadata: vi.fn(),
};
const renderWithProviders = (ui: React.ReactNode) => {
@@ -392,7 +392,7 @@ describe('MessageItem', () => {
};
beforeEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
mockConfig.DOCS_BASE_URL = undefined;
});
@@ -487,7 +487,7 @@ describe('MessageItem', () => {
describe('interactions', () => {
it('calls onCopyToClipboard when copy button is clicked', async () => {
const user = userEvent.setup();
- const onCopyToClipboard = jest.fn();
+ const onCopyToClipboard = vi.fn();
await act(async () => {
renderWithProviders(
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
index 81ea5a0f..85530e66 100644
--- 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
@@ -15,12 +15,12 @@ import { ChatProject } from '@/features/chat/types';
import { ProjectWelcomeMessage } from '../ProjectWelcomeMessage';
-jest.mock('react-i18next', () => ({
+vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
-jest.mock('i18next', () => ({
+vi.mock('i18next', () => ({
t: (key: string) => key,
}));
diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/SourceItem.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/SourceItem.test.tsx
index 1b03143d..66a9ba75 100644
--- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/SourceItem.test.tsx
+++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/SourceItem.test.tsx
@@ -1,23 +1,25 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import type { Mock, MockInstance } from 'vitest';
import { SourceItem } from '../SourceItem';
-jest.mock('next/navigation', () => ({
- useRouter: () => ({ push: jest.fn() }),
+vi.mock('react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useNavigate: () => vi.fn(),
}));
describe('SourceItem', () => {
- let consoleSpy: jest.SpyInstance;
+ let consoleSpy: MockInstance;
beforeEach(() => {
- consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
+ consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// Prevent real HTTP calls; override per test as needed
- globalThis.fetch = jest.fn().mockReturnValue(new Promise(() => {}));
+ globalThis.fetch = vi.fn().mockReturnValue(new Promise(() => {}));
});
afterEach(() => {
consoleSpy.mockRestore();
- jest.clearAllMocks();
+ vi.clearAllMocks();
});
describe('non-http URL', () => {
@@ -197,9 +199,7 @@ describe('SourceItem', () => {
});
it('uses hostname as title when CORS fetch fails', async () => {
- (globalThis.fetch as jest.Mock).mockRejectedValue(
- new Error('CORS error'),
- );
+ (globalThis.fetch as Mock).mockRejectedValue(new Error('CORS error'));
render( );
@@ -209,7 +209,7 @@ describe('SourceItem', () => {
});
it('parses page title from fetched HTML', async () => {
- (globalThis.fetch as jest.Mock).mockResolvedValue({
+ (globalThis.fetch as Mock).mockResolvedValue({
ok: true,
text: () =>
Promise.resolve('My Page '),
@@ -221,7 +221,7 @@ describe('SourceItem', () => {
});
it('uses hostname as title when response is not ok', async () => {
- (globalThis.fetch as jest.Mock).mockResolvedValue({
+ (globalThis.fetch as Mock).mockResolvedValue({
ok: false,
status: 404,
});
@@ -234,7 +234,7 @@ describe('SourceItem', () => {
});
it('does not fetch when metadata is loaded', () => {
- const fetchSpy = globalThis.fetch as jest.Mock;
+ const fetchSpy = globalThis.fetch as Mock;
render(
{
beforeEach(() => {
- jest.useFakeTimers();
+ vi.useFakeTimers();
});
afterEach(async () => {
- jest.useRealTimers();
+ vi.useRealTimers();
await i18next.changeLanguage('en');
});
@@ -47,7 +47,7 @@ describe('SuggestionCarousel', () => {
});
it('should start the interval when messagesLength is 0', () => {
- const setIntervalSpy = jest.spyOn(global, 'setInterval');
+ const setIntervalSpy = vi.spyOn(global, 'setInterval');
render( );
@@ -57,7 +57,7 @@ describe('SuggestionCarousel', () => {
});
it('should not start interval when messagesLength is greater than 0', () => {
- const setIntervalSpy = jest.spyOn(global, 'setInterval');
+ const setIntervalSpy = vi.spyOn(global, 'setInterval');
render( );
@@ -67,7 +67,7 @@ describe('SuggestionCarousel', () => {
});
it('should clear interval on unmount', () => {
- const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
+ const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
const { unmount } = render( );
unmount();
diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/ToolInvocationItem.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/ToolInvocationItem.test.tsx
index 40a590d3..d8cae3d5 100644
--- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/ToolInvocationItem.test.tsx
+++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/ToolInvocationItem.test.tsx
@@ -2,13 +2,13 @@ import { render, screen } from '@testing-library/react';
import { ToolInvocationItem } from '../ToolInvocationItem';
-jest.mock('react-i18next', () => ({
+vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
let mockStatusPageUrl: string | undefined = 'https://status.example.com';
-jest.mock('@/core', () => ({
+vi.mock('@/core', () => ({
useConfig: () => ({
data: { STATUS_PAGE_URL: mockStatusPageUrl },
}),
diff --git a/src/frontend/apps/conversations/src/features/chat/hooks/__tests__/useFileDragDrop.test.tsx b/src/frontend/apps/conversations/src/features/chat/hooks/__tests__/useFileDragDrop.test.tsx
index 36968811..939edf1c 100644
--- a/src/frontend/apps/conversations/src/features/chat/hooks/__tests__/useFileDragDrop.test.tsx
+++ b/src/frontend/apps/conversations/src/features/chat/hooks/__tests__/useFileDragDrop.test.tsx
@@ -30,7 +30,7 @@ const createDragEvent = (
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer });
Object.defineProperty(event, 'relatedTarget', { value: relatedTarget });
- Object.defineProperty(event, 'preventDefault', { value: jest.fn() });
+ Object.defineProperty(event, 'preventDefault', { value: vi.fn() });
return event;
};
@@ -38,13 +38,13 @@ const createDragEvent = (
describe('useFileDragDrop', () => {
const defaultProps = {
enabled: true,
- isFileAccepted: jest.fn(() => true),
- onFilesAccepted: jest.fn(),
- onFilesRejected: jest.fn(),
+ isFileAccepted: vi.fn(() => true),
+ onFilesAccepted: vi.fn(),
+ onFilesRejected: vi.fn(),
};
beforeEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
});
it('should initialize with isDragActive as false', () => {
@@ -129,7 +129,7 @@ describe('useFileDragDrop', () => {
});
it('should call onFilesAccepted with accepted files on drop', () => {
- const onFilesAccepted = jest.fn();
+ const onFilesAccepted = vi.fn();
const file = createFile('test.txt', 'text/plain');
renderHook(() =>
@@ -147,8 +147,8 @@ describe('useFileDragDrop', () => {
});
it('should call onFilesRejected with rejected file names', () => {
- const onFilesRejected = jest.fn();
- const isFileAccepted = jest.fn(() => false);
+ const onFilesRejected = vi.fn();
+ const isFileAccepted = vi.fn(() => false);
renderHook(() =>
useFileDragDrop({
@@ -168,9 +168,9 @@ describe('useFileDragDrop', () => {
});
it('should separate accepted and rejected files', () => {
- const onFilesAccepted = jest.fn();
- const onFilesRejected = jest.fn();
- const isFileAccepted = jest.fn((file: File) => file.type === 'text/plain');
+ const onFilesAccepted = vi.fn();
+ const onFilesRejected = vi.fn();
+ const isFileAccepted = vi.fn((file: File) => file.type === 'text/plain');
const acceptedFile = createFile('good.txt', 'text/plain');
const rejectedFile = createFile('bad.exe', 'application/exe');
@@ -230,7 +230,7 @@ describe('useFileDragDrop', () => {
});
it('should clean up event listeners on unmount', () => {
- const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
+ const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useFileDragDrop(defaultProps));
unmount();
diff --git a/src/frontend/apps/conversations/src/features/chat/hooks/__tests__/useFileUrls.test.tsx b/src/frontend/apps/conversations/src/features/chat/hooks/__tests__/useFileUrls.test.tsx
index cbc10b7d..82fc8fea 100644
--- a/src/frontend/apps/conversations/src/features/chat/hooks/__tests__/useFileUrls.test.tsx
+++ b/src/frontend/apps/conversations/src/features/chat/hooks/__tests__/useFileUrls.test.tsx
@@ -34,8 +34,8 @@ const createFileList = (files: File[]): FileList => {
describe('useFileUrls', () => {
let urlCounter: number;
- const mockCreateObjectURL = jest.fn();
- const mockRevokeObjectURL = jest.fn();
+ const mockCreateObjectURL = vi.fn();
+ const mockRevokeObjectURL = vi.fn();
beforeEach(() => {
urlCounter = 0;
@@ -49,7 +49,7 @@ describe('useFileUrls', () => {
});
afterEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
});
it('should return an empty Map when files is null', () => {
diff --git a/src/frontend/apps/conversations/src/features/footer/Footer.tsx b/src/frontend/apps/conversations/src/features/footer/Footer.tsx
index fef7ec6e..3ccf2546 100644
--- a/src/frontend/apps/conversations/src/features/footer/Footer.tsx
+++ b/src/frontend/apps/conversations/src/features/footer/Footer.tsx
@@ -1,4 +1,3 @@
-import Image from 'next/image';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styled, { css } from 'styled-components';
@@ -90,13 +89,10 @@ export const Footer = () => {
$height="fit-content"
>
{logo?.src && (
-
)}
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 e3bc0114..35cb858a 100644
--- a/src/frontend/apps/conversations/src/features/header/components/ButtonToggleLeftPanel.tsx
+++ b/src/frontend/apps/conversations/src/features/header/components/ButtonToggleLeftPanel.tsx
@@ -1,8 +1,8 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useQuery } from '@tanstack/react-query';
-import { useRouter } from 'next/router';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
+import { useParams } from 'react-router';
import { css } from 'styled-components';
import LeftPanelIcon from '@/assets/icons/left-panel-bold.svg';
@@ -31,12 +31,11 @@ const conversationTitleCss = css`
export const ButtonToggleLeftPanel = () => {
const { t } = useTranslation();
- const router = useRouter();
+ const { id } = useParams();
const { colorsTokens } = useCunninghamTheme();
const { data: projects } = useInfiniteProjects({ page: 1, page_size: 100 });
const { isPanelOpen, togglePanel } = useChatPreferencesStore();
- const currentConversationId =
- typeof router.query.id === 'string' ? router.query.id : undefined;
+ const currentConversationId = id;
const { data: currentConversation } = useQuery({
queryKey: [KEY_CONVERSATION, currentConversationId],
queryFn: () => {
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 82cc1fd0..9c61e661 100644
--- a/src/frontend/apps/conversations/src/features/header/components/Header.tsx
+++ b/src/frontend/apps/conversations/src/features/header/components/Header.tsx
@@ -1,14 +1,14 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
-import dynamic from 'next/dynamic';
-import { useRouter } from 'next/router';
import { useEffect as _useEffect, useState as _useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { useNavigate, useParams } from 'react-router';
import { css } from 'styled-components';
import NewChatIcon from '@/assets/icons/new-message-bold.svg';
import LogoAssistant from '@/assets/logo/logomark.svg';
import { Box } from '@/components/';
import { useCunninghamTheme } from '@/cunningham';
+import { UserInfo } from '@/features/auth/components/UserInfo';
import { useChatScroll } from '@/features/chat/hooks';
import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore';
import { useResponsiveStore } from '@/stores';
@@ -18,12 +18,6 @@ import { HEADER_HEIGHT } from '../conf';
import { ButtonToggleLeftPanel } from './ButtonToggleLeftPanel';
import { LaGaufre } from './LaGaufre';
-const UserInfo = dynamic(
- () =>
- import('@/features/auth/components/UserInfo').then((mod) => mod.UserInfo),
- { ssr: false },
-);
-
const headerStyles = css`
display: flex;
position: absolute;
@@ -44,9 +38,9 @@ export const Header = () => {
const { isDesktop } = useResponsiveStore();
const { setPanelOpen } = useChatPreferencesStore();
const { isAtTop } = useChatScroll();
- const router = useRouter();
- const hasConversationIdInRoute =
- router.query.id && router.query.id.length > 0;
+ const navigate = useNavigate();
+ const { id } = useParams();
+ const hasConversationIdInRoute = !!id && id.length > 0;
return (
{
{
- void router.push('/');
+ void navigate('/');
setPanelOpen(false);
}}
className="mobile-no-focus"
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 b1f24773..81c100c0 100644
--- a/src/frontend/apps/conversations/src/features/header/components/LaGaufre.tsx
+++ b/src/frontend/apps/conversations/src/features/header/components/LaGaufre.tsx
@@ -1,5 +1,4 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
-import Script from 'next/script';
import { useCallback, useEffect, useRef } from 'react';
declare global {
@@ -8,6 +7,10 @@ declare global {
}
}
+const WIDGET_SCRIPT_ID = 'lasuite-lagaufre-script';
+const WIDGET_SCRIPT_SRC =
+ 'https://static.suite.anct.gouv.fr/widgets/lagaufre.js';
+
export const LaGaufre = () => {
const buttonRef = useRef(null);
const hasInitializedRef = useRef(false);
@@ -51,6 +54,21 @@ export const LaGaufre = () => {
hasInitializedRef.current = true;
}, []);
+ // Load the widget script once for the whole app; `initLaGaufre` is
+ // idempotent so calling it again once loaded is safe.
+ useEffect(() => {
+ if (document.getElementById(WIDGET_SCRIPT_ID)) {
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.id = WIDGET_SCRIPT_ID;
+ script.src = WIDGET_SCRIPT_SRC;
+ script.async = true;
+ script.addEventListener('load', initLaGaufre);
+ document.body.appendChild(script);
+ }, [initLaGaufre]);
+
useEffect(() => {
const wrapper = document.querySelector('[data-gaufre-button-wrapper]');
const button = wrapper?.querySelector('button') as HTMLButtonElement;
@@ -115,13 +133,6 @@ export const LaGaufre = () => {
-