Skip to content

Commit 980f3ed

Browse files
authored
🩻 feat: improve agent filter controls for insights (#15807)
* feat(insights): improve agent filter controls * feat(insights): add search and fixed agent selection controls * fix(insights): show search before all agents control * test(e2e): allow steer consumption before arm response
1 parent afaf22f commit 980f3ed

5 files changed

Lines changed: 326 additions & 49 deletions

File tree

client/src/components/Insights/InsightsView.tsx

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect, useId, useMemo, useRef, useState } from 'react';
22
import { useTranslation } from 'react-i18next';
3-
import { AlertCircle, Info, Search } from 'lucide-react';
43
import { Navigate, useSearchParams } from 'react-router-dom';
4+
import { AlertCircle, Check, Info, Minus, Search } from 'lucide-react';
55
import {
66
Button,
77
Input,
@@ -565,6 +565,7 @@ export default function InsightsView() {
565565
const [searchInput, setSearchInput] = useState('');
566566
const [search, setSearch] = useState('');
567567
const [page, setPage] = useState(1);
568+
const [pendingAgentIds, setPendingAgentIds] = useState<string[] | null>(null);
568569
const dateRangeSelectionTimeout = useRef<number>();
569570
const isSmallScreen = useMediaQuery('(max-width: 768px)');
570571
const insightsFeatureEnabled = startupConfig?.insightsEnabled === true;
@@ -621,6 +622,7 @@ export default function InsightsView() {
621622
() => (selectedAgentIds.length > 0 ? selectedAgentIds : agentItems.map((agent) => agent.value)),
622623
[agentItems, selectedAgentIds],
623624
);
625+
const displayedAgentIds = pendingAgentIds ?? effectiveAgentIds;
624626

625627
useDocumentTitle(`${localize('com_insights_title')} | LibreChat`);
626628

@@ -699,9 +701,14 @@ export default function InsightsView() {
699701
};
700702

701703
const handleAgentSelection = (agentIds: string[]) => {
702-
if (agentIds.length === 0 || !data) {
704+
if (!data) {
705+
return;
706+
}
707+
if (agentIds.length === 0) {
708+
setPendingAgentIds([]);
703709
return;
704710
}
711+
setPendingAgentIds(null);
705712
const normalizedIds = [...new Set(agentIds)].sort();
706713
const allAgentIds = data.agents.map((agent) => agent.id).sort();
707714
const nextParams = new URLSearchParams(urlSearchParams);
@@ -717,6 +724,9 @@ export default function InsightsView() {
717724
setUrlSearchParams(nextParams, { replace: true });
718725
setPage(1);
719726
};
727+
const allAgentsDisplayed =
728+
agentItems.length > 0 && displayedAgentIds.length === agentItems.length;
729+
const someAgentsDisplayed = displayedAgentIds.length > 0;
720730

721731
if (
722732
configLoading ||
@@ -742,16 +752,30 @@ export default function InsightsView() {
742752
<div className="flex max-w-full flex-wrap items-center gap-2 md:flex-nowrap">
743753
{data && (
744754
<MultiSelect
755+
placeholder={localize('com_ui_agents')}
745756
items={agentItems}
746-
selectedValues={effectiveAgentIds}
757+
selectedValues={displayedAgentIds}
747758
setSelectedValues={handleAgentSelection}
748-
disabled={agentItems.length === 1}
759+
onOpenChange={(open) => {
760+
if (!open) {
761+
setPendingAgentIds(null);
762+
}
763+
}}
764+
disabled={agentItems.length <= 1}
749765
showSelectedValues
750-
className="w-full min-w-0 sm:w-56"
766+
showItemCheckboxes
767+
searchPlaceholder={
768+
agentItems.length > 10 ? localize('com_insights_search_agents') : undefined
769+
}
770+
searchEmptyText={localize('com_insights_no_agents_found')}
771+
className="w-full min-w-0 sm:w-72"
751772
selectClassName="h-8 w-full rounded border border-border-medium bg-surface-tertiary px-3 py-1 shadow-none hover:border-border-heavy data-[state=open]:border-border-heavy dark:hover:bg-chart-widget-stroke dark:data-[state=open]:bg-chart-widget-stroke"
752773
itemClassName="rounded-none px-4 py-1.5"
753774
popoverClassName="max-h-80 rounded border-border-medium bg-surface-primary px-0 py-2 dark:bg-chart-widget-surface"
754775
renderSelectedValues={(values) => {
776+
if (values.length === 0) {
777+
return localize('com_insights_no_agents_selected');
778+
}
755779
if (values.length === agentItems.length) {
756780
return localize('com_insights_all_agents', { count: agentItems.length });
757781
}
@@ -761,16 +785,40 @@ export default function InsightsView() {
761785
return localize('com_insights_agents_selected', { count: values.length });
762786
}}
763787
popoverHeader={
764-
<div className="border-b border-border-light pb-2">
788+
<div className="border-b border-border-light">
765789
<Button
766790
type="button"
767791
size="sm"
768792
variant="ghost"
769-
className="h-8 w-full justify-start rounded-none px-4 font-normal text-text-secondary hover:bg-surface-hover hover:text-text-primary"
770-
disabled={effectiveAgentIds.length === agentItems.length}
771-
onClick={() => handleAgentSelection(agentItems.map((agent) => agent.value))}
793+
aria-label={
794+
displayedAgentIds.length > 0
795+
? localize('com_ui_clear_all')
796+
: localize('com_insights_select_all_agents')
797+
}
798+
className="h-10 w-full justify-start gap-2 rounded-none px-4 font-medium text-text-primary hover:bg-surface-hover"
799+
onClick={() =>
800+
handleAgentSelection(
801+
displayedAgentIds.length > 0 ? [] : agentItems.map((agent) => agent.value),
802+
)
803+
}
772804
>
773-
{localize('com_insights_select_all_agents')}
805+
<span
806+
aria-hidden="true"
807+
className={cn(
808+
'flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-xheavy',
809+
displayedAgentIds.length > 0 && 'bg-surface-inverted text-text-inverted',
810+
)}
811+
>
812+
{allAgentsDisplayed && <Check className="size-3.5" strokeWidth={2} />}
813+
{someAgentsDisplayed && !allAgentsDisplayed && (
814+
<Minus className="size-3.5" strokeWidth={2} />
815+
)}
816+
</span>
817+
<span>{localize('com_insights_all_agents_label')}</span>
818+
{displayedAgentIds.length > 0 &&
819+
displayedAgentIds.length < agentItems.length && (
820+
<span className="text-text-secondary">({displayedAgentIds.length})</span>
821+
)}
774822
</Button>
775823
</div>
776824
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import React from 'react';
2+
import { MemoryRouter } from 'react-router-dom';
3+
import userEvent from '@testing-library/user-event';
4+
import { render, screen, waitFor } from '@testing-library/react';
5+
import type { TInsightsParams } from 'librechat-data-provider';
6+
import InsightsView from '../InsightsView';
7+
8+
const insightsData = {
9+
agents: [
10+
{ id: 'agent-1', name: 'Alpha' },
11+
{ id: 'agent-2', name: 'Beta' },
12+
{ id: 'agent-3', name: 'Gamma' },
13+
],
14+
summary: { totalConversations: 4, totalUsers: 2, totalMessages: 8, totalTokens: 100 },
15+
daily: [],
16+
latest: { conversations: [], page: 1, pages: 1 },
17+
topUsers: [],
18+
churnedUsers: [],
19+
};
20+
21+
const mockUseInsightsQuery = jest.fn((params: TInsightsParams) => ({
22+
data: insightsData,
23+
isLoading: false,
24+
isFetching: false,
25+
error: null,
26+
params,
27+
}));
28+
29+
jest.mock('~/data-provider', () => ({
30+
useGetStartupConfig: () => ({ data: { insightsEnabled: true }, isLoading: false }),
31+
useInsightsQuery: (params: TInsightsParams) => mockUseInsightsQuery(params),
32+
}));
33+
34+
jest.mock('~/hooks', () => ({
35+
useLocalize: () => (key: string, options?: { count?: number; name?: string }) => {
36+
if (options?.count != null) {
37+
return `${key}:${options.count}`;
38+
}
39+
return options?.name ? `${key}:${options.name}` : key;
40+
},
41+
useAuthContext: () => ({ user: { id: 'user-1' } }),
42+
useDocumentTitle: () => undefined,
43+
}));
44+
45+
jest.mock('react-i18next', () => ({
46+
useTranslation: () => ({ i18n: { language: 'en', resolvedLanguage: 'en' } }),
47+
}));
48+
49+
jest.mock('~/components/Chat/Menus/OpenSidebar', () => ({
50+
__esModule: true,
51+
default: () => null,
52+
}));
53+
54+
jest.mock('~/components/ui', () => ({
55+
LocalizedDateRangePicker: () => null,
56+
}));
57+
58+
function lastQueryParams(): TInsightsParams {
59+
return mockUseInsightsQuery.mock.calls[mockUseInsightsQuery.mock.calls.length - 1][0];
60+
}
61+
62+
function renderView() {
63+
return render(
64+
<MemoryRouter initialEntries={['/insights']}>
65+
<InsightsView />
66+
</MemoryRouter>,
67+
);
68+
}
69+
70+
async function openAgentMenu(user: ReturnType<typeof userEvent.setup>) {
71+
await user.click(screen.getByText('com_insights_all_agents:3'));
72+
await screen.findByRole('option', { name: 'Alpha' });
73+
}
74+
75+
describe('InsightsView agent selection', () => {
76+
beforeEach(() => {
77+
mockUseInsightsQuery.mockClear();
78+
mockUseInsightsQuery.mockImplementation((params) => ({
79+
data: insightsData,
80+
isLoading: false,
81+
isFetching: false,
82+
error: null,
83+
params,
84+
}));
85+
});
86+
87+
it('filters names without changing selection and clears search on close', async () => {
88+
mockUseInsightsQuery.mockImplementation((params) => ({
89+
data: {
90+
...insightsData,
91+
agents: Array.from({ length: 12 }, (_, i) => ({ id: `agent-${i}`, name: `Agent ${i}` })),
92+
},
93+
isLoading: false,
94+
isFetching: false,
95+
error: null,
96+
params,
97+
}));
98+
const user = userEvent.setup();
99+
renderView();
100+
await user.click(screen.getByText('com_insights_all_agents:12'));
101+
const search = screen.getByRole('textbox', { name: 'com_insights_search_agents' });
102+
await user.type(search, 'AGENT 11');
103+
expect(screen.getAllByRole('option')).toHaveLength(1);
104+
expect(lastQueryParams().agentIds).toBeUndefined();
105+
await user.click(screen.getByRole('button', { name: 'com_ui_clear_all' }));
106+
await user.click(screen.getByRole('button', { name: 'com_insights_select_all_agents' }));
107+
expect(screen.getByText('com_insights_all_agents:12')).toBeInTheDocument();
108+
await user.clear(search);
109+
await user.type(search, 'no match');
110+
expect(screen.getByRole('status')).toHaveTextContent('com_insights_no_agents_found');
111+
await user.keyboard('{Escape}');
112+
await user.click(screen.getByText('com_insights_all_agents:12'));
113+
expect(screen.getByRole('textbox', { name: 'com_insights_search_agents' })).toHaveValue('');
114+
expect(screen.getAllByRole('option')).toHaveLength(12);
115+
});
116+
117+
it('clears every agent without committing the empty selection', async () => {
118+
const user = userEvent.setup();
119+
renderView();
120+
await openAgentMenu(user);
121+
122+
await user.click(screen.getByRole('button', { name: 'com_ui_clear_all' }));
123+
124+
expect(screen.getByText('com_insights_no_agents_selected')).toBeInTheDocument();
125+
for (const option of screen.getAllByRole('option')) {
126+
expect(option).toHaveAttribute('aria-selected', 'false');
127+
}
128+
expect(lastQueryParams().agentIds).toBeUndefined();
129+
});
130+
131+
it('commits only the agents picked after clearing', async () => {
132+
const user = userEvent.setup();
133+
renderView();
134+
await openAgentMenu(user);
135+
136+
await user.click(screen.getByRole('button', { name: 'com_ui_clear_all' }));
137+
await user.click(screen.getByRole('option', { name: 'Beta' }));
138+
139+
await waitFor(() => expect(lastQueryParams().agentIds).toEqual(['agent-2']));
140+
});
141+
142+
it('restores the committed selection when the menu closes while empty', async () => {
143+
const user = userEvent.setup();
144+
renderView();
145+
await openAgentMenu(user);
146+
147+
await user.click(screen.getByRole('button', { name: 'com_ui_clear_all' }));
148+
await user.keyboard('{Escape}');
149+
150+
await waitFor(() => expect(screen.getByText('com_insights_all_agents:3')).toBeInTheDocument());
151+
expect(lastQueryParams().agentIds).toBeUndefined();
152+
});
153+
154+
it('re-enables select all from the cleared state', async () => {
155+
const user = userEvent.setup();
156+
renderView();
157+
await openAgentMenu(user);
158+
159+
await user.click(screen.getByRole('button', { name: 'com_ui_clear_all' }));
160+
const selectAll = screen.getByRole('button', { name: 'com_insights_select_all_agents' });
161+
162+
await user.click(selectAll);
163+
expect(screen.getByText('com_insights_all_agents:3')).toBeInTheDocument();
164+
expect(lastQueryParams().agentIds).toBeUndefined();
165+
});
166+
});

client/src/locales/en/translation.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2656,7 +2656,11 @@
26562656
"com_insights_load_error": "Insights could not be loaded. Try again.",
26572657
"com_insights_forbidden": "You do not have access to insights for these agents.",
26582658
"com_insights_all_agents": "All {{count}} agents",
2659+
"com_insights_all_agents_label": "All agents",
26592660
"com_insights_agents_selected": "{{count}} agents",
2661+
"com_insights_no_agents_selected": "No agents selected",
2662+
"com_insights_search_agents": "Search agents",
2663+
"com_insights_no_agents_found": "No agents found",
26602664
"com_insights_select_all_agents": "Select all",
26612665
"com_insights_total_users": "Unique users",
26622666
"com_insights_total_conversations": "Conversations",

e2e/specs/mock/steering-escalation.spec.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,8 @@ test.describe('escalating waiting messages to an interrupt', () => {
166166
expect(armResponse.status()).toBe(200);
167167
expect(((await armResponse.json()) as { armed?: boolean }).armed).toBe(true);
168168

169-
// Relabelled IN PLACE: still exactly one bubble with the same text, and
170-
// an interrupting steer no longer offers its escalation control.
171-
await expect(inFlightSteers(page)).toHaveCount(1);
169+
// The stream can consume the armed steer before the HTTP response arrives.
170+
// Whether waiting or already applied, it must no longer offer escalation.
172171
await expect(bubble.getByTestId('steer-escalate-now')).toHaveCount(0);
173172

174173
// The armed steer seals mid-stream and injects with no tool boundary.

0 commit comments

Comments
 (0)