diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.spec.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.spec.tsx
new file mode 100644
index 0000000000000..be18ccdf36deb
--- /dev/null
+++ b/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.spec.tsx
@@ -0,0 +1,44 @@
+import { mockAppRoot } from '@rocket.chat/mock-providers';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import NavBarAISearch from './NavBarAISearch';
+
+import '@testing-library/jest-dom';
+
+jest.mock('./NavBarAISearchListbox', () => () => null);
+jest.mock('./NavBarSearchListbox', () => () => null);
+jest.mock('tinykeys', () => ({ __esModule: true, default: () => jest.fn() }));
+jest.mock('./hooks/useNavBarAISearch', () => ({
+ useNavBarAISearch: () => ({
+ aiSearchActive: false,
+ canSearchWithAIFromTopBar: true,
+ appliedFilterChips: [],
+ aiSearchButtonTooltip: 'Search with AI',
+ handleRemoveFilter: jest.fn(),
+ handleToggleAISearch: jest.fn(),
+ }),
+}));
+
+describe('NavBarAISearch', () => {
+ it('clears the search text', async () => {
+ const user = userEvent.setup();
+ const wrapper = mockAppRoot()
+ .withTranslations('en', 'core', {
+ Clear: 'Clear',
+ Search_rooms: 'Search rooms',
+ })
+ .build();
+
+ render(, { wrapper });
+
+ const searchInput = screen.getByRole('combobox', { name: 'Search rooms' });
+ await user.type(searchInput, 'deployment errors');
+ expect(searchInput).toHaveValue('deployment errors');
+
+ await user.click(screen.getByRole('button', { name: 'Clear' }));
+
+ expect(searchInput).toHaveValue('');
+ expect(searchInput).toHaveFocus();
+ });
+});
diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.tsx
index da245b91129b5..d388f9865b738 100644
--- a/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.tsx
+++ b/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.tsx
@@ -24,15 +24,8 @@ const NavBarAISearch = () => {
const shortcut = getShortcutLabel();
const methods = useForm({ defaultValues: { filterText: '', appliedFilters: emptySearchFilters() } });
- const {
- formState: { isDirty },
- register,
- reset,
- setFocus,
- setValue,
- watch,
- } = methods;
- const { filterText, appliedFilters } = watch();
+ const { register, resetField, setFocus, setValue, watch } = methods;
+ const { filterText = '', appliedFilters = emptySearchFilters() } = watch();
const { ref: filterRef, ...rest } = register('filterText');
@@ -54,12 +47,13 @@ const NavBarAISearch = () => {
const placeholder = [searchLabel, shortcut].filter(Boolean).join(' ');
const handleEscSearch = useCallback(() => {
- reset();
+ resetField('filterText');
+ setValue('appliedFilters', emptySearchFilters(), { shouldDirty: true });
state.close();
- }, [reset, state]);
+ }, [resetField, setValue, state]);
const handleClearText = useStableCallback(() => {
- reset();
+ resetField('filterText');
setFocus('filterText');
});
@@ -106,7 +100,7 @@ const NavBarAISearch = () => {
appliedFilterChips={appliedFilterChips}
aiSearchActive={aiSearchActive}
aiSearchButtonTooltip={aiSearchButtonTooltip}
- isDirty={isDirty}
+ hasSearchText={Boolean(filterText)}
onClearText={handleClearText}
onRemoveFilter={handleRemoveFilter}
onToggleAISearch={handleToggleAISearch}
diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarAISearchListbox.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarAISearchListbox.tsx
index 3480632681e2a..9f913950bd97c 100644
--- a/apps/meteor/client/navbar/NavBarSearch/NavBarAISearchListbox.tsx
+++ b/apps/meteor/client/navbar/NavBarSearch/NavBarAISearchListbox.tsx
@@ -1,6 +1,6 @@
import type { OverlayTriggerAria } from '@react-aria/overlays';
import type { OverlayTriggerState } from '@react-stately/overlays';
-import type { NavBarSearchFormValues } from '@rocket.chat/ai-search';
+import { emptySearchFilters, type NavBarSearchFormValues } from '@rocket.chat/ai-search';
import { Tile } from '@rocket.chat/fuselage';
import { useOutsideClick, useStableCallback } from '@rocket.chat/fuselage-hooks';
import { CustomScrollbars } from '@rocket.chat/ui-client';
@@ -30,12 +30,13 @@ const NavBarAISearchListBox = ({ state, overlayProps, aiSearchActive, aiSearchAv
const handleKeyDown = useListboxNavigation(state);
useOutsideClick([containerRef], state.close);
- const { reset, watch } = useFormContext();
- const { filterText, appliedFilters } = watch();
+ const { resetField, setValue, watch } = useFormContext();
+ const { filterText = '', appliedFilters = emptySearchFilters() } = watch();
const handleSelect = useStableCallback(() => {
state.close();
- reset();
+ resetField('filterText');
+ setValue('appliedFilters', emptySearchFilters(), { shouldDirty: true });
});
const { data: aiItems, isFetching } = useAISearchItems(filterText, appliedFilters, aiSearchActive);
diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchFilterSuggestions.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchFilterSuggestions.tsx
index 702803fe27e0a..3da76cd69647b 100644
--- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchFilterSuggestions.tsx
+++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchFilterSuggestions.tsx
@@ -1,4 +1,5 @@
import {
+ emptySearchFilters,
mergeSearchFilters,
parseSearchFilterText,
type NavBarSearchFormValues,
@@ -46,7 +47,8 @@ const NavBarSearchFilterSuggestions = ({ suggestions }: NavBarSearchFilterSugges
event.preventDefault();
event.stopPropagation();
const { searchText, filters } = parseSearchFilterText(value);
- setValue('appliedFilters', mergeSearchFilters(getValues('appliedFilters'), filters), { shouldDirty: true });
+ const appliedFilters = getValues('appliedFilters') ?? emptySearchFilters();
+ setValue('appliedFilters', mergeSearchFilters(appliedFilters, filters), { shouldDirty: true });
setValue('filterText', searchText, { shouldDirty: true });
setFocus('filterText');
},
diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchInputAddon.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchInputAddon.tsx
index d0b7f599df571..cc8edcd21bdd6 100644
--- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchInputAddon.tsx
+++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchInputAddon.tsx
@@ -7,7 +7,7 @@ export type NavBarSearchInputAddonProps = {
appliedFilterChips: SearchFilterChip[];
aiSearchActive: boolean;
aiSearchButtonTooltip: string;
- isDirty: boolean;
+ hasSearchText: boolean;
onClearText: () => void;
onRemoveFilter: (filterKey: string) => void;
onToggleAISearch: () => void;
@@ -18,7 +18,7 @@ const NavBarSearchInputAddon = ({
appliedFilterChips,
aiSearchActive,
aiSearchButtonTooltip,
- isDirty,
+ hasSearchText,
onClearText,
onRemoveFilter,
onToggleAISearch,
@@ -45,7 +45,7 @@ const NavBarSearchInputAddon = ({
))}
)}
- {isDirty ? : }
+ {hasSearchText ? : }
();
- const { filterText, appliedFilters } = watch();
+ const { filterText = '', appliedFilters = emptySearchFilters() } = watch();
if (!items.length) {
return null;
diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useAISearchItems.spec.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useAISearchItems.spec.ts
new file mode 100644
index 0000000000000..e6f8461699ce9
--- /dev/null
+++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useAISearchItems.spec.ts
@@ -0,0 +1,332 @@
+import { AI_SEARCH_FILTER_SUGGESTION_LIMIT, AI_SEARCH_PAGE_SIZE, emptySearchFilters } from '@rocket.chat/ai-search';
+import { UserStatus } from '@rocket.chat/core-typings';
+import { mockAppRoot } from '@rocket.chat/mock-providers';
+import type { AISearchResult } from '@rocket.chat/rest-typings';
+import { act, renderHook, waitFor } from '@testing-library/react';
+
+import { useAISearchItems } from './useAISearchItems';
+import { createFakeSubscription } from '../../../../tests/mocks/data';
+
+let mockDebounce: (value: unknown) => unknown = (value) => value;
+
+jest.mock('@rocket.chat/fuselage-hooks', () => ({
+ ...jest.requireActual('@rocket.chat/fuselage-hooks'),
+ useDebouncedValue: (value: unknown) => mockDebounce(value),
+}));
+
+type AutocompleteUser = {
+ _id: string;
+ name: string;
+ username: string;
+ nickname: string;
+ status: UserStatus;
+ avatarETag: string;
+};
+
+const setup = ({
+ subscriptions = [],
+ intelligent = [],
+ users = [],
+}: {
+ subscriptions?: ReturnType[];
+ intelligent?: AISearchResult[];
+ users?: AutocompleteUser[];
+} = {}) => {
+ const aiSearchHandler = jest.fn(() => ({
+ intelligent,
+ meta: { intelligentSearchEnabled: true, intelligentSearchConfigured: true, answerGenerationConfigured: true },
+ }));
+ const usersAutocompleteHandler = jest.fn(() => ({ items: users }));
+
+ const wrapper = mockAppRoot()
+ .withTranslations('en', 'core', {
+ Search_in_this_room: 'Search in this room',
+ Search_messages_from_this_username: 'Search messages from this username',
+ Search_messages_from_this_user: 'Search messages from this user',
+ Today: 'Today',
+ Yesterday: 'Yesterday',
+ Last_7_days: 'Last 7 days',
+ })
+ .withSubscriptions(subscriptions)
+ .withEndpoint('GET', '/v1/ai.search', aiSearchHandler)
+ .withEndpoint('GET', '/v1/users.autocomplete', usersAutocompleteHandler)
+ .build();
+
+ return { wrapper, aiSearchHandler, usersAutocompleteHandler };
+};
+
+const formatDate = (date: Date): string =>
+ `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
+
+beforeEach(() => {
+ mockDebounce = (value) => value;
+});
+
+describe('useAISearchItems', () => {
+ describe('when AI search is inactive', () => {
+ it('passes the filter text through untouched and yields no items', async () => {
+ const { wrapper, aiSearchHandler, usersAutocompleteHandler } = setup();
+ const { result } = renderHook(
+ () => useAISearchItems('in:general deploy', { ...emptySearchFilters(), roomNames: ['support'] }, false),
+ { wrapper },
+ );
+
+ await act(async () => undefined);
+
+ expect(result.current.data.searchText).toBe('in:general deploy');
+ expect(result.current.data.intelligent).toEqual([]);
+ expect(result.current.data.filterSuggestions).toEqual([]);
+ expect(result.current.isFetching).toBe(false);
+ expect(aiSearchHandler).not.toHaveBeenCalled();
+ expect(usersAutocompleteHandler).not.toHaveBeenCalled();
+ });
+
+ it('defaults to inactive when only the filter text is provided', async () => {
+ const { wrapper, aiSearchHandler } = setup();
+ const { result } = renderHook(() => useAISearchItems('deploy'), { wrapper });
+
+ await act(async () => undefined);
+
+ expect(result.current.data.searchText).toBe('deploy');
+ expect(aiSearchHandler).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('intelligent search', () => {
+ it('strips inline filter tokens and queries ai.search with the parsed filters', async () => {
+ const intelligent = [{ _id: 'msg-1', msgId: 'msg-1', text: 'Deployment failed' }];
+ const { wrapper, aiSearchHandler } = setup({ intelligent });
+ const { result } = renderHook(
+ () => useAISearchItems('in:general from:john after:2026-01-01 before:2026-02-01 deploy failed', emptySearchFilters(), true),
+ { wrapper },
+ );
+
+ expect(result.current.data.searchText).toBe('deploy failed');
+ expect(result.current.isFetching).toBe(true);
+
+ await waitFor(() => expect(result.current.data.intelligent).toEqual(intelligent));
+ expect(result.current.isFetching).toBe(false);
+ expect(aiSearchHandler).toHaveBeenCalledTimes(1);
+ expect(aiSearchHandler).toHaveBeenCalledWith({
+ query: 'deploy failed',
+ intelligentCount: AI_SEARCH_PAGE_SIZE,
+ roomNames: 'general',
+ fromUsernames: 'john',
+ startDate: '2026-01-01',
+ endDate: '2026-02-01',
+ });
+ });
+
+ it('merges applied filters with inline filter tokens in the request', async () => {
+ const { wrapper, aiSearchHandler } = setup();
+ const { result } = renderHook(
+ () =>
+ useAISearchItems(
+ 'in:general hello',
+ { ...emptySearchFilters(), roomNames: ['support'], fromUsernames: ['jane'], startDate: '2026-05-01' },
+ true,
+ ),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(aiSearchHandler).toHaveBeenCalled());
+ expect(aiSearchHandler).toHaveBeenCalledWith({
+ query: 'hello',
+ intelligentCount: AI_SEARCH_PAGE_SIZE,
+ roomNames: 'support,general',
+ fromUsernames: 'jane',
+ startDate: '2026-05-01',
+ });
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+ });
+
+ it('does not hit ai.search when only filter tokens are present', async () => {
+ const { wrapper, aiSearchHandler } = setup();
+ const { result } = renderHook(() => useAISearchItems('in:general ', emptySearchFilters(), true), { wrapper });
+
+ await act(async () => undefined);
+
+ expect(result.current.data.searchText).toBe('');
+ expect(result.current.data.intelligent).toEqual([]);
+ expect(aiSearchHandler).not.toHaveBeenCalled();
+ });
+
+ it('withholds intelligent results while the debounced search text lags behind', async () => {
+ mockDebounce = () => 'stale';
+ const intelligent = [{ _id: 'msg-1', text: 'Old result' }];
+ const { wrapper, aiSearchHandler } = setup({ intelligent });
+ const { result } = renderHook(() => useAISearchItems('deploy', emptySearchFilters(), true), { wrapper });
+
+ await waitFor(() => expect(aiSearchHandler).toHaveBeenCalledWith(expect.objectContaining({ query: 'stale' })));
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ expect(result.current.data.intelligent).toEqual([]);
+ });
+ });
+
+ describe('room filter suggestions', () => {
+ it('suggests rooms while an in: token is being typed', async () => {
+ const subscriptions = [createFakeSubscription({ rid: 'rid-general', name: 'general', fname: 'General', t: 'c' })];
+ const { wrapper, aiSearchHandler } = setup({ subscriptions });
+ const { result } = renderHook(() => useAISearchItems('in:gen', emptySearchFilters(), true), { wrapper });
+
+ await act(async () => undefined);
+
+ expect(result.current.data.searchText).toBe('');
+ expect(aiSearchHandler).not.toHaveBeenCalled();
+ expect(result.current.data.filterSuggestions).toEqual([
+ {
+ key: 'in-rid-general',
+ group: 'rooms',
+ title: '#General',
+ description: 'Search in this room',
+ value: 'in:general ',
+ icon: 'hash',
+ },
+ ]);
+ });
+
+ it('caps room suggestions at the configured limit', async () => {
+ const subscriptions = Array.from({ length: AI_SEARCH_FILTER_SUGGESTION_LIMIT + 2 }, (_, index) =>
+ createFakeSubscription({ rid: `rid-${index}`, name: `general-${index}`, t: 'c' }),
+ );
+ const { wrapper } = setup({ subscriptions });
+ const { result } = renderHook(() => useAISearchItems('in:gen', emptySearchFilters(), true), { wrapper });
+
+ await act(async () => undefined);
+
+ expect(result.current.data.filterSuggestions).toHaveLength(AI_SEARCH_FILTER_SUGGESTION_LIMIT);
+ });
+
+ it('does not suggest rooms without an active in: token', async () => {
+ const subscriptions = [createFakeSubscription({ rid: 'rid-general', name: 'general', t: 'c' })];
+ const { wrapper } = setup({ subscriptions });
+ const { result } = renderHook(() => useAISearchItems('general stuff', emptySearchFilters(), true), { wrapper });
+
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ expect(result.current.data.filterSuggestions).toEqual([]);
+ });
+ });
+
+ describe('user filter suggestions', () => {
+ const john: AutocompleteUser = {
+ _id: 'user-1',
+ name: 'John Doe',
+ username: 'john',
+ nickname: '',
+ status: UserStatus.ONLINE,
+ avatarETag: '',
+ };
+
+ it('suggests usernames from users.autocomplete while a from: token is active', async () => {
+ const { wrapper, usersAutocompleteHandler } = setup({ users: [john] });
+ const { result } = renderHook(() => useAISearchItems('from:jo', emptySearchFilters(), true), { wrapper });
+
+ await waitFor(() =>
+ expect(result.current.data.filterSuggestions).toEqual([
+ {
+ key: 'from-user-1',
+ group: 'users',
+ title: '@john',
+ description: 'John Doe',
+ value: 'from:john ',
+ icon: 'user',
+ },
+ {
+ key: 'from-current',
+ group: 'users',
+ title: 'from:jo',
+ description: 'Search messages from this username',
+ value: 'from:jo ',
+ icon: 'user',
+ },
+ ]),
+ );
+ expect(usersAutocompleteHandler).toHaveBeenCalledWith({
+ selector: JSON.stringify({ term: 'jo', conditions: {}, exceptions: [] }),
+ });
+ });
+
+ it('withholds username suggestions while the debounced from: value lags behind', async () => {
+ mockDebounce = (value) => (value === 'jo' ? '' : value);
+ const { wrapper, usersAutocompleteHandler } = setup({ users: [john] });
+ const { result } = renderHook(() => useAISearchItems('from:jo', emptySearchFilters(), true), { wrapper });
+
+ await waitFor(() => expect(usersAutocompleteHandler).toHaveBeenCalled());
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ expect(result.current.data.filterSuggestions).toEqual([
+ {
+ key: 'from-current',
+ group: 'users',
+ title: 'from:jo',
+ description: 'Search messages from this username',
+ value: 'from:jo ',
+ icon: 'user',
+ },
+ ]);
+ });
+ });
+
+ describe('date filter suggestions', () => {
+ // freeze the clock so the hook's `new Date()` and the expected values below agree even across midnight
+ beforeEach(() => {
+ jest.useFakeTimers({ now: new Date('2026-07-15T12:00:00Z'), advanceTimers: true });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('offers date suggestions for a trailing after: token', async () => {
+ const { wrapper } = setup();
+ const { result } = renderHook(() => useAISearchItems('after:', emptySearchFilters(), true), { wrapper });
+
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ const today = new Date();
+ const yesterday = new Date(today);
+ yesterday.setDate(today.getDate() - 1);
+ const lastWeek = new Date(today);
+ lastWeek.setDate(today.getDate() - 7);
+
+ expect(result.current.data.filterSuggestions).toEqual([
+ {
+ key: `after-${formatDate(today)}`,
+ group: 'dates',
+ title: `after:${formatDate(today)}`,
+ description: 'Today',
+ value: `after:${formatDate(today)} `,
+ icon: 'calendar',
+ },
+ {
+ key: `after-${formatDate(yesterday)}`,
+ group: 'dates',
+ title: `after:${formatDate(yesterday)}`,
+ description: 'Yesterday',
+ value: `after:${formatDate(yesterday)} `,
+ icon: 'calendar',
+ },
+ {
+ key: `after-${formatDate(lastWeek)}`,
+ group: 'dates',
+ title: `after:${formatDate(lastWeek)}`,
+ description: 'Last 7 days',
+ value: `after:${formatDate(lastWeek)} `,
+ icon: 'calendar',
+ },
+ ]);
+ });
+
+ it('offers date suggestions for a trailing before: token', async () => {
+ const { wrapper } = setup();
+ const { result } = renderHook(() => useAISearchItems('before:', emptySearchFilters(), true), { wrapper });
+
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ expect(result.current.data.filterSuggestions).toHaveLength(3);
+ expect(result.current.data.filterSuggestions.every(({ key, group }) => key.startsWith('before-') && group === 'dates')).toBe(true);
+ });
+ });
+});