From 748f0d487fa1503413f1429ca9db87a195390e8b Mon Sep 17 00:00:00 2001 From: Christophe Blefari Date: Fri, 5 Jun 2026 11:11:06 +0200 Subject: [PATCH 1/6] New usage page --- apps/backend/src/queries/project.queries.ts | 18 + apps/backend/src/queries/usage.queries.ts | 72 ++- apps/backend/src/trpc/project.routes.ts | 9 +- apps/backend/src/trpc/usage.routes.ts | 4 + apps/backend/src/types/project.ts | 2 +- apps/backend/src/types/usage.ts | 6 + apps/backend/src/utils/date.ts | 4 +- .../src/components/recommendation-card.tsx | 6 +- .../src/components/settings-search-index.ts | 8 +- .../settings/chats-replay-columns.tsx | 48 +- .../settings/chats-replay-date-filter.tsx | 152 ------ .../components/settings/chats-replay-page.tsx | 164 ++----- .../settings/chats-replay-panel.tsx | 60 +-- .../settings/chats-replay-table.tsx | 81 ++-- .../settings/chats-replay-toolbar.tsx | 450 ------------------ .../components/settings/usage-chart-card.tsx | 34 +- .../src/components/settings/usage-filters.tsx | 233 +++++++-- .../components/settings/usage-route-search.ts | 60 +++ .../src/components/sidebar-settings-nav.tsx | 5 - .../components/tool-calls/display-chart.tsx | 145 +++++- apps/frontend/src/components/ui/chart.tsx | 44 +- .../src/components/ui/settings-card.tsx | 14 +- apps/frontend/src/routeTree.gen.ts | 67 +-- .../_sidebar-layout._chat-layout.$chatId.tsx | 8 +- .../_sidebar-layout.settings.chats-replay.tsx | 12 - ...r-layout.settings.usage.replay.$chatId.tsx | 30 ++ .../routes/_sidebar-layout.settings.usage.tsx | 361 ++++++++------ apps/shared/src/chart-builder.tsx | 52 +- apps/shared/src/types.ts | 6 + eslint.config.js | 7 + 30 files changed, 1043 insertions(+), 1119 deletions(-) delete mode 100644 apps/frontend/src/components/settings/chats-replay-date-filter.tsx delete mode 100644 apps/frontend/src/components/settings/chats-replay-toolbar.tsx create mode 100644 apps/frontend/src/components/settings/usage-route-search.ts delete mode 100644 apps/frontend/src/routes/_sidebar-layout.settings.chats-replay.tsx create mode 100644 apps/frontend/src/routes/_sidebar-layout.settings.usage.replay.$chatId.tsx diff --git a/apps/backend/src/queries/project.queries.ts b/apps/backend/src/queries/project.queries.ts index cd8237776..913abf2eb 100644 --- a/apps/backend/src/queries/project.queries.ts +++ b/apps/backend/src/queries/project.queries.ts @@ -465,6 +465,24 @@ export const listProjectChats = async ( if (expr) { filterWhereClauses.push(expr); } + } else if (filter.id === 'feedback') { + const exprs: SQL[] = []; + for (const v of filter.values) { + if (v === 'noVotes') { + const e = and(eq(upvotesExpr, 0), eq(downvotesExpr, 0)); + if (e) { + exprs.push(e); + } + } else if (v === 'upvotes') { + exprs.push(gt(upvotesExpr, 0)); + } else if (v === 'downvotes') { + exprs.push(gt(downvotesExpr, 0)); + } + } + const expr = or(...exprs); + if (expr) { + filterWhereClauses.push(expr); + } } } diff --git a/apps/backend/src/queries/usage.queries.ts b/apps/backend/src/queries/usage.queries.ts index d135f05d2..8bed7dd2c 100644 --- a/apps/backend/src/queries/usage.queries.ts +++ b/apps/backend/src/queries/usage.queries.ts @@ -1,11 +1,11 @@ import type { LlmProvider } from '@nao/shared/types'; -import { and, eq, isNotNull, SQL, sql, SQLWrapper, sum } from 'drizzle-orm'; +import { and, eq, isNotNull, or, SQL, sql, SQLWrapper, sum } from 'drizzle-orm'; import { LLM_PROVIDERS } from '../agents/providers'; import s from '../db/abstractSchema'; import { db } from '../db/db'; import dbConfig, { Dialect } from '../db/dbConfig'; -import type { Granularity, UsageFilter, UsageRecord } from '../types/usage'; +import type { Granularity, TotalUsageRecord, UsageFilter, UsageRecord } from '../types/usage'; import { fillMissingDates, getLookbackTimestamp } from '../utils/date'; import * as projectLlmConfigQueries from './project-llm-config.queries'; @@ -54,6 +54,28 @@ export async function createCostLookup(projectId: string) { return { table, joinCondition }; } +const MESSAGE_USAGE_PROVIDER_EXPR = sql`case + when ${s.chatMessage.role} = 'user' then ( + select next_message.llm_provider + from chat_message as next_message + where next_message.chat_id = ${s.chatMessage.chatId} + and next_message.role = 'assistant' + and next_message.llm_provider is not null + and next_message.created_at > ${s.chatMessage.createdAt} + and not exists ( + select 1 + from chat_message as next_user_message + where next_user_message.chat_id = ${s.chatMessage.chatId} + and next_user_message.role = 'user' + and next_user_message.created_at > ${s.chatMessage.createdAt} + and next_user_message.created_at < next_message.created_at + ) + order by next_message.created_at asc + limit 1 + ) + else ${s.chatMessage.llmProvider} +end`; + export const getMessagesUsage = async (projectId: string, filter: UsageFilter): Promise => { const { granularity, provider } = filter; const dateExpr = getDateExpr(s.chatMessage.createdAt, granularity); @@ -65,8 +87,9 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter): const whereConditions = [eq(s.chat.projectId, projectId), lookbackFilter]; if (provider) { - whereConditions.push(eq(s.chatMessage.llmProvider, provider)); + whereConditions.push(sql`${MESSAGE_USAGE_PROVIDER_EXPR} = ${provider}`); } + addUserNameFilter(whereConditions, filter.userNames); const costLookup = await createCostLookup(projectId); @@ -91,6 +114,7 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter): }) .from(s.chatMessage) .innerJoin(s.chat, eq(s.chatMessage.chatId, s.chat.id)) + .innerJoin(s.user, eq(s.chat.userId, s.user.id)) .leftJoin(costLookup.table, costLookup.joinCondition) .where(and(...whereConditions)) .groupBy(dateExpr); @@ -123,6 +147,36 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter): ); }; +export const getTotalUsage = async (projectId: string, filter: UsageFilter): Promise => { + const { granularity, provider } = filter; + const lookbackTs = getLookbackTimestamp(granularity); + const lookbackFilter = + dbConfig.dialect === Dialect.Postgres + ? sql`${s.chatMessage.createdAt} >= ${new Date(lookbackTs).toISOString()}` + : sql`${s.chatMessage.createdAt} >= ${lookbackTs}`; + + const whereConditions = [eq(s.chat.projectId, projectId), lookbackFilter]; + if (provider) { + whereConditions.push(sql`${MESSAGE_USAGE_PROVIDER_EXPR} = ${provider}`); + } + addUserNameFilter(whereConditions, filter.userNames); + + const rows = await db + .select({ + totalMessages: sql`count(distinct case when ${s.chatMessage.role} = 'user' then ${s.chatMessage.id} end)`, + uniqueUsers: sql`count(distinct ${s.chat.userId})`, + }) + .from(s.chatMessage) + .innerJoin(s.chat, eq(s.chatMessage.chatId, s.chat.id)) + .innerJoin(s.user, eq(s.chat.userId, s.user.id)) + .where(and(...whereConditions)); + + return { + totalMessages: rows[0].totalMessages, + uniqueUsers: rows[0].uniqueUsers, + }; +}; + export const getUsedProviders = async (projectId: string): Promise => { const rows = await db .selectDistinct({ provider: s.chatMessage.llmProvider }) @@ -134,6 +188,18 @@ export const getUsedProviders = async (projectId: string): Promise row.provider).filter((p): p is LlmProvider => p !== null); }; +function addUserNameFilter(whereConditions: SQL[], userNames: string[] | undefined) { + const names = userNames?.filter(Boolean) ?? []; + if (names.length === 0) { + return; + } + + const expr = or(...names.map((name) => eq(s.user.name, name))); + if (expr) { + whereConditions.push(expr); + } +} + function getDateExpr(field: SQLWrapper, granularity: Granularity): SQL { if (dbConfig.dialect === Dialect.Postgres) { const format = sql.raw(`'${pgFormats[granularity]}'`); diff --git a/apps/backend/src/trpc/project.routes.ts b/apps/backend/src/trpc/project.routes.ts index e591b2222..693353434 100644 --- a/apps/backend/src/trpc/project.routes.ts +++ b/apps/backend/src/trpc/project.routes.ts @@ -837,7 +837,7 @@ export const projectRoutes = { filters: z .array( z.object({ - id: z.enum(['userName', 'userRole', 'toolState']), + id: z.enum(['userName', 'userRole', 'toolState', 'feedback']), values: z.array(z.string()).default([]), }), ) @@ -876,7 +876,12 @@ export const projectRoutes = { } const ownerName = ownerId ? await userQueries.getUserName(ownerId) : null; - return { ...chat, ownerId: ownerId ?? null, ownerName }; + return { + ...chat, + ownerId: ownerId ?? null, + ownerName, + chatOwnerId: ownerId ?? null, + }; }), getEnvVars: adminProtectedProcedure.query(async ({ ctx }) => { diff --git a/apps/backend/src/trpc/usage.routes.ts b/apps/backend/src/trpc/usage.routes.ts index 30e1f9501..60a046bac 100644 --- a/apps/backend/src/trpc/usage.routes.ts +++ b/apps/backend/src/trpc/usage.routes.ts @@ -7,6 +7,10 @@ export const usageRoutes = { return usageQueries.getMessagesUsage(ctx.project.id, input); }), + getTotalUsage: adminProtectedProcedure.input(usageFilterSchema).query(async ({ ctx, input }) => { + return usageQueries.getTotalUsage(ctx.project.id, input); + }), + getUsedProviders: adminProtectedProcedure.query(async ({ ctx }) => { return usageQueries.getUsedProviders(ctx.project.id); }), diff --git a/apps/backend/src/types/project.ts b/apps/backend/src/types/project.ts index b2f28efdc..d487dd1f9 100644 --- a/apps/backend/src/types/project.ts +++ b/apps/backend/src/types/project.ts @@ -8,7 +8,7 @@ export interface UserWithRole { messagingProviderCode: string | null; } -export type ProjectChatsFacetKey = 'userName' | 'userRole' | 'toolState'; +export type ProjectChatsFacetKey = 'userName' | 'userRole' | 'toolState' | 'feedback'; export interface ListProjectChatsResponse { chats: ProjectChatListItem[]; diff --git a/apps/backend/src/types/usage.ts b/apps/backend/src/types/usage.ts index d1627af9e..54df418ff 100644 --- a/apps/backend/src/types/usage.ts +++ b/apps/backend/src/types/usage.ts @@ -8,6 +8,7 @@ export type Granularity = z.infer; export const usageFilterSchema = z.object({ granularity: granularitySchema.default('day'), provider: llmProviderSchema.optional(), + userNames: z.array(z.string()).optional(), }); export type UsageFilter = z.infer; @@ -31,3 +32,8 @@ export interface UsageRecord { outputCost: number; totalCost: number; } + +export interface TotalUsageRecord { + totalMessages: number; + uniqueUsers: number; +} diff --git a/apps/backend/src/utils/date.ts b/apps/backend/src/utils/date.ts index 091b5f651..dc54a0a3e 100644 --- a/apps/backend/src/utils/date.ts +++ b/apps/backend/src/utils/date.ts @@ -11,8 +11,8 @@ export function isValidIsoDateString(s: string): boolean { export const lookbackPeriods = { hour: 24, - day: 30, - month: 12, + day: 15, + month: 6, }; export function getLookbackTimestamp(granularity: Granularity): number { diff --git a/apps/frontend/src/components/recommendation-card.tsx b/apps/frontend/src/components/recommendation-card.tsx index 4b2629922..6ff7b0cbe 100644 --- a/apps/frontend/src/components/recommendation-card.tsx +++ b/apps/frontend/src/components/recommendation-card.tsx @@ -19,6 +19,7 @@ import type { inferRouterOutputs } from '@trpc/server'; import type { TrpcRouter } from '@nao/backend/trpc'; import { RecommendationDiffPanel } from '@/components/side-panel/recommendation-diff-panel'; import { RecommendationManualFixPanel } from '@/components/side-panel/recommendation-manual-fix-panel'; +import { DEFAULT_USAGE_SEARCH } from '@/components/settings/usage-route-search'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -226,8 +227,9 @@ export function RecommendationCard({ {chatIds.slice(0, 5).map((chatId) => ( {chatId.slice(0, 8)} diff --git a/apps/frontend/src/components/settings-search-index.ts b/apps/frontend/src/components/settings-search-index.ts index 40b0d209c..ab1f161dc 100644 --- a/apps/frontend/src/components/settings-search-index.ts +++ b/apps/frontend/src/components/settings-search-index.ts @@ -458,14 +458,14 @@ export const settingsSearchIndex: SettingsSearchEntry[] = [ adminOnly: true, }, - // ── Chats Replay ───────────────────────────────────────── + // ── Usage & Costs > Chats Replay ───────────────────────── { - page: '/settings/chats-replay', - pageLabel: 'Chats Replay', + page: '/settings/usage', + pageLabel: 'Usage & Costs', title: 'Chats Replay', description: 'Replay and review past chat conversations.', keywords: ['history', 'conversation', 'replay', 'review'], - adminOrContextAdmin: true, + adminOnly: true, }, // ── Context Recommendations ────────────────────────────── diff --git a/apps/frontend/src/components/settings/chats-replay-columns.tsx b/apps/frontend/src/components/settings/chats-replay-columns.tsx index 772ca7054..d04a9a666 100644 --- a/apps/frontend/src/components/settings/chats-replay-columns.tsx +++ b/apps/frontend/src/components/settings/chats-replay-columns.tsx @@ -1,22 +1,11 @@ -import { CircleAlert, Eye, ThumbsDown, ThumbsUp } from 'lucide-react'; +import { CircleAlert, ThumbsDown, ThumbsUp } from 'lucide-react'; import { differenceInDays, format, isToday, isYesterday } from 'date-fns'; -import { USER_ROLE_LABELS } from '@nao/shared/types'; import type { ColumnDef } from '@tanstack/react-table'; -import type { ProjectChatListItem, UserRole } from '@nao/shared/types'; +import type { ProjectChatListItem } from '@nao/shared/types'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; - -/** Render a project member role for display, leaving non-role values (e.g. "Former member") untouched. */ -export function formatUserRole(value: string): string { - return USER_ROLE_LABELS[value as UserRole] ?? value; -} - -export function getChatsReplayColumns(args: { - onOpenChat: (chat: ProjectChatListItem) => void; -}): ColumnDef[] { - const { onOpenChat } = args; +export function getChatsReplayColumns(): ColumnDef[] { return [ { accessorKey: 'updatedAt', @@ -27,30 +16,22 @@ export function getChatsReplayColumns(args: { return {formatted}; }, }, - { - accessorKey: 'userName', - header: 'User', - }, - { - accessorKey: 'userRole', - header: 'Role', - cell: ({ getValue }) => { - const value = getValue(); - return value ? formatUserRole(value) : '—'; - }, - }, { accessorKey: 'title', header: 'Title', cell: ({ getValue }) => { const value = getValue() ?? ''; return ( - + {value} ); }, }, + { + accessorKey: 'userName', + header: 'User', + }, { accessorKey: 'numberOfMessages', header: 'Messages' }, { accessorKey: 'totalTokens', header: 'Tokens' }, { @@ -107,19 +88,6 @@ export function getChatsReplayColumns(args: { ); }, }, - { - id: 'actions', - header: '', - enableHiding: false, - cell: ({ row }) => { - const chat = row.original; - return ( - - ); - }, - }, ]; } diff --git a/apps/frontend/src/components/settings/chats-replay-date-filter.tsx b/apps/frontend/src/components/settings/chats-replay-date-filter.tsx deleted file mode 100644 index aa7a47b6b..000000000 --- a/apps/frontend/src/components/settings/chats-replay-date-filter.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useState } from 'react'; -import DatePicker from 'react-datepicker'; -import { Calendar } from 'lucide-react'; - -import type { UpdatedAtFilter } from '@nao/shared/types'; -import { cn, toLocalDateString } from '@/lib/utils'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; - -import 'react-datepicker/dist/react-datepicker.css'; - -type ChatsReplayDateFilterProps = { - value: UpdatedAtFilter | undefined; - onChange: (value: UpdatedAtFilter | undefined) => void; -}; - -export function ChatsReplayDateFilter({ value, onChange }: ChatsReplayDateFilterProps) { - const [mode, setMode] = useState<'single' | 'range'>(value?.mode ?? 'single'); - const [singleDate, setSingleDate] = useState( - value?.mode === 'single' && value.value ? new Date(value.value + 'T12:00:00') : null, - ); - const [startDate, setStartDate] = useState( - value?.mode === 'range' && value.start ? new Date(value.start + 'T12:00:00') : null, - ); - const [endDate, setEndDate] = useState( - value?.mode === 'range' && value.end ? new Date(value.end + 'T12:00:00') : null, - ); - const [open, setOpen] = useState(false); - - const applyFilter = ( - single: Date | null, - start: Date | null, - end: Date | null, - currentMode: 'single' | 'range', - ) => { - if (currentMode === 'single') { - if (single) { - onChange({ mode: 'single', value: toLocalDateString(single) }); - } else { - onChange(undefined); - } - return; - } - if (start && end) { - onChange({ mode: 'range', start: toLocalDateString(start), end: toLocalDateString(end) }); - } else if (!start && !end) { - onChange(undefined); - } - }; - - const handleSingleChange = (date: Date | null) => { - setSingleDate(date); - applyFilter(date, null, null, 'single'); - }; - - const handleModeToggle = () => { - const nextMode = mode === 'single' ? 'range' : 'single'; - setMode(nextMode); - if (nextMode === 'single') { - applyFilter(singleDate, null, null, 'single'); - } else { - applyFilter(null, startDate, endDate, 'range'); - } - }; - - const clearFilter = () => { - setSingleDate(null); - setStartDate(null); - setEndDate(null); - onChange(undefined); - setOpen(false); - }; - - const hasActiveFilter = - (value?.mode === 'single' && value.value) || (value?.mode === 'range' && value.start && value.end); - - const label = - hasActiveFilter && value - ? value.mode === 'single' - ? value.value - : `${value.start} – ${value.end}` - : 'Last update'; - - return ( - - - - - -
- Last update -
-
- {mode === 'single' ? ( - - ) : ( - { - const [start, end] = range ?? [null, null]; - setStartDate(start); - setEndDate(end); - if (start && end) { - applyFilter(null, start, end, 'range'); - } else if (!start && !end) { - onChange(undefined); - } - }} - inline - popperProps={{ strategy: 'fixed' }} - calendarClassName='react-datepicker--no-shadow chats-replay-datepicker' - /> - )} -
-
- - -
-
-
- ); -} diff --git a/apps/frontend/src/components/settings/chats-replay-page.tsx b/apps/frontend/src/components/settings/chats-replay-page.tsx index b4f60e8a2..5b7167a89 100644 --- a/apps/frontend/src/components/settings/chats-replay-page.tsx +++ b/apps/frontend/src/components/settings/chats-replay-page.tsx @@ -1,79 +1,69 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { getRouteApi } from '@tanstack/react-router'; import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; -import type { ColumnFiltersState, PaginationState, SortingState, VisibilityState } from '@tanstack/react-table'; +import type { PaginationState, SortingState } from '@tanstack/react-table'; -import type { ProjectChatListItem, UpdatedAtFilter } from '@nao/shared/types'; +import type { ChatReplayFeedbackState, ChatReplayToolState } from '@nao/shared/types'; import { getChatsReplayColumns } from '@/components/settings/chats-replay-columns'; -import { ChatsReplayPanel } from '@/components/settings/chats-replay-panel'; import { ChatsReplayTable } from '@/components/settings/chats-replay-table'; -import { ChatsReplayToolbar } from '@/components/settings/chats-replay-toolbar'; -import { SettingsCard } from '@/components/ui/settings-card'; -import { cn } from '@/lib/utils'; import { trpc } from '@/main'; -const routeApi = getRouteApi('/_sidebar-layout/settings/chats-replay'); +type ChatsReplayPageProps = { + selectedUserNames: string[] | undefined; + selectedFeedbackStates: ChatReplayFeedbackState[] | undefined; + selectedToolStates: ChatReplayToolState[] | undefined; + onOpenChat: (chatId: string) => void; +}; -export function ChatsReplayPage() { +type ProjectChatsFilter = { + id: 'userName' | 'feedback' | 'toolState'; + values: string[]; +}; + +export function ChatsReplayPage({ + selectedUserNames, + selectedFeedbackStates, + selectedToolStates, + onOpenChat, +}: ChatsReplayPageProps) { const [sorting, setSorting] = useState([]); - const [globalFilter, setGlobalFilter] = useState(''); - const [columnFilters, setColumnFilters] = useState([]); - const [columnVisibility, setColumnVisibility] = useState({ - userRole: false, - title: false, - }); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 30, }); - const { chatId: deepLinkChatId } = routeApi.useSearch(); - const navigate = routeApi.useNavigate(); - const [selectedChat, setSelectedChat] = useState(null); - const [isPanelOpen, setIsPanelOpen] = useState(false); - - const deepLinkChatQuery = useQuery({ - ...trpc.project.getChatReplay.queryOptions({ chatId: deepLinkChatId ?? '' }), - enabled: !!deepLinkChatId, - }); + const columns = useMemo(() => getChatsReplayColumns(), []); - const openChatPanel = useCallback((chat: ProjectChatListItem) => { - setSelectedChat(chat); - setIsPanelOpen(true); - }, []); + useEffect(() => { + setPagination((current) => ({ ...current, pageIndex: 0 })); + }, [selectedFeedbackStates, selectedToolStates, selectedUserNames]); - const closeChatPanel = useCallback(() => { - setIsPanelOpen(false); - setSelectedChat(null); - if (deepLinkChatId) { - navigate({ search: (prev) => ({ ...prev, chatId: undefined }) }); + const queryInput = useMemo(() => { + const filters: ProjectChatsFilter[] = []; + if (selectedUserNames?.length) { + filters.push({ id: 'userName', values: selectedUserNames }); + } + if (selectedFeedbackStates?.length) { + filters.push({ id: 'feedback', values: selectedFeedbackStates }); + } + if (selectedToolStates?.length) { + filters.push({ id: 'toolState', values: selectedToolStates }); } - }, [deepLinkChatId, navigate]); - - const columns = useMemo(() => getChatsReplayColumns({ onOpenChat: openChatPanel }), [openChatPanel]); - const queryInput = useMemo(() => { - const filters = columnFilters - .map((f) => ({ id: f.id, values: (f.value as string[]) ?? [] })) - .filter( - (f): f is { id: 'userName' | 'userRole' | 'toolState'; values: string[] } => - (f.id === 'userName' || f.id === 'userRole' || f.id === 'toolState') && f.values.length > 0, - ); - const updatedAtFilter = columnFilters.find((f) => f.id === 'updatedAt')?.value as UpdatedAtFilter | undefined; - const hasValidDateFilter = - updatedAtFilter && - ((updatedAtFilter.mode === 'single' && updatedAtFilter.value) || - (updatedAtFilter.mode === 'range' && updatedAtFilter.start && updatedAtFilter.end)); return { page: pagination.pageIndex, pageSize: pagination.pageSize, - search: globalFilter || undefined, filters: filters.length ? filters : undefined, - updatedAtFilter: hasValidDateFilter ? updatedAtFilter : undefined, sorting: sorting.length ? sorting : undefined, }; - }, [columnFilters, globalFilter, pagination.pageIndex, pagination.pageSize, sorting]); + }, [ + pagination.pageIndex, + pagination.pageSize, + selectedFeedbackStates, + selectedToolStates, + selectedUserNames, + sorting, + ]); const projectChatsQuery = useQuery({ ...trpc.project.getProjectChats.queryOptions(queryInput), @@ -81,85 +71,25 @@ export function ChatsReplayPage() { }); const chats = projectChatsQuery.data?.chats ?? []; const total = projectChatsQuery.data?.total ?? 0; - const defaultToolStateFacet = { noToolsUsed: 0, toolsNoErrors: 0, toolsWithErrors: 0 }; - const facets = projectChatsQuery.data?.facets ?? { - userNames: [], - userNameCounts: {}, - userRoles: [], - userRoleCounts: {}, - toolState: defaultToolStateFacet, - }; const table = useReactTable({ data: chats, columns, - state: { sorting, columnFilters, globalFilter, pagination, columnVisibility }, + state: { sorting, pagination }, onSortingChange: setSorting, - onGlobalFilterChange: setGlobalFilter, - onColumnFiltersChange: setColumnFilters, - onColumnVisibilityChange: setColumnVisibility, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), manualPagination: true, manualSorting: true, - manualFiltering: true, rowCount: total, pageCount: Math.ceil(total / pagination.pageSize), }); - const selectedChatInfo = selectedChat - ? { - chatId: selectedChat.id, - chatOwnerId: selectedChat.userId, - userName: selectedChat.userName, - updatedAt: selectedChat.updatedAt, - feedbackCount: selectedChat.upvotes + selectedChat.downvotes, - feedbackText: selectedChat.feedbackText, - toolErrorCount: selectedChat.toolErrorCount, - } - : null; - const deepLinkChatInfo = deepLinkChatId - ? { - chatId: deepLinkChatId, - chatOwnerId: deepLinkChatQuery.data?.ownerId ?? '', - userName: deepLinkChatQuery.data?.ownerName ?? '—', - updatedAt: deepLinkChatQuery.data?.updatedAt ?? Date.now(), - feedbackCount: 0, - feedbackText: '', - toolErrorCount: 0, - } - : null; - const panelChatInfo = selectedChatInfo ?? deepLinkChatInfo; - const isPanelVisible = isPanelOpen || !!deepLinkChatId; - return ( -
- {!isPanelVisible ? ( -
-
-

Chats Replay

-

- Browse chats across the organization and replay them. -

-
- -
- - - -
-
-
- ) : ( - - )} +
+
+ onOpenChat(chat.id)} /> +
); } diff --git a/apps/frontend/src/components/settings/chats-replay-panel.tsx b/apps/frontend/src/components/settings/chats-replay-panel.tsx index 6c9d9fdc6..314fdc490 100644 --- a/apps/frontend/src/components/settings/chats-replay-panel.tsx +++ b/apps/frontend/src/components/settings/chats-replay-panel.tsx @@ -1,6 +1,6 @@ import { useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { X } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import { formatDate } from 'date-fns'; import { SidePanelProvider } from '@/contexts/side-panel'; @@ -18,25 +18,17 @@ import { trpc } from '@/main'; import { useSession } from '@/lib/auth-client'; type ChatsReplayPanelProps = { - chatInfo: { - chatId: string; - chatOwnerId: string; - userName: string; - updatedAt: number; - feedbackCount: number; - feedbackText: string; - toolErrorCount: number; - } | null; - onClose: () => void; + chatId: string; + onBack: () => void; }; -export function ChatsReplayPanel({ chatInfo, onClose }: ChatsReplayPanelProps) { +export function ChatsReplayPanel({ chatId, onBack }: ChatsReplayPanelProps) { const scrollContainerRef = useRef(null); const chatReplayQuery = useQuery( trpc.project.getChatReplay.queryOptions( - { chatId: chatInfo?.chatId ?? '' }, + { chatId }, { - enabled: !!chatInfo?.chatId, + enabled: !!chatId, }, ), ); @@ -63,19 +55,27 @@ export function ChatsReplayPanel({ chatInfo, onClose }: ChatsReplayPanelProps) { shouldCollapseSidebar: false, }); const { data: session } = useSession(); - const isOwner = session?.user?.id === chatInfo?.chatOwnerId; + const isOwner = session?.user?.id === chatReplayQuery.data?.chatOwnerId; + const title = chatReplayQuery.data?.title ?? 'Chat replay'; + const updatedAt = chatReplayQuery.data?.updatedAt; return (
-
-

Chat by {chatInfo?.userName ?? '—'}

- - {chatInfo?.updatedAt != null ? formatDate(new Date(chatInfo.updatedAt), 'yyyy-MM-dd') : '—'} - +
+ +
+

{title}

+ + {updatedAt != null ? formatDate(new Date(updatedAt), 'yyyy-MM-dd') : '—'} + +
- {chatInfo?.chatId && chatReplayQuery.data && ( + {chatReplayQuery.data && ( )} -
@@ -98,25 +95,18 @@ export function ChatsReplayPanel({ chatInfo, onClose }: ChatsReplayPanelProps) { rootClassName='flex-1 min-h-0' className='flex-1 min-h-0 overflow-hidden bg-background border p-0' > - {!chatInfo?.chatId ? ( -
- Select a chat to preview. -
- ) : chatReplayQuery.isLoading ? ( + {chatReplayQuery.isLoading ? (
Loading chat…
) : chatReplayQuery.isError ? (
Failed to load chat.
) : chatReplayQuery.data ? ( - - + + ({ table }: { table: Table }) { +type ChatsReplayTableProps = { + table: Table; + onRowClick: (chat: ProjectChatListItem) => void; +}; + +export function ChatsReplayTable({ table, onRowClick }: ChatsReplayTableProps) { const colSpan = table.getVisibleLeafColumns().length; + const edgeCellClassName = 'first:pl-4 first:md:pl-8 last:pr-4 last:md:pr-8'; return (
- - +
+ {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( -
- {header.id === 'actions' ? ( - Open chat - ) : ( - <> - - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - - - )} +
+ + {flexRender(header.column.columnDef.header, header.getContext())} + +
))} @@ -64,9 +65,13 @@ export function ChatsReplayTable({ table }: { table: Table }) { ) : ( table.getRowModel().rows.map((row) => ( - + onRowClick(row.original)} + className='!border-0 cursor-pointer hover:bg-muted/50' + > {row.getVisibleCells().map((cell) => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} @@ -77,14 +82,16 @@ export function ChatsReplayTable({ table }: { table: Table }) {
- table.setPageIndex(p)} - onPageSizeChange={(s) => table.setPageSize(s)} - /> +
+ table.setPageIndex(p)} + onPageSizeChange={(s) => table.setPageSize(s)} + /> +
); } diff --git a/apps/frontend/src/components/settings/chats-replay-toolbar.tsx b/apps/frontend/src/components/settings/chats-replay-toolbar.tsx deleted file mode 100644 index 0b45aad39..000000000 --- a/apps/frontend/src/components/settings/chats-replay-toolbar.tsx +++ /dev/null @@ -1,450 +0,0 @@ -import { Columns2, Filter, Users, X } from 'lucide-react'; -import { ChatsReplayDateFilter } from './chats-replay-date-filter'; -import { formatUserRole } from './chats-replay-columns'; -import type { ColumnFiltersState, Table } from '@tanstack/react-table'; - -import type { ProjectChatReplayFacets, UpdatedAtFilter } from '@nao/shared/types'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Input } from '@/components/ui/input'; -import { cn } from '@/lib/utils'; - -type ChatsReplayToolbarProps = { - globalFilter: string; - onGlobalFilterChange: (value: string) => void; - columnFilters: ColumnFiltersState; - onColumnFiltersChange: (next: ColumnFiltersState) => void; - facets: ProjectChatReplayFacets; - table: Table; -}; - -export function ChatsReplayToolbar({ - globalFilter, - onGlobalFilterChange, - columnFilters, - onColumnFiltersChange, - facets, - table, -}: ChatsReplayToolbarProps) { - const selectedUsers = - (columnFilters.find((f) => f.id === 'userName')?.value as string[] | undefined) ?? facets.userNames; - const someUsersUnchecked = selectedUsers.length < facets.userNames.length; - - const updatedAtFilter = columnFilters.find((f) => f.id === 'updatedAt')?.value as UpdatedAtFilter | undefined; - - const setUpdatedAtFilter = (value: UpdatedAtFilter | undefined) => { - const next = value - ? [...columnFilters.filter((f) => f.id !== 'updatedAt'), { id: 'updatedAt', value }] - : columnFilters.filter((f) => f.id !== 'updatedAt'); - onColumnFiltersChange(next); - }; - - const toolStateValues = [ - { value: 'noToolsUsed', label: 'No tools used', count: facets.toolState.noToolsUsed }, - { value: 'toolsNoErrors', label: 'Tools, no errors', count: facets.toolState.toolsNoErrors }, - { value: 'toolsWithErrors', label: 'Tools with errors', count: facets.toolState.toolsWithErrors }, - ].filter((o) => o.count > 0); - - const getAllValuesForFilterId = (id: string): string[] => { - if (id === 'userName') { - return facets.userNames; - } - if (id === 'userRole') { - return facets.userRoles; - } - if (id === 'toolState') { - return toolStateValues.map((o) => o.value); - } - return []; - }; - - const activeFilters = columnFilters.filter((f) => { - if (f.id === 'updatedAt') { - const v = f.value as UpdatedAtFilter | undefined; - return !!(v && ((v.mode === 'single' && v.value) || (v.mode === 'range' && v.start && v.end))); - } - const v = (f.value as string[]) ?? []; - const all = getAllValuesForFilterId(f.id); - return v.length > 0 && v.length < all.length; - }); - - const setSelectedUsers = (next: string[]) => { - const nextFilters = columnFilters.filter((f) => f.id !== 'userName'); - if (next.length > 0 && next.length < facets.userNames.length) { - nextFilters.push({ id: 'userName', value: next }); - } - onColumnFiltersChange(nextFilters); - }; - - const removeFilterValue = (columnId: string, value: string) => { - onColumnFiltersChange( - columnFilters - .map((f) => { - if (f.id !== columnId) { - return f; - } - const current = (f.value as string[]) ?? []; - const next = current.filter((v) => v !== value); - return { ...f, value: next }; - }) - .filter((f) => { - const v = f.value as string[]; - const all = getAllValuesForFilterId(f.id); - return v.length > 0 && v.length < all.length; - }), - ); - }; - - const clearAllFilters = () => { - onColumnFiltersChange([]); - onGlobalFilterChange(''); - }; - - const removeUpdatedAtFilter = () => { - onColumnFiltersChange(columnFilters.filter((f) => f.id !== 'updatedAt')); - }; - - const toggleableColumns = table.getAllLeafColumns().filter((col) => col.getCanHide()); - const visibleCount = toggleableColumns.filter((col) => col.getIsVisible()).length; - const hiddenCount = toggleableColumns.length - visibleCount; - - const filterConfigs = [ - { - id: 'userRole', - label: 'Role', - values: facets.userRoles, - valueLabels: Object.fromEntries(facets.userRoles.map((role) => [role, formatUserRole(role)])), - valueCounts: facets.userRoles.reduce>((acc, role) => { - acc[role] = facets.userRoleCounts?.[role] ?? 0; - return acc; - }, {}), - }, - { - id: 'toolState', - label: 'Tool State', - values: toolStateValues.map((o) => o.value), - valueLabels: Object.fromEntries(toolStateValues.map((o) => [o.value, o.label])), - valueCounts: Object.fromEntries(toolStateValues.map((o) => [o.value, o.count])), - }, - ] as const; - - const totalFilterSelectedCount = filterConfigs.reduce((acc, cfg) => { - const selected = (columnFilters.find((f) => f.id === cfg.id)?.value as string[] | undefined) ?? cfg.values; - return acc + selected.length; - }, 0); - - return ( -
-
- onGlobalFilterChange(e.target.value)} - placeholder='Search chats...' - className='h-8 text-sm max-w-sm' - /> - - - - - - - - -
- Users -
- - {facets.userNames.length === 0 ? ( -
No users
- ) : ( - <> - {facets.userNames.map((name) => ( - { - const next = checked - ? [...selectedUsers, name] - : selectedUsers.filter((v) => v !== name); - setSelectedUsers(next); - }} - > -
- {name} - {typeof facets.userNameCounts?.[name] === 'number' && - facets.userNameCounts[name] > 0 && ( - - {facets.userNameCounts[name]} - - )} -
-
- ))} - {someUsersUnchecked && ( - <> - - - - )} - - )} -
-
- - - - - - -
- Visible columns -
- - {toggleableColumns.map((column) => ( - column.toggleVisibility(!!value)} - > - - {typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id} - - - ))} - {hiddenCount > 0 && ( - <> - - - - )} -
-
- - - - - - - -
- Filter by -
- - - {filterConfigs.map((cfg) => { - const selected = - (columnFilters.find((f) => f.id === cfg.id)?.value as string[] | undefined) ?? - cfg.values; - const someUnchecked = selected.length < cfg.values.length; - - const toggleValue = (value: string, checked: boolean) => { - const next = checked ? [...selected, value] : selected.filter((v) => v !== value); - const nextFilters = columnFilters.filter((f) => f.id !== cfg.id); - if (next.length > 0 && next.length < cfg.values.length) { - nextFilters.push({ id: cfg.id, value: next }); - } - onColumnFiltersChange(nextFilters); - }; - - const showAll = () => { - onColumnFiltersChange(columnFilters.filter((f) => f.id !== cfg.id)); - }; - - return ( - - - {cfg.label} - {someUnchecked && ( - - {selected.length} - - )} - - - -
- {cfg.label} -
- - {cfg.values.length === 0 ? ( -
- No values -
- ) : ( - <> - {cfg.values.map((value) => ( - toggleValue(value, checked)} - > -
- - {cfg.valueLabels?.[value] ?? value} - - {typeof cfg.valueCounts?.[value] === 'number' && - cfg.valueCounts[value] > 0 && ( - - {cfg.valueCounts[value]} - - )} -
-
- ))} - {someUnchecked && ( - <> - - - - )} - - )} -
-
- ); - })} - - {(activeFilters.length > 0 || globalFilter || updatedAtFilter) && ( - <> - - - - )} -
-
-
- - {(activeFilters.length > 0 || globalFilter || updatedAtFilter) && ( -
- Active: - - {updatedAtFilter && - ((updatedAtFilter.mode === 'single' && updatedAtFilter.value) || - (updatedAtFilter.mode === 'range' && updatedAtFilter.start && updatedAtFilter.end)) && ( - - Last update: - - {updatedAtFilter.mode === 'single' - ? updatedAtFilter.value - : `${updatedAtFilter.start} – ${updatedAtFilter.end}`} - - - - )} - - {globalFilter && ( - - search: - {globalFilter} - - - )} - - {activeFilters - .filter((f) => f.id !== 'updatedAt') - .flatMap((filter) => { - const cfg = filterConfigs.find((c) => c.id === filter.id); - const getLabel = (val: string) => cfg?.valueLabels?.[val] ?? val; - return (filter.value as string[]).map((val) => ( - - {filter.id}: - {getLabel(val)} - - - )); - })} - - -
- )} -
- ); -} diff --git a/apps/frontend/src/components/settings/usage-chart-card.tsx b/apps/frontend/src/components/settings/usage-chart-card.tsx index ac7d633d5..735696d2f 100644 --- a/apps/frontend/src/components/settings/usage-chart-card.tsx +++ b/apps/frontend/src/components/settings/usage-chart-card.tsx @@ -1,23 +1,22 @@ -import type { UsageRecord } from '@nao/backend/usage'; +import type { ReactNode } from 'react'; +import type { TotalUsageRecord, UsageRecord } from '@nao/backend/usage'; import { ChartDisplay } from '@/components/tool-calls/display-chart'; -import { SettingsCard } from '@/components/ui/settings-card'; export interface UsageChartCardProps { title: string; - description: string; isLoading: boolean; isFetching: boolean; isError: boolean; - data: UsageRecord[]; - chartType: 'bar' | 'stacked_bar'; + data: UsageRecord[] | TotalUsageRecord[]; + chartType: 'bar' | 'stacked_bar' | 'kpi_card'; series: { data_key: string; color: string; label: string }[]; - xAxisLabelFormatter: (value: string) => string; - filters: React.ReactNode; + xAxisLabelFormatter?: (value: string) => string; + valueFormatter?: (value: number) => string; + titleAccessory?: ReactNode; } export function UsageChartCard({ title, - description, isLoading, isFetching, isError, @@ -25,10 +24,11 @@ export function UsageChartCard({ chartType, series, xAxisLabelFormatter, - filters, + valueFormatter, + titleAccessory, }: UsageChartCardProps) { return ( - + <> {isError ? (

Error loading usage data.

@@ -44,16 +44,28 @@ export function UsageChartCard({ ) : (
[]} chartType={chartType} xAxisKey='date' xAxisType='category' xAxisLabelFormatter={xAxisLabelFormatter} + valueFormatter={valueFormatter} series={series} + titleAccessory={titleAccessory} showGrid={true} + chartContainerClassName={ + chartType === 'kpi_card' + ? undefined + : 'max-lg:h-[200px] max-lg:max-h-[200px] h-[320px] :max-h-[320px]' + } + chartContentClassName={ + chartType === 'kpi_card' ? undefined : 'max-lg:min-h-0 max-lg:flex-1 max-lg:aspect-auto' + } />
)} - + ); } diff --git a/apps/frontend/src/components/settings/usage-filters.tsx b/apps/frontend/src/components/settings/usage-filters.tsx index c880c5543..765d4a211 100644 --- a/apps/frontend/src/components/settings/usage-filters.tsx +++ b/apps/frontend/src/components/settings/usage-filters.tsx @@ -1,21 +1,38 @@ -import { providerLabels } from '@nao/shared/types'; +import { ThumbsUp, Users, Wrench } from 'lucide-react'; +import { CHAT_REPLAY_FEEDBACK_STATES, CHAT_REPLAY_TOOL_STATES, providerLabels } from '@nao/shared/types'; import type { Granularity } from '@nao/backend/usage'; -import type { LlmProvider } from '@nao/shared/types'; +import type { + ChatReplayFeedbackState, + ChatReplayToolState, + LlmProvider, + ProjectChatReplayFacets, +} from '@nao/shared/types'; +import type { LucideIcon } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { cn } from '@/lib/utils'; -export type ChartView = 'messages' | 'tokens' | 'cost'; +type UsagePeriod = '24h' | '15d' | '6m'; -const granularityOptions: { value: Granularity; label: string }[] = [ - { value: 'hour', label: 'Hour' }, - { value: 'day', label: 'Day' }, - { value: 'month', label: 'Month' }, +const periodOptions: { value: UsagePeriod; label: string; granularity: Granularity }[] = [ + { value: '24h', label: 'Last 24 hours', granularity: 'hour' }, + { value: '15d', label: 'Last 15 days', granularity: 'day' }, + { value: '6m', label: 'Last 6 months', granularity: 'month' }, ]; -const chartViewOptions: { value: ChartView; label: string }[] = [ - { value: 'messages', label: 'Messages' }, - { value: 'tokens', label: 'Tokens' }, - { value: 'cost', label: 'Cost' }, -]; +const periodByGranularity: Record = { + hour: '24h', + day: '15d', + month: '6m', +}; export const dateFormats: Record = { hour: 'MMM d, HH:00', @@ -24,38 +41,52 @@ export const dateFormats: Record = { }; interface UsageFiltersProps { - chartView: ChartView; - onChartViewChange: (value: ChartView) => void; provider: LlmProvider | 'all'; onProviderChange: (value: LlmProvider | 'all') => void; granularity: Granularity; onGranularityChange: (value: Granularity) => void; availableProviders: LlmProvider[] | undefined; + chatFacets: ProjectChatReplayFacets | undefined; + selectedUserNames: string[] | undefined; + onSelectedUserNamesChange: (value: string[] | undefined) => void; + selectedFeedbackStates: ChatReplayFeedbackState[] | undefined; + onSelectedFeedbackStatesChange: (value: ChatReplayFeedbackState[] | undefined) => void; + selectedToolStates: ChatReplayToolState[] | undefined; + onSelectedToolStatesChange: (value: ChatReplayToolState[] | undefined) => void; } export function UsageFilters({ - chartView, - onChartViewChange, provider, onProviderChange, granularity, onGranularityChange, availableProviders, + chatFacets, + selectedUserNames, + onSelectedUserNamesChange, + selectedFeedbackStates, + onSelectedFeedbackStatesChange, + selectedToolStates, + onSelectedToolStatesChange, }: UsageFiltersProps) { + const period = periodByGranularity[granularity]; + const userOptions = (chatFacets?.userNames ?? []).map((name) => ({ + value: name, + label: name, + count: chatFacets?.userNameCounts[name], + })); + const toolStateOptions = CHAT_REPLAY_TOOL_STATES.map((value) => ({ + value, + label: toolStateLabels[value], + count: chatFacets?.toolState[value] ?? 0, + })).filter((option) => option.count > 0); + const feedbackOptions = CHAT_REPLAY_FEEDBACK_STATES.map((value) => ({ + value, + label: feedbackStateLabels[value], + })); + return ( -
- +
- { + const option = periodOptions.find((o) => o.value === value); + if (option) { + onGranularityChange(option.granularity); + } + }} + > + - {granularityOptions.map((option) => ( + {periodOptions.map((option) => ( {option.label} ))} + + + +
); } + +type FilterOption = { + value: T; + label: string; + count?: number; +}; + +type MultiSelectFilterProps = { + label: string; + icon: LucideIcon; + options: FilterOption[]; + selectedValues: T[] | undefined; + onChange: (value: T[] | undefined) => void; +}; + +const toolStateLabels: Record = { + noToolsUsed: 'No tools used', + toolsNoErrors: 'Tools, no errors', + toolsWithErrors: 'Tools with errors', +}; + +const feedbackStateLabels: Record = { + noVotes: 'No votes', + upvotes: 'Upvotes', + downvotes: 'Downvotes', +}; + +function MultiSelectFilter({ + label, + icon: Icon, + options, + selectedValues, + onChange, +}: MultiSelectFilterProps) { + const allValues = options.map((option) => option.value); + const currentValues = selectedValues ?? allValues; + const hasPartialSelection = selectedValues !== undefined && selectedValues.length < allValues.length; + + const updateSelection = (next: T[]) => { + onChange(next.length === 0 || next.length === allValues.length ? undefined : next); + }; + + return ( + + + + + +
+ {label} +
+ + {options.map((option) => ( + event.preventDefault()} + onCheckedChange={(checked) => { + if (!selectedValues) { + updateSelection([option.value]); + return; + } + + const next = checked + ? Array.from(new Set([...currentValues, option.value])) + : currentValues.filter((value) => value !== option.value); + updateSelection(next); + }} + > +
+ {option.label} + {typeof option.count === 'number' && ( + + {option.count} + + )} +
+
+ ))} + {hasPartialSelection && ( + <> + + + + )} +
+
+ ); +} diff --git a/apps/frontend/src/components/settings/usage-route-search.ts b/apps/frontend/src/components/settings/usage-route-search.ts new file mode 100644 index 000000000..ef1fbab3f --- /dev/null +++ b/apps/frontend/src/components/settings/usage-route-search.ts @@ -0,0 +1,60 @@ +import { CHAT_REPLAY_FEEDBACK_STATES, CHAT_REPLAY_TOOL_STATES, providerLabels } from '@nao/shared/types'; +import type { Granularity } from '@nao/backend/usage'; +import type { ChatReplayFeedbackState, ChatReplayToolState, LlmProvider } from '@nao/shared/types'; + +export type TokenChartDisplayMode = 'tokens' | 'dollars'; + +export type UsageRouteSearch = { + provider: LlmProvider | 'all'; + granularity: Granularity; + users: string[] | undefined; + feedback: ChatReplayFeedbackState[] | undefined; + tools: ChatReplayToolState[] | undefined; + tokenView: TokenChartDisplayMode; +}; + +export const DEFAULT_USAGE_SEARCH: UsageRouteSearch = { + provider: 'all', + granularity: 'day', + users: undefined, + feedback: undefined, + tools: undefined, + tokenView: 'tokens', +}; + +const granularities = ['hour', 'day', 'month'] as const satisfies readonly Granularity[]; +const tokenViews = ['tokens', 'dollars'] as const satisfies readonly TokenChartDisplayMode[]; + +export function validateUsageSearch(search: Record): UsageRouteSearch { + return { + provider: parseProvider(search.provider), + granularity: parseOneOf(search.granularity, granularities) ?? 'day', + users: parseStringArray(search.users), + feedback: parseArrayOf(search.feedback, CHAT_REPLAY_FEEDBACK_STATES), + tools: parseArrayOf(search.tools, CHAT_REPLAY_TOOL_STATES), + tokenView: parseOneOf(search.tokenView, tokenViews) ?? 'tokens', + }; +} + +function parseProvider(value: unknown): LlmProvider | 'all' { + if (value === 'all' || (typeof value === 'string' && value in providerLabels)) { + return value as LlmProvider | 'all'; + } + return 'all'; +} + +function parseStringArray(value: unknown): string[] | undefined { + const values = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []; + const parsed = values.filter((item): item is string => typeof item === 'string' && item.length > 0); + return parsed.length ? parsed : undefined; +} + +function parseArrayOf(value: unknown, allowedValues: readonly T[]): T[] | undefined { + const allowed = new Set(allowedValues); + const parsed = parseStringArray(value)?.filter((item): item is T => allowed.has(item)) ?? []; + return parsed.length ? parsed : undefined; +} + +function parseOneOf(value: unknown, allowedValues: readonly T[]): T | undefined { + return typeof value === 'string' && allowedValues.includes(value as T) ? (value as T) : undefined; +} diff --git a/apps/frontend/src/components/sidebar-settings-nav.tsx b/apps/frontend/src/components/sidebar-settings-nav.tsx index 04f155460..7a005837f 100644 --- a/apps/frontend/src/components/sidebar-settings-nav.tsx +++ b/apps/frontend/src/components/sidebar-settings-nav.tsx @@ -72,11 +72,6 @@ const settingsNavItems: NavItem[] = [ to: '/settings/usage', visible: ({ isAdmin }) => isAdmin, }, - { - label: 'Chats Replay', - to: '/settings/chats-replay', - visible: ({ isAdmin, isContextAdmin }) => isAdmin || isContextAdmin, - }, { label: 'Recommendations', to: '/settings/recommendations', diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index 5b19721f4..d39573e76 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -1,8 +1,8 @@ import { buildChart, bucketPieData, buildStoryChartBlock, labelize } from '@nao/shared'; import { displayChart } from '@nao/shared/tools'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Code, Download, FilePlus, Pencil } from 'lucide-react'; -import { memo, useCallback, useMemo, useState } from 'react'; +import { ChevronLeft, ChevronRight, Code, Download, FilePlus, Pencil } from 'lucide-react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useOptionalAgentContext } from '../../contexts/agent.provider'; import GraphLoaderAnimated from '../icons/graph-loader-animated'; @@ -37,6 +37,7 @@ import { SidePanelContent } from '@/components/side-panel/sql-editor'; const Colors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']; const EMPTY_MESSAGES: UIMessage[] = []; +const LEGEND_SCROLL_OFFSET = 120; export const DisplayChartToolCall = ({ toolPart: { state, input, output, toolCallId }, @@ -309,12 +310,18 @@ export interface ChartDisplayProps { xAxisKey: string; xAxisType: 'number' | 'category'; xAxisLabelFormatter?: (value: string) => string; + valueFormatter?: (value: number) => string; series: displayChart.SeriesConfig[]; title?: string; + titleStyle?: 'default' | 'left'; + titleAccessory?: React.ReactNode; showGrid?: boolean; yAxisMin?: number; yAxisMax?: number; showDataLabels?: boolean; + className?: string; + chartContainerClassName?: string; + chartContentClassName?: string; } export const ChartDisplay = memo(function ChartDisplay({ @@ -323,12 +330,18 @@ export const ChartDisplay = memo(function ChartDisplay({ xAxisKey: xAxisKeyProp, xAxisType, xAxisLabelFormatter, + valueFormatter, series: seriesProp, title, + titleStyle = 'default', + titleAccessory, showGrid = true, yAxisMin, yAxisMax, showDataLabels, + className, + chartContainerClassName, + chartContentClassName, }: ChartDisplayProps) { const dateFormat = useDateFormat(); @@ -347,6 +360,8 @@ export const ChartDisplay = memo(function ChartDisplay({ () => (isPie ? bucketPieData(data, xAxisKey, pieValueKey) : data), [isPie, data, xAxisKey, pieValueKey], ); + const useInlineHeader = titleStyle === 'left' && Boolean(title) && !isPie; + const { scrollRef, canScrollLeft, canScrollRight, scrollLegend } = useHorizontalScrollControls(); const chartConfig = useMemo((): ChartConfig => { if (isPie) { @@ -428,6 +443,7 @@ export const ChartDisplay = memo(function ChartDisplay({ series: visibleSeries, colorFor, labelFormatter, + valueFormatter, showGrid, showDataLabels, margin: { top: 0, right: 0, bottom: 0, left: 0 }, @@ -440,10 +456,14 @@ export const ChartDisplay = memo(function ChartDisplay({ animationEasing='linear' allowEscapeViewBox={{ y: true, x: false }} content={ - + } />, - chartType !== 'kpi_card' && ( + chartType !== 'kpi_card' && !useInlineHeader && ( ), - ], - title, + ].filter(Boolean), + title: useInlineHeader ? undefined : title, renderTitle: false, }), [ @@ -472,6 +492,7 @@ export const ChartDisplay = memo(function ChartDisplay({ colorFor, labelFormatter, tooltipLabelFormatter, + valueFormatter, showGrid, yAxisMin, yAxisMax, @@ -480,15 +501,63 @@ export const ChartDisplay = memo(function ChartDisplay({ handleToggleSeriesVisibility, title, isPercentStacked, + useInlineHeader, ], ); + const inlineHeader = useInlineHeader ? ( +
+
+ {title} + {titleAccessory} +
+ {canScrollLeft && ( + + )} +
+ +
+ {canScrollRight && ( + + )} +
+ ) : undefined; + return ( -
+
{chartType === 'kpi_card' ? ( chartElement ) : ( - + {chartElement} )} @@ -496,6 +565,66 @@ export const ChartDisplay = memo(function ChartDisplay({ ); }); +const useHorizontalScrollControls = () => { + const scrollRef = useRef(null); + const [scrollState, setScrollState] = useState({ + canScrollLeft: false, + canScrollRight: false, + }); + + const updateScrollState = useCallback(() => { + const element = scrollRef.current; + if (!element) { + return; + } + + const maxScrollLeft = element.scrollWidth - element.clientWidth; + setScrollState({ + canScrollLeft: element.scrollLeft > 1, + canScrollRight: element.scrollLeft < maxScrollLeft - 1, + }); + }, []); + + useEffect(() => { + const element = scrollRef.current; + if (!element) { + return; + } + + updateScrollState(); + element.addEventListener('scroll', updateScrollState, { passive: true }); + + if (typeof ResizeObserver === 'undefined') { + return () => element.removeEventListener('scroll', updateScrollState); + } + + const resizeObserver = new ResizeObserver(updateScrollState); + resizeObserver.observe(element); + + if (element.firstElementChild) { + resizeObserver.observe(element.firstElementChild); + } + + return () => { + element.removeEventListener('scroll', updateScrollState); + resizeObserver.disconnect(); + }; + }, [updateScrollState]); + + const scrollLegend = useCallback((direction: 'left' | 'right') => { + scrollRef.current?.scrollBy({ + left: direction === 'left' ? -LEGEND_SCROLL_OFFSET : LEGEND_SCROLL_OFFSET, + behavior: 'smooth', + }); + }, []); + + return { + ...scrollState, + scrollRef, + scrollLegend, + }; +}; + /** Manages which series are visible and hidden */ const useSeriesVisibility = (series: displayChart.SeriesConfig[]) => { const [hiddenSeriesKeys, setHiddenSeriesKeys] = useState>(new Set()); diff --git a/apps/frontend/src/components/ui/chart.tsx b/apps/frontend/src/components/ui/chart.tsx index f3e276bb7..f71ea2850 100644 --- a/apps/frontend/src/components/ui/chart.tsx +++ b/apps/frontend/src/components/ui/chart.tsx @@ -35,11 +35,15 @@ function useChart() { function ChartContainer({ id, className, + contentClassName, + header, children, config, ...props }: React.ComponentProps<'div'> & { config: ChartConfig; + contentClassName?: string; + header?: React.ReactNode; children: React.ComponentProps['children']; }) { const uniqueId = React.useId(); @@ -47,17 +51,17 @@ function ChartContainer({ return ( -
- - {children} +
+ {header} +
+ + {children} +
); @@ -109,6 +113,7 @@ function ChartTooltipContent({ nameKey, labelKey, percent = false, + valueFormatter = formatCompactNumber, }: React.ComponentProps & React.ComponentProps<'div'> & { hideLabel?: boolean; @@ -117,6 +122,7 @@ function ChartTooltipContent({ nameKey?: string; labelKey?: string; percent?: boolean; + valueFormatter?: (value: number) => string; }) { const { config } = useChart(); @@ -164,8 +170,7 @@ function ChartTooltipContent({ ); // In 100% stacked mode every category totals 100%, so ignore already-aggregated total series. const showTotal = numericValues.length > 1 && (percent || !hasTotalSeries); - const formatValue = (value: number) => - percent ? formatPercentShare(value, shareBase) : formatCompactNumber(value); + const formatValue = (value: number) => (percent ? formatPercentShare(value, shareBase) : valueFormatter(value)); return (
- {item.value && ( + {item.value !== undefined && item.value !== null && ( {typeof item.value === 'number' ? formatValue(item.value) @@ -247,7 +252,7 @@ function ChartTooltipContent({
Total - {percent ? '100%' : formatCompactNumber(seriesTotal)} + {percent ? '100%' : valueFormatter(seriesTotal)}
@@ -265,10 +270,11 @@ function ChartLegendContent({ payload, verticalAlign = 'bottom', layout = 'horizontal', + align = 'center', nameKey, onItemClick, }: React.ComponentProps<'div'> & - Pick & { + Pick & { hideIcon?: boolean; nameKey?: string; onItemClick?: (dataKey: string) => void; @@ -288,7 +294,11 @@ function ChartLegendContent({ 'flex gap-4', isVertical ? 'flex-col items-start justify-center gap-2 pl-4' - : cn('items-center justify-center', verticalAlign === 'top' ? 'pb-3' : 'pt-3'), + : cn( + 'w-full items-center', + align === 'right' ? 'justify-end' : align === 'left' ? 'justify-start' : 'justify-center', + verticalAlign === 'top' ? 'pb-3' : 'pt-3', + ), className, )} > diff --git a/apps/frontend/src/components/ui/settings-card.tsx b/apps/frontend/src/components/ui/settings-card.tsx index 86ad32885..e6c25ce0e 100644 --- a/apps/frontend/src/components/ui/settings-card.tsx +++ b/apps/frontend/src/components/ui/settings-card.tsx @@ -1,9 +1,14 @@ import { cn } from '@/lib/utils'; -export function SettingsPageWrapper({ children }: { children: React.ReactNode }) { +export function SettingsPageWrapper({ children, wide }: { children: React.ReactNode; wide?: boolean }) { return (
-
+
{children}
@@ -20,6 +25,7 @@ interface SettingsCardProps { rootClassName?: string; className?: string; divide?: boolean; + unstyled?: boolean; } export function SettingsCard({ @@ -32,6 +38,7 @@ export function SettingsCard({ rootClassName, className, divide = false, + unstyled = false, }: SettingsCardProps) { return (
SidebarLayoutSettingsRoute, } as any) -const SidebarLayoutSettingsChatsReplayRoute = - SidebarLayoutSettingsChatsReplayRouteImport.update({ - id: '/chats-replay', - path: '/chats-replay', - getParentRoute: () => SidebarLayoutSettingsRoute, - } as any) const SidebarLayoutSettingsAccountRoute = SidebarLayoutSettingsAccountRouteImport.update({ id: '/account', @@ -304,6 +298,12 @@ const SidebarLayoutStoriesPreviewChatIdStorySlugRoute = path: '/stories/preview/$chatId/$storySlug', getParentRoute: () => SidebarLayoutRoute, } as any) +const SidebarLayoutSettingsUsageReplayChatIdRoute = + SidebarLayoutSettingsUsageReplayChatIdRouteImport.update({ + id: '/replay/$chatId', + path: '/replay/$chatId', + getParentRoute: () => SidebarLayoutSettingsUsageRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof SidebarLayoutChatLayoutIndexRoute @@ -317,7 +317,6 @@ export interface FileRoutesByFullPath { '/$chatId': typeof SidebarLayoutChatLayoutChatIdRoute '/automations/$automationId': typeof SidebarLayoutAutomationsAutomationIdRoute '/settings/account': typeof SidebarLayoutSettingsAccountRoute - '/settings/chats-replay': typeof SidebarLayoutSettingsChatsReplayRoute '/settings/context-explorer': typeof SidebarLayoutSettingsContextExplorerRoute '/settings/enterprise': typeof SidebarLayoutSettingsEnterpriseRoute '/settings/logs': typeof SidebarLayoutSettingsLogsRoute @@ -326,7 +325,7 @@ export interface FileRoutesByFullPath { '/settings/organization': typeof SidebarLayoutSettingsOrganizationRoute '/settings/project': typeof SidebarLayoutSettingsProjectRouteWithChildren '/settings/recommendations': typeof SidebarLayoutSettingsRecommendationsRoute - '/settings/usage': typeof SidebarLayoutSettingsUsageRoute + '/settings/usage': typeof SidebarLayoutSettingsUsageRouteWithChildren '/settings/white-label': typeof SidebarLayoutSettingsWhiteLabelRoute '/shared-chat/$shareId': typeof SidebarLayoutSharedChatShareIdRoute '/embed/chart/$chartEmbedId': typeof EmbedChartChartEmbedIdRoute @@ -347,6 +346,7 @@ export interface FileRoutesByFullPath { '/stories/shared/$shareId': typeof SidebarLayoutStoriesSharedShareIdRoute '/stories/standalone/$storyId': typeof SidebarLayoutStoriesStandaloneStoryIdRoute '/settings/project/': typeof SidebarLayoutSettingsProjectIndexRoute + '/settings/usage/replay/$chatId': typeof SidebarLayoutSettingsUsageReplayChatIdRoute '/stories/preview/$chatId/$storySlug': typeof SidebarLayoutStoriesPreviewChatIdStorySlugRoute } export interface FileRoutesByTo { @@ -360,7 +360,6 @@ export interface FileRoutesByTo { '/$chatId': typeof SidebarLayoutChatLayoutChatIdRoute '/automations/$automationId': typeof SidebarLayoutAutomationsAutomationIdRoute '/settings/account': typeof SidebarLayoutSettingsAccountRoute - '/settings/chats-replay': typeof SidebarLayoutSettingsChatsReplayRoute '/settings/context-explorer': typeof SidebarLayoutSettingsContextExplorerRoute '/settings/enterprise': typeof SidebarLayoutSettingsEnterpriseRoute '/settings/logs': typeof SidebarLayoutSettingsLogsRoute @@ -368,7 +367,7 @@ export interface FileRoutesByTo { '/settings/memory': typeof SidebarLayoutSettingsMemoryRoute '/settings/organization': typeof SidebarLayoutSettingsOrganizationRoute '/settings/recommendations': typeof SidebarLayoutSettingsRecommendationsRoute - '/settings/usage': typeof SidebarLayoutSettingsUsageRoute + '/settings/usage': typeof SidebarLayoutSettingsUsageRouteWithChildren '/settings/white-label': typeof SidebarLayoutSettingsWhiteLabelRoute '/shared-chat/$shareId': typeof SidebarLayoutSharedChatShareIdRoute '/embed/chart/$chartEmbedId': typeof EmbedChartChartEmbedIdRoute @@ -389,6 +388,7 @@ export interface FileRoutesByTo { '/stories/shared/$shareId': typeof SidebarLayoutStoriesSharedShareIdRoute '/stories/standalone/$storyId': typeof SidebarLayoutStoriesStandaloneStoryIdRoute '/settings/project': typeof SidebarLayoutSettingsProjectIndexRoute + '/settings/usage/replay/$chatId': typeof SidebarLayoutSettingsUsageReplayChatIdRoute '/stories/preview/$chatId/$storySlug': typeof SidebarLayoutStoriesPreviewChatIdStorySlugRoute } export interface FileRoutesById { @@ -405,7 +405,6 @@ export interface FileRoutesById { '/_sidebar-layout/_chat-layout/$chatId': typeof SidebarLayoutChatLayoutChatIdRoute '/_sidebar-layout/automations/$automationId': typeof SidebarLayoutAutomationsAutomationIdRoute '/_sidebar-layout/settings/account': typeof SidebarLayoutSettingsAccountRoute - '/_sidebar-layout/settings/chats-replay': typeof SidebarLayoutSettingsChatsReplayRoute '/_sidebar-layout/settings/context-explorer': typeof SidebarLayoutSettingsContextExplorerRoute '/_sidebar-layout/settings/enterprise': typeof SidebarLayoutSettingsEnterpriseRoute '/_sidebar-layout/settings/logs': typeof SidebarLayoutSettingsLogsRoute @@ -414,7 +413,7 @@ export interface FileRoutesById { '/_sidebar-layout/settings/organization': typeof SidebarLayoutSettingsOrganizationRoute '/_sidebar-layout/settings/project': typeof SidebarLayoutSettingsProjectRouteWithChildren '/_sidebar-layout/settings/recommendations': typeof SidebarLayoutSettingsRecommendationsRoute - '/_sidebar-layout/settings/usage': typeof SidebarLayoutSettingsUsageRoute + '/_sidebar-layout/settings/usage': typeof SidebarLayoutSettingsUsageRouteWithChildren '/_sidebar-layout/settings/white-label': typeof SidebarLayoutSettingsWhiteLabelRoute '/_sidebar-layout/shared-chat/$shareId': typeof SidebarLayoutSharedChatShareIdRoute '/embed/chart/$chartEmbedId': typeof EmbedChartChartEmbedIdRoute @@ -436,6 +435,7 @@ export interface FileRoutesById { '/_sidebar-layout/stories/shared/$shareId': typeof SidebarLayoutStoriesSharedShareIdRoute '/_sidebar-layout/stories/standalone/$storyId': typeof SidebarLayoutStoriesStandaloneStoryIdRoute '/_sidebar-layout/settings/project/': typeof SidebarLayoutSettingsProjectIndexRoute + '/_sidebar-layout/settings/usage/replay/$chatId': typeof SidebarLayoutSettingsUsageReplayChatIdRoute '/_sidebar-layout/stories/preview/$chatId/$storySlug': typeof SidebarLayoutStoriesPreviewChatIdStorySlugRoute } export interface FileRouteTypes { @@ -452,7 +452,6 @@ export interface FileRouteTypes { | '/$chatId' | '/automations/$automationId' | '/settings/account' - | '/settings/chats-replay' | '/settings/context-explorer' | '/settings/enterprise' | '/settings/logs' @@ -482,6 +481,7 @@ export interface FileRouteTypes { | '/stories/shared/$shareId' | '/stories/standalone/$storyId' | '/settings/project/' + | '/settings/usage/replay/$chatId' | '/stories/preview/$chatId/$storySlug' fileRoutesByTo: FileRoutesByTo to: @@ -495,7 +495,6 @@ export interface FileRouteTypes { | '/$chatId' | '/automations/$automationId' | '/settings/account' - | '/settings/chats-replay' | '/settings/context-explorer' | '/settings/enterprise' | '/settings/logs' @@ -524,6 +523,7 @@ export interface FileRouteTypes { | '/stories/shared/$shareId' | '/stories/standalone/$storyId' | '/settings/project' + | '/settings/usage/replay/$chatId' | '/stories/preview/$chatId/$storySlug' id: | '__root__' @@ -539,7 +539,6 @@ export interface FileRouteTypes { | '/_sidebar-layout/_chat-layout/$chatId' | '/_sidebar-layout/automations/$automationId' | '/_sidebar-layout/settings/account' - | '/_sidebar-layout/settings/chats-replay' | '/_sidebar-layout/settings/context-explorer' | '/_sidebar-layout/settings/enterprise' | '/_sidebar-layout/settings/logs' @@ -570,6 +569,7 @@ export interface FileRouteTypes { | '/_sidebar-layout/stories/shared/$shareId' | '/_sidebar-layout/stories/standalone/$storyId' | '/_sidebar-layout/settings/project/' + | '/_sidebar-layout/settings/usage/replay/$chatId' | '/_sidebar-layout/stories/preview/$chatId/$storySlug' fileRoutesById: FileRoutesById } @@ -767,13 +767,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SidebarLayoutSettingsContextExplorerRouteImport parentRoute: typeof SidebarLayoutSettingsRoute } - '/_sidebar-layout/settings/chats-replay': { - id: '/_sidebar-layout/settings/chats-replay' - path: '/chats-replay' - fullPath: '/settings/chats-replay' - preLoaderRoute: typeof SidebarLayoutSettingsChatsReplayRouteImport - parentRoute: typeof SidebarLayoutSettingsRoute - } '/_sidebar-layout/settings/account': { id: '/_sidebar-layout/settings/account' path: '/account' @@ -893,6 +886,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SidebarLayoutStoriesPreviewChatIdStorySlugRouteImport parentRoute: typeof SidebarLayoutRoute } + '/_sidebar-layout/settings/usage/replay/$chatId': { + id: '/_sidebar-layout/settings/usage/replay/$chatId' + path: '/replay/$chatId' + fullPath: '/settings/usage/replay/$chatId' + preLoaderRoute: typeof SidebarLayoutSettingsUsageReplayChatIdRouteImport + parentRoute: typeof SidebarLayoutSettingsUsageRoute + } } } @@ -957,9 +957,23 @@ const SidebarLayoutSettingsProjectRouteWithChildren = SidebarLayoutSettingsProjectRouteChildren, ) +interface SidebarLayoutSettingsUsageRouteChildren { + SidebarLayoutSettingsUsageReplayChatIdRoute: typeof SidebarLayoutSettingsUsageReplayChatIdRoute +} + +const SidebarLayoutSettingsUsageRouteChildren: SidebarLayoutSettingsUsageRouteChildren = + { + SidebarLayoutSettingsUsageReplayChatIdRoute: + SidebarLayoutSettingsUsageReplayChatIdRoute, + } + +const SidebarLayoutSettingsUsageRouteWithChildren = + SidebarLayoutSettingsUsageRoute._addFileChildren( + SidebarLayoutSettingsUsageRouteChildren, + ) + interface SidebarLayoutSettingsRouteChildren { SidebarLayoutSettingsAccountRoute: typeof SidebarLayoutSettingsAccountRoute - SidebarLayoutSettingsChatsReplayRoute: typeof SidebarLayoutSettingsChatsReplayRoute SidebarLayoutSettingsContextExplorerRoute: typeof SidebarLayoutSettingsContextExplorerRoute SidebarLayoutSettingsEnterpriseRoute: typeof SidebarLayoutSettingsEnterpriseRoute SidebarLayoutSettingsLogsRoute: typeof SidebarLayoutSettingsLogsRoute @@ -968,14 +982,13 @@ interface SidebarLayoutSettingsRouteChildren { SidebarLayoutSettingsOrganizationRoute: typeof SidebarLayoutSettingsOrganizationRoute SidebarLayoutSettingsProjectRoute: typeof SidebarLayoutSettingsProjectRouteWithChildren SidebarLayoutSettingsRecommendationsRoute: typeof SidebarLayoutSettingsRecommendationsRoute - SidebarLayoutSettingsUsageRoute: typeof SidebarLayoutSettingsUsageRoute + SidebarLayoutSettingsUsageRoute: typeof SidebarLayoutSettingsUsageRouteWithChildren SidebarLayoutSettingsWhiteLabelRoute: typeof SidebarLayoutSettingsWhiteLabelRoute SidebarLayoutSettingsIndexRoute: typeof SidebarLayoutSettingsIndexRoute } const SidebarLayoutSettingsRouteChildren: SidebarLayoutSettingsRouteChildren = { SidebarLayoutSettingsAccountRoute: SidebarLayoutSettingsAccountRoute, - SidebarLayoutSettingsChatsReplayRoute: SidebarLayoutSettingsChatsReplayRoute, SidebarLayoutSettingsContextExplorerRoute: SidebarLayoutSettingsContextExplorerRoute, SidebarLayoutSettingsEnterpriseRoute: SidebarLayoutSettingsEnterpriseRoute, @@ -988,7 +1001,7 @@ const SidebarLayoutSettingsRouteChildren: SidebarLayoutSettingsRouteChildren = { SidebarLayoutSettingsProjectRouteWithChildren, SidebarLayoutSettingsRecommendationsRoute: SidebarLayoutSettingsRecommendationsRoute, - SidebarLayoutSettingsUsageRoute: SidebarLayoutSettingsUsageRoute, + SidebarLayoutSettingsUsageRoute: SidebarLayoutSettingsUsageRouteWithChildren, SidebarLayoutSettingsWhiteLabelRoute: SidebarLayoutSettingsWhiteLabelRoute, SidebarLayoutSettingsIndexRoute: SidebarLayoutSettingsIndexRoute, } diff --git a/apps/frontend/src/routes/_sidebar-layout._chat-layout.$chatId.tsx b/apps/frontend/src/routes/_sidebar-layout._chat-layout.$chatId.tsx index 2eabb27ff..131551e13 100644 --- a/apps/frontend/src/routes/_sidebar-layout._chat-layout.$chatId.tsx +++ b/apps/frontend/src/routes/_sidebar-layout._chat-layout.$chatId.tsx @@ -7,6 +7,7 @@ import type { SelectionData } from '@/components/highlight-bubble'; import { NEW_CHAT_ID } from '@/lib/ai'; import { StoryOpenButton } from '@/components/story-open-button'; import { StoryViewer } from '@/components/side-panel/story-viewer'; +import { DEFAULT_USAGE_SEARCH } from '@/components/settings/usage-route-search'; import { ChatInput } from '@/components/chat-input'; import { ChatMessages } from '@/components/chat-messages/chat-messages'; import { HighlightBubble } from '@/components/highlight-bubble'; @@ -66,7 +67,12 @@ function ChatPage() { useEffect(() => { if (shouldRedirectToReplay) { - router.navigate({ to: '/settings/chats-replay', search: { chatId }, replace: true }); + router.navigate({ + to: '/settings/usage/replay/$chatId', + params: { chatId }, + search: DEFAULT_USAGE_SEARCH, + replace: true, + }); } }, [shouldRedirectToReplay, chatId, router]); diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.chats-replay.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.chats-replay.tsx deleted file mode 100644 index 3871cc621..000000000 --- a/apps/frontend/src/routes/_sidebar-layout.settings.chats-replay.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; - -import { ChatsReplayPage } from '@/components/settings/chats-replay-page'; -import { requireContextAdminOrAdmin } from '@/lib/require-admin'; - -export const Route = createFileRoute('/_sidebar-layout/settings/chats-replay')({ - validateSearch: (search: Record): { chatId?: string } => ({ - chatId: typeof search.chatId === 'string' ? search.chatId : undefined, - }), - beforeLoad: requireContextAdminOrAdmin, - component: ChatsReplayPage, -}); diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.usage.replay.$chatId.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.usage.replay.$chatId.tsx new file mode 100644 index 000000000..fd172b0b8 --- /dev/null +++ b/apps/frontend/src/routes/_sidebar-layout.settings.usage.replay.$chatId.tsx @@ -0,0 +1,30 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router'; + +import { ChatsReplayPanel } from '@/components/settings/chats-replay-panel'; +import { validateUsageSearch } from '@/components/settings/usage-route-search'; +import { requireContextAdminOrAdmin } from '@/lib/require-admin'; + +export const Route = createFileRoute('/_sidebar-layout/settings/usage/replay/$chatId')({ + beforeLoad: requireContextAdminOrAdmin, + validateSearch: validateUsageSearch, + component: ChatReplayRoute, +}); + +function ChatReplayRoute() { + const { chatId } = Route.useParams(); + const usageSearch = Route.useSearch(); + const navigate = useNavigate(); + + return ( + { + navigate({ + to: '/settings/usage', + search: usageSearch, + replace: true, + }); + }} + /> + ); +} diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx index cbabbba16..13d2d1b7f 100644 --- a/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx @@ -1,179 +1,248 @@ -import { useState } from 'react'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { format, formatDistanceToNow } from 'date-fns'; -import { ThumbsDown, ThumbsUp } from 'lucide-react'; -import type { Granularity } from '@nao/backend/usage'; -import type { LlmProvider } from '@nao/shared/types'; -import type { ChartView } from '@/components/settings/usage-filters'; +import { format } from 'date-fns'; +import type { TokenChartDisplayMode, UsageRouteSearch } from '@/components/settings/usage-route-search'; +import { ChatsReplayPage } from '@/components/settings/chats-replay-page'; import { UsageChartCard } from '@/components/settings/usage-chart-card'; import { UsageFilters, dateFormats } from '@/components/settings/usage-filters'; -import { SettingsCard, SettingsPageWrapper } from '@/components/ui/settings-card'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { validateUsageSearch } from '@/components/settings/usage-route-search'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { trpc } from '@/main'; -import { Empty } from '@/components/ui/empty'; -import { requireAdmin } from '@/lib/require-admin'; +import { requireAdmin, requireContextAdminOrAdmin } from '@/lib/require-admin'; export const Route = createFileRoute('/_sidebar-layout/settings/usage')({ - beforeLoad: requireAdmin, + beforeLoad: ({ location }) => + location.pathname.startsWith('/settings/usage/replay/') ? requireContextAdminOrAdmin() : requireAdmin(), + validateSearch: validateUsageSearch, component: UsagePage, }); +const tokenChartDisplayOptions: { value: TokenChartDisplayMode; label: string }[] = [ + { value: 'tokens', label: 'Show in tokens' }, + { value: 'dollars', label: 'Show in dollars' }, +]; + +const tokenSeries = [ + { data_key: 'inputNoCacheTokens', color: 'var(--chart-1)', label: 'Input' }, + { data_key: 'inputCacheReadTokens', color: 'var(--chart-2)', label: 'Cache read' }, + { data_key: 'inputCacheWriteTokens', color: 'var(--chart-3)', label: 'Cache write' }, + { data_key: 'outputTotalTokens', color: 'var(--chart-4)', label: 'Output' }, +]; + +const costSeries = [ + { data_key: 'inputNoCacheCost', color: 'var(--chart-1)', label: 'Input' }, + { data_key: 'inputCacheReadCost', color: 'var(--chart-2)', label: 'Cache read' }, + { data_key: 'inputCacheWriteCost', color: 'var(--chart-3)', label: 'Cache write' }, + { data_key: 'outputCost', color: 'var(--chart-4)', label: 'Output' }, +]; + function UsagePage() { - const [granularity, setGranularity] = useState('day'); - const [provider, setProvider] = useState('all'); - const [chartView, setChartView] = useState('messages'); + const usageSearch = Route.useSearch(); + const navigate = useNavigate(); + const isReplayRoute = useRouterState({ + select: (state) => state.location.pathname.startsWith('/settings/usage/replay/'), + }); + + if (isReplayRoute) { + return ; + } + + return ( + { + navigate({ + to: '/settings/usage', + search: { ...usageSearch, ...next }, + replace: true, + }); + }} + onOpenChatReplay={(chatId) => { + navigate({ + to: '/settings/usage/replay/$chatId', + params: { chatId }, + search: usageSearch, + }); + }} + /> + ); +} + +function UsageOverview({ + usageSearch, + onUpdateSearch, + onOpenChatReplay, +}: { + usageSearch: UsageRouteSearch; + onUpdateSearch: (next: Partial) => void; + onOpenChatReplay: (chatId: string) => void; +}) { + const { granularity, provider, users, feedback, tools, tokenView } = usageSearch; const usedProviders = useQuery(trpc.usage.getUsedProviders.queryOptions()); + const chatFacets = useQuery({ + ...trpc.project.getProjectChats.queryOptions({ + page: 0, + pageSize: 1, + }), + placeholderData: keepPreviousData, + }); const messagesUsage = useQuery({ ...trpc.usage.getMessagesUsage.queryOptions({ granularity, provider: provider === 'all' ? undefined : provider, + userNames: users, + }), + placeholderData: keepPreviousData, + }); + const totalUsage = useQuery({ + ...trpc.usage.getTotalUsage.queryOptions({ + granularity, + provider: provider === 'all' ? undefined : provider, + userNames: users, }), placeholderData: keepPreviousData, }); - const recentFeedbacks = useQuery(trpc.feedback.getRecent.queryOptions({ limit: 10 })); const chartData = messagesUsage.data ?? []; + const totalUsageChartData = totalUsage.data ? [totalUsage.data] : []; + const showCost = tokenView === 'dollars'; const filtersComponent = ( onUpdateSearch({ provider: value })} granularity={granularity} - onGranularityChange={setGranularity} + onGranularityChange={(value) => onUpdateSearch({ granularity: value })} availableProviders={usedProviders.data} + chatFacets={chatFacets.data?.facets} + selectedUserNames={users} + onSelectedUserNamesChange={(value) => onUpdateSearch({ users: value })} + selectedFeedbackStates={feedback} + onSelectedFeedbackStatesChange={(value) => onUpdateSearch({ feedback: value })} + selectedToolStates={tools} + onSelectedToolStatesChange={(value) => onUpdateSearch({ tools: value })} /> ); return ( - - {chartView === 'messages' && ( - format(new Date(value), dateFormats[granularity])} - series={[ - { data_key: 'webMessageCount', color: 'var(--chart-1)', label: 'Web' }, - { data_key: 'slackMessageCount', color: 'var(--chart-2)', label: 'Slack' }, - { data_key: 'teamsMessageCount', color: 'var(--chart-3)', label: 'Teams' }, - { data_key: 'telegramMessageCount', color: 'var(--chart-4)', label: 'Telegram' }, - { data_key: 'whatsappMessageCount', color: 'var(--chart-5)', label: 'WhatsApp' }, - ]} - filters={filtersComponent} - /> - )} - - {chartView === 'tokens' && ( - format(new Date(value), dateFormats[granularity])} - series={[ - { data_key: 'inputNoCacheTokens', color: 'var(--chart-1)', label: 'Input' }, - { data_key: 'inputCacheReadTokens', color: 'var(--chart-2)', label: 'Input (cache read)' }, - { data_key: 'inputCacheWriteTokens', color: 'var(--chart-3)', label: 'Input (cache write)' }, - { data_key: 'outputTotalTokens', color: 'var(--chart-4)', label: 'Output' }, - ]} - filters={filtersComponent} - /> - )} - - {chartView === 'cost' && ( - format(new Date(value), dateFormats[granularity])} - series={[ - { data_key: 'inputNoCacheCost', color: 'var(--chart-1)', label: 'Input' }, - { data_key: 'inputCacheReadCost', color: 'var(--chart-2)', label: 'Input (cache read)' }, - { data_key: 'inputCacheWriteCost', color: 'var(--chart-3)', label: 'Input (cache write)' }, - { data_key: 'outputCost', color: 'var(--chart-4)', label: 'Output' }, - ]} - filters={filtersComponent} - /> - )} - - - {recentFeedbacks.isLoading ? ( - Loading feedbacks... - ) : recentFeedbacks.isError ? ( - Failed to load feedbacks. - ) : !recentFeedbacks.data?.length ? ( - No feedbacks yet. - ) : ( - - - - Vote - User - Message - Reason - Date - - - - {recentFeedbacks.data.map((feedback) => ( - - - {feedback.vote === 'up' ? ( - - ) : ( - - )} - - {feedback.userName} - - {feedback.messageText ? ( - - {feedback.messageText.length > 60 - ? `${feedback.messageText.slice(0, 60)}...` - : feedback.messageText} - - ) : ( - No text. - )} - - - {feedback.explanation ? ( - - {feedback.explanation.length > 40 - ? `${feedback.explanation.slice(0, 40)}...` - : feedback.explanation} - - ) : ( - - - )} - - - {formatDistanceToNow(new Date(feedback.createdAt), { addSuffix: true })} - - - ))} - -
- )} -
-
+
+
+
+ {filtersComponent} + +
+
+ +
+ + format(new Date(value), dateFormats[granularity])} + titleAccessory={ + Number of messages by source + } + series={[ + { data_key: 'webMessageCount', color: 'var(--chart-1)', label: 'Web' }, + { data_key: 'slackMessageCount', color: 'var(--chart-2)', label: 'Slack' }, + { data_key: 'teamsMessageCount', color: 'var(--chart-3)', label: 'Teams' }, + { data_key: 'telegramMessageCount', color: 'var(--chart-4)', label: 'Telegram' }, + { data_key: 'whatsappMessageCount', color: 'var(--chart-5)', label: 'WhatsApp' }, + ]} + /> + + format(new Date(value), dateFormats[granularity])} + valueFormatter={showCost ? formatUsd : undefined} + series={showCost ? costSeries : tokenSeries} + titleAccessory={ + + } + /> +
+
+ +
+ +
+
+
); } + +function formatUsd(value: number): string { + const abs = Math.abs(value); + + if (abs >= 10_000) { + return `${value < 0 ? '-' : ''}$${formatCompactCurrency(Math.abs(value))}`; + } + + if (abs > 0 && abs < 0.01) { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 4, + maximumFractionDigits: 4, + }).format(value); + } + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: abs === 0 ? 0 : 2, + maximumFractionDigits: 2, + }).format(value); +} + +function formatCompactCurrency(value: number): string { + if (value >= 1_000_000_000) { + return `${(value / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`; + } + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + } + return `${(value / 1_000).toFixed(1).replace(/\.0$/, '')}K`; +} diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index b5d052018..e140d6c52 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -154,6 +154,7 @@ export interface BuildChartProps { series: displayChart.SeriesConfig[]; colorFor?: (key: string, index: number) => string; labelFormatter?: (value: string) => string; + valueFormatter?: (value: number) => string; showGrid?: boolean; children?: React.ReactNode[]; margin?: { top?: number; right?: number; bottom?: number; left?: number }; @@ -351,12 +352,17 @@ export function niceAxisMax(dataMax: number, tickCount = 5): number { } function buildKpiCard(props: ResolvedProps) { - const { data, series } = props; + const { data, series, valueFormatter } = props; return ( {series.map((s) => ( - + ))} ); @@ -366,11 +372,19 @@ function KpiCardContainer({ children }: { children: React.ReactNode }) { return
{children}
; } -function KpiCard({ value, displayName }: { value: unknown; displayName: string }) { +function KpiCard({ + value, + displayName, + valueFormatter = formatCompactNumber, +}: { + value: unknown; + displayName: string; + valueFormatter?: (value: number) => string; +}) { let formattedValue = ''; if (typeof value === 'number') { - formattedValue = formatCompactNumber(value); + formattedValue = valueFormatter(value); } else if (typeof value === 'string') { formattedValue = value; } @@ -383,7 +397,7 @@ function KpiCard({ value, displayName }: { value: unknown; displayName: string } ); } -function renderValueYAxis(isPercent = false) { +function renderValueYAxis(isPercent = false, valueFormatter = formatYAxisTick) { return ( ); } @@ -440,6 +454,7 @@ function buildBarChart(props: ResolvedProps) { yAxisMin, yAxisMax, showDataLabels, + valueFormatter, } = props; const isStacked = displayChart.isStackedChartType(chartType); const isPercent = displayChart.isPercentStackedChartType(chartType); @@ -460,7 +475,7 @@ function buildBarChart(props: ResolvedProps) { tickLine={false} axisLine={false} minTickGap={12} - tickFormatter={formatYAxisTick} + tickFormatter={valueFormatter ?? formatYAxisTick} domain={resolveYAxisDomain(yAxisMin, yAxisMax, axisValues, true)} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> @@ -542,6 +557,7 @@ function buildAreaChart(props: ResolvedProps) { yAxisMin, yAxisMax, showDataLabels, + valueFormatter, } = props; const isStacked = displayChart.isStackedChartType(chartType); const isPercent = displayChart.isPercentStackedChartType(chartType); @@ -574,7 +590,7 @@ function buildAreaChart(props: ResolvedProps) { tickLine={false} axisLine={false} minTickGap={12} - tickFormatter={formatYAxisTick} + tickFormatter={valueFormatter ?? formatYAxisTick} domain={resolveYAxisDomain(yAxisMin, yAxisMax, axisValues, zeroBaseline)} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> @@ -600,7 +616,19 @@ function buildAreaChart(props: ResolvedProps) { } function buildScatterChart(props: ResolvedProps) { - const { data, xAxisKey, xAxisType, series, colorFor, showGrid, children, margin, yAxisMin, yAxisMax } = props; + const { + data, + xAxisKey, + xAxisType, + series, + colorFor, + showGrid, + children, + margin, + yAxisMin, + yAxisMax, + valueFormatter, + } = props; const axisValues = collectAxisValues( data, series.map((s) => s.data_key), @@ -622,7 +650,7 @@ function buildScatterChart(props: ResolvedProps) { tickLine={false} axisLine={false} minTickGap={12} - tickFormatter={formatYAxisTick} + tickFormatter={valueFormatter ?? formatYAxisTick} domain={resolveYAxisDomain(yAxisMin, yAxisMax, axisValues, false)} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> @@ -640,13 +668,13 @@ function buildScatterChart(props: ResolvedProps) { } function buildRadarChart(props: ResolvedProps) { - const { data, xAxisKey, series, colorFor, children, margin } = props; + const { data, xAxisKey, series, colorFor, children, margin, valueFormatter } = props; return ( - + {children} {series.map((s, i) => ( Date: Tue, 21 Jul 2026 17:52:19 +0200 Subject: [PATCH 2/6] Re-order settings nav --- .../components/settings/chats-replay-page.tsx | 4 +- .../settings/chats-replay-table.tsx | 6 +-- .../components/settings/usage-chart-card.tsx | 7 +++- .../src/components/sidebar-settings-nav.tsx | 38 +++++++++---------- .../components/tool-calls/display-chart.tsx | 35 +++++++++-------- .../routes/_sidebar-layout.settings.usage.tsx | 29 ++++++++------ 6 files changed, 67 insertions(+), 52 deletions(-) diff --git a/apps/frontend/src/components/settings/chats-replay-page.tsx b/apps/frontend/src/components/settings/chats-replay-page.tsx index 5b7167a89..539c1106e 100644 --- a/apps/frontend/src/components/settings/chats-replay-page.tsx +++ b/apps/frontend/src/components/settings/chats-replay-page.tsx @@ -86,8 +86,8 @@ export function ChatsReplayPage({ }); return ( -
-
+
+
onOpenChat(chat.id)} />
diff --git a/apps/frontend/src/components/settings/chats-replay-table.tsx b/apps/frontend/src/components/settings/chats-replay-table.tsx index 9e9869f17..000a0f010 100644 --- a/apps/frontend/src/components/settings/chats-replay-table.tsx +++ b/apps/frontend/src/components/settings/chats-replay-table.tsx @@ -17,10 +17,10 @@ export function ChatsReplayTable({ table, onRowClick }: ChatsReplayTableProps) { const edgeCellClassName = 'first:pl-4 first:md:pl-8 last:pr-4 last:md:pr-8'; return ( -
-
+
+
- + {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( diff --git a/apps/frontend/src/components/settings/usage-chart-card.tsx b/apps/frontend/src/components/settings/usage-chart-card.tsx index 735696d2f..cc09326c2 100644 --- a/apps/frontend/src/components/settings/usage-chart-card.tsx +++ b/apps/frontend/src/components/settings/usage-chart-card.tsx @@ -13,6 +13,7 @@ export interface UsageChartCardProps { xAxisLabelFormatter?: (value: string) => string; valueFormatter?: (value: number) => string; titleAccessory?: ReactNode; + showLegend?: boolean; } export function UsageChartCard({ @@ -26,9 +27,10 @@ export function UsageChartCard({ xAxisLabelFormatter, valueFormatter, titleAccessory, + showLegend, }: UsageChartCardProps) { return ( - <> +
{isError ? (

Error loading usage data.

@@ -54,6 +56,7 @@ export function UsageChartCard({ valueFormatter={valueFormatter} series={series} titleAccessory={titleAccessory} + showLegend={showLegend} showGrid={true} chartContainerClassName={ chartType === 'kpi_card' @@ -66,6 +69,6 @@ export function UsageChartCard({ />
)} - +
); } diff --git a/apps/frontend/src/components/sidebar-settings-nav.tsx b/apps/frontend/src/components/sidebar-settings-nav.tsx index 7a005837f..874f7b029 100644 --- a/apps/frontend/src/components/sidebar-settings-nav.tsx +++ b/apps/frontend/src/components/sidebar-settings-nav.tsx @@ -68,10 +68,20 @@ const settingsNavItems: NavItem[] = [ visible: ({ isAdmin }) => isAdmin, }, { - label: 'Usage & costs', + label: 'Usage, costs & replay', to: '/settings/usage', visible: ({ isAdmin }) => isAdmin, }, + { + label: 'Server logs', + to: '/settings/logs', + visible: ({ isAdmin, isCloud }) => isAdmin && !isCloud, + }, + { + label: 'Context', + type: 'divider', + visible: ({ isViewer }) => !isViewer, + }, { label: 'Recommendations', to: '/settings/recommendations', @@ -80,9 +90,14 @@ const settingsNavItems: NavItem[] = [ badgeVariant: 'new', }, { - label: 'Logs', - to: '/settings/logs', - visible: ({ isAdmin, isCloud }) => isAdmin && !isCloud, + label: 'File Explorer', + to: '/settings/context-explorer', + visible: ({ isAdmin }) => isAdmin, + }, + { + label: 'Memory', + to: '/settings/memory', + visible: ({ isViewer }) => !isViewer, }, { label: 'Enterprise', @@ -99,21 +114,6 @@ const settingsNavItems: NavItem[] = [ to: '/settings/white-label', visible: ({ isAdmin, isCloud }) => isAdmin && !isCloud, }, - { - label: 'Context', - type: 'divider', - visible: ({ isViewer }) => !isViewer, - }, - { - label: 'Memory', - to: '/settings/memory', - visible: ({ isViewer }) => !isViewer, - }, - { - label: 'File Explorer', - to: '/settings/context-explorer', - visible: ({ isAdmin }) => isAdmin, - }, ]; interface SidebarSettingsNavProps { diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index d39573e76..cf568aa4f 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -315,6 +315,7 @@ export interface ChartDisplayProps { title?: string; titleStyle?: 'default' | 'left'; titleAccessory?: React.ReactNode; + showLegend?: boolean; showGrid?: boolean; yAxisMin?: number; yAxisMax?: number; @@ -335,6 +336,7 @@ export const ChartDisplay = memo(function ChartDisplay({ title, titleStyle = 'default', titleAccessory, + showLegend = true, showGrid = true, yAxisMin, yAxisMax, @@ -463,7 +465,7 @@ export const ChartDisplay = memo(function ChartDisplay({ /> } />, - chartType !== 'kpi_card' && !useInlineHeader && ( + showLegend && chartType !== 'kpi_card' && !useInlineHeader && ( -
+
{title} {titleAccessory}
- {canScrollLeft && ( + {showLegend && canScrollLeft && ( )} -
- -
- {canScrollRight && ( + {showLegend && ( +
+ +
+ )} + {showLegend && canScrollRight && (

{title}

- - {updatedAt != null ? formatDate(new Date(updatedAt), 'yyyy-MM-dd') : '—'} - +
+ + {updatedAt != null ? formatDate(new Date(updatedAt), 'yyyy-MM-dd') : '—'} + + {metadataAction} +
diff --git a/apps/frontend/src/components/settings/chats-replay-table.tsx b/apps/frontend/src/components/settings/chats-replay-table.tsx index 000a0f010..933d38b46 100644 --- a/apps/frontend/src/components/settings/chats-replay-table.tsx +++ b/apps/frontend/src/components/settings/chats-replay-table.tsx @@ -19,7 +19,7 @@ export function ChatsReplayTable({ table, onRowClick }: ChatsReplayTableProps) { return (
-
+
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/apps/frontend/src/components/settings/usage-filters.tsx b/apps/frontend/src/components/settings/usage-filters.tsx index 765d4a211..839bd84be 100644 --- a/apps/frontend/src/components/settings/usage-filters.tsx +++ b/apps/frontend/src/components/settings/usage-filters.tsx @@ -1,6 +1,7 @@ -import { ThumbsUp, Users, Wrench } from 'lucide-react'; +import { Radio, ThumbsUp, Users, Wrench } from 'lucide-react'; import { CHAT_REPLAY_FEEDBACK_STATES, CHAT_REPLAY_TOOL_STATES, providerLabels } from '@nao/shared/types'; -import type { Granularity } from '@nao/backend/usage'; +import { USAGE_SOURCES } from '@nao/backend/usage'; +import type { Granularity, UsageSource } from '@nao/backend/usage'; import type { ChatReplayFeedbackState, ChatReplayToolState, @@ -53,6 +54,8 @@ interface UsageFiltersProps { onSelectedFeedbackStatesChange: (value: ChatReplayFeedbackState[] | undefined) => void; selectedToolStates: ChatReplayToolState[] | undefined; onSelectedToolStatesChange: (value: ChatReplayToolState[] | undefined) => void; + selectedSources: UsageSource[] | undefined; + onSelectedSourcesChange: (value: UsageSource[] | undefined) => void; } export function UsageFilters({ @@ -68,6 +71,8 @@ export function UsageFilters({ onSelectedFeedbackStatesChange, selectedToolStates, onSelectedToolStatesChange, + selectedSources, + onSelectedSourcesChange, }: UsageFiltersProps) { const period = periodByGranularity[granularity]; const userOptions = (chatFacets?.userNames ?? []).map((name) => ({ @@ -84,6 +89,10 @@ export function UsageFilters({ value, label: feedbackStateLabels[value], })); + const sourceOptions = USAGE_SOURCES.map((value) => ({ + value, + label: sourceLabels[value], + })); return (
@@ -121,6 +130,13 @@ export function UsageFilters({ + = { downvotes: 'Downvotes', }; +const sourceLabels: Record = { + web: 'Web', + slack: 'Slack', + teams: 'Teams', + telegram: 'Telegram', + whatsapp: 'WhatsApp', + admin: 'Admin mode', + mcp: 'MCP', +}; + function MultiSelectFilter({ label, icon: Icon, diff --git a/apps/frontend/src/components/settings/usage-route-search.ts b/apps/frontend/src/components/settings/usage-route-search.ts index ef1fbab3f..e3a97369e 100644 --- a/apps/frontend/src/components/settings/usage-route-search.ts +++ b/apps/frontend/src/components/settings/usage-route-search.ts @@ -1,6 +1,8 @@ import { CHAT_REPLAY_FEEDBACK_STATES, CHAT_REPLAY_TOOL_STATES, providerLabels } from '@nao/shared/types'; -import type { Granularity } from '@nao/backend/usage'; +import { USAGE_SOURCES } from '@nao/backend/usage'; +import type { Granularity, UsageSource } from '@nao/backend/usage'; import type { ChatReplayFeedbackState, ChatReplayToolState, LlmProvider } from '@nao/shared/types'; +import { getActiveProjectId } from '@/lib/active-project'; export type TokenChartDisplayMode = 'tokens' | 'dollars'; @@ -10,6 +12,7 @@ export type UsageRouteSearch = { users: string[] | undefined; feedback: ChatReplayFeedbackState[] | undefined; tools: ChatReplayToolState[] | undefined; + sources: UsageSource[] | undefined; tokenView: TokenChartDisplayMode; }; @@ -19,11 +22,35 @@ export const DEFAULT_USAGE_SEARCH: UsageRouteSearch = { users: undefined, feedback: undefined, tools: undefined, + sources: undefined, tokenView: 'tokens', }; const granularities = ['hour', 'day', 'month'] as const satisfies readonly Granularity[]; const tokenViews = ['tokens', 'dollars'] as const satisfies readonly TokenChartDisplayMode[]; +const filterSearchKeys = ['provider', 'granularity', 'users', 'feedback', 'tools', 'sources'] as const; +const usageFiltersStorageKey = 'nao.usage-filters'; + +export function validateUsageSearchWithStoredFilters(search: Record): UsageRouteSearch { + const hasSearchFilters = filterSearchKeys.some((key) => search[key] !== undefined); + const storedFilters = hasSearchFilters ? {} : readStoredUsageFilters(); + + return validateUsageSearch({ ...storedFilters, ...search }); +} + +export function saveUsageFilters(search: UsageRouteSearch): void { + if (typeof window === 'undefined') { + return; + } + + const filters = Object.fromEntries(filterSearchKeys.map((key) => [key, search[key]])); + + try { + localStorage.setItem(getUsageFiltersStorageKey(), JSON.stringify(filters)); + } catch { + return; + } +} export function validateUsageSearch(search: Record): UsageRouteSearch { return { @@ -32,10 +59,31 @@ export function validateUsageSearch(search: Record): UsageRoute users: parseStringArray(search.users), feedback: parseArrayOf(search.feedback, CHAT_REPLAY_FEEDBACK_STATES), tools: parseArrayOf(search.tools, CHAT_REPLAY_TOOL_STATES), + sources: parseArrayOf(search.sources, USAGE_SOURCES), tokenView: parseOneOf(search.tokenView, tokenViews) ?? 'tokens', }; } +function readStoredUsageFilters(): Record { + if (typeof window === 'undefined') { + return {}; + } + + try { + const stored = localStorage.getItem(getUsageFiltersStorageKey()); + const parsed = stored ? JSON.parse(stored) : null; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function getUsageFiltersStorageKey(): string { + return `${usageFiltersStorageKey}.${getActiveProjectId() ?? 'default'}`; +} + function parseProvider(value: unknown): LlmProvider | 'all' { if (value === 'all' || (typeof value === 'string' && value in providerLabels)) { return value as LlmProvider | 'all'; diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.usage.replay.$chatId.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.usage.replay.$chatId.tsx index fd172b0b8..91252bf2d 100644 --- a/apps/frontend/src/routes/_sidebar-layout.settings.usage.replay.$chatId.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.settings.usage.replay.$chatId.tsx @@ -1,7 +1,9 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { Check, Link } from 'lucide-react'; import { ChatsReplayPanel } from '@/components/settings/chats-replay-panel'; import { validateUsageSearch } from '@/components/settings/usage-route-search'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { requireContextAdminOrAdmin } from '@/lib/require-admin'; export const Route = createFileRoute('/_sidebar-layout/settings/usage/replay/$chatId')({ @@ -14,10 +16,21 @@ function ChatReplayRoute() { const { chatId } = Route.useParams(); const usageSearch = Route.useSearch(); const navigate = useNavigate(); + const { isCopied, copy } = useCopyToClipboard(); return ( copy(window.location.href).catch(console.error)} + > + {isCopied ? : } + {isCopied ? 'Copied!' : 'Copy link'} + + } onBack={() => { navigate({ to: '/settings/usage', diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx index f49c8390c..aa9030ca7 100644 --- a/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { createFileRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { format } from 'date-fns'; @@ -5,7 +6,7 @@ import type { TokenChartDisplayMode, UsageRouteSearch } from '@/components/setti import { ChatsReplayPage } from '@/components/settings/chats-replay-page'; import { UsageChartCard } from '@/components/settings/usage-chart-card'; import { UsageFilters, dateFormats } from '@/components/settings/usage-filters'; -import { validateUsageSearch } from '@/components/settings/usage-route-search'; +import { saveUsageFilters, validateUsageSearchWithStoredFilters } from '@/components/settings/usage-route-search'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { trpc } from '@/main'; import { requireAdmin, requireContextAdminOrAdmin } from '@/lib/require-admin'; @@ -13,7 +14,7 @@ import { requireAdmin, requireContextAdminOrAdmin } from '@/lib/require-admin'; export const Route = createFileRoute('/_sidebar-layout/settings/usage')({ beforeLoad: ({ location }) => location.pathname.startsWith('/settings/usage/replay/') ? requireContextAdminOrAdmin() : requireAdmin(), - validateSearch: validateUsageSearch, + validateSearch: validateUsageSearchWithStoredFilters, component: UsagePage, }); @@ -42,6 +43,8 @@ const messageSeries = [ { data_key: 'teamsMessageCount', color: 'var(--chart-3)', label: 'Teams' }, { data_key: 'telegramMessageCount', color: 'var(--chart-4)', label: 'Telegram' }, { data_key: 'whatsappMessageCount', color: 'var(--chart-5)', label: 'WhatsApp' }, + { data_key: 'adminMessageCount', color: 'var(--violet)', label: 'Admin mode' }, + { data_key: 'mcpMessageCount', color: 'var(--destructive)', label: 'MCP' }, ] as const; function UsagePage() { @@ -51,6 +54,10 @@ function UsagePage() { select: (state) => state.location.pathname.startsWith('/settings/usage/replay/'), }); + useEffect(() => { + saveUsageFilters(usageSearch); + }, [usageSearch]); + if (isReplayRoute) { return ; } @@ -85,7 +92,7 @@ function UsageOverview({ onUpdateSearch: (next: Partial) => void; onOpenChatReplay: (chatId: string) => void; }) { - const { granularity, provider, users, feedback, tools, tokenView } = usageSearch; + const { granularity, provider, users, feedback, tools, sources, tokenView } = usageSearch; const usedProviders = useQuery(trpc.usage.getUsedProviders.queryOptions()); const chatFacets = useQuery({ @@ -100,6 +107,7 @@ function UsageOverview({ granularity, provider: provider === 'all' ? undefined : provider, userNames: users, + sources, }), placeholderData: keepPreviousData, }); @@ -108,6 +116,7 @@ function UsageOverview({ granularity, provider: provider === 'all' ? undefined : provider, userNames: users, + sources, }), placeholderData: keepPreviousData, }); @@ -134,6 +143,8 @@ function UsageOverview({ onSelectedFeedbackStatesChange={(value) => onUpdateSearch({ feedback: value })} selectedToolStates={tools} onSelectedToolStatesChange={(value) => onUpdateSearch({ tools: value })} + selectedSources={sources} + onSelectedSourcesChange={(value) => onUpdateSearch({ sources: value })} /> ); @@ -212,6 +223,7 @@ function UsageOverview({ selectedUserNames={users} selectedFeedbackStates={feedback} selectedToolStates={tools} + selectedSources={sources} onOpenChat={onOpenChatReplay} /> diff --git a/apps/shared/src/types.ts b/apps/shared/src/types.ts index d236c5c2a..b49a435d1 100644 --- a/apps/shared/src/types.ts +++ b/apps/shared/src/types.ts @@ -131,6 +131,7 @@ export type ProjectChatListItem = { userName: string; userRole: UserRole | null; title: string; + source: string | null; numberOfMessages: number; totalTokens: number; feedbackText: string; From 17dea6cae462351d63b9a65e5c7075b03468af2d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 14:34:08 +0000 Subject: [PATCH 4/6] fix review feedback for usage page Co-authored-by: Christophe Blefari --- apps/backend/src/queries/project.queries.ts | 13 +- apps/backend/src/queries/usage.queries.ts | 22 +-- apps/backend/src/types/usage.ts | 4 +- apps/backend/src/utils/date.ts | 1 + apps/backend/tests/usage-queries.test.ts | 78 +++++++++ .../src/components/settings-search-index.ts | 2 +- .../settings/chats-replay-columns.tsx | 6 +- .../settings/chats-replay-table.test.tsx | 55 ++++++ .../settings/chats-replay-table.tsx | 19 +- .../components/settings/usage-chart-card.tsx | 2 +- .../src/components/settings/usage-filters.tsx | 121 +++++++------ .../settings/usage-route-search.test.ts | 21 +++ .../components/settings/usage-route-search.ts | 2 +- .../src/components/sidebar-settings-nav.tsx | 5 + .../tool-calls/display-chart.test.tsx | 35 ++++ .../components/tool-calls/display-chart.tsx | 14 +- .../routes/_sidebar-layout.settings.usage.tsx | 165 ++++++++++-------- eslint.config.js | 7 - 18 files changed, 414 insertions(+), 158 deletions(-) create mode 100644 apps/backend/tests/usage-queries.test.ts create mode 100644 apps/frontend/src/components/settings/chats-replay-table.test.tsx create mode 100644 apps/frontend/src/components/settings/usage-route-search.test.ts create mode 100644 apps/frontend/src/components/tool-calls/display-chart.test.tsx diff --git a/apps/backend/src/queries/project.queries.ts b/apps/backend/src/queries/project.queries.ts index 01abb7c67..b332870bc 100644 --- a/apps/backend/src/queries/project.queries.ts +++ b/apps/backend/src/queries/project.queries.ts @@ -454,18 +454,7 @@ export const listProjectChats = async ( filterWhereClauses.push(expr); } } else if (filter.id === 'source') { - const expr = or( - ...filter.values.map( - (source) => sql`exists ( - select 1 - from ${s.chatMessage} as source_message - where source_message.chat_id = ${s.chat.id} - and source_message.role = 'user' - and source_message.source = ${source} - and source_message.superseded_at is null - )`, - ), - ); + const expr = or(...filter.values.map((source) => eq(sourceExpr, source))); if (expr) { filterWhereClauses.push(expr); } diff --git a/apps/backend/src/queries/usage.queries.ts b/apps/backend/src/queries/usage.queries.ts index e721f19ec..cb5b000e0 100644 --- a/apps/backend/src/queries/usage.queries.ts +++ b/apps/backend/src/queries/usage.queries.ts @@ -118,6 +118,7 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter): whatsappMessageCount: sql`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'whatsapp' then ${s.chatMessage.id} end)`, adminMessageCount: sql`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'admin' then ${s.chatMessage.id} end)`, mcpMessageCount: sql`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'mcp' then ${s.chatMessage.id} end)`, + contextRecommendationsMessageCount: sql`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'contextRecommendations' then ${s.chatMessage.id} end)`, inputNoCacheTokens: sum(s.chatMessage.inputNoCacheTokens), inputCacheReadTokens: sum(s.chatMessage.inputCacheReadTokens), inputCacheWriteTokens: sum(s.chatMessage.inputCacheWriteTokens), @@ -138,14 +139,15 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter): return fillMissingDates( rows.map((row) => ({ date: row.date, - messageCount: row.messageCount, - webMessageCount: row.webMessageCount, - slackMessageCount: row.slackMessageCount, - teamsMessageCount: row.teamsMessageCount, - telegramMessageCount: row.telegramMessageCount, - whatsappMessageCount: row.whatsappMessageCount, - adminMessageCount: row.adminMessageCount, - mcpMessageCount: row.mcpMessageCount, + messageCount: Number(row.messageCount ?? 0), + webMessageCount: Number(row.webMessageCount ?? 0), + slackMessageCount: Number(row.slackMessageCount ?? 0), + teamsMessageCount: Number(row.teamsMessageCount ?? 0), + telegramMessageCount: Number(row.telegramMessageCount ?? 0), + whatsappMessageCount: Number(row.whatsappMessageCount ?? 0), + adminMessageCount: Number(row.adminMessageCount ?? 0), + mcpMessageCount: Number(row.mcpMessageCount ?? 0), + contextRecommendationsMessageCount: Number(row.contextRecommendationsMessageCount ?? 0), inputNoCacheTokens: Number(row.inputNoCacheTokens ?? 0), inputCacheReadTokens: Number(row.inputCacheReadTokens ?? 0), inputCacheWriteTokens: Number(row.inputCacheWriteTokens ?? 0), @@ -191,8 +193,8 @@ export const getTotalUsage = async (projectId: string, filter: UsageFilter): Pro .where(and(...whereConditions)); return { - totalMessages: rows[0].totalMessages, - uniqueUsers: rows[0].uniqueUsers, + totalMessages: Number(rows[0]?.totalMessages ?? 0), + uniqueUsers: Number(rows[0]?.uniqueUsers ?? 0), }; }; diff --git a/apps/backend/src/types/usage.ts b/apps/backend/src/types/usage.ts index 3972fef80..d9e765664 100644 --- a/apps/backend/src/types/usage.ts +++ b/apps/backend/src/types/usage.ts @@ -1,11 +1,12 @@ import { z } from 'zod/v4'; +import { MESSAGE_SOURCES } from './chat'; import { llmProviderSchema } from './llm'; export const granularitySchema = z.enum(['hour', 'day', 'month']); export type Granularity = z.infer; -export const USAGE_SOURCES = ['web', 'slack', 'teams', 'telegram', 'whatsapp', 'admin', 'mcp'] as const; +export const USAGE_SOURCES = MESSAGE_SOURCES; export type UsageSource = (typeof USAGE_SOURCES)[number]; export const usageFilterSchema = z.object({ @@ -26,6 +27,7 @@ export interface UsageRecord { whatsappMessageCount: number; adminMessageCount: number; mcpMessageCount: number; + contextRecommendationsMessageCount: number; inputNoCacheTokens: number; inputCacheReadTokens: number; inputCacheWriteTokens: number; diff --git a/apps/backend/src/utils/date.ts b/apps/backend/src/utils/date.ts index b0d858fe2..a5eead1cb 100644 --- a/apps/backend/src/utils/date.ts +++ b/apps/backend/src/utils/date.ts @@ -113,6 +113,7 @@ export function fillMissingDates(records: UsageRecord[], granularity: Granularit whatsappMessageCount: 0, adminMessageCount: 0, mcpMessageCount: 0, + contextRecommendationsMessageCount: 0, inputNoCacheTokens: 0, inputCacheReadTokens: 0, inputCacheWriteTokens: 0, diff --git a/apps/backend/tests/usage-queries.test.ts b/apps/backend/tests/usage-queries.test.ts new file mode 100644 index 000000000..d61b3a103 --- /dev/null +++ b/apps/backend/tests/usage-queries.test.ts @@ -0,0 +1,78 @@ +import '../src/env'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getMessagesUsage, getTotalUsage } from '../src/queries/usage.queries'; +import { formatDate } from '../src/utils/date'; + +const queryRows = vi.hoisted(() => ({ value: [] as Record[] })); + +vi.mock('../src/db/db', () => { + const query = { + from: () => query, + innerJoin: () => query, + leftJoin: () => query, + where: () => query, + groupBy: async () => queryRows.value, + then: (resolve: (rows: Record[]) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve(queryRows.value).then(resolve, reject), + }; + + return { + db: { + select: () => query, + }, + }; +}); + +vi.mock('../src/queries/project-llm-config.queries', () => ({ + getProjectLlmConfigs: async () => [], +})); + +describe('usage query results', () => { + beforeEach(() => { + queryRows.value = []; + }); + + it('normalizes total usage aggregates to numbers', async () => { + queryRows.value = [{ totalMessages: '12', uniqueUsers: '4' }]; + + await expect(getTotalUsage('project-1', { granularity: 'day' })).resolves.toEqual({ + totalMessages: 12, + uniqueUsers: 4, + }); + }); + + it('normalizes message aggregates and includes context recommendations', async () => { + const date = formatDate(new Date(), 'day'); + queryRows.value = [ + { + date, + messageCount: '8', + webMessageCount: '1', + slackMessageCount: '1', + teamsMessageCount: '1', + telegramMessageCount: '1', + whatsappMessageCount: '1', + adminMessageCount: '1', + mcpMessageCount: '1', + contextRecommendationsMessageCount: '1', + }, + ]; + + const records = await getMessagesUsage('project-1', { granularity: 'day' }); + const record = records.find((item) => item.date === date); + + expect(record).toMatchObject({ + messageCount: 8, + webMessageCount: 1, + slackMessageCount: 1, + teamsMessageCount: 1, + telegramMessageCount: 1, + whatsappMessageCount: 1, + adminMessageCount: 1, + mcpMessageCount: 1, + contextRecommendationsMessageCount: 1, + }); + }); +}); diff --git a/apps/frontend/src/components/settings-search-index.ts b/apps/frontend/src/components/settings-search-index.ts index ab1f161dc..759f766a3 100644 --- a/apps/frontend/src/components/settings-search-index.ts +++ b/apps/frontend/src/components/settings-search-index.ts @@ -465,7 +465,7 @@ export const settingsSearchIndex: SettingsSearchEntry[] = [ title: 'Chats Replay', description: 'Replay and review past chat conversations.', keywords: ['history', 'conversation', 'replay', 'review'], - adminOnly: true, + adminOrContextAdmin: true, }, // ── Context Recommendations ────────────────────────────── diff --git a/apps/frontend/src/components/settings/chats-replay-columns.tsx b/apps/frontend/src/components/settings/chats-replay-columns.tsx index 8436fae79..d4b268fae 100644 --- a/apps/frontend/src/components/settings/chats-replay-columns.tsx +++ b/apps/frontend/src/components/settings/chats-replay-columns.tsx @@ -1,4 +1,4 @@ -import { CircleAlert, CircleHelp, Globe2, Shield, ThumbsDown, ThumbsUp } from 'lucide-react'; +import { CircleAlert, CircleHelp, Globe2, Lightbulb, Shield, ThumbsDown, ThumbsUp } from 'lucide-react'; import { differenceInDays, format, isToday, isYesterday } from 'date-fns'; import type { ColumnDef } from '@tanstack/react-table'; @@ -18,6 +18,10 @@ const sourceConfig = { whatsapp: { label: 'WhatsApp', icon: }, admin: { label: 'Admin mode', icon: }, mcp: { label: 'MCP', icon: }, + contextRecommendations: { + label: 'Context recommendations', + icon: , + }, } as const; export function getChatsReplayColumns(): ColumnDef[] { diff --git a/apps/frontend/src/components/settings/chats-replay-table.test.tsx b/apps/frontend/src/components/settings/chats-replay-table.test.tsx new file mode 100644 index 000000000..768bf0030 --- /dev/null +++ b/apps/frontend/src/components/settings/chats-replay-table.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ColumnDef } from '@tanstack/react-table'; +import type { ProjectChatListItem } from '@nao/shared/types'; +import { ChatsReplayTable } from './chats-replay-table'; + +const chat: ProjectChatListItem = { + id: 'chat-1', + updatedAt: Date.now(), + userId: 'user-1', + userName: 'User', + userRole: 'user', + title: 'Quarterly report', + source: 'web', + numberOfMessages: 2, + totalTokens: 10, + feedbackText: '', + downvotes: 0, + upvotes: 0, + toolErrorCount: 0, + toolAvailableCount: 0, +}; + +const columns: ColumnDef[] = [{ accessorKey: 'title', header: 'Title' }]; + +describe('ChatsReplayTable', () => { + afterEach(cleanup); + + it('opens a focused row with Enter or Space', () => { + const onRowClick = vi.fn(); + render(); + const row = screen.getByLabelText('Open chat: Quarterly report'); + + fireEvent.keyDown(row, { key: 'Enter' }); + fireEvent.keyDown(row, { key: ' ' }); + + expect(onRowClick).toHaveBeenCalledTimes(2); + expect(onRowClick).toHaveBeenCalledWith(chat); + expect(row.getAttribute('tabindex')).toBe('0'); + }); +}); + +function TestTable({ onRowClick }: { onRowClick: (chat: ProjectChatListItem) => void }) { + const table = useReactTable({ + data: [chat], + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ; +} diff --git a/apps/frontend/src/components/settings/chats-replay-table.tsx b/apps/frontend/src/components/settings/chats-replay-table.tsx index 933d38b46..376532029 100644 --- a/apps/frontend/src/components/settings/chats-replay-table.tsx +++ b/apps/frontend/src/components/settings/chats-replay-table.tsx @@ -1,6 +1,7 @@ import { ChevronDown } from 'lucide-react'; import { flexRender } from '@tanstack/react-table'; import type { Table } from '@tanstack/react-table'; +import type { KeyboardEvent } from 'react'; import type { ProjectChatListItem } from '@nao/shared/types'; import { TablePagination } from '@/components/ui/table-pagination'; @@ -68,7 +69,10 @@ export function ChatsReplayTable({ table, onRowClick }: ChatsReplayTableProps) { onRowClick(row.original)} - className='!border-0 cursor-pointer hover:bg-muted/50' + onKeyDown={(event) => handleRowKeyDown(event, row.original, onRowClick)} + tabIndex={0} + aria-label={`Open chat: ${row.original.title || 'Untitled'}`} + className='!border-0 cursor-pointer hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' > {row.getVisibleCells().map((cell) => ( @@ -95,3 +99,16 @@ export function ChatsReplayTable({ table, onRowClick }: ChatsReplayTableProps) {
); } + +function handleRowKeyDown( + event: KeyboardEvent, + chat: ProjectChatListItem, + onRowClick: (chat: ProjectChatListItem) => void, +) { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + onRowClick(chat); +} diff --git a/apps/frontend/src/components/settings/usage-chart-card.tsx b/apps/frontend/src/components/settings/usage-chart-card.tsx index cc09326c2..9eaa5802b 100644 --- a/apps/frontend/src/components/settings/usage-chart-card.tsx +++ b/apps/frontend/src/components/settings/usage-chart-card.tsx @@ -61,7 +61,7 @@ export function UsageChartCard({ chartContainerClassName={ chartType === 'kpi_card' ? undefined - : 'max-lg:h-[200px] max-lg:max-h-[200px] h-[320px] :max-h-[320px]' + : 'max-lg:h-[200px] max-lg:max-h-[200px] h-[320px] max-h-[320px]' } chartContentClassName={ chartType === 'kpi_card' ? undefined : 'max-lg:min-h-0 max-lg:flex-1 max-lg:aspect-auto' diff --git a/apps/frontend/src/components/settings/usage-filters.tsx b/apps/frontend/src/components/settings/usage-filters.tsx index 839bd84be..126babf71 100644 --- a/apps/frontend/src/components/settings/usage-filters.tsx +++ b/apps/frontend/src/components/settings/usage-filters.tsx @@ -42,6 +42,7 @@ export const dateFormats: Record = { }; interface UsageFiltersProps { + showUsageControls?: boolean; provider: LlmProvider | 'all'; onProviderChange: (value: LlmProvider | 'all') => void; granularity: Granularity; @@ -50,15 +51,12 @@ interface UsageFiltersProps { chatFacets: ProjectChatReplayFacets | undefined; selectedUserNames: string[] | undefined; onSelectedUserNamesChange: (value: string[] | undefined) => void; - selectedFeedbackStates: ChatReplayFeedbackState[] | undefined; - onSelectedFeedbackStatesChange: (value: ChatReplayFeedbackState[] | undefined) => void; - selectedToolStates: ChatReplayToolState[] | undefined; - onSelectedToolStatesChange: (value: ChatReplayToolState[] | undefined) => void; selectedSources: UsageSource[] | undefined; onSelectedSourcesChange: (value: UsageSource[] | undefined) => void; } export function UsageFilters({ + showUsageControls = true, provider, onProviderChange, granularity, @@ -67,10 +65,6 @@ export function UsageFilters({ chatFacets, selectedUserNames, onSelectedUserNamesChange, - selectedFeedbackStates, - onSelectedFeedbackStatesChange, - selectedToolStates, - onSelectedToolStatesChange, selectedSources, onSelectedSourcesChange, }: UsageFiltersProps) { @@ -80,15 +74,6 @@ export function UsageFilters({ label: name, count: chatFacets?.userNameCounts[name], })); - const toolStateOptions = CHAT_REPLAY_TOOL_STATES.map((value) => ({ - value, - label: toolStateLabels[value], - count: chatFacets?.toolState[value] ?? 0, - })).filter((option) => option.count > 0); - const feedbackOptions = CHAT_REPLAY_FEEDBACK_STATES.map((value) => ({ - value, - label: feedbackStateLabels[value], - })); const sourceOptions = USAGE_SOURCES.map((value) => ({ value, label: sourceLabels[value], @@ -96,39 +81,43 @@ export function UsageFilters({ return (
- - + {showUsageControls && ( + <> + + + + )} +
+ ); +} + +type ReplayFiltersProps = { + chatFacets: ProjectChatReplayFacets | undefined; + selectedFeedbackStates: ChatReplayFeedbackState[] | undefined; + onSelectedFeedbackStatesChange: (value: ChatReplayFeedbackState[] | undefined) => void; + selectedToolStates: ChatReplayToolState[] | undefined; + onSelectedToolStatesChange: (value: ChatReplayToolState[] | undefined) => void; +}; + +export function ReplayFilters({ + chatFacets, + selectedFeedbackStates, + onSelectedFeedbackStatesChange, + selectedToolStates, + onSelectedToolStatesChange, +}: ReplayFiltersProps) { + const toolStateOptions = CHAT_REPLAY_TOOL_STATES.map((value) => ({ + value, + label: toolStateLabels[value], + count: chatFacets?.toolState[value] ?? 0, + })).filter((option) => option.count > 0); + const feedbackOptions = CHAT_REPLAY_FEEDBACK_STATES.map((value) => ({ + value, + label: feedbackStateLabels[value], + })); + + return ( +
= { whatsapp: 'WhatsApp', admin: 'Admin mode', mcp: 'MCP', + contextRecommendations: 'Context recommendations', }; function MultiSelectFilter({ diff --git a/apps/frontend/src/components/settings/usage-route-search.test.ts b/apps/frontend/src/components/settings/usage-route-search.test.ts new file mode 100644 index 000000000..8f92ccea0 --- /dev/null +++ b/apps/frontend/src/components/settings/usage-route-search.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { validateUsageSearch } from './usage-route-search'; + +describe('validateUsageSearch', () => { + it('accepts supported providers', () => { + expect(validateUsageSearch({ provider: 'openai' }).provider).toBe('openai'); + expect(validateUsageSearch({ provider: 'all' }).provider).toBe('all'); + }); + + it('rejects inherited provider label properties', () => { + expect(validateUsageSearch({ provider: 'constructor' }).provider).toBe('all'); + expect(validateUsageSearch({ provider: 'toString' }).provider).toBe('all'); + }); + + it('accepts context recommendations as a source', () => { + expect(validateUsageSearch({ sources: ['contextRecommendations'] }).sources).toEqual([ + 'contextRecommendations', + ]); + }); +}); diff --git a/apps/frontend/src/components/settings/usage-route-search.ts b/apps/frontend/src/components/settings/usage-route-search.ts index e3a97369e..a1432de74 100644 --- a/apps/frontend/src/components/settings/usage-route-search.ts +++ b/apps/frontend/src/components/settings/usage-route-search.ts @@ -85,7 +85,7 @@ function getUsageFiltersStorageKey(): string { } function parseProvider(value: unknown): LlmProvider | 'all' { - if (value === 'all' || (typeof value === 'string' && value in providerLabels)) { + if (value === 'all' || (typeof value === 'string' && Object.hasOwn(providerLabels, value))) { return value as LlmProvider | 'all'; } return 'all'; diff --git a/apps/frontend/src/components/sidebar-settings-nav.tsx b/apps/frontend/src/components/sidebar-settings-nav.tsx index 874f7b029..5cd6c54a4 100644 --- a/apps/frontend/src/components/sidebar-settings-nav.tsx +++ b/apps/frontend/src/components/sidebar-settings-nav.tsx @@ -72,6 +72,11 @@ const settingsNavItems: NavItem[] = [ to: '/settings/usage', visible: ({ isAdmin }) => isAdmin, }, + { + label: 'Chats replay', + to: '/settings/usage', + visible: ({ isAdmin, isContextAdmin }) => !isAdmin && isContextAdmin, + }, { label: 'Server logs', to: '/settings/logs', diff --git a/apps/frontend/src/components/tool-calls/display-chart.test.tsx b/apps/frontend/src/components/tool-calls/display-chart.test.tsx new file mode 100644 index 000000000..3143e6d54 --- /dev/null +++ b/apps/frontend/src/components/tool-calls/display-chart.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ChartDisplay } from './display-chart'; + +vi.mock('@/hooks/use-date-format', () => ({ + useDateFormat: () => ({ preset: 'european' }), +})); + +describe('ChartDisplay', () => { + afterEach(cleanup); + + it('renders a left-aligned title for KPI cards', () => { + render( + , + ); + + expect(screen.getByText('Messages')).toBeDefined(); + expect(screen.getByText('Total messages')).toBeDefined(); + expect(screen.getByText('Unique users')).toBeDefined(); + }); +}); diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index cf568aa4f..810d30cd2 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -363,6 +363,7 @@ export const ChartDisplay = memo(function ChartDisplay({ [isPie, data, xAxisKey, pieValueKey], ); const useInlineHeader = titleStyle === 'left' && Boolean(title) && !isPie; + const showInlineLegend = showLegend && chartType !== 'kpi_card'; const { scrollRef, canScrollLeft, canScrollRight, scrollLegend } = useHorizontalScrollControls(); const chartConfig = useMemo((): ChartConfig => { @@ -510,11 +511,11 @@ export const ChartDisplay = memo(function ChartDisplay({ const inlineHeader = useInlineHeader ? (
-
+
{title} {titleAccessory}
- {showLegend && canScrollLeft && ( + {showInlineLegend && canScrollLeft && ( )} - {showLegend && ( + {showInlineLegend && (
)} - {showLegend && canScrollRight && ( + {showInlineLegend && canScrollRight && (