-
- {t('modals.uploadDoc.connectors.googleDrive.selectedFiles')}
-
+
handleOpenPicker()}
- className="bg-[#A076F6] hover:bg-[#8A5FD4]"
- disabled={isLoading}
+ loading={isLoading}
>
- {isLoading
- ? t('modals.uploadDoc.connectors.googleDrive.loading')
- : t(
- 'modals.uploadDoc.connectors.googleDrive.selectFiles',
- )}
+ {t('modals.uploadDoc.connectors.googleDrive.selectFiles')}
{selectedFiles.length === 0 && selectedFolders.length === 0 ? (
-
+
{t(
'modals.uploadDoc.connectors.googleDrive.noFilesSelected',
)}
@@ -295,29 +294,33 @@ const GoogleDrivePicker: React.FC = ({
) : (
{selectedFolders.length > 0 && (
-
-
- {t('modals.uploadDoc.connectors.googleDrive.folders')}
-
+
+
{selectedFolders.map((folder) => (
{folder.name}
{
const newSelectedFolders =
selectedFolders.filter(
@@ -329,7 +332,6 @@ const GoogleDrivePicker: React.FC = ({
newSelectedFolders.map((f) => f.id),
);
}}
- className="ml-2 h-auto p-0 text-sm text-red-500 hover:text-red-700 hover:no-underline"
>
{t(
'modals.uploadDoc.connectors.googleDrive.remove',
@@ -341,29 +343,33 @@ const GoogleDrivePicker: React.FC = ({
)}
{selectedFiles.length > 0 && (
-
-
- {t('modals.uploadDoc.connectors.googleDrive.files')}
-
+
+
{selectedFiles.map((file) => (
{file.name}
{
const newSelectedFiles = selectedFiles.filter(
(f) => f.id !== file.id,
@@ -374,7 +380,6 @@ const GoogleDrivePicker: React.FC = ({
selectedFolders.map((f) => f.id),
);
}}
- className="ml-2 h-auto p-0 text-sm text-red-500 hover:text-red-700 hover:no-underline"
>
{t(
'modals.uploadDoc.connectors.googleDrive.remove',
diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx
index f61b62e1..c4c048e3 100644
--- a/frontend/src/components/GraphView.tsx
+++ b/frontend/src/components/GraphView.tsx
@@ -1,14 +1,16 @@
import { forceCollide, type SimulationNodeDatum } from 'd3-force';
-import { Network, X } from 'lucide-react';
+import { ArrowLeft, Network, X } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d';
import userService from '../api/services/userService';
-import ArrowLeft from '../assets/arrow-left.svg';
import { selectToken } from '../preferences/preferenceSlice';
-import { Button } from './ui/button';
+import { useThemeVersion } from '../utils/chartUtils';
+import { Badge } from './ui/badge';
+import { IconButton } from './ui/icon-button';
+import { SectionHeader } from './ui/section-header';
import SkeletonLoader from './SkeletonLoader';
import {
ForceGraphData,
@@ -19,6 +21,7 @@ import {
maxDegree,
nodeAtPoint,
nodeRadius,
+ readGraphPalette,
toForceGraphData,
} from './graphViewUtils';
@@ -53,6 +56,11 @@ const GraphView: React.FC = ({
const hoveredNodeIdRef = useRef(null);
const fgRef = useRef(undefined);
const [size, setSize] = useState({ width: 0, height: 480 });
+ // The canvas can't read CSS variables: resolve the tokens, and re-read them
+ // whenever the theme changes.
+ const themeVersion = useThemeVersion();
+ // themeVersion is not used inside the factory: it only signals a change.
+ const palette = useMemo(() => readGraphPalette(), [themeVersion]);
useEffect(() => {
let cancelled = false;
@@ -129,6 +137,11 @@ const GraphView: React.FC = ({
if (fg) fg.zoom(fg.zoom());
};
+ // A settled simulation stops drawing, so paint the new colours once.
+ useEffect(() => {
+ repaint();
+ }, [palette]);
+
const handlePointerMove = (event: React.MouseEvent) => {
const node = pickNodeAt(event.clientX, event.clientY);
if (containerRef.current) {
@@ -159,29 +172,24 @@ const GraphView: React.FC = ({
return (
-
-
-
+ label={t('settings.sources.backToAll')}
+ icon={ArrowLeft}
+ side="bottom"
+ />
{sourceName}
{headerAction ?
{headerAction}
: null}
-
-
+
+
{t('settings.sources.graphrag.view.title')}
@@ -194,7 +202,7 @@ const GraphView: React.FC = ({
) : isEmpty ? (
-
+
{t('settings.sources.graphrag.view.empty')}
) : (
@@ -225,7 +233,7 @@ const GraphView: React.FC = ({
nodeVal={(node) =>
nodeRadius((node as GraphNode).degree, maxNodeDegree)
}
- linkColor={() => 'rgba(150,150,150,0.35)'}
+ linkColor={() => palette.link}
cooldownTicks={80}
nodeCanvasObject={(node, ctx, globalScale) => {
const graphNode = node as GraphNode & {
@@ -237,8 +245,13 @@ const GraphView: React.FC = ({
const hovered = hoveredNodeIdRef.current === graphNode.id;
ctx.beginPath();
ctx.arc(graphNode.x, graphNode.y ?? 0, r, 0, 2 * Math.PI);
- ctx.fillStyle = hovered ? '#7aa7d9' : '#4a7fb5';
+ ctx.fillStyle = palette.node;
ctx.fill();
+ if (hovered) {
+ ctx.lineWidth = 2 / globalScale;
+ ctx.strokeStyle = palette.hoverStroke;
+ ctx.stroke();
+ }
if (globalScale >= 1.2) {
const label = graphNode.name ?? '';
const x = graphNode.x;
@@ -248,11 +261,9 @@ const GraphView: React.FC = ({
ctx.textBaseline = 'top';
ctx.lineWidth = 3 / globalScale;
ctx.lineJoin = 'round';
- ctx.strokeStyle = 'rgba(10,10,10,0.85)';
+ ctx.strokeStyle = palette.halo;
ctx.strokeText(label, x, y);
- ctx.fillStyle = hovered
- ? 'rgba(255,255,255,0.98)'
- : 'rgba(210,210,210,0.95)';
+ ctx.fillStyle = palette.label;
ctx.fillText(label, x, y);
}
}}
@@ -267,23 +278,27 @@ const GraphView: React.FC = ({
-
- {selectedNode.name}
-
+
{selectedNode.type && (
-
+
{selectedNode.type}
-
+
)}
-
setSelectedNode(null)}
- aria-label={t('settings.sources.graphrag.view.close')}
- className="text-muted-foreground hover:text-foreground shrink-0"
- >
-
-
+ label={t('settings.sources.graphrag.view.close')}
+ icon={X}
+ side="bottom"
+ />
{selectedNode.description && (
@@ -292,25 +307,29 @@ const GraphView: React.FC
= ({
)}
-
- {t('settings.sources.graphrag.view.linkedChunks')}
-
- {selectedNode.chunks.length === 0 ? (
-
- {t('settings.sources.graphrag.view.noChunks')}
-
- ) : (
-
- {selectedNode.chunks.map((chunk) => (
-
- {chunk.text}
-
- ))}
-
- )}
+
+
+ {selectedNode.chunks.length === 0 ? (
+
+ {t('settings.sources.graphrag.view.noChunks')}
+
+ ) : (
+
+ {selectedNode.chunks.map((chunk) => (
+
+ {chunk.text}
+
+ ))}
+
+ )}
+
) : (
diff --git a/frontend/src/components/Help.tsx b/frontend/src/components/Help.tsx
index 721ba5ba..fffe2c63 100644
--- a/frontend/src/components/Help.tsx
+++ b/frontend/src/components/Help.tsx
@@ -1,108 +1,60 @@
-import { useState, useRef, useEffect } from 'react';
-import { ShieldCheck } from 'lucide-react';
+import { FileText, Info, Mail, ShieldCheck } from 'lucide-react';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
-import Info from '../assets/info.svg';
-import PageIcon from '../assets/documentation.svg';
-import EmailIcon from '../assets/envelope.svg';
import { useTranslation } from 'react-i18next';
import { selectIsAdmin } from '../preferences/preferenceSlice';
import { Button } from './ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from './ui/dropdown-menu';
+
const Help = () => {
- const [isOpen, setIsOpen] = useState(false);
- const dropdownRef = useRef(null);
- const buttonRef = useRef(null);
const { t } = useTranslation();
const isAdmin = useSelector(selectIsAdmin);
- const toggleDropdown = () => {
- setIsOpen((prev) => !prev);
- };
-
- const handleClickOutside = (event: MouseEvent) => {
- if (
- dropdownRef.current &&
- !dropdownRef.current.contains(event.target as Node) &&
- buttonRef.current &&
- !buttonRef.current.contains(event.target as Node)
- ) {
- setIsOpen(false);
- }
- };
-
- useEffect(() => {
- document.addEventListener('mousedown', handleClickOutside);
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, []);
-
return (
-
-
-
- {t('help')}
-
- {isOpen && (
-
);
};
diff --git a/frontend/src/components/MarkdownPreview.tsx b/frontend/src/components/MarkdownPreview.tsx
index d671e8e1..dcb595a2 100644
--- a/frontend/src/components/MarkdownPreview.tsx
+++ b/frontend/src/components/MarkdownPreview.tsx
@@ -1,6 +1,10 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
+import { markdownHeadings } from '@/lib/markdown';
+
+import { Button } from './ui/button';
+
/**
* Minimal GFM markdown renderer for artifact text previews. ReactMarkdown
* parses to React elements (no `dangerouslySetInnerHTML`), so untrusted
@@ -9,20 +13,18 @@ import remarkGfm from 'remark-gfm';
export default function MarkdownPreview({ content }: { content: string }) {
return (
-
+
- {children}
-
+
+
+ {children}
+
+
);
},
ul({ children }) {
@@ -39,19 +41,10 @@ export default function MarkdownPreview({ content }: { content: string }) {
);
},
- h1({ children }) {
- return {children} ;
- },
- h2({ children }) {
- return {children} ;
- },
- h3({ children }) {
- return {children} ;
- },
code({ children, ...rest }) {
return (
{children}
@@ -60,7 +53,7 @@ export default function MarkdownPreview({ content }: { content: string }) {
},
blockquote({ children }) {
return (
-
+
{children}
);
diff --git a/frontend/src/components/MermaidRenderer.test.tsx b/frontend/src/components/MermaidRenderer.test.tsx
index 409235c0..bf144826 100644
--- a/frontend/src/components/MermaidRenderer.test.tsx
+++ b/frontend/src/components/MermaidRenderer.test.tsx
@@ -6,7 +6,10 @@ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const renderMermaidDiagramMock = vi.hoisted(() => vi.fn());
vi.mock('react-i18next', () => ({
- useTranslation: () => ({ t: (key: string) => key }),
+ useTranslation: () => ({
+ t: (key: string, opts?: { format?: string }) =>
+ opts?.format ? `${key} ${opts.format}` : key,
+ }),
}));
vi.mock('react-redux', () => ({ useSelector: () => 'idle' }));
vi.mock('../hooks', () => ({ useDarkTheme: () => [false] }));
@@ -48,7 +51,44 @@ describe('MermaidRenderer', () => {
);
});
- expect(container.querySelector('pre.mermaid')).not.toBeNull();
+ // The diagram host is the only while the code view is closed.
+ expect(container.querySelectorAll('pre')).toHaveLength(1);
+ expect(container.querySelector('pre')?.id).toMatch(/^mermaid-/);
expect(container.querySelector('svg[data-rendered="true"]')).not.toBeNull();
});
+
+ it('opens the Download menu with a menu item per format', async () => {
+ renderMermaidDiagramMock.mockResolvedValue({ svg: ' ' });
+
+ await act(async () => {
+ root.render(
+ B'} isLoading={false} />,
+ );
+ });
+
+ const trigger = container.querySelector(
+ 'button[aria-haspopup="menu"]',
+ );
+ expect(trigger).not.toBeNull();
+ expect(trigger?.textContent).toContain('mermaid.download');
+ expect(trigger?.hasAttribute('title')).toBe(false);
+ expect(trigger?.getAttribute('aria-haspopup')).toBe('menu');
+ expect(document.querySelector('[role="menu"]')).toBeNull();
+
+ await act(async () => {
+ trigger?.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
+ );
+ });
+
+ expect(trigger?.getAttribute('aria-expanded')).toBe('true');
+ const items = Array.from(
+ document.querySelectorAll('[role="menuitem"]'),
+ ).map((item) => item.textContent);
+ expect(items).toEqual([
+ 'mermaid.downloadAs SVG',
+ 'mermaid.downloadAs PNG',
+ 'mermaid.downloadAs MMD',
+ ]);
+ });
});
diff --git a/frontend/src/components/MermaidRenderer.tsx b/frontend/src/components/MermaidRenderer.tsx
index ff86d882..16c41e2d 100644
--- a/frontend/src/components/MermaidRenderer.tsx
+++ b/frontend/src/components/MermaidRenderer.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
+import { CircleAlert } from 'lucide-react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneLight,
@@ -11,7 +12,15 @@ import { selectStatus } from '../conversation/conversationSlice';
import { useDarkTheme } from '../hooks';
import CopyButton from './CopyButton';
import { renderMermaidDiagram } from './mermaidSecurity';
+import { Alert, AlertDescription } from './ui/alert';
import { Button } from './ui/button';
+import { IconButton } from './ui/icon-button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from './ui/dropdown-menu';
import { MermaidRendererProps } from './types';
const MermaidRenderer: React.FC = ({
@@ -27,8 +36,6 @@ const MermaidRenderer: React.FC = ({
const status = useSelector(selectStatus);
const [error, setError] = useState(null);
const [showCode, setShowCode] = useState(false);
- const [showDownloadMenu, setShowDownloadMenu] = useState(false);
- const downloadMenuRef = useRef(null);
const containerRef = useRef(null);
const diagramRef = useRef(null);
const [hoverPosition, setHoverPosition] = useState<{
@@ -114,7 +121,9 @@ const MermaidRenderer: React.FC = ({
if (cancelled) return;
console.error('Error rendering mermaid diagram:', err);
setError(
- `Failed to render diagram: ${err instanceof Error ? err.message : String(err)}`,
+ t('mermaid.renderFailed', {
+ message: err instanceof Error ? err.message : String(err),
+ }),
);
}
}
@@ -126,22 +135,6 @@ const MermaidRenderer: React.FC = ({
};
}, [code, isDarkTheme, isCurrentlyLoading]);
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (
- downloadMenuRef.current &&
- !downloadMenuRef.current.contains(event.target as Node)
- ) {
- setShowDownloadMenu(false);
- }
- };
-
- document.addEventListener('mousedown', handleClickOutside);
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, [showDownloadMenu]);
-
const downloadSvg = (): void => {
const element = document.getElementById(diagramId.current);
if (!element) return;
@@ -265,73 +258,52 @@ const MermaidRenderer: React.FC = ({
};
const downloadOptions = [
- { label: 'Download as SVG', action: downloadSvg },
- { label: 'Download as PNG', action: downloadPng },
- { label: 'Download as MMD', action: downloadMmd },
+ { label: t('mermaid.downloadAs', { format: 'SVG' }), action: downloadSvg },
+ { label: t('mermaid.downloadAs', { format: 'PNG' }), action: downloadPng },
+ { label: t('mermaid.downloadAs', { format: 'MMD' }), action: downloadMmd },
];
const showDiagramOptions = !isCurrentlyLoading && !error;
const errorRender = !isCurrentlyLoading && error;
return (
-
+
-
- mermaid
-
+
mermaid
-
+
{showDiagramOptions && (
-
-
setShowDownloadMenu(!showDownloadMenu)}
- className="h-auto gap-1 rounded-sm bg-gray-100 px-2 py-1 text-xs dark:bg-gray-700"
- title={t('mermaid.downloadOptions')}
- >
- Download ▼
-
- {showDownloadMenu && (
-
-
- {downloadOptions.map((option, index) => (
-
- {
- option.action();
- setShowDownloadMenu(false);
- }}
- className="h-auto w-full justify-start rounded-none px-4 py-2 text-left text-xs font-normal"
- >
- {option.label}
-
-
- ))}
-
-
- )}
-
+
+
+
+ {t('mermaid.download')} ▼
+
+
+
+ {downloadOptions.map((option) => (
+ option.action()}
+ >
+ {option.label}
+
+ ))}
+
+
)}
{showDiagramOptions && (
setShowCode(!showCode)}
- className={`h-auto rounded px-2 py-1 text-xs ${
- showCode
- ? 'bg-blue-200 hover:bg-blue-200/90 dark:bg-blue-800 dark:hover:bg-blue-800/90'
- : 'bg-gray-100 hover:bg-gray-100/90 dark:bg-gray-700 dark:hover:bg-gray-700/90'
- }`}
- title={t('mermaid.viewCode')}
>
- Code
+ {t('mermaid.code')}
)}
@@ -339,16 +311,17 @@ const MermaidRenderer: React.FC
= ({
{isCurrentlyLoading ? (
-
- Loading diagram...
+
+ {t('mermaid.loading')}
) : errorRender ? (
-
+
+
) : (
<>
= ({
{isHovering && (
<>
-
setZoomFactor((prev) => Math.max(1, prev - 0.5))
}
- className="h-auto px-1 py-0 text-white hover:bg-gray-600 hover:text-white"
- title={t('mermaid.decreaseZoom')}
+ /* eslint-disable-next-line shadcn/no-restyle --
+ zoom controls sit on the bg-black/70 overlay; ghost's accent hover would paint a light square on it */
+ className="hover:bg-white/20 hover:text-white"
>
-
-
- {
- setZoomFactor(2);
- }}
- title={t('mermaid.resetZoom')}
- >
- {zoomFactor.toFixed(1)}x
-
+
setZoomFactor(2)}
+ title={t('mermaid.resetZoom')}
+ /* eslint-disable-next-line shadcn/no-restyle --
+ on the bg-black/70 zoom overlay, like its − / + siblings: keeps the overlay's white 12px regular */
+ className="text-xs font-normal text-current"
+ >
+ {zoomFactor.toFixed(1)}x
+
+
setZoomFactor((prev) => Math.min(6, prev + 0.5))
}
- className="h-auto px-1 py-0 text-white hover:bg-gray-600 hover:text-white"
- title={t('mermaid.increaseZoom')}
+ /* eslint-disable-next-line shadcn/no-restyle --
+ zoom controls sit on the bg-black/70 overlay; ghost's accent hover would paint a light square on it */
+ className="hover:bg-white/20 hover:text-white"
>
+
-
+
>
)}
= ({
{showCode && (
-
- Mermaid Code
+
+ {t('mermaid.codeTitle')}
{
const getVoiceInputSupportError = (): string | null => {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
- return 'Voice input is unavailable right now.';
+ return i18n.t('conversation.voice.unavailable');
}
if (!window.isSecureContext) {
- return 'Voice input requires a secure connection (HTTPS or localhost).';
+ return i18n.t('conversation.voice.insecure');
}
if (!navigator.mediaDevices?.getUserMedia && !getLegacyGetUserMedia()) {
- return 'Voice input is not available in this browser.';
+ return i18n.t('conversation.voice.unsupported');
}
if (!getAudioContextConstructor()) {
- return 'Voice input requires Web Audio support in this browser.';
+ return i18n.t('conversation.voice.noWebAudio');
}
return null;
@@ -152,9 +154,7 @@ const getUserMediaStream = (
const legacyGetUserMedia = getLegacyGetUserMedia();
if (!legacyGetUserMedia) {
- return Promise.reject(
- new Error('Voice input is not available in this browser.'),
- );
+ return Promise.reject(new Error(i18n.t('conversation.voice.unsupported')));
}
return new Promise((resolve, reject) => {
@@ -164,7 +164,7 @@ const getUserMediaStream = (
const getVoiceInputErrorMessage = (error: unknown): string => {
if (typeof window !== 'undefined' && !window.isSecureContext) {
- return 'Voice input requires a secure connection (HTTPS or localhost).';
+ return i18n.t('conversation.voice.insecure');
}
if (error instanceof DOMException) {
@@ -172,15 +172,15 @@ const getVoiceInputErrorMessage = (error: unknown): string => {
case 'NotAllowedError':
case 'PermissionDeniedError':
case 'SecurityError':
- return 'Microphone access was blocked. Allow microphone permission and try again.';
+ return i18n.t('conversation.voice.micBlocked');
case 'NotFoundError':
case 'DevicesNotFoundError':
- return 'No microphone was found on this device.';
+ return i18n.t('conversation.voice.micNotFound');
case 'NotReadableError':
case 'TrackStartError':
- return 'The microphone is unavailable or already in use.';
+ return i18n.t('conversation.voice.micBusy');
case 'AbortError':
- return 'Microphone access was interrupted before recording started.';
+ return i18n.t('conversation.voice.micInterrupted');
default:
break;
}
@@ -190,7 +190,7 @@ const getVoiceInputErrorMessage = (error: unknown): string => {
return error.message;
}
- return 'Microphone access was denied.';
+ return i18n.t('conversation.voice.micDenied');
};
const downsampleFloat32Buffer = (
@@ -1060,7 +1060,9 @@ export default function MessageInput({
const data = await response.json();
if (!response.ok || !data?.success) {
- throw new Error(data?.message || 'Failed to transcribe audio.');
+ throw new Error(
+ data?.message || t('conversation.voice.transcribeFailed'),
+ );
}
if (typeof data.text !== 'string' || !data.text.trim()) {
@@ -1078,7 +1080,9 @@ export default function MessageInput({
console.error('Uploaded audio transcription failed', error);
setRecordingState('error');
setVoiceError(
- error instanceof Error ? error.message : 'Failed to transcribe audio.',
+ error instanceof Error
+ ? error.message
+ : t('conversation.voice.transcribeFailed'),
);
}
};
@@ -1155,7 +1159,7 @@ export default function MessageInput({
if (!response.ok || !data?.success) {
throw new Error(
- data?.message || 'Failed to finalize live transcription.',
+ data?.message || t('conversation.voice.finalizeFailed'),
);
}
@@ -1175,7 +1179,7 @@ export default function MessageInput({
setVoiceError(
error instanceof Error
? error.message
- : 'Failed to finalize live transcription.',
+ : t('conversation.voice.finalizeFailed'),
);
} finally {
resetLiveTranscriptionState();
@@ -1227,7 +1231,9 @@ export default function MessageInput({
const data = await response.json();
if (!response.ok || !data?.success) {
- throw new Error(data?.message || 'Failed to transcribe audio.');
+ throw new Error(
+ data?.message || t('conversation.voice.transcribeFailed'),
+ );
}
if (typeof data.transcript_text === 'string') {
@@ -1235,7 +1241,9 @@ export default function MessageInput({
}
} catch (error) {
await failLiveTranscription(
- error instanceof Error ? error.message : 'Failed to transcribe audio.',
+ error instanceof Error
+ ? error.message
+ : t('conversation.voice.transcribeFailed'),
);
return;
} finally {
@@ -1308,7 +1316,7 @@ export default function MessageInput({
const AudioContextConstructor = getAudioContextConstructor();
if (!AudioContextConstructor) {
setRecordingState('error');
- setVoiceError('Voice input requires Web Audio support in this browser.');
+ setVoiceError(t('conversation.voice.noWebAudio'));
return;
}
@@ -1326,7 +1334,7 @@ export default function MessageInput({
const liveStartData = await liveStartResponse.json();
if (!liveStartResponse.ok || !liveStartData?.success) {
throw new Error(
- liveStartData?.message || 'Failed to start live transcription.',
+ liveStartData?.message || t('conversation.voice.startFailed'),
);
}
@@ -1410,7 +1418,7 @@ export default function MessageInput({
setVoiceError(
error instanceof Error
? error.message
- : 'Failed to start live transcription.',
+ : t('conversation.voice.startFailed'),
);
}
};
@@ -1494,7 +1502,7 @@ export default function MessageInput({
own: t('conversation.sources.groupOwn'),
team: t('conversation.sources.groupTeam'),
},
- SourceIcon,
+ ,
),
[sourceDocs, t],
);
@@ -1541,7 +1549,7 @@ export default function MessageInput({
const toolItems: MultiSelectPopoverItem[] = userTools.map((tool) => ({
id: tool.id,
label: tool.customName || tool.displayName,
- icon: ,
+ icon: ,
}));
const selectedToolIds = userTools
@@ -1678,7 +1686,7 @@ export default function MessageInput({
controls (dead Attach button, see AttachFileButton). */}
-
{t('conversation.attachments.cancelQueuedSend')}
-
+
)}
{sendArmed && sendReadiness.state === 'blocked' && (
{t('conversation.attachments.sendBlockedByFailed', {
@@ -1721,7 +1733,7 @@ export default function MessageInput({
)}
{voiceError && (
-
+
{voiceError}
)}
@@ -1742,7 +1754,7 @@ export default function MessageInput({
}
tabIndex={1}
placeholder={t('inputPlaceholder')}
- className="inputbox-style dark:text-foreground dark:placeholder:text-muted-foreground/50 w-full scrollbar-thin overflow-x-hidden overflow-y-auto rounded-t-3xl bg-transparent px-2 text-base leading-tight whitespace-pre-wrap opacity-100 placeholder:text-gray-500 focus:outline-hidden sm:px-3"
+ className="text-foreground placeholder:text-muted-foreground w-full resize-none overflow-x-hidden overflow-y-auto rounded-t-3xl bg-transparent px-2 text-base leading-tight whitespace-pre-wrap opacity-100 focus:outline-hidden sm:px-3"
onKeyDown={handleKeyDown}
onPaste={handlePaste}
aria-label={t('inputPlaceholder')}
@@ -1787,37 +1799,40 @@ export default function MessageInput({
{loading ? (
-
-
-
+
+
) : (
-
-
+
)}
@@ -1834,12 +1849,12 @@ export default function MessageInput({
{handleDragActive &&
createPortal(
-
-
-
+
+
+
{t('modals.uploadDoc.drag.title')}
-
+
{t('modals.uploadDoc.drag.description')}
,
diff --git a/frontend/src/components/MultiSelectPopover.test.tsx b/frontend/src/components/MultiSelectPopover.test.tsx
new file mode 100644
index 00000000..c3089f56
--- /dev/null
+++ b/frontend/src/components/MultiSelectPopover.test.tsx
@@ -0,0 +1,93 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+const media = { isMobile: false, isDesktop: true };
+vi.mock('../hooks', () => ({
+ useMediaQuery: () => media,
+ useDarkTheme: () => [false, () => undefined],
+}));
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string) => fallback ?? key,
+ }),
+}));
+
+import { MultiSelectPopover } from './MultiSelectPopover';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+const items = [
+ { id: 'a', label: 'Alpha' },
+ { id: 'b', label: 'Beta' },
+];
+
+describe('MultiSelectPopover', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ media.isMobile = false;
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ document.body.innerHTML = '';
+ });
+
+ const render = () =>
+ act(() => {
+ root.render(
+ Open}
+ items={items}
+ selectedIds={['b']}
+ onToggle={() => undefined}
+ className="max-h-40"
+ />,
+ );
+ });
+
+ it('puts className on the desktop popover', () => {
+ render();
+ const content = document.querySelector('[data-slot="popover-content"]');
+ expect(content?.className).toContain('max-h-40');
+ });
+
+ it('puts className on the mobile sheet', () => {
+ media.isMobile = true;
+ render();
+ const content = document.querySelector('[data-slot="sheet-content"]');
+ expect(content?.className).toContain('max-h-40');
+ });
+
+ it('uses the shared bottom-sheet shape and handle on phones', () => {
+ media.isMobile = true;
+ render();
+ const content = document.querySelector('[data-slot="sheet-content"]')!;
+ expect(content.querySelectorAll('[data-slot="sheet-handle"]')).toHaveLength(
+ 1,
+ );
+ expect(content.className).toContain('rounded-t-2xl');
+ expect(content.className).not.toContain('env(');
+ expect(content.className).not.toContain('dark:bg-card');
+ });
+
+ it('marks chosen rows with CommandItem checked and a primary tick', () => {
+ render();
+ const rows = document.querySelectorAll('[data-slot="command-item"]');
+ expect(rows).toHaveLength(2);
+ expect(rows[0].hasAttribute('data-checked')).toBe(false);
+ expect(rows[1].getAttribute('data-checked')).toBe('true');
+ rows.forEach((row) => expect(row.querySelector('img')).toBeNull());
+ const tick = rows[1].querySelector('svg');
+ expect(tick).not.toBeNull();
+ expect(tick?.getAttribute('class')).toContain('text-primary');
+ expect(rows[0].querySelector('svg')).toBeNull();
+ });
+});
diff --git a/frontend/src/components/MultiSelectPopover.tsx b/frontend/src/components/MultiSelectPopover.tsx
index fc711c50..9fdffcff 100644
--- a/frontend/src/components/MultiSelectPopover.tsx
+++ b/frontend/src/components/MultiSelectPopover.tsx
@@ -1,12 +1,11 @@
+import { Check } from 'lucide-react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
-import CheckmarkIcon from '../assets/checkmark.svg';
-import NoFilesDarkIcon from '../assets/no-files-dark.svg';
-import NoFilesIcon from '../assets/no-files.svg';
-import { useDarkTheme, useMediaQuery } from '../hooks';
+import { useMediaQuery } from '../hooks';
import { cn } from '@/lib/utils';
-import Spinner from './Spinner';
+import { EmptyState } from '@/components/ui/empty-state';
+import { LoadingState } from '@/components/ui/loading-state';
import {
Command,
CommandEmpty,
@@ -16,6 +15,7 @@ import {
CommandList,
} from './ui/command';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
+import { SectionHeader } from './ui/section-header';
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from './ui/sheet';
export type MultiSelectPopoverItem = {
@@ -42,7 +42,8 @@ export type MultiSelectPopoverProps = {
footer?: React.ReactNode;
align?: 'start' | 'center' | 'end';
side?: 'top' | 'right' | 'bottom' | 'left';
- contentClassName?: string;
+ /** Extra layout classes for the popup (the desktop popover or the mobile sheet). */
+ className?: string;
emptyMessage?: string;
selectedFirst?: boolean;
title?: string;
@@ -52,16 +53,11 @@ function renderIcon(icon: MultiSelectPopoverItem['icon']) {
if (!icon) return null;
if (typeof icon === 'string') {
return (
-
+
);
}
return (
-
+
{icon}
);
@@ -80,13 +76,12 @@ export function MultiSelectPopover({
footer,
align = 'start',
side = 'bottom',
- contentClassName,
+ className,
emptyMessage,
selectedFirst = false,
title,
}: MultiSelectPopoverProps) {
const { t } = useTranslation();
- const [isDarkTheme] = useDarkTheme();
const { isMobile } = useMediaQuery();
const selectedSet = React.useMemo(() => new Set(selectedIds), [selectedIds]);
@@ -128,14 +123,15 @@ export function MultiSelectPopover({
onSelect={() => {
if (!item.disabled) onToggle(item.id);
}}
- className="dark:border-border data-[selected=true]:bg-accent flex cursor-pointer items-center justify-between gap-2 rounded-none border-b border-[#D9D9D9] px-3 py-3 last:border-b-0"
+ checked={isSelected}
+ className="cursor-pointer justify-between"
aria-selected={isSelected}
>
-
+
{renderIcon(item.icon)}
{item.label}
@@ -150,17 +146,11 @@ export function MultiSelectPopover({
{isSelected && (
-
+
)}
@@ -168,26 +158,19 @@ export function MultiSelectPopover({
};
const renderEmptyState = () => (
-
-
-
- {emptyMessage || t('settings.tools.noToolsFound', 'No results found')}
-
-
+
);
const commandBody = (
-
+
{title && (
-
- {title}
-
+
)}
{searchable && (
@@ -197,11 +180,9 @@ export function MultiSelectPopover({
)}
{loading ? (
-
-
-
+
) : (
-
+
{renderEmptyState()}
{hasGroups ? (
@@ -212,23 +193,20 @@ export function MultiSelectPopover({
{groupItems.map(renderItem)}
);
})
) : (
-
- {orderedItems.map(renderItem)}
-
+ {orderedItems.map(renderItem)}
)}
)}
{footer && (
-
+
{footer}
)}
@@ -241,20 +219,14 @@ export function MultiSelectPopover({
{trigger}
e.preventDefault()}
- className={cn(
- 'border-border bg-background dark:border-border dark:bg-card flex max-h-[85vh] flex-col gap-0 overflow-hidden rounded-t-2xl p-0 pb-[env(safe-area-inset-bottom)]',
- contentClassName,
- )}
+ className={cn('overflow-hidden', className)}
>
{title || effectivePlaceholder}
-
{commandBody}
@@ -268,8 +240,8 @@ export function MultiSelectPopover({
align={align}
side={side}
className={cn(
- 'border-border bg-background dark:border-border dark:bg-card flex max-h-[min(600px,80vh)] w-[min(462px,calc(100vw-20px))] flex-col overflow-hidden rounded-lg p-0 shadow-md',
- contentClassName,
+ 'flex max-h-[min(600px,80vh)] w-[min(462px,calc(100vw-20px))] flex-col overflow-hidden p-0',
+ className,
)}
>
{commandBody}
diff --git a/frontend/src/components/Notification.tsx b/frontend/src/components/Notification.tsx
index def9f580..20666fc0 100644
--- a/frontend/src/components/Notification.tsx
+++ b/frontend/src/components/Notification.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
-import close from '../assets/cross.svg';
-import { Button } from './ui/button';
+import { X } from 'lucide-react';
+import { IconButton } from './ui/icon-button';
interface NotificationProps {
notificationText: string;
@@ -25,114 +25,87 @@ export default function Notification({
}: NotificationProps) {
const { t } = useTranslation();
return (
- <>
-
-
- {/* Animated stars background */}
-
- {stars.map((star) => (
-
- {/* 4-pointed Christmas star */}
-
-
- ))}
-
-
-
- {notificationText}
-
-
+
+ {/* Animated stars background. The banner is always the purple gradient,
+ so its imagery is white in both themes. */}
+
+ {stars.map((star) => (
- {/* Arrow tail - grows leftward from arrow head's back point on hover */}
-
- {/* Arrow head - pushed forward by the tail on hover */}
+ {/* 4-pointed Christmas star */}
-
+ ))}
+
- {
- e.stopPropagation();
- e.preventDefault();
- handleCloseNotification();
- }}
+
+ {notificationText}
+
+
+
-
-
-
- >
+ {/* Arrow tail - grows leftward from arrow head's back point on hover */}
+
+ {/* Arrow head - pushed forward by the tail on hover */}
+
+
+
+
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ handleCloseNotification();
+ }}
+ />
+
);
}
diff --git a/frontend/src/components/PageToolbar.test.tsx b/frontend/src/components/PageToolbar.test.tsx
new file mode 100644
index 00000000..44c95675
--- /dev/null
+++ b/frontend/src/components/PageToolbar.test.tsx
@@ -0,0 +1,46 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it } from 'vitest';
+
+import PageToolbar from './PageToolbar';
+
+describe('PageToolbar', () => {
+ it('stacks intro, the search/action row and an opt-in rule', () => {
+ const html = renderToStaticMarkup(
+
}
+ action={
Add Tool }
+ divider
+ />,
+ );
+ expect(html).toContain('text-muted-foreground mb-5 text-sm leading-6');
+ expect(html).toContain(
+ 'mb-6 flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between',
+ );
+ expect(html).toContain('w-full max-w-md');
+ expect(html).toContain('data-slot="separator"');
+ expect(html).toContain('mb-8');
+ });
+
+ it('puts the intro in the left slot when there is no search', () => {
+ const html = renderToStaticMarkup(
+
Create}
+ />,
+ );
+ expect(html).toContain('max-w-2xl');
+ expect(html).not.toContain('data-slot="separator"');
+ });
+
+ it('renders notices between the row and the rule', () => {
+ const html = renderToStaticMarkup(
+ } divider>
+ limit reached
+ ,
+ );
+ expect(html.indexOf('limit reached')).toBeLessThan(
+ html.indexOf('data-slot="separator"'),
+ );
+ });
+});
diff --git a/frontend/src/components/PageToolbar.tsx b/frontend/src/components/PageToolbar.tsx
new file mode 100644
index 00000000..cba98bc9
--- /dev/null
+++ b/frontend/src/components/PageToolbar.tsx
@@ -0,0 +1,46 @@
+import type { ReactNode } from 'react';
+
+import { Separator } from './ui/separator';
+
+/**
+ * The block under a section page's title: an intro line, then a row with the
+ * page search on the left and the page action (an Add pill) on the right,
+ * optionally closed by a rule. Notices that belong to the action (a limit
+ * warning) go in `children`, under the row.
+ */
+export default function PageToolbar({
+ intro,
+ search,
+ action,
+ divider = false,
+ children,
+}: {
+ intro?: ReactNode;
+ search?: ReactNode;
+ action?: ReactNode;
+ divider?: boolean;
+ children?: ReactNode;
+}) {
+ // With no search, the intro takes the row's left slot beside the action.
+ const introInRow = !search && intro;
+
+ return (
+
+ {intro && !introInRow ? (
+
{intro}
+ ) : null}
+
+ {introInRow ? (
+
+ {intro}
+
+ ) : search ? (
+
{search}
+ ) : null}
+ {action ?
{action}
: null}
+
+ {children}
+ {divider ?
: null}
+
+ );
+}
diff --git a/frontend/src/components/ProfileButton.tsx b/frontend/src/components/ProfileButton.tsx
index 790d9a07..401b6645 100644
--- a/frontend/src/components/ProfileButton.tsx
+++ b/frontend/src/components/ProfileButton.tsx
@@ -3,7 +3,9 @@ import { useTranslation } from 'react-i18next';
import useTokenAuth from '../hooks/useTokenAuth';
import { Avatar } from './ui/avatar';
+import { Button } from './ui/button';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
+import { Separator } from './ui/separator';
/**
* Top-right account menu for OIDC sessions: an avatar that opens a popover
@@ -17,41 +19,45 @@ export default function ProfileButton() {
if (authType !== 'oidc' || (!userName && !userEmail)) return null;
const initial = (userName || userEmail || '?').charAt(0).toUpperCase();
- const renderAvatar = (size: string, text: string) =>
+ // `sm` is size-8/text-sm, `lg` is size-10/text-base; the size variant
+ // also brings the flex centring and the overflow-hidden that clips both the
+ // photo and the initials to the circle.
+ const renderAvatar = (size: 'sm' | 'lg') =>
userPicture ? (
) : (
-
-
- {initial}
-
+
+ {initial}
);
return (
-
- {renderAvatar('size-8', 'text-sm')}
-
+ {renderAvatar('sm')}
+
- {renderAvatar('size-10', 'text-base')}
+ {renderAvatar('lg')}
{userName && (
-
+
{userName}
)}
@@ -62,20 +68,18 @@ export default function ProfileButton() {
)}
-
+
-
-
+
{t('auth.signOut')}
-
+
diff --git a/frontend/src/components/SearchInput.test.tsx b/frontend/src/components/SearchInput.test.tsx
new file mode 100644
index 00000000..af7608cd
--- /dev/null
+++ b/frontend/src/components/SearchInput.test.tsx
@@ -0,0 +1,24 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it } from 'vitest';
+
+import SearchInput from './SearchInput';
+
+describe('SearchInput', () => {
+ it('is a 38px pill with a search icon and a floating label', () => {
+ const html = renderToStaticMarkup( );
+ expect(html).toContain('rounded-full');
+ expect(html).toContain('h-9.5');
+ expect(html).toContain('pl-10');
+ expect(html).toContain(' {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).not.toContain(' & {
+ size?: 'field' | 'sm';
+};
+
+/**
+ * The search field above a list or grid: a 38px pill with a search icon.
+ * Give it a `label` (the floating label, the default for page searches) or a
+ * `placeholder`; a placeholder-only field is named by its placeholder.
+ */
+export default function SearchInput({
+ size = 'field',
+ labelSurface = 'background',
+ type = 'text',
+ label,
+ placeholder,
+ ...props
+}: SearchInputProps) {
+ return (
+ }
+ {...props}
+ />
+ );
+}
diff --git a/frontend/src/components/SkeletonLoader.tsx b/frontend/src/components/SkeletonLoader.tsx
index eea39673..6f3b6e7f 100644
--- a/frontend/src/components/SkeletonLoader.tsx
+++ b/frontend/src/components/SkeletonLoader.tsx
@@ -1,6 +1,11 @@
-import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
+import { Card } from '@/components/ui/card';
+import { Separator } from '@/components/ui/separator';
+import { Skeleton } from '@/components/ui/skeleton';
+import { useMediaQuery } from '@/hooks';
+import { cn } from '@/lib/utils';
+
interface SkeletonLoaderProps {
count?: number;
component?:
@@ -24,44 +29,25 @@ const SkeletonLoader: React.FC = ({
component = 'default',
}) => {
const { t } = useTranslation();
- const [skeletonCount, setSkeletonCount] = useState(count);
-
- useEffect(() => {
- const handleResize = () => {
- const windowWidth = window.innerWidth;
-
- if (windowWidth > 1024) {
- setSkeletonCount(1);
- } else if (windowWidth > 768) {
- setSkeletonCount(count);
- } else {
- setSkeletonCount(Math.min(count, 2));
- }
- };
-
- handleResize();
- window.addEventListener('resize', handleResize);
-
- return () => {
- window.removeEventListener('resize', handleResize);
- };
- }, [count]);
+ const { isDesktop } = useMediaQuery();
+ // One placeholder beside the desktop shell, at most two below it.
+ const skeletonCount = isDesktop ? 1 : Math.min(count, 2);
const renderTable = () => (
<>
{[...Array(4)].map((_, idx) => (
-
+
-
+
-
+
-
+
-
+
))}
@@ -71,18 +57,18 @@ const SkeletonLoader: React.FC = ({
const renderChatbot = () => (
<>
{[...Array(4)].map((_, idx) => (
-
+
-
+
-
+
-
+
-
+
))}
@@ -94,24 +80,21 @@ const SkeletonLoader: React.FC = ({
);
const renderLogs = () => (
-
+
{[...Array(8)].map((_, idx) => (
-
+
@@ -124,34 +107,37 @@ const SkeletonLoader: React.FC
= ({
{[...Array(skeletonCount)].map((_, idx) => (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
))}
@@ -162,9 +148,9 @@ const SkeletonLoader: React.FC
= ({
<>
{[...Array(skeletonCount)].map((_, idx) => (
-
-
-
+
+
+
@@ -174,8 +160,8 @@ const SkeletonLoader: React.FC
= ({
-
-
+
@@ -193,13 +179,13 @@ const SkeletonLoader: React.FC
= ({
{Array.from({ length: count }).map((_, index) => (
-
+
-
+
@@ -216,30 +202,31 @@ const SkeletonLoader: React.FC
= ({
const renderSourceCards = () => (
<>
{Array.from({ length: count }).map((_, idx) => (
-
+
))}
>
);
@@ -247,22 +234,26 @@ const SkeletonLoader: React.FC
= ({
const renderAddToolCards = () => (
<>
{Array.from({ length: count }).map((_, idx) => (
-
+
))}
>
);
@@ -270,19 +261,26 @@ const SkeletonLoader: React.FC
= ({
const renderAgentCards = () => (
<>
{Array.from({ length: count }).map((_, idx) => (
-
-
+
))}
>
);
@@ -290,25 +288,31 @@ const SkeletonLoader: React.FC
= ({
const renderToolCards = () => (
<>
{Array.from({ length: count }).map((_, idx) => (
-
-
+
+
))}
>
);
@@ -317,7 +321,7 @@ const SkeletonLoader: React.FC
= ({
@@ -326,13 +330,13 @@ const SkeletonLoader: React.FC
= ({
);
const renderFilesSection = () => (
-
+
);
diff --git a/frontend/src/components/SourcesPopoverFooter.tsx b/frontend/src/components/SourcesPopoverFooter.tsx
index 056d47fc..906ac6b0 100644
--- a/frontend/src/components/SourcesPopoverFooter.tsx
+++ b/frontend/src/components/SourcesPopoverFooter.tsx
@@ -1,7 +1,7 @@
+import { ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
-import RedirectIcon from '../assets/redirect.svg';
import { Button } from './ui/button';
type SourcesPopoverFooterProps = {
@@ -25,13 +25,14 @@ export default function SourcesPopoverFooter({
onClick={onNavigate}
>
{t('settings.sources.goToSources')}
-
+
{t('settings.sources.uploadNew')}
diff --git a/frontend/src/components/Spinner.tsx b/frontend/src/components/Spinner.tsx
deleted file mode 100644
index dda8b8c3..00000000
--- a/frontend/src/components/Spinner.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import React from 'react';
-
-type SpinnerProps = {
- size?: 'small' | 'medium' | 'large';
-};
-
-const SIZE_MAP: Record
, string> = {
- small: '20px',
- medium: '30px',
- large: '40px',
-};
-
-export default function Spinner({ size = 'medium' }: SpinnerProps) {
- const spinnerSize = SIZE_MAP[size];
-
- const spinnerStyle: React.CSSProperties = {
- width: spinnerSize,
- height: spinnerSize,
- aspectRatio: '1',
- borderRadius: '50%',
- background: `
- radial-gradient(farthest-side, currentColor 94%, transparent) top/8px 8px no-repeat,
- conic-gradient(transparent 30%, currentColor)
- `,
- WebkitMask:
- 'radial-gradient(farthest-side, transparent calc(100% - 8px), #000 0)',
- mask: 'radial-gradient(farthest-side, transparent calc(100% - 8px), #000 0)',
- animation: 'spinner-rotate 1s infinite linear',
- color: 'currentColor',
- };
-
- return
;
-}
diff --git a/frontend/src/components/StatCard.test.tsx b/frontend/src/components/StatCard.test.tsx
new file mode 100644
index 00000000..afbeebff
--- /dev/null
+++ b/frontend/src/components/StatCard.test.tsx
@@ -0,0 +1,96 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it } from 'vitest';
+
+import StatCard from './StatCard';
+
+describe('StatCard', () => {
+ it('renders a subtle, large-padded Card with label and value', () => {
+ const html = renderToStaticMarkup( );
+ expect(html).toContain('data-slot="card"');
+ expect(html).toContain('data-variant="subtle"');
+ expect(html).toContain('data-padding="lg"');
+ expect(html).toContain('gap-1');
+ expect(html).not.toContain('gap-3');
+ expect(html).toContain('text-muted-foreground text-sm');
+ expect(html).toContain('>Users<');
+ expect(html).toContain('text-2xl font-bold tabular-nums');
+ expect(html).toContain('>42<');
+ expect(html).not.toMatch(/\bmt-1\b/);
+ });
+
+ it('renders the sub line only when given', () => {
+ expect(
+ renderToStaticMarkup( ),
+ ).not.toContain('text-xs');
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('text-muted-foreground text-xs');
+ expect(html).toContain('>last 30 days<');
+ });
+
+ it('turns a hint into a native tooltip with a help cursor', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('title="How this is counted"');
+ expect(html).toContain('cursor-help');
+ expect(
+ renderToStaticMarkup( ),
+ ).not.toContain('cursor-help');
+ });
+
+ it('passes layout classes and data attributes through', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('col-span-2');
+ expect(html).toContain('data-testid="t"');
+ });
+
+ it('defaults to subtle and takes outline for tiles inside a modal', () => {
+ expect(renderToStaticMarkup( )).toContain(
+ 'data-variant="subtle"',
+ );
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('data-variant="outline"');
+ expect(html).toContain('bg-card');
+ expect(html).not.toContain('bg-background');
+ });
+
+ it('passes a destructive tone to the Card', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('data-tone="destructive"');
+ expect(html).toContain('bg-destructive/10');
+ expect(
+ renderToStaticMarkup( ),
+ ).not.toContain('data-tone');
+ });
+
+ it.each([
+ ['destructive', 'text-destructive'],
+ ['warning', 'text-warning'],
+ ['info', 'text-info'],
+ ['muted', 'text-muted-foreground'],
+ ] as const)('colours the figure with valueTone=%s', (valueTone, cls) => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain(`text-2xl font-bold tabular-nums ${cls}`);
+ });
+
+ it('renders a figure-sized Skeleton instead of the value while loading', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('data-slot="skeleton"');
+ expect(html).toContain('h-8 w-12');
+ expect(html).not.toContain('>99<');
+ expect(html).not.toContain('tabular-nums');
+ expect(html).toContain('>A<');
+ });
+});
diff --git a/frontend/src/components/StatCard.tsx b/frontend/src/components/StatCard.tsx
new file mode 100644
index 00000000..ab75ed15
--- /dev/null
+++ b/frontend/src/components/StatCard.tsx
@@ -0,0 +1,78 @@
+import * as React from 'react';
+
+import { Card } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import { cn } from '@/lib/utils';
+
+const VALUE_TONES = {
+ destructive: 'text-destructive',
+ warning: 'text-warning',
+ info: 'text-info',
+ muted: 'text-muted-foreground',
+} as const;
+
+type StatCardProps = Omit, 'title' | 'children'> & {
+ /** Muted caption above the figure. */
+ label: React.ReactNode;
+ /** The figure itself, set large in tabular numerals. */
+ value: React.ReactNode;
+ /** Optional 12px muted line under the figure. */
+ sub?: React.ReactNode;
+ /** Native tooltip on the tile; adds a help cursor. */
+ hint?: string;
+ /** Card surface: `subtle` on a page, `outline` inside a modal (bg-card). */
+ variant?: 'subtle' | 'outline';
+ /** `destructive` gives the tile the danger soft fill and border. */
+ tone?: 'default' | 'destructive';
+ /** Colours the figure by meaning; omitted, it inherits the card foreground. */
+ valueTone?: keyof typeof VALUE_TONES;
+ /** Shows a figure-sized Skeleton in place of the value. */
+ loading?: boolean;
+};
+
+/**
+ * A stat tile: label, big tabular figure and an optional sub line on a
+ * Card. `className` is for layout only (grid placement, width).
+ */
+function StatCard({
+ label,
+ value,
+ sub,
+ hint,
+ variant = 'subtle',
+ tone = 'default',
+ valueTone,
+ loading = false,
+ className,
+ ...rest
+}: StatCardProps) {
+ return (
+
+ {label}
+ {loading ? (
+
+ ) : (
+
+ {value}
+
+ )}
+ {sub ? {sub}
: null}
+
+ );
+}
+
+export default StatCard;
+export { StatCard };
+export type { StatCardProps };
diff --git a/frontend/src/components/TextToSpeechButton.tsx b/frontend/src/components/TextToSpeechButton.tsx
index 028a4bed..1eff24ff 100644
--- a/frontend/src/components/TextToSpeechButton.tsx
+++ b/frontend/src/components/TextToSpeechButton.tsx
@@ -1,9 +1,8 @@
+import { Square, Volume2 } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
-import Speaker from '../assets/speaker.svg?react';
-import Stopspeech from '../assets/stopspeech.svg?react';
+import { useTranslation } from 'react-i18next';
import userService from '../api/services/userService';
-import { Button } from './ui/button';
-import Spinner from './Spinner';
+import { IconButton } from './ui/icon-button';
let currentlyPlayingAudio: {
audio: HTMLAudioElement;
@@ -43,6 +42,7 @@ function setCachedAudio(text: string, audioBase64: string) {
}
export default function SpeakButton({ text }: { text: string }) {
+ const { t } = useTranslation();
const [isSpeaking, setIsSpeaking] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const audioRef = useRef(null);
@@ -172,30 +172,21 @@ export default function SpeakButton({ text }: { text: string }) {
};
return (
-
- {isLoading ? (
-
- ) : isSpeaking ? (
-
- ) : (
-
- )}
-
+ variant={isSpeaking || isLoading ? 'secondary' : 'ghost-muted'}
+ size="icon-sm"
+ shape="pill"
+ className="cursor-pointer"
+ onClick={handleSpeakClick}
+ icon={isSpeaking ? Square : Volume2}
+ loading={isLoading}
+ />
);
}
diff --git a/frontend/src/components/ToolIcon.tsx b/frontend/src/components/ToolIcon.tsx
index bd9edab3..7688f15b 100644
--- a/frontend/src/components/ToolIcon.tsx
+++ b/frontend/src/components/ToolIcon.tsx
@@ -36,13 +36,5 @@ export default function ToolIcon({ name, className, title }: ToolIconProps) {
const a11y = title
? { role: 'img' as const, 'aria-label': title }
: { 'aria-hidden': true as const };
- return (
-
- );
+ return ;
}
diff --git a/frontend/src/components/UploadToast.test.tsx b/frontend/src/components/UploadToast.test.tsx
new file mode 100644
index 00000000..efed2d77
--- /dev/null
+++ b/frontend/src/components/UploadToast.test.tsx
@@ -0,0 +1,121 @@
+import { configureStore } from '@reduxjs/toolkit';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { Provider } from 'react-redux';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+import uploadReducer, {
+ addUploadTask,
+ type UploadTask,
+} from '../upload/uploadSlice';
+import UploadToast from './UploadToast';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+const makeStore = () => configureStore({ reducer: { upload: uploadReducer } });
+
+const task = (over: Partial): UploadTask => ({
+ id: 't1',
+ fileName: 'a.pdf',
+ progress: 40,
+ status: 'training',
+ ...over,
+});
+
+describe('UploadToast', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+ let store: ReturnType;
+
+ beforeEach(() => {
+ localStorage.clear();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ store = makeStore();
+ });
+
+ afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ });
+
+ const render = async () => {
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ };
+
+ it('renders nothing without tasks', async () => {
+ await render();
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('renders one card of rows with no rail of its own', async () => {
+ store.dispatch(
+ addUploadTask(task({ id: 't1', fileName: 'a.pdf', stage: 'embedding' })),
+ );
+ store.dispatch(
+ addUploadTask(task({ id: 't2', fileName: 'b.md', status: 'completed' })),
+ );
+ await render();
+ expect(container.querySelectorAll('[data-slot="toast"]')).toHaveLength(1);
+ expect(container.querySelector('[data-slot="toast-viewport"]')).toBeNull();
+ expect(container.querySelector('[role="status"]')).toBeNull();
+ expect(container.querySelector('.fixed')).toBeNull();
+ const rows = container.querySelectorAll('[data-slot="toast-item"]');
+ expect(rows).toHaveLength(2);
+ expect(rows[0].textContent).toContain('b.md');
+ expect(rows[0].querySelector('[data-status="success"]')).not.toBeNull();
+ expect(rows[1].textContent).toContain(
+ 'modals.uploadDoc.progress.embedding',
+ );
+ expect(rows[1].querySelector('[role="progressbar"]')).not.toBeNull();
+ expect(
+ container.querySelector('[data-slot="toast-title"]')?.textContent,
+ ).toBe('modals.uploadDoc.progress.upload');
+ });
+
+ it('tints the header and explains a failure', async () => {
+ store.dispatch(
+ addUploadTask(task({ status: 'failed', errorMessage: 'Too big' })),
+ );
+ await render();
+ expect(
+ container
+ .querySelector('[data-slot="toast-header"]')
+ ?.getAttribute('data-variant'),
+ ).toBe('destructive');
+ const row = container.querySelector('[data-slot="toast-item"]');
+ expect(row?.querySelector('[data-status="destructive"]')).not.toBeNull();
+ const message = container.querySelector('[data-slot="toast-message"]');
+ expect(message?.textContent).toBe('Too big');
+ expect(message?.className).toContain('text-destructive');
+ });
+
+ it('collapses the rows and dismisses every task', async () => {
+ store.dispatch(addUploadTask(task({})));
+ await render();
+ const collapse = container.querySelector(
+ 'button[aria-label="modals.uploadDoc.progress.collapseDetails"]',
+ ) as HTMLButtonElement;
+ await act(async () => collapse.click());
+ expect(
+ container.querySelector(
+ 'button[aria-label="modals.uploadDoc.progress.expandDetails"]',
+ ),
+ ).not.toBeNull();
+ const close = container.querySelector(
+ 'button[aria-label="modals.uploadDoc.progress.dismiss"]',
+ ) as HTMLButtonElement;
+ await act(async () => close.click());
+ expect(container.innerHTML).toBe('');
+ });
+});
diff --git a/frontend/src/components/UploadToast.tsx b/frontend/src/components/UploadToast.tsx
index 3c132c29..e6e2d462 100644
--- a/frontend/src/components/UploadToast.tsx
+++ b/frontend/src/components/UploadToast.tsx
@@ -1,18 +1,26 @@
-import { X } from 'lucide-react';
+import { ChevronDown, X } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
-import { selectWorkflowPreviewOpen } from '../agents/workflow/workflowPreviewSlice';
-import CheckCircleFilled from '../assets/check-circle-filled.svg';
-import ChevronDown from '../assets/chevron-down.svg';
-import WarnIcon from '../assets/warn.svg';
+import { cn } from '@/lib/utils';
+
import {
dismissUploadTask,
selectUploadTasks,
type UploadTask,
} from '../upload/uploadSlice';
import { Button } from './ui/button';
+import {
+ Toast,
+ ToastActions,
+ ToastContent,
+ ToastHeader,
+ ToastItem,
+ ToastMessage,
+ ToastStatus,
+ ToastTitle,
+} from './ui/toast';
const PROGRESS_RADIUS = 10;
const PROGRESS_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RADIUS;
@@ -30,6 +38,11 @@ const IN_PROGRESS_STATUSES = new Set([
* newest task overall if all are terminal). Per-task progress lives
* on each row.
*
+ * Renders only its ``Toast`` card: App.tsx mounts the shared
+ * ``ToastViewport`` and places this card at the bottom of the stack
+ * (and moves the whole stack left while the workflow Preview drawer is
+ * open).
+ *
* Dismissal: the header X dismisses every visible task at once
* (mirrors the GDrive panel close — keeps the surface tidy without
* per-row controls). The chevron collapses the row list.
@@ -40,10 +53,6 @@ export default function UploadToast() {
const { t } = useTranslation();
const dispatch = useDispatch();
const uploadTasks = useSelector(selectUploadTasks);
- // The workflow Preview drawer occupies the right edge; shift the toast to
- // the bottom-left while it's open so it stays visible without covering the
- // drawer's attach/send controls.
- const previewOpen = useSelector(selectWorkflowPreviewOpen);
const visibleTasks = uploadTasks.filter((task) => !task.dismissed);
if (visibleTasks.length === 0) return null;
@@ -64,80 +73,62 @@ export default function UploadToast() {
};
return (
- e.stopPropagation()}
- role="status"
- aria-live="polite"
- aria-atomic="false"
- >
+
+
+ {headerLabel}
+
+ setCollapsed((prev) => !prev)}
+ aria-label={
+ collapsed
+ ? t('modals.uploadDoc.progress.expandDetails')
+ : t('modals.uploadDoc.progress.collapseDetails')
+ }
+ >
+
+
+
+
+
+
+
+
-
- {headerLabel}
-
-
-
setCollapsed((prev) => !prev)}
- aria-label={
- collapsed
- ? t('modals.uploadDoc.progress.expandDetails')
- : t('modals.uploadDoc.progress.collapseDetails')
- }
- className="text-black opacity-70 hover:bg-transparent hover:opacity-100 dark:text-white dark:hover:bg-transparent"
- >
-
-
-
-
-
-
-
-
-
-
-
- {visibleTasks.map((task) => (
-
- ))}
-
-
+
+ {visibleTasks.map((task) => (
+
+ ))}
+
-
+
);
}
@@ -154,91 +145,80 @@ function UploadRow({
const progressOffset = PROGRESS_CIRCUMFERENCE * (1 - rawProgress / 100);
return (
-
-
-
-
+ {task.fileName}}
+ meta={
+ task.status === 'training' && task.stage
+ ? t(`modals.uploadDoc.progress.${task.stage}`)
+ : undefined
+ }
+ >
+ {showProgress && (
+ // Determinate ring: ToastStatus "pending" is an indeterminate
+ // spinner, and each row reports its own percentage.
+
- {task.fileName}
-
- {task.status === 'training' && task.stage && (
-
- {t(`modals.uploadDoc.progress.${task.stage}`)}
-
- )}
-
-
-
- {showProgress && (
-
-
-
-
- )}
-
- {task.status === 'completed' && (
-
- )}
-
- {task.status === 'failed' && (
-
- )}
-
-
+
+ )}
+
+ {task.status === 'completed' && (
+
+ )}
+
+ {task.status === 'failed' && (
+
+ )}
+
{task.status === 'failed' &&
(task.tokenLimitReached || task.errorMessage) && (
-
+
{task.tokenLimitReached
? t('modals.uploadDoc.progress.tokenLimit')
: task.errorMessage}
-
+
)}
-
+ >
);
}
diff --git a/frontend/src/components/WikiViewer.tsx b/frontend/src/components/WikiViewer.tsx
index 6ef4181d..47942cb7 100644
--- a/frontend/src/components/WikiViewer.tsx
+++ b/frontend/src/components/WikiViewer.tsx
@@ -1,22 +1,22 @@
-import { BookOpen } from 'lucide-react';
+import { ArrowLeft, BookOpen } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
+import { markdownHeadings } from '@/lib/markdown';
+import { cn } from '@/lib/utils';
+
import userService from '../api/services/userService';
-import ArrowLeft from '../assets/arrow-left.svg';
import { selectToken } from '../preferences/preferenceSlice';
+import { formatRelative } from '../utils/dateTimeUtils';
import { decodeJwtPayload } from '../utils/jwtUtils';
import { Button } from './ui/button';
+import { IconButton } from './ui/icon-button';
+import { Textarea } from './ui/textarea';
import SkeletonLoader from './SkeletonLoader';
-import {
- WikiPageNode,
- formatRelativeTime,
- provenanceKey,
- saveWikiPage,
-} from './wikiViewerUtils';
+import { WikiPageNode, provenanceKey, saveWikiPage } from './wikiViewerUtils';
interface WikiViewerProps {
docId: string;
@@ -28,15 +28,7 @@ interface WikiViewerProps {
}
const markdownComponents = {
- h1: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
- h2: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
- h3: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
+ ...markdownHeadings,
p: ({ children }: { children?: React.ReactNode }) => (
{children}
),
@@ -47,17 +39,14 @@ const markdownComponents = {
{children}
),
a: ({ children, href }: { children?: React.ReactNode; href?: string }) => (
-
- {children}
-
+
+
+ {children}
+
+
),
code: ({ children }: { children?: React.ReactNode }) => (
-
+
{children}
),
@@ -194,7 +183,7 @@ const WikiViewer: React.FC = ({
const renderStamp = () => {
if (!page) return null;
const who = provenanceLabel(page.updated_via, page.updated_by);
- const when = formatRelativeTime(page.updated_at);
+ const when = formatRelative(page.updated_at);
const parts = [t('settings.sources.wiki.stamp.editedBy', { who })];
if (when) parts.push(when);
if (page.version != null) {
@@ -210,29 +199,24 @@ const WikiViewer: React.FC = ({
return (
-
-
-
+ label={t('settings.sources.backToAll')}
+ icon={ArrowLeft}
+ side="bottom"
+ />
{sourceName}
{headerAction ?
{headerAction}
: null}
-
-
+
+
{t('settings.sources.wiki.livingTitle')}
@@ -259,11 +243,12 @@ const WikiViewer: React.FC = ({
setSelectedPath(p.path)}
- className={`w-full truncate rounded-lg px-3 py-2 text-left text-sm transition-colors ${
+ className={cn(
+ 'w-full truncate rounded-lg px-3 py-2 text-left text-sm transition-colors',
selectedPath === p.path
- ? 'bg-muted text-foreground dark:bg-accent'
- : 'text-muted-foreground hover:bg-muted dark:hover:bg-accent'
- }`}
+ ? 'bg-accent text-foreground'
+ : 'text-muted-foreground hover:bg-accent',
+ )}
title={p.path}
>
{p.title || p.path}
@@ -304,11 +289,12 @@ const WikiViewer: React.FC = ({
{isEditing ? (