💡 feat: add DB-backed admin insights (#14898)

* feat: add Mongo-backed admin insights

* feat: gate insights with environment variable

* fix: tighten insights access and activity metrics

* fix: preserve insights date selections

* perf: parallelize insights search aggregation

* test: wait for MCP conflict recovery

* test: satisfy strict MCP recovery typing

* fix: disable insights pagination while loading

* fix: localize insights range shortcuts

* fix: bound insights search input
This commit is contained in:
Ravi Kumar L 2026-08-18 13:51:51 +02:00 committed by GitHub
parent 389cfebea1
commit 006e421cd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 3447 additions and 51 deletions

View file

@ -0,0 +1,746 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertCircle, Info, Search } from 'lucide-react';
import { Button, Input, Spinner, TooltipAnchor, useMediaQuery } from '@librechat/client';
import {
INSIGHTS_MAX_RANGE_DAYS,
INSIGHTS_SEARCH_MAX_LENGTH,
INSIGHTS_SEARCH_MIN_LENGTH,
SystemRoles,
} from 'librechat-data-provider';
import type {
InsightsRange,
TInsightsChurnedUser,
TInsightsConversation,
TInsightsParams,
TInsightsUser,
} from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import { useGetStartupConfig, useInsightsAccessQuery, useInsightsQuery } from '~/data-provider';
import { useAuthContext, useDocumentTitle, useLocalize } from '~/hooks';
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
import { LocalizedDateRangePicker } from '~/components/ui';
import { getRollingDateRange } from './dateRange';
import { cn } from '~/utils';
type ShortcutRange = Exclude<InsightsRange, 'custom'>;
type Localize = ReturnType<typeof useLocalize>;
type SparklinePoint = { date: string; value: number };
type CustomDateRange = { startDate: Date; endDate: Date };
type KpiCardData = {
id: 'conversations' | 'users' | 'messages' | 'tokens';
title: string;
value: number;
sparkline: SparklinePoint[];
};
const ranges: Array<{ value: ShortcutRange; labelKey: TranslationKeys; days: number }> = [
{ value: '24h', labelKey: 'com_insights_range_24_hours', days: 1 },
{ value: '7d', labelKey: 'com_insights_range_7_days', days: 7 },
{ value: '30d', labelKey: 'com_insights_range_30_days', days: 30 },
];
const dateRangeSelectionDelayMs = 350;
const searchDelayMs = 350;
function formatValue(value: number, locale: string) {
return new Intl.NumberFormat(locale, {
notation: 'compact',
maximumFractionDigits: 1,
}).format(value);
}
function formatExactValue(value: number, locale: string) {
return new Intl.NumberFormat(locale).format(value);
}
function formatDate(value: string, locale: string) {
return new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(value));
}
function formatRecentChatDate(value: string, locale: string) {
const date = new Date(value);
const now = new Date();
const elapsedMinutes = Math.floor((now.getTime() - date.getTime()) / 60_000);
const relativeTime = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' });
if (elapsedMinutes < 1) {
return relativeTime.format(0, 'minute');
}
if (elapsedMinutes < 60) {
return relativeTime.format(-elapsedMinutes, 'minute');
}
if (elapsedMinutes < 24 * 60) {
return relativeTime.format(-Math.floor(elapsedMinutes / 60), 'hour');
}
if (elapsedMinutes < 7 * 24 * 60) {
return relativeTime.format(-Math.floor(elapsedMinutes / (24 * 60)), 'day');
}
return new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
}).format(date);
}
function displayUserName(name: string, localize: Localize) {
return name || localize('com_insights_unknown_user');
}
function responseStatus(error: unknown) {
return (error as { response?: { status?: number } } | undefined)?.response?.status;
}
function getShortcutDateRange(range: ShortcutRange) {
const days = ranges.find((item) => item.value === range)?.days ?? 7;
return getRollingDateRange(new Date(), days);
}
function Panel({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<section
className={cn(
'min-w-0 rounded-lg border border-border-light bg-surface-primary p-5',
className,
)}
>
{children}
</section>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex min-h-40 items-center justify-center text-sm text-text-secondary">
{message}
</div>
);
}
function LoadingState({ message }: { message: string }) {
return (
<Panel className="flex min-h-52 flex-col items-center justify-center gap-3">
<Spinner className="size-7 text-text-secondary" />
<span className="text-sm text-text-secondary">{message}</span>
</Panel>
);
}
function Sparkline({
values,
label,
locale,
}: {
values: SparklinePoint[];
label: string;
locale: string;
}) {
const patternId = useId().replace(/:/g, '');
const [activeIndex, setActiveIndex] = useState<number>();
const points = useMemo(() => {
if (values.length < 2) {
return [];
}
const maximum = Math.max(1, ...values.map((point) => point.value));
return values.map((point, index) => ({
...point,
x: (index / (values.length - 1)) * 300,
y: 52 - (point.value / maximum) * 46,
}));
}, [values]);
if (points.length < 2) {
return <div className="h-14" />;
}
const line = points.map((point) => `${point.x},${point.y}`).join(' ');
const area = `M 0 56 L ${points.map((point) => `${point.x} ${point.y}`).join(' L ')} L 300 56 Z`;
const activePoint = activeIndex == null ? undefined : points[activeIndex];
const formatter = new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
return (
<div
className="relative mt-2 h-14 w-full text-status-info outline-none"
role="img"
tabIndex={0}
aria-label={label}
onFocus={() => setActiveIndex(points.length - 1)}
onBlur={() => setActiveIndex(undefined)}
onMouseLeave={() => setActiveIndex(undefined)}
onMouseMove={(event) => {
const bounds = event.currentTarget.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width));
setActiveIndex(Math.round(ratio * (points.length - 1)));
}}
>
<svg
className="h-full w-full overflow-visible"
viewBox="0 0 300 56"
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<pattern id={patternId} width="6" height="6" patternUnits="userSpaceOnUse">
<path
d="M-1 1 L1 -1 M0 6 L6 0 M5 7 L7 5"
stroke="currentColor"
strokeWidth="0.8"
opacity="0.22"
/>
</pattern>
</defs>
<path d={area} fill={`url(#${patternId})`} />
<polyline
points={line}
fill="none"
stroke="currentColor"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
</svg>
{activePoint && (
<>
<span
className="pointer-events-none absolute top-0 h-full w-px bg-border-medium"
style={{ left: `${(activePoint.x / 300) * 100}%` }}
/>
<span
className="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-status-info ring-2 ring-surface-primary"
style={{
left: `${(activePoint.x / 300) * 100}%`,
top: `${(activePoint.y / 56) * 100}%`,
}}
/>
<span
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-light bg-surface-primary px-2.5 py-1.5 text-xs text-text-primary shadow-lg"
style={{ left: `${Math.max(14, Math.min(86, (activePoint.x / 300) * 100))}%` }}
>
{formatter.format(new Date(activePoint.date))}{' '}
<strong>{formatExactValue(activePoint.value, locale)}</strong>
</span>
</>
)}
</div>
);
}
function KpiCard({ card, locale }: { card: KpiCardData; locale: string }) {
const localize = useLocalize();
return (
<Panel>
<h2 className="text-lg font-normal text-text-secondary">{card.title}</h2>
<div className="mt-3 text-4xl font-semibold tabular-nums leading-none text-text-primary">
{formatValue(card.value, locale)}
</div>
<Sparkline
values={card.sparkline}
label={localize('com_insights_sparkline_accessibility', { label: card.title })}
locale={locale}
/>
</Panel>
);
}
function TablePanel({ title, children }: { title: React.ReactNode; children: React.ReactNode }) {
return (
<Panel className="overflow-hidden">
<h2 className="mb-3 text-base font-semibold text-text-primary">{title}</h2>
{children}
</Panel>
);
}
function UserCell({ name, email, localize }: { name: string; email: string; localize: Localize }) {
return (
<div className="min-w-0">
<div className="truncate text-text-primary">{displayUserName(name, localize)}</div>
<div className="truncate text-xs text-text-secondary">{email}</div>
</div>
);
}
function TopUsersTable({
rows,
localize,
locale,
}: {
rows: TInsightsUser[];
localize: Localize;
locale: string;
}) {
return (
<TablePanel title={localize('com_insights_top_users')}>
{rows.length === 0 ? (
<EmptyState message={localize('com_insights_no_data')} />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[420px] text-left text-sm">
<thead className="border-b border-border-medium text-xs text-text-secondary">
<tr>
<th className="px-2 py-2 font-medium">{localize('com_insights_user')}</th>
<th className="px-2 py-2 text-right font-medium">
{localize('com_insights_messages')}
</th>
<th className="px-2 py-2 text-right font-medium">
{localize('com_insights_chats')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{rows.map((entry) => (
<tr key={entry.userId} className="hover:bg-surface-hover">
<td className="px-2 py-3">
<UserCell {...entry} localize={localize} />
</td>
<td className="px-2 py-3 text-right tabular-nums">
{formatExactValue(entry.messages, locale)}
</td>
<td className="px-2 py-3 text-right tabular-nums">
{formatExactValue(entry.conversations, locale)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TablePanel>
);
}
function ChurnedUsersTable({
rows,
localize,
locale,
}: {
rows: TInsightsChurnedUser[];
localize: Localize;
locale: string;
}) {
const definition = localize('com_insights_churned_users_definition');
return (
<TablePanel
title={
<span className="inline-flex items-center gap-1.5">
{localize('com_insights_churned_users')}
<TooltipAnchor
description={definition}
render={
<button
type="button"
aria-label={definition}
className="text-text-secondary hover:text-text-primary"
>
<Info className="size-4" aria-hidden="true" />
</button>
}
/>
</span>
}
>
{rows.length === 0 ? (
<EmptyState message={localize('com_insights_no_data')} />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] table-fixed text-left text-sm">
<thead className="border-b border-border-medium text-xs text-text-secondary">
<tr>
<th className="w-[34%] px-2 py-2 font-medium">{localize('com_insights_user')}</th>
<th className="w-[12%] px-2 py-2 text-right font-medium">
{localize('com_insights_messages')}
</th>
<th className="w-[12%] px-2 py-2 text-right font-medium">
{localize('com_insights_chats')}
</th>
<th className="w-[21%] px-2 py-2 text-right font-medium">
{localize('com_insights_first_seen')}
</th>
<th className="w-[21%] px-2 py-2 text-right font-medium">
{localize('com_insights_last_seen')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{rows.map((entry) => (
<tr key={entry.userId} className="hover:bg-surface-hover">
<td className="overflow-hidden px-2 py-3">
<UserCell {...entry} localize={localize} />
</td>
<td className="px-2 py-3 text-right tabular-nums">
{formatExactValue(entry.messages, locale)}
</td>
<td className="px-2 py-3 text-right tabular-nums">
{formatExactValue(entry.conversations, locale)}
</td>
<td className="whitespace-nowrap px-2 py-3 text-right text-text-secondary">
{formatDate(entry.firstSeen, locale)}
</td>
<td className="whitespace-nowrap px-2 py-3 text-right text-text-secondary">
{formatDate(entry.lastSeen, locale)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TablePanel>
);
}
function LatestConversations({
rows,
search,
activeSearch,
page,
pages,
isFetching,
setPage,
setSearch,
localize,
locale,
}: {
rows: TInsightsConversation[];
search: string;
activeSearch: string;
page: number;
pages: number;
isFetching: boolean;
setPage: React.Dispatch<React.SetStateAction<number>>;
setSearch: React.Dispatch<React.SetStateAction<string>>;
localize: Localize;
locale: string;
}) {
return (
<Panel className="overflow-hidden">
<div className="mb-3 flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold">
{localize('com_insights_latest_conversations')}
</h2>
{isFetching && <Spinner className="size-4 text-text-secondary" />}
</div>
<div className="relative w-full sm:max-w-md">
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-text-secondary"
aria-hidden="true"
/>
<Input
aria-label={localize('com_insights_search_placeholder')}
className="pl-9"
maxLength={INSIGHTS_SEARCH_MAX_LENGTH}
onChange={(event) => setSearch(event.target.value)}
placeholder={localize('com_insights_search_placeholder')}
value={search}
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] table-fixed text-left text-sm">
<thead className="border-b border-border-medium text-xs text-text-secondary">
<tr>
<th className="w-[120px] px-2 py-2 font-medium">{localize('com_insights_date')}</th>
<th className="w-[192px] px-2 py-2 font-medium">{localize('com_insights_user')}</th>
<th className="px-2 py-2 font-medium">{localize('com_insights_first_message')}</th>
<th className="w-20 px-2 py-2 text-right font-medium">
{localize('com_insights_messages')}
</th>
<th className="w-20 px-2 py-2 text-right font-medium">
{localize('com_insights_total_tokens')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{rows.map((conversation) => (
<tr
key={`${conversation.conversationId}:${conversation.userId}`}
className="hover:bg-surface-hover"
>
<td className="whitespace-nowrap px-2 py-3 text-text-secondary">
{formatRecentChatDate(conversation.date, locale)}
</td>
<td className="px-2 py-3">
<UserCell {...conversation} localize={localize} />
</td>
<td className="max-w-xl px-2 py-3">
<span className="line-clamp-2">
{conversation.firstMessage || localize('com_insights_no_message')}
</span>
</td>
<td className="px-2 py-3 text-right tabular-nums">
{formatExactValue(conversation.messages, locale)}
</td>
<td className="px-2 py-3 text-right tabular-nums">
{formatValue(conversation.totalTokens, locale)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{rows.length === 0 && (
<EmptyState
message={
activeSearch
? localize('com_insights_no_search_results')
: localize('com_insights_no_data')
}
/>
)}
<div className="mt-3 flex items-center justify-between gap-3 border-t border-border-light pt-3 text-sm text-text-secondary">
<span>{localize('com_insights_page_of', { page, pages })}</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={isFetching || page <= 1}
onClick={() => setPage((value) => Math.max(1, value - 1))}
>
{localize('com_ui_prev')}
</Button>
<Button
variant="outline"
size="sm"
disabled={isFetching || page >= pages}
onClick={() => setPage((value) => value + 1)}
>
{localize('com_ui_next')}
</Button>
</div>
</div>
</Panel>
);
}
export default function InsightsView() {
const localize = useLocalize();
const { i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en';
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const { user } = useAuthContext();
const { data: startupConfig, isLoading: configLoading } = useGetStartupConfig();
const [range, setRange] = useState<ShortcutRange>('7d');
const [customDateRange, setCustomDateRange] = useState<CustomDateRange>();
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const dateRangeSelectionTimeout = useRef<number>();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const insightsFeatureEnabled = startupConfig?.insightsEnabled === true;
const shouldCheckAccess = user?.role === SystemRoles.ADMIN && insightsFeatureEnabled;
const access = useInsightsAccessQuery(user?.id, {
enabled: shouldCheckAccess,
});
const isAllowed = insightsFeatureEnabled && access.data?.access === true;
const insightsParams = useMemo<TInsightsParams>(() => {
const params: TInsightsParams = { page, pageSize: 10, search, timeZone };
if (customDateRange) {
return {
...params,
range: 'custom',
fromTimestamp: customDateRange.startDate.toISOString(),
toTimestamp: customDateRange.endDate.toISOString(),
};
}
return { ...params, range };
}, [customDateRange, page, range, search, timeZone]);
const displayDateRange = useMemo(
() => customDateRange ?? getShortcutDateRange(range),
[customDateRange, range],
);
const insights = useInsightsQuery(insightsParams, { enabled: isAllowed });
const data = insights.data;
useDocumentTitle(`${localize('com_insights_title')} | LibreChat`);
useEffect(
() => () => {
if (dateRangeSelectionTimeout.current != null) {
window.clearTimeout(dateRangeSelectionTimeout.current);
}
},
[],
);
useEffect(() => {
const trimmedSearch = searchInput.trim().slice(0, INSIGHTS_SEARCH_MAX_LENGTH);
const nextSearch = trimmedSearch.length >= INSIGHTS_SEARCH_MIN_LENGTH ? trimmedSearch : '';
if (nextSearch === search) {
return;
}
const timeout = window.setTimeout(() => {
setSearch(nextSearch);
setPage(1);
}, searchDelayMs);
return () => window.clearTimeout(timeout);
}, [search, searchInput]);
const kpiCards = useMemo<KpiCardData[]>(() => {
if (!data) {
return [];
}
return [
{
id: 'conversations',
title: localize('com_insights_total_conversations'),
value: data.summary.totalConversations,
sparkline: data.daily.map((row) => ({ date: row.date, value: row.conversations })),
},
{
id: 'users',
title: localize('com_insights_total_users'),
value: data.summary.totalUsers,
sparkline: data.daily.map((row) => ({ date: row.date, value: row.users })),
},
{
id: 'messages',
title: localize('com_insights_messages'),
value: data.summary.totalMessages,
sparkline: data.daily.map((row) => ({ date: row.date, value: row.messages })),
},
{
id: 'tokens',
title: localize('com_insights_total_tokens'),
value: data.summary.totalTokens,
sparkline: data.daily.map((row) => ({ date: row.date, value: row.totalTokens })),
},
];
}, [data, localize]);
const handleSelectDateRange = (startDate: Date, endDate: Date) => {
if (dateRangeSelectionTimeout.current != null) {
window.clearTimeout(dateRangeSelectionTimeout.current);
}
dateRangeSelectionTimeout.current = window.setTimeout(() => {
setCustomDateRange({ startDate: new Date(startDate), endDate: new Date(endDate) });
setPage(1);
dateRangeSelectionTimeout.current = undefined;
}, dateRangeSelectionDelayMs);
};
if (configLoading || (shouldCheckAccess && access.isLoading)) {
return (
<div className="h-full w-full bg-presentation p-4">
<LoadingState message={localize('com_insights_loading')} />
</div>
);
}
const accessStatus = responseStatus(access.error);
if (access.isError && accessStatus !== 403 && accessStatus !== 404) {
return (
<div className="h-full w-full bg-presentation p-4">
<Panel className="flex items-center gap-2">
<AlertCircle className="size-4 text-status-error" />
<span className="text-sm">{localize('com_insights_load_error')}</span>
</Panel>
</div>
);
}
if (!isAllowed) {
return <Navigate to="/c/new" replace />;
}
return (
<div className="flex h-full w-full min-w-0 flex-col bg-presentation text-text-primary">
<header className="z-20 flex min-h-14 w-full flex-shrink-0 flex-col gap-3 border-b border-border-light bg-presentation px-4 py-3 sm:px-5 md:flex-row md:items-center md:justify-between md:px-6 lg:px-8">
<div className="flex min-w-0 items-center gap-3">
{isSmallScreen && <OpenSidebar />}
<h1 className="text-base font-semibold">{localize('com_insights_title')}</h1>
</div>
<div className="flex max-w-full flex-wrap items-center gap-2 md:flex-nowrap">
<div className="inline-flex rounded-lg border border-border-light p-0.5">
{ranges.map((item) => (
<Button
key={item.value}
size="sm"
variant="ghost"
aria-pressed={!customDateRange && range === item.value}
className={cn(
'h-8 rounded-md px-3',
!customDateRange && range === item.value && 'bg-surface-active-alt',
)}
onClick={() => {
if (dateRangeSelectionTimeout.current != null) {
window.clearTimeout(dateRangeSelectionTimeout.current);
dateRangeSelectionTimeout.current = undefined;
}
setRange(item.value);
setCustomDateRange(undefined);
setPage(1);
}}
>
{localize(item.labelKey)}
</Button>
))}
</div>
<div className="w-full min-w-0 sm:w-[340px]">
<LocalizedDateRangePicker
endDate={displayDateRange.endDate}
futureDatesDisabled
labels={{
apply: localize('com_ui_done'),
cancel: localize('com_ui_cancel'),
endDate: localize('com_insights_end_date'),
invalidRange: localize('com_insights_invalid_date_range', {
days: INSIGHTS_MAX_RANGE_DAYS,
}),
startDate: localize('com_insights_start_date'),
}}
locale={locale}
maxRangeLength={INSIGHTS_MAX_RANGE_DAYS}
onSelectDateRange={handleSelectDateRange}
placeholder={localize('com_insights_date_range_placeholder')}
startDate={displayDateRange.startDate}
/>
</div>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
<div className="flex w-full min-w-0 flex-col gap-5 px-4 py-4 sm:px-5 md:px-6 lg:px-8">
{insights.isLoading && <LoadingState message={localize('com_insights_loading')} />}
{insights.isError && (
<Panel className="flex items-center gap-2">
<AlertCircle className="size-4 text-status-error" />
<span className="text-sm">{localize('com_insights_load_error')}</span>
</Panel>
)}
{data && (
<>
<div className="grid w-full grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-3">
{kpiCards.map((card) => (
<KpiCard key={card.id} card={card} locale={locale} />
))}
</div>
<div className="grid w-full grid-cols-[repeat(auto-fit,minmax(min(100%,580px),1fr))] gap-3">
<TopUsersTable rows={data.topUsers} localize={localize} locale={locale} />
<ChurnedUsersTable rows={data.churnedUsers} localize={localize} locale={locale} />
</div>
<LatestConversations
rows={data.latest.conversations}
search={searchInput}
activeSearch={search}
page={data.latest.page}
pages={data.latest.pages}
isFetching={insights.isFetching}
setPage={setPage}
setSearch={setSearchInput}
localize={localize}
locale={locale}
/>
</>
)}
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,12 @@
import { getRollingDateRange } from './dateRange';
describe('Insights date ranges', () => {
it('builds shortcut bounds from the same rolling duration as the query', () => {
const endDate = new Date(2026, 7, 31, 15, 30);
const range = getRollingDateRange(endDate, 30);
expect(range.endDate.getTime() - range.startDate.getTime()).toBe(30 * 24 * 60 * 60 * 1000);
expect(range.endDate).toEqual(endDate);
});
});

View file

@ -0,0 +1,7 @@
const millisecondsPerDay = 24 * 60 * 60 * 1000;
export function getRollingDateRange(endDate: Date, days: number) {
const rangeEnd = new Date(endDate);
const rangeStart = new Date(endDate.getTime() - days * millisecondsPerDay);
return { startDate: rangeStart, endDate: rangeEnd };
}

View file

@ -0,0 +1 @@
export { default } from './InsightsView';

View file

@ -1,6 +1,7 @@
import { memo, useCallback, lazy, Suspense } from 'react';
import { useRecoilValue } from 'recoil';
import { SquarePen } from 'lucide-react';
import { useLocation } from 'react-router-dom';
import { Skeleton, Sidebar, Button, TooltipAnchor } from '@librechat/client';
import type { NavLink } from '~/common';
import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts';
@ -58,6 +59,8 @@ const NavIconButton = memo(function NavIconButton({
setActive,
onExpand,
onCollapse,
onNavigate,
onLeaveInsights,
}: {
link: NavLink;
isActive: boolean;
@ -65,6 +68,8 @@ const NavIconButton = memo(function NavIconButton({
setActive: (id: string) => void;
onExpand?: () => void;
onCollapse?: () => void;
onNavigate?: () => void;
onLeaveInsights?: () => void;
}) {
const localize = useLocalize();
@ -72,6 +77,7 @@ const NavIconButton = memo(function NavIconButton({
(e: React.MouseEvent<HTMLButtonElement>) => {
if (link.onClick) {
link.onClick(e);
onNavigate?.();
return;
}
if (isActive && expanded) {
@ -83,9 +89,11 @@ const NavIconButton = memo(function NavIconButton({
}
if (!expanded) {
onExpand?.();
} else {
onLeaveInsights?.();
}
},
[link, isActive, setActive, expanded, onExpand, onCollapse],
[link, isActive, setActive, expanded, onExpand, onCollapse, onNavigate, onLeaveInsights],
);
return (
@ -117,15 +125,21 @@ function ExpandedPanel({
expanded = true,
onCollapse,
onExpand,
onNavigate,
onLeaveInsights,
}: {
links: NavLink[];
expanded?: boolean;
onCollapse?: () => void;
onExpand?: () => void;
onNavigate?: () => void;
onLeaveInsights?: () => void;
}) {
const localize = useLocalize();
const location = useLocation();
const { active, setActive } = useActivePanel();
const effectiveActive = resolveActivePanel(active, links);
const isInsightsRoute = location.pathname.startsWith('/insights');
const toggleLabel = expanded ? 'com_nav_close_sidebar' : 'com_nav_open_sidebar';
const toggleClick = expanded ? onCollapse : onExpand;
@ -160,11 +174,17 @@ function ExpandedPanel({
<NavIconButton
key={link.id}
link={link}
isActive={link.id === effectiveActive}
isActive={
link.id === 'insights'
? isInsightsRoute
: !isInsightsRoute && link.id === effectiveActive
}
expanded={expanded ?? true}
setActive={setActive}
onExpand={onExpand}
onCollapse={onCollapse}
onNavigate={onNavigate}
onLeaveInsights={isInsightsRoute ? onLeaveInsights : undefined}
/>
))}
</div>

View file

@ -9,6 +9,7 @@ function Sidebar({
expanded,
onCollapse,
onExpand,
onLeaveInsights,
onResizeStart,
onResizeKeyboard,
}: {
@ -16,6 +17,7 @@ function Sidebar({
expanded: boolean;
onCollapse: () => void;
onExpand: () => void;
onLeaveInsights: () => void;
onResizeStart: (e: React.MouseEvent) => void;
onResizeKeyboard: (direction: 'shrink' | 'grow') => void;
}) {
@ -27,6 +29,7 @@ function Sidebar({
expanded={expanded}
onCollapse={onCollapse}
onExpand={onExpand}
onLeaveInsights={onLeaveInsights}
/>
<nav
className={cn(

View file

@ -1,5 +1,6 @@
import { useCallback, useState, useEffect, useRef, memo } from 'react';
import { useForm } from 'react-hook-form';
import { useLocation, useNavigate } from 'react-router-dom';
import type { ReactNode } from 'react';
import type { ChatFormValues } from '~/common';
import {
@ -44,6 +45,8 @@ function SidebarChatProvider({ children }: { children: ReactNode }) {
function UnifiedSidebar() {
const localize = useLocalize();
const location = useLocation();
const navigate = useNavigate();
const { isSmallScreen, expanded } = useSidebarState();
const { setSidebarOpen } = useSidebarToggle();
const [sidebarWidth, setSidebarWidth] = useState(getInitialWidth);
@ -51,6 +54,8 @@ function UnifiedSidebar() {
const resizeHandlers = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null);
const links = useUnifiedSidebarLinks();
const isInsightsRoute = location.pathname.startsWith('/insights');
const panelExpanded = expanded && !isInsightsRoute;
const handleCollapse = useCallback(
(afterSlide?: () => void) => {
@ -63,6 +68,17 @@ function UnifiedSidebar() {
setSidebarOpen(true);
}, [setSidebarOpen]);
const handleLeaveInsights = useCallback(() => {
navigate('/c/new');
}, [navigate]);
const handlePanelExpand = useCallback(() => {
if (isInsightsRoute) {
handleLeaveInsights();
}
handleExpand();
}, [handleExpand, handleLeaveInsights, isInsightsRoute]);
const handleResizeStart = useCallback(() => {
setIsResizing(true);
document.body.style.userSelect = 'none';
@ -163,14 +179,24 @@ function UnifiedSidebar() {
>
<SidebarChatProvider>
<ActivePanelProvider>
<MobileHeader links={links} expanded={expanded} onClose={handleCollapse} />
<MobileHeader
links={links}
expanded={expanded}
onClose={handleCollapse}
onLeaveInsights={handleLeaveInsights}
routeActiveId={isInsightsRoute ? 'insights' : undefined}
/>
<nav
id="chat-history-nav"
className="min-h-0 flex-1 overflow-hidden bg-surface-primary-alt"
>
<SidePanelNav links={links} />
</nav>
<MobileShortcutTargets links={links} />
<MobileShortcutTargets
links={links}
onLeaveInsights={handleLeaveInsights}
routeActiveId={isInsightsRoute ? 'insights' : undefined}
/>
<MobileBottomBar links={links} onNewChat={handleCollapse} />
</ActivePanelProvider>
</SidebarChatProvider>
@ -184,9 +210,9 @@ function UnifiedSidebar() {
<aside
className="relative flex h-full flex-shrink-0 overflow-hidden"
style={{
width: expanded ? sidebarWidth : COLLAPSED_WIDTH,
minWidth: expanded ? EXPANDED_MIN : COLLAPSED_WIDTH,
maxWidth: expanded ? '40%' : COLLAPSED_WIDTH,
width: panelExpanded ? sidebarWidth : COLLAPSED_WIDTH,
minWidth: panelExpanded ? EXPANDED_MIN : COLLAPSED_WIDTH,
maxWidth: panelExpanded ? '40%' : COLLAPSED_WIDTH,
transition: isResizing
? 'none'
: `width ${TRANSITION_MS}ms ${EASING}, min-width ${TRANSITION_MS}ms ${EASING}, max-width ${TRANSITION_MS}ms ${EASING}`,
@ -195,9 +221,10 @@ function UnifiedSidebar() {
>
<Sidebar
links={links}
expanded={expanded}
expanded={panelExpanded}
onCollapse={handleCollapse}
onExpand={handleExpand}
onExpand={handlePanelExpand}
onLeaveInsights={handleLeaveInsights}
onResizeStart={handleResizeStart}
onResizeKeyboard={handleResizeKeyboard}
/>

View file

@ -1,11 +1,13 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import '@testing-library/jest-dom/extend-expect';
import { MemoryRouter } from 'react-router-dom';
import { MessagesSquare, NotebookPen } from 'lucide-react';
import { render, fireEvent, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { MutableSnapshot } from 'recoil';
import { ActivePanelProvider, DEFAULT_PANEL } from '~/Providers/ActivePanelContext';
import type { NavLink } from '~/common';
import { ActivePanelProvider, DEFAULT_PANEL } from '~/Providers';
const mockNewConversation = jest.fn();
const mockClearMessagesCache = jest.fn();
@ -86,7 +88,7 @@ jest.mock('~/components/Nav/AccountSettings', () => ({
import ExpandedPanel from '../ExpandedPanel';
import store from '~/store';
const createLinks = () => [
const createLinks = (): NavLink[] => [
{
title: 'com_ui_chat_history' as const,
icon: MessagesSquare,
@ -103,14 +105,18 @@ const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { r
function renderPanel({
expanded = true,
links = createLinks(),
onCollapse = jest.fn(),
onExpand = jest.fn(),
onNavigate,
initialPanel = DEFAULT_PANEL,
initializeState,
}: {
expanded?: boolean;
links?: NavLink[];
onCollapse?: jest.Mock;
onExpand?: jest.Mock;
onNavigate?: jest.Mock;
initialPanel?: string;
initializeState?: (snapshot: MutableSnapshot) => void;
} = {}) {
@ -119,18 +125,21 @@ function renderPanel({
}
const result = render(
<QueryClientProvider client={createQueryClient()}>
<RecoilRoot initializeState={initializeState}>
<ActivePanelProvider>
<ExpandedPanel
links={createLinks()}
expanded={expanded}
onCollapse={onCollapse}
onExpand={onExpand}
/>
</ActivePanelProvider>
</RecoilRoot>
</QueryClientProvider>,
<MemoryRouter>
<QueryClientProvider client={createQueryClient()}>
<RecoilRoot initializeState={initializeState}>
<ActivePanelProvider>
<ExpandedPanel
links={links}
expanded={expanded}
onCollapse={onCollapse}
onExpand={onExpand}
onNavigate={onNavigate}
/>
</ActivePanelProvider>
</RecoilRoot>
</QueryClientProvider>
</MemoryRouter>,
);
return { ...result, onCollapse, onExpand };
@ -172,6 +181,26 @@ describe('ExpandedPanel', () => {
expect(onExpand).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('side:active-panel')).toBe('prompts');
});
it('notifies mobile navigation after a route link is selected', () => {
const onClick = jest.fn();
const onNavigate = jest.fn();
const links = [
...createLinks(),
{
title: 'com_insights_navigation' as const,
icon: NotebookPen,
id: 'insights',
onClick,
},
];
renderPanel({ links, onNavigate });
fireEvent.click(screen.getByRole('button', { name: 'com_insights_navigation' }));
expect(onClick).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledTimes(1);
});
});
describe('NewChatButton panel switch', () => {

View file

@ -20,10 +20,14 @@ function Header({
links,
expanded,
onClose,
onLeaveInsights,
routeActiveId,
}: {
links: NavLink[];
expanded: boolean;
onClose: () => void;
onLeaveInsights?: () => void;
routeActiveId?: string;
}) {
const localize = useLocalize();
const toggleSidebarAriaKey = useShortcutAriaKey('toggleSidebar');
@ -64,7 +68,12 @@ function Header({
>
<Sidebar className="icon-md" aria-hidden="true" />
</Button>
<Switcher links={links} />
<Switcher
links={links}
onLeaveInsights={onLeaveInsights}
onNavigate={onClose}
routeActiveId={routeActiveId}
/>
<Suspense fallback={<Skeleton className="size-9 rounded-lg" />}>
<AccountSettings collapsed />
</Suspense>

View file

@ -16,9 +16,17 @@ import { useActivePanel, resolveActivePanel } from '~/Providers';
* Only available links render, so a shortcut for a panel this endpoint does not
* offer still correctly does nothing.
*/
function ShortcutTargets({ links }: { links: NavLink[] }) {
function ShortcutTargets({
links,
onLeaveInsights,
routeActiveId,
}: {
links: NavLink[];
onLeaveInsights?: () => void;
routeActiveId?: string;
}) {
const { active, setActive } = useActivePanel();
const activeId = resolveActivePanel(active, links);
const activeId = routeActiveId ?? resolveActivePanel(active, links);
return (
<>
@ -30,7 +38,16 @@ function ShortcutTargets({ links }: { links: NavLink[] }) {
tabIndex={-1}
data-testid={`nav-panel-${link.id}`}
aria-pressed={link.id === activeId}
onClick={() => setActive(link.id)}
onClick={() => {
if (link.onClick) {
link.onClick();
return;
}
setActive(link.id);
if (routeActiveId) {
onLeaveInsights?.();
}
}}
/>
))}
</>

View file

@ -13,13 +13,23 @@ import { cn } from '~/utils';
* you leave it. Replaces the icon rail's ten unlabelled glyphs with labelled
* rows, and costs no standing width.
*/
function Switcher({ links }: { links: NavLink[] }) {
function Switcher({
links,
onLeaveInsights,
onNavigate,
routeActiveId,
}: {
links: NavLink[];
onLeaveInsights?: () => void;
onNavigate?: () => void;
routeActiveId?: string;
}) {
const localize = useLocalize();
const menuId = useId();
const [isOpen, setIsOpen] = useState(false);
const { active, setActive } = useActivePanel();
const activeId = resolveActivePanel(active, links);
const activeId = routeActiveId ?? resolveActivePanel(active, links);
const activeLink = links.find((link) => link.id === activeId);
const items = useMemo<t.MenuItemProps[]>(
@ -30,9 +40,19 @@ function Switcher({ links }: { links: NavLink[] }) {
ariaChecked: link.id === activeId,
className: link.id === activeId ? 'bg-surface-active-alt' : undefined,
icon: <link.icon className="size-5 text-text-primary" aria-hidden="true" />,
onClick: () => setActive(link.id),
onClick: () => {
if (link.onClick) {
link.onClick();
onNavigate?.();
return;
}
setActive(link.id);
if (routeActiveId) {
onLeaveInsights?.();
}
},
})),
[links, activeId, localize, setActive],
[links, activeId, localize, onLeaveInsights, onNavigate, routeActiveId, setActive],
);
if (!activeLink) {

View file

@ -1,5 +1,5 @@
import { MessagesSquare, NotebookPen } from 'lucide-react';
import { render, screen, fireEvent } from '@testing-library/react';
import { BarChart3, MessagesSquare, NotebookPen } from 'lucide-react';
import type { NavLink } from '~/common';
import { ActivePanelProvider } from '~/Providers/ActivePanelContext';
import ShortcutTargets from '../ShortcutTargets';
@ -9,10 +9,22 @@ const links = [
{ id: 'prompts', title: 'com_ui_prompts', icon: NotebookPen, Component: () => null },
] as unknown as NavLink[];
const renderTargets = () =>
const renderTargets = ({
targetLinks = links,
onLeaveInsights,
routeActiveId,
}: {
targetLinks?: NavLink[];
onLeaveInsights?: () => void;
routeActiveId?: string;
} = {}) =>
render(
<ActivePanelProvider>
<ShortcutTargets links={links} />
<ShortcutTargets
links={targetLinks}
onLeaveInsights={onLeaveInsights}
routeActiveId={routeActiveId}
/>
</ActivePanelProvider>,
);
@ -47,6 +59,35 @@ describe('ShortcutTargets', () => {
expect(localStorage.getItem('side:active-panel')).toBe('prompts');
});
it('uses a route link callback without changing the active panel', () => {
const onClick = jest.fn();
const targetLinks = [
...links,
{
id: 'insights',
title: 'com_insights_navigation',
icon: BarChart3,
onClick,
},
] as unknown as NavLink[];
renderTargets({ targetLinks });
fireEvent.click(screen.getByTestId('nav-panel-insights'));
expect(onClick).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('side:active-panel')).toBeNull();
});
it('leaves a route when a panel target is selected', () => {
const onLeaveInsights = jest.fn();
renderTargets({ onLeaveInsights, routeActiveId: 'insights' });
fireEvent.click(screen.getByTestId('nav-panel-prompts'));
expect(onLeaveInsights).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('side:active-panel')).toBe('prompts');
});
it('offers no target for a panel this endpoint does not have', () => {
renderTargets();

View file

@ -0,0 +1,207 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@librechat/client';
import * as Popover from '@radix-ui/react-popover';
import { CalendarDays, ChevronDown } from 'lucide-react';
type DateRangePickerLabels = {
apply: string;
cancel: string;
endDate: string;
invalidRange: string;
startDate: string;
};
type LocalizedDateRangePickerProps = {
endDate?: Date;
futureDatesDisabled?: boolean;
labels: DateRangePickerLabels;
locale: string;
maxRangeLength?: number;
onSelectDateRange: (startDate: Date, endDate: Date) => void;
placeholder: string;
startDate?: Date;
};
const millisecondsPerDay = 24 * 60 * 60 * 1000;
function toDateInputValue(date?: Date) {
if (!date) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function parseDateInput(value: string, endOfDay = false) {
const [year, month, day] = value.split('-').map(Number);
if (!year || !month || !day) {
return undefined;
}
return endOfDay
? new Date(year, month - 1, day, 23, 59, 59, 999)
: new Date(year, month - 1, day);
}
function addCalendarDays(value: string, days: number) {
const date = parseDateInput(value);
if (!date) {
return '';
}
date.setDate(date.getDate() + days);
return toDateInputValue(date);
}
function calendarDayDifference(startDate: Date, endDate: Date) {
const start = Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
const end = Date.UTC(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
return Math.round((end - start) / millisecondsPerDay);
}
function earliestDate(...dates: Array<string | undefined>) {
return dates.filter((date): date is string => Boolean(date)).sort()[0];
}
export default function LocalizedDateRangePicker({
endDate,
futureDatesDisabled = false,
labels,
locale,
maxRangeLength = -1,
onSelectDateRange,
placeholder,
startDate,
}: LocalizedDateRangePickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [pendingStartDate, setPendingStartDate] = useState(() => toDateInputValue(startDate));
const [pendingEndDate, setPendingEndDate] = useState(() => toDateInputValue(endDate));
const today = toDateInputValue(new Date());
useEffect(() => {
if (!isOpen) {
setPendingStartDate(toDateInputValue(startDate));
setPendingEndDate(toDateInputValue(endDate));
}
}, [endDate, isOpen, startDate]);
const formattedRange = useMemo(() => {
if (!startDate || !endDate) {
return placeholder;
}
const formatter = new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'short',
year: 'numeric',
});
return `${formatter.format(startDate)} - ${formatter.format(endDate)}`;
}, [endDate, locale, placeholder, startDate]);
const parsedStartDate = parseDateInput(pendingStartDate);
const parsedEndDate = parseDateInput(pendingEndDate, true);
const rangeLength =
parsedStartDate && parsedEndDate
? calendarDayDifference(parsedStartDate, parsedEndDate)
: undefined;
const matchesSelectedRange =
pendingStartDate === toDateInputValue(startDate) &&
pendingEndDate === toDateInputValue(endDate);
const selectedRangeDuration =
startDate && endDate ? endDate.getTime() - startDate.getTime() : undefined;
const selectedRangeIsValid =
matchesSelectedRange &&
selectedRangeDuration != null &&
selectedRangeDuration >= 0 &&
(maxRangeLength < 0 || selectedRangeDuration <= maxRangeLength * millisecondsPerDay);
const isValidRange =
rangeLength != null &&
rangeLength >= 0 &&
(maxRangeLength < 0 || rangeLength < maxRangeLength || selectedRangeIsValid) &&
(!futureDatesDisabled || pendingEndDate <= today);
const latestEndDate =
maxRangeLength > 0 && pendingStartDate
? addCalendarDays(pendingStartDate, maxRangeLength - 1)
: undefined;
const endDateMax = earliestDate(latestEndDate, futureDatesDisabled ? today : undefined);
const handleOpenChange = (open: boolean) => {
if (open) {
setPendingStartDate(toDateInputValue(startDate));
setPendingEndDate(toDateInputValue(endDate));
}
setIsOpen(open);
};
const handleApply = () => {
if (!parsedStartDate || !parsedEndDate || !isValidRange) {
return;
}
if (matchesSelectedRange) {
setIsOpen(false);
return;
}
onSelectDateRange(parsedStartDate, parsedEndDate);
setIsOpen(false);
};
return (
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
<Popover.Trigger asChild>
<Button variant="outline" className="w-full min-w-0 justify-start px-3">
<CalendarDays className="size-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-left">{formattedRange}</span>
<ChevronDown className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
</Button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="end"
sideOffset={6}
className="z-50 w-[320px] max-w-[calc(100vw-2rem)] rounded-lg border border-border-light bg-surface-primary p-4 text-text-primary shadow-lg"
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm">{labels.startDate}</span>
<input
type="date"
lang={locale}
max={futureDatesDisabled ? today : undefined}
value={pendingStartDate}
onChange={(event) => setPendingStartDate(event.target.value)}
className="h-9 min-w-0 rounded-md border border-border-medium bg-surface-primary px-2 text-sm text-text-primary outline-none focus:border-border-heavy"
/>
</label>
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm">{labels.endDate}</span>
<input
type="date"
lang={locale}
min={pendingStartDate || undefined}
max={endDateMax}
value={pendingEndDate}
onChange={(event) => setPendingEndDate(event.target.value)}
className="h-9 min-w-0 rounded-md border border-border-medium bg-surface-primary px-2 text-sm text-text-primary outline-none focus:border-border-heavy"
/>
</label>
</div>
{!isValidRange && pendingStartDate && pendingEndDate && (
<span className="text-sm text-status-error">{labels.invalidRange}</span>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setIsOpen(false)}>
{labels.cancel}
</Button>
<Button size="sm" disabled={!isValidRange} onClick={handleApply}>
{labels.apply}
</Button>
</div>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View file

@ -0,0 +1,92 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react';
import LocalizedDateRangePicker from '../LocalizedDateRangePicker';
const labels = {
apply: 'Apply',
cancel: 'Cancel',
endDate: 'End date',
invalidRange: 'Invalid range',
startDate: 'Start date',
};
const germanDateFormatter = new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
const triggerName = `${germanDateFormatter.format(new Date(2026, 2, 4))} - ${germanDateFormatter.format(new Date(2026, 2, 9))}`;
function renderPicker(onSelectDateRange = jest.fn()) {
render(
<LocalizedDateRangePicker
endDate={new Date(2026, 2, 9, 17)}
futureDatesDisabled={false}
labels={labels}
locale="de-DE"
maxRangeLength={10}
onSelectDateRange={onSelectDateRange}
placeholder="Select dates"
startDate={new Date(2026, 2, 4, 9)}
/>,
);
}
describe('LocalizedDateRangePicker', () => {
it('formats the range with the active locale and exposes localized labels', () => {
renderPicker();
fireEvent.click(screen.getByRole('button', { name: triggerName }));
expect(screen.getByLabelText(labels.startDate)).toHaveAttribute('lang', 'de-DE');
expect(screen.getByLabelText(labels.endDate)).toHaveAttribute('lang', 'de-DE');
expect(screen.getByRole('button', { name: labels.apply })).toBeInTheDocument();
expect(screen.getByRole('button', { name: labels.cancel })).toBeInTheDocument();
});
it('validates the range and returns complete local calendar days', () => {
const onSelectDateRange = jest.fn();
renderPicker(onSelectDateRange);
fireEvent.click(screen.getByRole('button', { name: triggerName }));
fireEvent.change(screen.getByLabelText(labels.endDate), { target: { value: '2026-03-14' } });
expect(screen.getByText(labels.invalidRange)).toBeInTheDocument();
expect(screen.getByRole('button', { name: labels.apply })).toBeDisabled();
fireEvent.change(screen.getByLabelText(labels.endDate), { target: { value: '2026-03-10' } });
fireEvent.click(screen.getByRole('button', { name: labels.apply }));
const [startDate, endDate] = onSelectDateRange.mock.calls[0] as [Date, Date];
expect([startDate.getFullYear(), startDate.getMonth(), startDate.getDate()]).toEqual([
2026, 2, 4,
]);
expect([startDate.getHours(), startDate.getMinutes()]).toEqual([0, 0]);
expect([endDate.getFullYear(), endDate.getMonth(), endDate.getDate()]).toEqual([2026, 2, 10]);
expect([endDate.getHours(), endDate.getMinutes(), endDate.getSeconds()]).toEqual([23, 59, 59]);
});
it('does not convert an unchanged rolling shortcut to a custom range', () => {
const onSelectDateRange = jest.fn();
const startDate = new Date(2026, 7, 1, 15, 30);
const endDate = new Date(2026, 7, 31, 15, 30);
const rangeName = `${germanDateFormatter.format(startDate)} - ${germanDateFormatter.format(endDate)}`;
render(
<LocalizedDateRangePicker
endDate={endDate}
futureDatesDisabled={false}
labels={labels}
locale="de-DE"
maxRangeLength={30}
onSelectDateRange={onSelectDateRange}
placeholder="Select dates"
startDate={startDate}
/>,
);
fireEvent.click(screen.getByRole('button', { name: rangeName }));
fireEvent.click(screen.getByRole('button', { name: labels.apply }));
expect(onSelectDateRange).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: labels.apply })).not.toBeInTheDocument();
});
});

View file

@ -4,4 +4,5 @@ export { default as PanelFooter } from './PanelFooter';
export { default as PanelContent } from './PanelContent';
export { default as TermsAndConditionsModal } from './TermsAndConditionsModal';
export { default as AdminSettingsDialog } from './AdminSettingsDialog';
export { default as LocalizedDateRangePicker } from './LocalizedDateRangePicker';
export type { PermissionConfig, AdminSettingsDialogProps } from './AdminSettingsDialog';

View file

@ -0,0 +1 @@
export * from './queries';

View file

@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import type {
TInsightsAccessResponse,
TInsightsParams,
TInsightsResponse,
} from 'librechat-data-provider';
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
export const useInsightsQuery = (
params: TInsightsParams,
config?: UseQueryOptions<TInsightsResponse>,
): QueryObserverResult<TInsightsResponse> =>
useQuery<TInsightsResponse>([QueryKeys.insights, params], () => dataService.getInsights(params), {
keepPreviousData: true,
refetchOnWindowFocus: false,
...config,
});
export const useInsightsAccessQuery = (
userId?: string,
config?: UseQueryOptions<TInsightsAccessResponse>,
): QueryObserverResult<TInsightsAccessResponse> =>
useQuery<TInsightsAccessResponse>(
[QueryKeys.insightsAccess, userId ?? 'anonymous'],
() => dataService.getInsightsAccess(),
{
retry: false,
staleTime: 60_000,
refetchOnWindowFocus: false,
...config,
},
);

View file

@ -4,6 +4,7 @@ export * from './Endpoints';
export * from './Skills';
export * from './Files';
export * from './Langfuse';
export * from './Insights';
/* Memories */
export * from './Memories';
export * from './Messages';

View file

@ -1,18 +1,23 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { MessagesSquare } from 'lucide-react';
import { BarChart3, MessagesSquare } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
import { getConfigDefaults, getEndpointField } from 'librechat-data-provider';
import { getConfigDefaults, getEndpointField, SystemRoles } from 'librechat-data-provider';
import type { TEndpointsConfig } from 'librechat-data-provider';
import type { NavLink } from '~/common';
import { useGetEndpointsQuery, useGetStartupConfig, useInsightsAccessQuery } from '~/data-provider';
import ConversationsSection from '~/components/UnifiedSidebar/ConversationsSection';
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import useSideNavLinks from '~/hooks/Nav/useSideNavLinks';
import { useAuthContext } from '~/hooks';
import store from '~/store';
const defaultInterface = getConfigDefaults().interface;
export default function useUnifiedSidebarLinks() {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuthContext();
/** Selector instead of the full conversation atom: the links only depend on
* the endpoint, so parameter edits and other conversation writes stay out. */
const endpoint = useRecoilValue(store.conversationEndpointByIndex(0)) ?? undefined;
@ -23,6 +28,10 @@ export default function useUnifiedSidebarLinks() {
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
);
const insightsFeatureEnabled = startupConfig?.insightsEnabled === true;
const { data: insightsAccess } = useInsightsAccessQuery(user?.id, {
enabled: user?.role === SystemRoles.ADMIN && insightsFeatureEnabled,
});
const endpointType = useMemo(
() => getEndpointField(endpointsConfig, endpoint, 'type'),
@ -59,8 +68,27 @@ export default function useUnifiedSidebarLinks() {
Component: ConversationsSection,
};
return [conversationLink, ...sideNavLinks];
}, [sideNavLinks]);
if (!insightsFeatureEnabled || insightsAccess?.access !== true) {
return [conversationLink, ...sideNavLinks];
}
const insightsLink: NavLink = {
title: 'com_insights_navigation',
label: '',
icon: BarChart3,
id: 'insights',
onClick: () => {
if (!location.pathname.startsWith('/insights')) {
navigate('/insights');
}
},
};
const mcpIndex = sideNavLinks.findIndex((link) => link.id === 'mcp-builder');
const nextLinks = [...sideNavLinks];
nextLinks.splice(mcpIndex >= 0 ? mcpIndex + 1 : nextLinks.length, 0, insightsLink);
return [conversationLink, ...nextLinks];
}, [insightsAccess?.access, insightsFeatureEnabled, location.pathname, navigate, sideNavLinks]);
return links;
}

View file

@ -2307,5 +2307,37 @@
"com_ui_zoom_in": "Zoom in",
"com_ui_zoom_level": "Zoom level",
"com_ui_zoom_out": "Zoom out",
"com_user_message": "You"
"com_user_message": "You",
"com_insights_title": "Insights",
"com_insights_navigation": "Insights",
"com_insights_loading": "Loading insights",
"com_insights_load_error": "Insights could not be loaded. Try again.",
"com_insights_total_users": "Unique users",
"com_insights_total_conversations": "Conversations",
"com_insights_churned_users": "Churned users",
"com_insights_churned_users_definition": "Users whose latest message sent to an assistant reached 28 days old during the selected date range.",
"com_insights_top_users": "Top users",
"com_insights_no_data": "No data yet",
"com_insights_user": "User",
"com_insights_unknown_user": "Unknown user",
"com_insights_chats": "Chats",
"com_insights_search_placeholder": "Search by chat id or user",
"com_insights_no_search_results": "No chats match this search.",
"com_insights_latest_conversations": "Latest conversations",
"com_insights_date": "Date",
"com_insights_first_message": "First message",
"com_insights_first_seen": "First seen",
"com_insights_last_seen": "Last seen",
"com_insights_messages": "Messages",
"com_insights_total_tokens": "Tokens",
"com_insights_no_message": "No message",
"com_insights_page_of": "Page {{page}} of {{pages}}",
"com_insights_range_24_hours": "24h",
"com_insights_range_7_days": "7d",
"com_insights_range_30_days": "30d",
"com_insights_date_range_placeholder": "Select dates",
"com_insights_start_date": "Start date",
"com_insights_end_date": "End date",
"com_insights_invalid_date_range": "Choose an end date after the start date, within {{days}} days.",
"com_insights_sparkline_accessibility": "{{label}} over time"
}

View file

@ -41,6 +41,11 @@ const loadSkillsView = () =>
Component: m.default,
}));
const loadInsightsView = () =>
import('~/components/Insights').then((m) => ({
Component: m.default,
}));
const loadProjectsView = () =>
import('~/components/Projects').then((m) => ({
Component: m.ProjectsView,
@ -151,6 +156,10 @@ export const router = createBrowserRouter(
path: 'skills',
lazy: loadSkillsView,
},
{
path: 'insights',
lazy: loadInsightsView,
},
{
path: 'skills/new',
lazy: loadSkillsView,