diff --git a/.changeset/small-mangos-bake.md b/.changeset/small-mangos-bake.md
new file mode 100644
index 00000000..5013a536
--- /dev/null
+++ b/.changeset/small-mangos-bake.md
@@ -0,0 +1,5 @@
+---
+"@hyperdx/app": minor
+---
+
+feat: Add input filter pills below search input to make filters usage more clear on seach page.
diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx
index b7617cae..514bbbf6 100644
--- a/packages/app/src/DBSearchPage.tsx
+++ b/packages/app/src/DBSearchPage.tsx
@@ -78,6 +78,7 @@ import { useIsFetching } from '@tanstack/react-query';
import { SortingState } from '@tanstack/react-table';
import CodeMirror from '@uiw/react-codemirror';
+import { ActiveFilterPills } from '@/components/ActiveFilterPills';
import { ContactSupportText } from '@/components/ContactSupportText';
import { DBSearchPageFilters } from '@/components/DBSearchPageFilters';
import { DBTimeChart } from '@/components/DBTimeChart';
@@ -1779,6 +1780,7 @@ function DBSearchPage() {
+
{searchedConfig != null && searchedSource != null && (
void;
+}) {
+ const isExcluded = pill.type === 'excluded';
+ const operator = isExcluded ? ' != ' : pill.type === 'range' ? ': ' : ' = ';
+
+ return (
+
+
+
+ {pill.field}
+
+
+ {operator}
+
+
+ {pill.value}
+
+
+
+
+
+
+ );
+}
+
+export const ActiveFilterPills = memo(function ActiveFilterPills({
+ searchFilters,
+ ...flexProps
+}: {
+ searchFilters: FilterStateHook;
+} & FlexProps) {
+ const { filters, setFilterValue, clearFilter, clearAllFilters } =
+ searchFilters;
+
+ const pills = useMemo(() => flattenFilters(filters), [filters]);
+ const [expanded, setExpanded] = useState(false);
+ const [confirmClear, setConfirmClear] = useState(false);
+ const confirmTimerRef = useRef>(undefined);
+
+ useEffect(() => {
+ return () => clearTimeout(confirmTimerRef.current);
+ }, []);
+
+ const handleRemove = useCallback(
+ (pill: PillItem) => {
+ if (pill.type === 'range') {
+ clearFilter(pill.field);
+ } else {
+ setFilterValue(
+ pill.field,
+ pill.rawValue!,
+ pill.type === 'excluded' ? 'exclude' : undefined,
+ );
+ }
+ },
+ [setFilterValue, clearFilter],
+ );
+
+ const handleClearAll = useCallback(() => {
+ if (!confirmClear) {
+ setConfirmClear(true);
+ clearTimeout(confirmTimerRef.current);
+ confirmTimerRef.current = setTimeout(() => setConfirmClear(false), 2000);
+ return;
+ }
+ clearAllFilters();
+ setConfirmClear(false);
+ clearTimeout(confirmTimerRef.current);
+ }, [confirmClear, clearAllFilters]);
+
+ if (pills.length === 0) {
+ return null;
+ }
+
+ const visiblePills = expanded ? pills : pills.slice(0, MAX_VISIBLE_PILLS);
+ const hiddenCount = pills.length - MAX_VISIBLE_PILLS;
+
+ return (
+
+ {visiblePills.map((pill, i) => (
+ handleRemove(pill)}
+ />
+ ))}
+ {!expanded && hiddenCount > 0 && (
+ setExpanded(true)}
+ >
+ +{hiddenCount} more
+
+ )}
+ {expanded && hiddenCount > 0 && (
+ setExpanded(false)}
+ >
+ Show less
+
+ )}
+ {pills.length >= 2 && (
+ setConfirmClear(false)}
+ >
+ {confirmClear ? 'Confirm clear all?' : 'Clear all'}
+
+ )}
+
+ );
+});
diff --git a/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx
new file mode 100644
index 00000000..cbe4efd4
--- /dev/null
+++ b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx
@@ -0,0 +1,277 @@
+import { act, fireEvent, screen } from '@testing-library/react';
+
+import type { FilterStateHook } from '@/searchFilters';
+
+import { ActiveFilterPills } from '../ActiveFilterPills';
+
+function makeSearchFilters(
+ filters: FilterStateHook['filters'],
+): FilterStateHook {
+ return {
+ filters,
+ setFilters: jest.fn(),
+ setFilterValue: jest.fn(),
+ setFilterRange: jest.fn(),
+ clearFilter: jest.fn(),
+ clearAllFilters: jest.fn(),
+ };
+}
+
+describe('ActiveFilterPills', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('renders nothing when there are no filters', () => {
+ const searchFilters = makeSearchFilters({});
+ renderWithMantine();
+ expect(screen.queryByText('Clear all')).not.toBeInTheDocument();
+ expect(screen.queryByText(' = ')).not.toBeInTheDocument();
+ });
+
+ it('renders included filter pills', () => {
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(['200', '404']),
+ excluded: new Set(),
+ },
+ });
+ renderWithMantine();
+ expect(screen.getByText('200')).toBeInTheDocument();
+ expect(screen.getByText('404')).toBeInTheDocument();
+ expect(screen.getAllByText('status')).toHaveLength(2);
+ });
+
+ it('renders excluded filter pills', () => {
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(),
+ excluded: new Set(['500']),
+ },
+ });
+ renderWithMantine();
+ expect(screen.getByText('500')).toBeInTheDocument();
+ expect(screen.getByText('status')).toBeInTheDocument();
+ });
+
+ it('renders range filter pills', () => {
+ const searchFilters = makeSearchFilters({
+ duration: {
+ included: new Set(),
+ excluded: new Set(),
+ range: { min: 100, max: 500 },
+ },
+ });
+ renderWithMantine();
+ expect(screen.getByText('duration')).toBeInTheDocument();
+ expect(screen.getByText('100 – 500')).toBeInTheDocument();
+ });
+
+ it('calls setFilterValue when removing an included pill', () => {
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(['200']),
+ excluded: new Set(),
+ },
+ });
+ renderWithMantine();
+ // Click the x button (the svg icon's parent ActionIcon)
+ const removeButtons = screen.getAllByRole('button');
+ fireEvent.click(removeButtons[0]);
+ expect(searchFilters.setFilterValue).toHaveBeenCalledWith(
+ 'status',
+ '200',
+ undefined,
+ );
+ });
+
+ it('calls setFilterValue with exclude action when removing an excluded pill', () => {
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(),
+ excluded: new Set(['500']),
+ },
+ });
+ renderWithMantine();
+ const removeButtons = screen.getAllByRole('button');
+ fireEvent.click(removeButtons[0]);
+ expect(searchFilters.setFilterValue).toHaveBeenCalledWith(
+ 'status',
+ '500',
+ 'exclude',
+ );
+ });
+
+ it('calls clearFilter when removing a range pill', () => {
+ const searchFilters = makeSearchFilters({
+ duration: {
+ included: new Set(),
+ excluded: new Set(),
+ range: { min: 0, max: 100 },
+ },
+ });
+ renderWithMantine();
+ const removeButtons = screen.getAllByRole('button');
+ fireEvent.click(removeButtons[0]);
+ expect(searchFilters.clearFilter).toHaveBeenCalledWith('duration');
+ });
+
+ it('does not show "Clear all" when there is only one pill', () => {
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(['200']),
+ excluded: new Set(),
+ },
+ });
+ renderWithMantine();
+ expect(screen.queryByText('Clear all')).not.toBeInTheDocument();
+ });
+
+ it('shows "Clear all" when there are 2+ pills', () => {
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(['200', '404']),
+ excluded: new Set(),
+ },
+ });
+ renderWithMantine();
+ expect(screen.getByText('Clear all')).toBeInTheDocument();
+ });
+
+ it('requires double click to clear all filters', () => {
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(['200', '404']),
+ excluded: new Set(),
+ },
+ });
+ renderWithMantine();
+
+ // First click shows confirmation
+ fireEvent.click(screen.getByText('Clear all'));
+ expect(searchFilters.clearAllFilters).not.toHaveBeenCalled();
+ expect(screen.getByText('Confirm clear all?')).toBeInTheDocument();
+
+ // Second click actually clears
+ fireEvent.click(screen.getByText('Confirm clear all?'));
+ expect(searchFilters.clearAllFilters).toHaveBeenCalledTimes(1);
+ });
+
+ it('resets confirm state after 2s timeout', () => {
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(['200', '404']),
+ excluded: new Set(),
+ },
+ });
+ renderWithMantine();
+
+ fireEvent.click(screen.getByText('Clear all'));
+ expect(screen.getByText('Confirm clear all?')).toBeInTheDocument();
+
+ act(() => {
+ jest.advanceTimersByTime(2000);
+ });
+
+ expect(screen.getByText('Clear all')).toBeInTheDocument();
+ expect(screen.queryByText('Confirm clear all?')).not.toBeInTheDocument();
+ });
+
+ it('resets confirm state on mouse leave', () => {
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(['200', '404']),
+ excluded: new Set(),
+ },
+ });
+ renderWithMantine();
+
+ fireEvent.click(screen.getByText('Clear all'));
+ expect(screen.getByText('Confirm clear all?')).toBeInTheDocument();
+
+ fireEvent.mouseLeave(screen.getByText('Confirm clear all?'));
+ expect(screen.getByText('Clear all')).toBeInTheDocument();
+ });
+
+ it('collapses pills beyond MAX_VISIBLE_PILLS and shows "+N more"', () => {
+ // Create 10 included values to exceed the limit of 8
+ const values = new Set(
+ Array.from({ length: 10 }, (_, i) => `val${i}`),
+ );
+ const searchFilters = makeSearchFilters({
+ field: {
+ included: values,
+ excluded: new Set(),
+ },
+ });
+ renderWithMantine();
+
+ expect(screen.getByText('+2 more')).toBeInTheDocument();
+ // Only 8 value pills should be visible
+ expect(screen.queryByText('val8')).not.toBeInTheDocument();
+ expect(screen.queryByText('val9')).not.toBeInTheDocument();
+ });
+
+ it('expands to show all pills when clicking "+N more"', () => {
+ const values = new Set(
+ Array.from({ length: 10 }, (_, i) => `val${i}`),
+ );
+ const searchFilters = makeSearchFilters({
+ field: {
+ included: values,
+ excluded: new Set(),
+ },
+ });
+ renderWithMantine();
+
+ fireEvent.click(screen.getByText('+2 more'));
+
+ // All values should now be visible
+ for (let i = 0; i < 10; i++) {
+ expect(screen.getByText(`val${i}`)).toBeInTheDocument();
+ }
+ expect(screen.getByText('Show less')).toBeInTheDocument();
+ expect(screen.queryByText('+2 more')).not.toBeInTheDocument();
+ });
+
+ it('collapses back when clicking "Show less"', () => {
+ const values = new Set(
+ Array.from({ length: 10 }, (_, i) => `val${i}`),
+ );
+ const searchFilters = makeSearchFilters({
+ field: {
+ included: values,
+ excluded: new Set(),
+ },
+ });
+ renderWithMantine();
+
+ fireEvent.click(screen.getByText('+2 more'));
+ fireEvent.click(screen.getByText('Show less'));
+
+ expect(screen.getByText('+2 more')).toBeInTheDocument();
+ expect(screen.queryByText('val8')).not.toBeInTheDocument();
+ });
+
+ it('renders mixed included, excluded, and range filters together', () => {
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(['200']),
+ excluded: new Set(['500']),
+ },
+ duration: {
+ included: new Set(),
+ excluded: new Set(),
+ range: { min: 10, max: 200 },
+ },
+ });
+ renderWithMantine();
+
+ expect(screen.getByText('200')).toBeInTheDocument();
+ expect(screen.getByText('500')).toBeInTheDocument();
+ expect(screen.getByText('10 – 200')).toBeInTheDocument();
+ });
+});