From 3a6dc3ef5ec8795e668b3d4157524a71b0f6bc62 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 16 Jan 2026 09:24:53 +0000 Subject: [PATCH 01/21] init --- examples/react/getting-started/src/App.tsx | 8 +- .../widgets/chat/tools/SearchIndexTool.tsx | 151 +++++++++++++++++- 2 files changed, 152 insertions(+), 7 deletions(-) diff --git a/examples/react/getting-started/src/App.tsx b/examples/react/getting-started/src/App.tsx index ad1b9961829..ce4a8256b05 100644 --- a/examples/react/getting-started/src/App.tsx +++ b/examples/react/getting-started/src/App.tsx @@ -2,7 +2,6 @@ import { liteClient as algoliasearch } from 'algoliasearch/lite'; import { Hit } from 'instantsearch.js'; import React from 'react'; import { - Configure, Highlight, Hits, InstantSearch, @@ -45,17 +44,20 @@ export function App() { searchClient={searchClient} indexName="instant_search" insights={true} + routing > -
+ + +
- +
diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 865b52832ad..95fbb385adb 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -1,10 +1,16 @@ +import AlgoliaSearchHelper from 'algoliasearch-helper'; import { ChevronLeftIcon, ChevronRightIcon, ArrowRightIcon, createButtonComponent, } from 'instantsearch-ui-components'; +import { isIndexWidget } from 'instantsearch.js/es/lib/utils'; import React, { createElement } from 'react'; +import { + useInstantSearch, + useInstantSearchContext, +} from 'react-instantsearch-core'; import { Carousel } from '../../../components'; @@ -20,6 +26,34 @@ import type { ComponentProps } from 'react'; type ItemComponent = RecommendComponentProps['itemComponent']; +type SearchToolInput = { + query: string; + number_of_results?: number; + facet_filters?: string[][]; +}; + +function getLocalWidgetsUiState( + widgets: Array, + widgetStateOptions: WidgetUiStateOptions, + initialUiState: IndexUiState = {} +) { + return widgets.reduce((uiState, widget) => { + if (isIndexWidget(widget)) { + return uiState; + } + + if (!widget.getWidgetUiState && !widget.getWidgetState) { + return uiState; + } + + if (widget.getWidgetUiState) { + return widget.getWidgetUiState(uiState, widgetStateOptions); + } + + return widget.getWidgetState!(uiState, widgetStateOptions); + }, initialUiState); +} + function createCarouselTool( showViewAll: boolean, itemComponent?: ItemComponent, @@ -101,7 +135,7 @@ function createCarouselTool( scrollLeft, scrollRight, nbHits, - query, + input, hitsPerPage, setIndexUiState, indexUiState, @@ -114,13 +148,16 @@ function createCarouselTool( scrollLeft: () => void; scrollRight: () => void; nbHits?: number; - query?: string; + input?: SearchToolInput; hitsPerPage?: number; setIndexUiState: IndexWidget['setIndexUiState']; indexUiState: IndexUiState; getSearchPageURL?: (nextUiState: IndexUiState) => string; onClose: () => void; }) { + const search = useInstantSearchContext(); + const { indexRenderState } = useInstantSearch(); + if ((hitsPerPage ?? 0) < 1) { return null; } @@ -139,9 +176,115 @@ function createCarouselTool( variant="ghost" size="sm" onClick={() => { - if (!query) return; + if (!input?.query) return; + + input.facet_filters = [ + ['brand:Samsung'], + ['categories:Cell Phones', 'categories:Unlocked Cell Phones'], + ]; + + const attributeMap = input.facet_filters.reduce( + (acc, filters) => { + filters.forEach((filter) => { + const [facet, value] = filter.split(':'); + if (!acc[facet]) { + acc[facet] = []; + } + acc[facet].push(value); + }); + return acc; + }, + {} as Record + ); + + const facetFilters: string[] = []; + const facetRefinements: Record = {}; + const disjunctiveFacets: string[] = []; + const disjunctiveFacetsRefinements: Record = + {}; + + // go over the refinementlist widgets + if (indexRenderState.refinementList) { + Object.keys(indexRenderState.refinementList).forEach( + (attribute) => { + // check widget params of widget if it has the AND operator + if ( + indexRenderState.refinementList?.[attribute] + .widgetParams.operator === 'and' + ) { + facetFilters.push(attribute); + facetRefinements[attribute] = attributeMap[attribute]; + } else { + disjunctiveFacets.push(attribute); + disjunctiveFacetsRefinements[attribute] = + attributeMap[attribute]; + } + } + ); + } + + console.log('facetFilters', facetFilters); + console.log( + 'facetRefinements', + facetRefinements, + 'disjunctiveFacets', + disjunctiveFacets, + 'disjunctiveFacetsRefinements', + disjunctiveFacetsRefinements + ); + + const newState = getLocalWidgetsUiState( + search.mainIndex.getWidgets(), + { + searchParameters: new AlgoliaSearchHelper.SearchParameters({ + // query: input.query, + facetFilters: facetFilters.length + ? input.facet_filters + : undefined, + facetsRefinements: + Object.keys(facetRefinements).length > 0 + ? facetRefinements + : undefined, + disjunctiveFacets: disjunctiveFacets.length + ? disjunctiveFacets + : undefined, + disjunctiveFacetsRefinements: + Object.keys(disjunctiveFacetsRefinements).length > 0 + ? disjunctiveFacetsRefinements + : undefined, + // disjunctiveFacets: input.facet_filters + // ? input.facet_filters.reduce((acc, filters) => { + // filters.forEach((filter) => { + // const [facet] = filter.split(':'); + // if (!acc.includes(facet)) { + // acc.push(facet); + // } + // }); + // return acc; + // }, [] as string[]) + // : [], + // disjunctiveFacetsRefinements: input.facet_filters + // ? input.facet_filters.reduce((acc, filters) => { + // filters.forEach((filter) => { + // const [facet, value] = filter.split(':'); + // if (!acc[facet]) { + // acc[facet] = []; + // } + // acc[facet].push(value); + // }); + // return acc; + // }, {} as Record) + // : {}, + }), + } + ); + + console.log('newState', newState); - const nextUiState = { ...indexUiState, query }; + const nextUiState = { + ...indexUiState, + ...newState, + }; // If no main search page URL or we are on the search page, just update the state if ( From fba316203849094b91f425495d74487ae7729f08 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 16 Jan 2026 12:08:38 +0000 Subject: [PATCH 02/21] fix conjuctive facets --- examples/react/getting-started/src/App.tsx | 6 ++--- .../widgets/chat/tools/SearchIndexTool.tsx | 27 +++++++++++++++---- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/examples/react/getting-started/src/App.tsx b/examples/react/getting-started/src/App.tsx index ce4a8256b05..7d5ec452742 100644 --- a/examples/react/getting-started/src/App.tsx +++ b/examples/react/getting-started/src/App.tsx @@ -48,12 +48,12 @@ export function App() { >
- - - + + +
diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 95fbb385adb..fd97d9ee00e 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -100,7 +100,7 @@ function createCarouselTool( ) => ( ( ); }, [ items.length, - input?.query, + input, output?.nbHits, setIndexUiState, onClose, @@ -233,14 +233,31 @@ function createCarouselTool( disjunctiveFacetsRefinements ); + const searchParameters = + new AlgoliaSearchHelper.SearchParameters({ + // query: input.query, + facets: facetFilters.length ? facetFilters : undefined, + facetsRefinements: + Object.keys(facetRefinements).length > 0 + ? facetRefinements + : undefined, + disjunctiveFacets: disjunctiveFacets.length + ? disjunctiveFacets + : undefined, + disjunctiveFacetsRefinements: + Object.keys(disjunctiveFacetsRefinements).length > 0 + ? disjunctiveFacetsRefinements + : undefined, + }); + + console.log('searchParameters', searchParameters); + const newState = getLocalWidgetsUiState( search.mainIndex.getWidgets(), { searchParameters: new AlgoliaSearchHelper.SearchParameters({ // query: input.query, - facetFilters: facetFilters.length - ? input.facet_filters - : undefined, + facets: facetFilters.length ? facetFilters : undefined, facetsRefinements: Object.keys(facetRefinements).length > 0 ? facetRefinements From 5d57326b59a5f70fbf44ef211f9c5a76d9e08c85 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Tue, 20 Jan 2026 12:54:26 +0000 Subject: [PATCH 03/21] update view all logic --- .../widgets/chat/tools/SearchIndexTool.tsx | 220 +++++++----------- 1 file changed, 88 insertions(+), 132 deletions(-) diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index fd97d9ee00e..c606a956a97 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -172,91 +172,62 @@ function createCarouselTool(
)} {showViewAll && ( - + // Navigate to different page + window.location.href = getSearchPageURL(nextUiState); + }} + className="ais-ChatToolSearchIndexCarouselHeaderViewAll" + > + View all + + + )}
From 16331da20979e66511e6aceb16258f038e88d9b5 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Tue, 20 Jan 2026 12:54:51 +0000 Subject: [PATCH 04/21] update current refinements --- .../connectCurrentRefinements.ts | 15 +++++++++++++++ .../src/ui/CurrentRefinements.tsx | 3 +++ .../src/widgets/CurrentRefinements.tsx | 5 +++-- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/instantsearch.js/src/connectors/current-refinements/connectCurrentRefinements.ts b/packages/instantsearch.js/src/connectors/current-refinements/connectCurrentRefinements.ts index df541aaed43..a16d42dfba4 100644 --- a/packages/instantsearch.js/src/connectors/current-refinements/connectCurrentRefinements.ts +++ b/packages/instantsearch.js/src/connectors/current-refinements/connectCurrentRefinements.ts @@ -142,6 +142,9 @@ export type CurrentRefinementsRenderState = { * Generates a URL for the next state. */ createURL: CreateURL; + + aiMode: boolean; + setAiMode: (value: boolean) => void; }; const withUsage = createDocumentationMessageGenerator({ @@ -170,6 +173,12 @@ const connectCurrentRefinements: CurrentRefinementsConnector = checkRendering(renderFn, withUsage()); return (widgetParams) => { + let isAiMode = false; + + const setAiMode = (value: boolean) => { + isAiMode = value; + }; + if ( (widgetParams || {}).includedAttributes && (widgetParams || {}).excludedAttributes @@ -262,12 +271,18 @@ const connectCurrentRefinements: CurrentRefinementsConnector = const items = getItems(); + if (items.length === 0) { + setAiMode(false); + } + return { items, canRefine: items.length > 0, refine: (refinement) => clearRefinement(helper, refinement), createURL: (refinement) => createURL(clearRefinementFromState(helper.state, refinement)), + aiMode: isAiMode, + setAiMode, widgetParams, }; }, diff --git a/packages/react-instantsearch/src/ui/CurrentRefinements.tsx b/packages/react-instantsearch/src/ui/CurrentRefinements.tsx index 1493d12b63d..b8e2cfa6aa3 100644 --- a/packages/react-instantsearch/src/ui/CurrentRefinements.tsx +++ b/packages/react-instantsearch/src/ui/CurrentRefinements.tsx @@ -15,6 +15,7 @@ export type CurrentRefinementsProps = React.ComponentProps<'div'> & { Record >; hasRefinements?: boolean; + aiMode?: boolean; }; export type CurrentRefinementsClassNames = { @@ -60,6 +61,7 @@ export function CurrentRefinements({ classNames = {}, items = [], hasRefinements = false, + aiMode, ...props }: CurrentRefinementsProps) { return ( @@ -76,6 +78,7 @@ export function CurrentRefinements({ props.className )} > + {aiMode &&

AI Refinements Applied

}
    ; export type CurrentRefinementsProps = Omit< @@ -23,7 +23,7 @@ export function CurrentRefinements({ transformItems, ...props }: CurrentRefinementsProps) { - const { items, canRefine } = useCurrentRefinements( + const { items, canRefine, aiMode } = useCurrentRefinements( { includedAttributes, excludedAttributes, @@ -37,6 +37,7 @@ export function CurrentRefinements({ const uiProps: UiProps = { items, hasRefinements: canRefine, + aiMode, }; return ; From 0991aa48c0ca7aaefe12d07ffece54ea672894d1 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Wed, 21 Jan 2026 12:56:57 +0000 Subject: [PATCH 05/21] update view all logic --- .../widgets/chat/tools/SearchIndexTool.tsx | 114 ++++++------------ 1 file changed, 35 insertions(+), 79 deletions(-) diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index c606a956a97..c7f14a331ea 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -1,4 +1,3 @@ -import AlgoliaSearchHelper from 'algoliasearch-helper'; import { ChevronLeftIcon, ChevronRightIcon, @@ -7,10 +6,7 @@ import { } from 'instantsearch-ui-components'; import { isIndexWidget } from 'instantsearch.js/es/lib/utils'; import React, { createElement } from 'react'; -import { - useInstantSearch, - useInstantSearchContext, -} from 'react-instantsearch-core'; +import { useInstantSearchContext } from 'react-instantsearch-core'; import { Carousel } from '../../../components'; @@ -156,7 +152,6 @@ function createCarouselTool( onClose: () => void; }) { const search = useInstantSearchContext(); - const { indexRenderState } = useInstantSearch(); if ((hitsPerPage ?? 0) < 1) { return null; @@ -187,91 +182,52 @@ function createCarouselTool( ], ]; - const attributeMap = input.facet_filters.reduce( - (acc, filters) => { - filters.forEach((filter) => { - const [facet, value] = filter.split(':'); - if (!acc[facet]) { - acc[facet] = []; - } - acc[facet].push(value); - }); - return acc; - }, - {} as Record - ); + const attributes = input.facet_filters + .flat() + .map((filter) => { + const [attribute, value] = filter.split(':'); + + return { attribute, value }; + }); - const facetFilters: string[] = []; - const facetRefinements: Record = {}; - const disjunctiveFacets: string[] = []; - const disjunctiveFacetsRefinements: Record = - {}; + const helper = search.mainIndex.getHelper(); + if (!helper) return; - if (indexRenderState.refinementList) { - Object.keys(indexRenderState.refinementList).forEach( - (attribute) => { - if ( - indexRenderState.refinementList?.[attribute] - .widgetParams.operator === 'and' - ) { - facetFilters.push(attribute); - facetRefinements[attribute] = attributeMap[attribute]; - } else { - disjunctiveFacets.push(attribute); - disjunctiveFacetsRefinements[attribute] = - attributeMap[attribute]; - } - } - ); + if (input.query) { + helper.setQuery(input.query); } - const searchParameters = - new AlgoliaSearchHelper.SearchParameters({ - query: input.query, - facets: facetFilters.length ? facetFilters : undefined, - facetsRefinements: - Object.keys(facetRefinements).length > 0 - ? facetRefinements - : undefined, - disjunctiveFacets: disjunctiveFacets.length - ? disjunctiveFacets - : undefined, - disjunctiveFacetsRefinements: - Object.keys(disjunctiveFacetsRefinements).length > 0 - ? disjunctiveFacetsRefinements - : undefined, - }); + attributes.forEach(({ attribute }) => { + helper.clearRefinements(attribute); + }); - const newState = getLocalWidgetsUiState( - search.mainIndex.getWidgets(), - { - searchParameters, - } - ); + attributes.forEach(({ attribute, value }) => { + const hierarchicalFacet = + helper.state.hierarchicalFacets.find( + (facet) => facet.name === attribute + ); - const nextUiState = { - ...indexUiState, - ...newState, - }; + if (hierarchicalFacet) { + helper.toggleFacetRefinement( + hierarchicalFacet.name, + value + ); + } else { + helper.toggleFacetRefinement(attribute, value); + } + }); - if (!indexRenderState.currentRefinements?.aiMode) { - indexRenderState.currentRefinements?.setAiMode(true); - } + helper.search(); - // If no main search page URL or we are on the search page, just update the state if ( - !getSearchPageURL || - (getSearchPageURL && - new URL(getSearchPageURL(nextUiState)).pathname === - window.location.pathname) + getSearchPageURL && + new URL(getSearchPageURL(helper.state)).pathname !== + window.location.pathname ) { - setIndexUiState(nextUiState); + window.location.href = getSearchPageURL(helper.state); + } else { onClose(); - return; } - - // Navigate to different page - window.location.href = getSearchPageURL(nextUiState); }} className="ais-ChatToolSearchIndexCarouselHeaderViewAll" > From 5471d1343d547d9d8b581cf2c8dd4ee962fc8c47 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 23 Jan 2026 10:36:49 +0000 Subject: [PATCH 06/21] add js widget --- .../src/connectors/chat/connectChat.ts | 13 +++ .../instantsearch.js/src/lib/utils/index.ts | 1 + .../utils/updateStateFromSearchToolInput.ts | 44 ++++++++++ .../src/widgets/chat/chat.tsx | 65 +++++++-------- .../widgets/chat/tools/SearchIndexTool.tsx | 83 +------------------ 5 files changed, 93 insertions(+), 113 deletions(-) create mode 100644 packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index f7904258bcb..90599f948c6 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -11,6 +11,7 @@ import { getAlgoliaAgent, getAppIdAndApiKey, noop, + updateStateFromSearchToolInput, warning, } from '../../lib/utils'; @@ -171,6 +172,7 @@ export default (function connectChat( let setInput: ChatRenderState['setInput']; let setOpen: ChatRenderState['setOpen']; let setIsClearing: (value: boolean) => void; + let helperTest: (toolInput: {}) => void; const agentId = 'agentId' in options ? options.agentId : undefined; @@ -357,6 +359,13 @@ export default (function connectChat( _chatInstance = makeChatInstance(instantSearchInstance); + helperTest = (toolInput: {}) => { + updateStateFromSearchToolInput( + toolInput as any, + instantSearchInstance.mainIndex.getHelper()! + ); + }; + const render = () => { renderFn( { @@ -482,6 +491,10 @@ export default (function connectChat( return true; }, + helperTest(toolInput: {}) { + return helperTest(toolInput); + }, + get chatInstance() { return _chatInstance; }, diff --git a/packages/instantsearch.js/src/lib/utils/index.ts b/packages/instantsearch.js/src/lib/utils/index.ts index 406ed5619b3..0b9c9a1ab0a 100644 --- a/packages/instantsearch.js/src/lib/utils/index.ts +++ b/packages/instantsearch.js/src/lib/utils/index.ts @@ -50,3 +50,4 @@ export * from './safelyRunOnBrowser'; export * from './serializer'; export * from './toArray'; export * from './uniq'; +export * from './updateStateFromSearchToolInput'; diff --git a/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts b/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts new file mode 100644 index 00000000000..4b060a7499b --- /dev/null +++ b/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts @@ -0,0 +1,44 @@ +import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; + +type SearchToolInput = { + query: string; + number_of_results?: number; + facet_filters?: string[][]; +}; + +export function updateStateFromSearchToolInput( + input: SearchToolInput, + helper: AlgoliaSearchHelper +) { + if (input.query) { + helper.setQuery(input.query); + } + + if (input.facet_filters) { + const attributes = input.facet_filters.flat().map((filter) => { + const [attribute, value] = filter.split(':'); + + return { attribute, value }; + }); + + attributes.forEach(({ attribute }) => { + helper.clearRefinements(attribute); + }); + + attributes.forEach(({ attribute, value }) => { + const hierarchicalFacet = helper.state.hierarchicalFacets.find( + (facet) => facet.name === attribute + ); + + if (hierarchicalFacet) { + helper.toggleFacetRefinement(hierarchicalFacet.name, value); + } else { + helper.toggleFacetRefinement(attribute, value); + } + }); + } + + helper.search(); + + return true; +} diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 599bb462ba3..a122e53bf89 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -70,12 +70,19 @@ function getDefinedProperties(obj: T): Partial { ) as Partial; } +type SearchToolInput = { + query: string; + number_of_results?: number; + facet_filters?: string[][]; +}; + function createCarouselTool< THit extends RecordWithObjectID = RecordWithObjectID >( showViewAll: boolean, templates: ChatTemplates, - getSearchPageURL?: (nextUiState: IndexUiState) => string + getSearchPageURL?: (nextUiState: IndexUiState) => string, + helperTest?: (input: {}) => void ): UserClientSideToolWithTemplate { const Button = createButtonComponent({ createElement: h, @@ -118,7 +125,7 @@ function createCarouselTool< ) => ( void; scrollRight: () => void; nbHits?: number; - query?: string; + input?: SearchToolInput; hitsPerPage?: number; setIndexUiState: IndexWidget['setIndexUiState']; indexUiState: IndexUiState; @@ -201,24 +204,7 @@ function createCarouselTool< variant="ghost" size="sm" onClick={() => { - if (!query) return; - - const nextUiState = { ...indexUiState, query }; - - // If no main search page URL or we are on the search page, just update the state - if ( - !getSearchPageURL || - (getSearchPageURL && - new URL(getSearchPageURL(nextUiState)).pathname === - window.location.pathname) - ) { - setIndexUiState(nextUiState); - onClose(); - return; - } - - // Navigate to different page - window.location.href = getSearchPageURL(nextUiState); + helperTest?.(input || {}); }} className="ais-ChatToolSearchIndexCarouselHeaderViewAll" > @@ -265,7 +251,8 @@ function createDefaultTools< THit extends RecordWithObjectID = RecordWithObjectID >( templates: ChatTemplates, - getSearchPageURL?: (nextUiState: IndexUiState) => string + getSearchPageURL?: (nextUiState: IndexUiState) => string, + helperTest: (input: {}) => void ): UserClientSideToolsWithTemplate { return { [SearchIndexToolType]: createCarouselTool( @@ -1172,7 +1159,9 @@ export default (function chat< ...userTemplates, }; - const defaultTools = createDefaultTools(templates, getSearchPageURL); + const defaultTools = createDefaultTools(templates, getSearchPageURL, () => { + // no default action + }); const tools = { ...defaultTools, ...userTools }; @@ -1188,12 +1177,20 @@ export default (function chat< render(null, containerNode) ); + // eslint-disable-next-line @typescript-eslint/unbound-method + const { helperTest, ...rest } = makeWidget({ + resume, + tools: { + ...createDefaultTools(templates, getSearchPageURL, () => { + helperTest?.({}); + }), + ...userTools, + }, + ...options, + }); + return { - ...makeWidget({ - resume, - tools, - ...options, - }), + ...rest, $$widgetType: 'ais.chat', }; } satisfies ChatWidget); diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index c7f14a331ea..b5eb9d236c9 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -4,7 +4,6 @@ import { ArrowRightIcon, createButtonComponent, } from 'instantsearch-ui-components'; -import { isIndexWidget } from 'instantsearch.js/es/lib/utils'; import React, { createElement } from 'react'; import { useInstantSearchContext } from 'react-instantsearch-core'; @@ -19,6 +18,7 @@ import type { } from 'instantsearch-ui-components'; import type { IndexUiState, IndexWidget } from 'instantsearch.js'; import type { ComponentProps } from 'react'; +import { updateStateFromSearchToolInput } from 'instantsearch.js/es/lib/utils'; type ItemComponent = RecommendComponentProps['itemComponent']; @@ -28,28 +28,6 @@ type SearchToolInput = { facet_filters?: string[][]; }; -function getLocalWidgetsUiState( - widgets: Array, - widgetStateOptions: WidgetUiStateOptions, - initialUiState: IndexUiState = {} -) { - return widgets.reduce((uiState, widget) => { - if (isIndexWidget(widget)) { - return uiState; - } - - if (!widget.getWidgetUiState && !widget.getWidgetState) { - return uiState; - } - - if (widget.getWidgetUiState) { - return widget.getWidgetUiState(uiState, widgetStateOptions); - } - - return widget.getWidgetState!(uiState, widgetStateOptions); - }, initialUiState); -} - function createCarouselTool( showViewAll: boolean, itemComponent?: ItemComponent, @@ -133,10 +111,6 @@ function createCarouselTool( nbHits, input, hitsPerPage, - setIndexUiState, - indexUiState, - // eslint-disable-next-line no-shadow - getSearchPageURL, onClose, }: { canScrollLeft: boolean; @@ -172,60 +146,11 @@ function createCarouselTool( variant="ghost" size="sm" onClick={() => { - if (!input?.query) return; - - input.facet_filters = [ - ['brand:Samsung'], - [ - 'categories:Cell Phones', - 'categories:Unlocked Cell Phones', - ], - ]; - - const attributes = input.facet_filters - .flat() - .map((filter) => { - const [attribute, value] = filter.split(':'); - - return { attribute, value }; - }); - const helper = search.mainIndex.getHelper(); - if (!helper) return; - - if (input.query) { - helper.setQuery(input.query); - } - - attributes.forEach(({ attribute }) => { - helper.clearRefinements(attribute); - }); - - attributes.forEach(({ attribute, value }) => { - const hierarchicalFacet = - helper.state.hierarchicalFacets.find( - (facet) => facet.name === attribute - ); - - if (hierarchicalFacet) { - helper.toggleFacetRefinement( - hierarchicalFacet.name, - value - ); - } else { - helper.toggleFacetRefinement(attribute, value); - } - }); - - helper.search(); + if (!helper || !input) return; - if ( - getSearchPageURL && - new URL(getSearchPageURL(helper.state)).pathname !== - window.location.pathname - ) { - window.location.href = getSearchPageURL(helper.state); - } else { + const success = updateStateFromSearchToolInput(input, helper); + if (success) { onClose(); } }} From 55647cb144893d2e6d4eb28baca232e840240bb8 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 23 Jan 2026 15:21:45 +0000 Subject: [PATCH 07/21] move to connector --- .../src/components/chat/ChatMessage.tsx | 1 + .../src/components/chat/types.ts | 13 ++++- .../src/connectors/chat/connectChat.ts | 18 ++++--- .../src/widgets/chat/chat.tsx | 47 ++++++++----------- .../widgets/chat/tools/SearchIndexTool.tsx | 15 +++--- 5 files changed, 49 insertions(+), 45 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index 8b7f5599e2c..327555d0ae2 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -235,6 +235,7 @@ export function createChatMessageComponent({ createElement }: Renderer) { indexUiState={indexUiState} setIndexUiState={setIndexUiState} addToolResult={boundAddToolResult} + applyFilters={tool.applyFilters} onClose={onClose} />
diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index 4c6164dc496..b09d6f15d0b 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -18,12 +18,19 @@ export type AddToolResultWithOutput = ( params: Pick[0], 'output'> ) => ReturnType; +export type SearchToolInput = { + query: string; + number_of_results?: number; + facet_filters?: string[][]; +}; + export type ClientSideToolComponentProps = { message: ChatToolMessage; indexUiState: object; setIndexUiState: (state: object) => void; onClose: () => void; addToolResult: AddToolResultWithOutput; + applyFilters: (params: SearchToolInput) => boolean; }; export type ClientSideToolComponent = ( @@ -40,8 +47,12 @@ export type ClientSideTool = { addToolResult: AddToolResultWithOutput; } ) => void; + applyFilters: (params: SearchToolInput) => boolean; }; export type ClientSideTools = Record; -export type UserClientSideTool = Omit; +export type UserClientSideTool = Omit< + ClientSideTool, + 'addToolResult' | 'applyFilters' +>; export type UserClientSideTools = Record; diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index 90599f948c6..e06e61086ca 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -37,6 +37,7 @@ import type { UserClientSideTool, ClientSideTools, ClientSideTool, + SearchToolInput, } from 'instantsearch-ui-components'; const withUsage = createDocumentationMessageGenerator({ @@ -172,7 +173,7 @@ export default (function connectChat( let setInput: ChatRenderState['setInput']; let setOpen: ChatRenderState['setOpen']; let setIsClearing: (value: boolean) => void; - let helperTest: (toolInput: {}) => void; + let applyFilters: (toolInput: SearchToolInput) => boolean; const agentId = 'agentId' in options ? options.agentId : undefined; @@ -359,11 +360,11 @@ export default (function connectChat( _chatInstance = makeChatInstance(instantSearchInstance); - helperTest = (toolInput: {}) => { - updateStateFromSearchToolInput( - toolInput as any, - instantSearchInstance.mainIndex.getHelper()! - ); + applyFilters = (inputParam: SearchToolInput) => { + const helper = instantSearchInstance.mainIndex.getHelper(); + if (!helper || !inputParam) return false; + + return updateStateFromSearchToolInput(inputParam, helper); }; const render = () => { @@ -449,6 +450,7 @@ export default (function connectChat( const toolWithAddToolResult: ClientSideTool = { ...tool, addToolResult: _chatInstance.addToolResult, + applyFilters, }; toolsWithAddToolResult[key] = toolWithAddToolResult; }); @@ -491,10 +493,6 @@ export default (function connectChat( return true; }, - helperTest(toolInput: {}) { - return helperTest(toolInput); - }, - get chatInstance() { return _chatInstance; }, diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index a122e53bf89..e26004d896c 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -54,6 +54,7 @@ import type { ClientSideToolComponentProps, ClientSideTools, RecordWithObjectID, + SearchToolInput, UserClientSideTool, } from 'instantsearch-ui-components'; import type { ComponentProps } from 'preact'; @@ -70,19 +71,12 @@ function getDefinedProperties(obj: T): Partial { ) as Partial; } -type SearchToolInput = { - query: string; - number_of_results?: number; - facet_filters?: string[][]; -}; - function createCarouselTool< THit extends RecordWithObjectID = RecordWithObjectID >( showViewAll: boolean, templates: ChatTemplates, - getSearchPageURL?: (nextUiState: IndexUiState) => string, - helperTest?: (input: {}) => void + getSearchPageURL?: (nextUiState: IndexUiState) => string ): UserClientSideToolWithTemplate { const Button = createButtonComponent({ createElement: h, @@ -92,6 +86,7 @@ function createCarouselTool< message, indexUiState, setIndexUiState, + applyFilters, onClose, }: ClientSideToolComponentProps) { const input = message?.input as @@ -130,6 +125,7 @@ function createCarouselTool< setIndexUiState={setIndexUiState} indexUiState={indexUiState} getSearchPageURL={getSearchPageURL} + applyFilters={applyFilters} onClose={onClose} {...props} /> @@ -138,6 +134,7 @@ function createCarouselTool< items.length, input, output?.nbHits, + applyFilters, setIndexUiState, indexUiState, onClose, @@ -172,6 +169,7 @@ function createCarouselTool< nbHits, input, hitsPerPage, + applyFilters, onClose, }: { canScrollLeft: boolean; @@ -182,6 +180,7 @@ function createCarouselTool< input?: SearchToolInput; hitsPerPage?: number; setIndexUiState: IndexWidget['setIndexUiState']; + applyFilters?: (params: SearchToolInput) => boolean; indexUiState: IndexUiState; onClose: () => void; getSearchPageURL?: (nextUiState: IndexUiState) => string; @@ -204,7 +203,12 @@ function createCarouselTool< variant="ghost" size="sm" onClick={() => { - helperTest?.(input || {}); + if (!input || !applyFilters) return; + + const success = applyFilters(input); + if (success) { + onClose(); + } }} className="ais-ChatToolSearchIndexCarouselHeaderViewAll" > @@ -251,8 +255,7 @@ function createDefaultTools< THit extends RecordWithObjectID = RecordWithObjectID >( templates: ChatTemplates, - getSearchPageURL?: (nextUiState: IndexUiState) => string, - helperTest: (input: {}) => void + getSearchPageURL?: (nextUiState: IndexUiState) => string ): UserClientSideToolsWithTemplate { return { [SearchIndexToolType]: createCarouselTool( @@ -1159,9 +1162,7 @@ export default (function chat< ...userTemplates, }; - const defaultTools = createDefaultTools(templates, getSearchPageURL, () => { - // no default action - }); + const defaultTools = createDefaultTools(templates, getSearchPageURL); const tools = { ...defaultTools, ...userTools }; @@ -1177,20 +1178,12 @@ export default (function chat< render(null, containerNode) ); - // eslint-disable-next-line @typescript-eslint/unbound-method - const { helperTest, ...rest } = makeWidget({ - resume, - tools: { - ...createDefaultTools(templates, getSearchPageURL, () => { - helperTest?.({}); - }), - ...userTools, - }, - ...options, - }); - return { - ...rest, + ...makeWidget({ + resume, + tools, + ...options, + }), $$widgetType: 'ais.chat', }; } satisfies ChatWidget); diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index b5eb9d236c9..7950f1ca22f 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -5,7 +5,6 @@ import { createButtonComponent, } from 'instantsearch-ui-components'; import React, { createElement } from 'react'; -import { useInstantSearchContext } from 'react-instantsearch-core'; import { Carousel } from '../../../components'; @@ -18,7 +17,6 @@ import type { } from 'instantsearch-ui-components'; import type { IndexUiState, IndexWidget } from 'instantsearch.js'; import type { ComponentProps } from 'react'; -import { updateStateFromSearchToolInput } from 'instantsearch.js/es/lib/utils'; type ItemComponent = RecommendComponentProps['itemComponent']; @@ -41,6 +39,7 @@ function createCarouselTool( message, indexUiState, setIndexUiState, + applyFilters, onClose, }: ClientSideToolComponentProps) { const input = message?.input as @@ -66,6 +65,7 @@ function createCarouselTool( | 'nbHits' | 'query' | 'hitsPerPage' + | 'applyFilters' | 'setIndexUiState' | 'indexUiState' | 'getSearchPageURL' @@ -80,6 +80,7 @@ function createCarouselTool( indexUiState={indexUiState} getSearchPageURL={getSearchPageURL} onClose={onClose} + applyFilters={applyFilters} {...props} /> ); @@ -87,6 +88,7 @@ function createCarouselTool( items.length, input, output?.nbHits, + applyFilters, setIndexUiState, onClose, indexUiState, @@ -111,6 +113,7 @@ function createCarouselTool( nbHits, input, hitsPerPage, + applyFilters, onClose, }: { canScrollLeft: boolean; @@ -122,11 +125,10 @@ function createCarouselTool( hitsPerPage?: number; setIndexUiState: IndexWidget['setIndexUiState']; indexUiState: IndexUiState; + applyFilters: (toolInput: SearchToolInput) => boolean; getSearchPageURL?: (nextUiState: IndexUiState) => string; onClose: () => void; }) { - const search = useInstantSearchContext(); - if ((hitsPerPage ?? 0) < 1) { return null; } @@ -146,10 +148,9 @@ function createCarouselTool( variant="ghost" size="sm" onClick={() => { - const helper = search.mainIndex.getHelper(); - if (!helper || !input) return; + if (!input || !applyFilters) return; - const success = updateStateFromSearchToolInput(input, helper); + const success = applyFilters(input); if (success) { onClose(); } From 1a94ac647dd6832bdc6f1785f974a46059b97d74 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 23 Jan 2026 15:22:03 +0000 Subject: [PATCH 08/21] revert current refinements --- .../connectCurrentRefinements.ts | 15 --------------- .../src/ui/CurrentRefinements.tsx | 3 --- .../src/widgets/CurrentRefinements.tsx | 5 ++--- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/packages/instantsearch.js/src/connectors/current-refinements/connectCurrentRefinements.ts b/packages/instantsearch.js/src/connectors/current-refinements/connectCurrentRefinements.ts index a16d42dfba4..df541aaed43 100644 --- a/packages/instantsearch.js/src/connectors/current-refinements/connectCurrentRefinements.ts +++ b/packages/instantsearch.js/src/connectors/current-refinements/connectCurrentRefinements.ts @@ -142,9 +142,6 @@ export type CurrentRefinementsRenderState = { * Generates a URL for the next state. */ createURL: CreateURL; - - aiMode: boolean; - setAiMode: (value: boolean) => void; }; const withUsage = createDocumentationMessageGenerator({ @@ -173,12 +170,6 @@ const connectCurrentRefinements: CurrentRefinementsConnector = checkRendering(renderFn, withUsage()); return (widgetParams) => { - let isAiMode = false; - - const setAiMode = (value: boolean) => { - isAiMode = value; - }; - if ( (widgetParams || {}).includedAttributes && (widgetParams || {}).excludedAttributes @@ -271,18 +262,12 @@ const connectCurrentRefinements: CurrentRefinementsConnector = const items = getItems(); - if (items.length === 0) { - setAiMode(false); - } - return { items, canRefine: items.length > 0, refine: (refinement) => clearRefinement(helper, refinement), createURL: (refinement) => createURL(clearRefinementFromState(helper.state, refinement)), - aiMode: isAiMode, - setAiMode, widgetParams, }; }, diff --git a/packages/react-instantsearch/src/ui/CurrentRefinements.tsx b/packages/react-instantsearch/src/ui/CurrentRefinements.tsx index b8e2cfa6aa3..1493d12b63d 100644 --- a/packages/react-instantsearch/src/ui/CurrentRefinements.tsx +++ b/packages/react-instantsearch/src/ui/CurrentRefinements.tsx @@ -15,7 +15,6 @@ export type CurrentRefinementsProps = React.ComponentProps<'div'> & { Record >; hasRefinements?: boolean; - aiMode?: boolean; }; export type CurrentRefinementsClassNames = { @@ -61,7 +60,6 @@ export function CurrentRefinements({ classNames = {}, items = [], hasRefinements = false, - aiMode, ...props }: CurrentRefinementsProps) { return ( @@ -78,7 +76,6 @@ export function CurrentRefinements({ props.className )} > - {aiMode &&

AI Refinements Applied

}
    ; export type CurrentRefinementsProps = Omit< @@ -23,7 +23,7 @@ export function CurrentRefinements({ transformItems, ...props }: CurrentRefinementsProps) { - const { items, canRefine, aiMode } = useCurrentRefinements( + const { items, canRefine } = useCurrentRefinements( { includedAttributes, excludedAttributes, @@ -37,7 +37,6 @@ export function CurrentRefinements({ const uiProps: UiProps = { items, hasRefinements: canRefine, - aiMode, }; return ; From 88835f45c4824b56594fb022d069fbf0b7ba3310 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 23 Jan 2026 15:59:43 +0000 Subject: [PATCH 09/21] check for non existing attributes --- .../src/lib/utils/updateStateFromSearchToolInput.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts b/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts index 4b060a7499b..ea77b8a0e19 100644 --- a/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts +++ b/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts @@ -21,6 +21,17 @@ export function updateStateFromSearchToolInput( return { attribute, value }; }); + if ( + attributes.some( + ({ attribute }) => + !helper.state.isConjunctiveFacet(attribute) && + !helper.state.isHierarchicalFacet(attribute) && + !helper.state.isDisjunctiveFacet(attribute) + ) + ) { + return false; + } + attributes.forEach(({ attribute }) => { helper.clearRefinements(attribute); }); From da1d461aba01f5585519dd32311e9f11c4682132 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 23 Jan 2026 17:52:30 +0000 Subject: [PATCH 10/21] add tests --- .../src/connectors/chat/connectChat.ts | 16 +- .../src/lib/utils/__tests__/flat-test.ts | 34 ++++ .../instantsearch.js/src/lib/utils/flat.ts | 3 + .../utils/updateStateFromSearchToolInput.ts | 4 +- .../src/__tests__/common-widgets.test.tsx | 16 +- tests/common/widgets/chat/index.ts | 6 +- tests/common/widgets/chat/options.tsx | 148 ++++++++++++++++++ 7 files changed, 214 insertions(+), 13 deletions(-) create mode 100644 packages/instantsearch.js/src/lib/utils/__tests__/flat-test.ts create mode 100644 packages/instantsearch.js/src/lib/utils/flat.ts diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index e06e61086ca..6eb9f6cc336 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -173,7 +173,6 @@ export default (function connectChat( let setInput: ChatRenderState['setInput']; let setOpen: ChatRenderState['setOpen']; let setIsClearing: (value: boolean) => void; - let applyFilters: (toolInput: SearchToolInput) => boolean; const agentId = 'agentId' in options ? options.agentId : undefined; @@ -360,13 +359,6 @@ export default (function connectChat( _chatInstance = makeChatInstance(instantSearchInstance); - applyFilters = (inputParam: SearchToolInput) => { - const helper = instantSearchInstance.mainIndex.getHelper(); - if (!helper || !inputParam) return false; - - return updateStateFromSearchToolInput(inputParam, helper); - }; - const render = () => { renderFn( { @@ -432,7 +424,7 @@ export default (function connectChat( }, getWidgetRenderState(renderOptions) { - const { instantSearchInstance, parent } = renderOptions; + const { instantSearchInstance, parent, helper } = renderOptions; if (!_chatInstance) { this.init!({ ...renderOptions, uiState: {}, results: undefined }); } @@ -445,6 +437,12 @@ export default (function connectChat( }); } + function applyFilters(inputParam: SearchToolInput): boolean { + if (!helper || !inputParam) return false; + + return updateStateFromSearchToolInput(inputParam, helper); + } + const toolsWithAddToolResult: ClientSideTools = {}; Object.entries(tools).forEach(([key, tool]) => { const toolWithAddToolResult: ClientSideTool = { diff --git a/packages/instantsearch.js/src/lib/utils/__tests__/flat-test.ts b/packages/instantsearch.js/src/lib/utils/__tests__/flat-test.ts new file mode 100644 index 00000000000..9c979542fff --- /dev/null +++ b/packages/instantsearch.js/src/lib/utils/__tests__/flat-test.ts @@ -0,0 +1,34 @@ +import { flat } from '../flat'; + +describe('flat', () => { + test('flattens a 2D array into a 1D array', () => { + const input = [ + ['a', 'b'], + ['c', 'd'], + ['e', 'f'], + ]; + const expectedOutput = ['a', 'b', 'c', 'd', 'e', 'f']; + + const actualOutput = flat(input); + + expect(actualOutput).toEqual(expectedOutput); + }); + + test('returns an empty array when given an empty array', () => { + const input: never[][] = []; + const expectedOutput: never[] = []; + + const actualOutput = flat(input); + + expect(actualOutput).toEqual(expectedOutput); + }); + + test('handles arrays with empty sub-arrays', () => { + const input = [['a', 'b'], [], ['c'], [], ['d', 'e']]; + const expectedOutput = ['a', 'b', 'c', 'd', 'e']; + + const actualOutput = flat(input); + + expect(actualOutput).toEqual(expectedOutput); + }); +}); diff --git a/packages/instantsearch.js/src/lib/utils/flat.ts b/packages/instantsearch.js/src/lib/utils/flat.ts new file mode 100644 index 00000000000..81ed14dcf4c --- /dev/null +++ b/packages/instantsearch.js/src/lib/utils/flat.ts @@ -0,0 +1,3 @@ +export function flat(arr: T[][]): T[] { + return arr.reduce((acc, array) => acc.concat(array), []); +} diff --git a/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts b/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts index ea77b8a0e19..d9fb2df43b9 100644 --- a/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts +++ b/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts @@ -1,3 +1,5 @@ +import { flat } from './flat'; + import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; type SearchToolInput = { @@ -15,7 +17,7 @@ export function updateStateFromSearchToolInput( } if (input.facet_filters) { - const attributes = input.facet_filters.flat().map((filter) => { + const attributes = flat(input.facet_filters).map((filter) => { const [attribute, value] = filter.split(':'); return { attribute, value }; diff --git a/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx b/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx index 7a300b447c4..b9e64e96e88 100644 --- a/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx +++ b/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx @@ -395,9 +395,23 @@ const testSetups: TestSetupsMap = { ); }, createChatWidgetTests({ instantSearchOptions, widgetParams }) { + const { renderRefinements, ...chatWidgetParams } = widgetParams; render( - + {renderRefinements && ( + <> + + + + + )} + ); diff --git a/tests/common/widgets/chat/index.ts b/tests/common/widgets/chat/index.ts index ff15bc33a86..ab1fa35cd57 100644 --- a/tests/common/widgets/chat/index.ts +++ b/tests/common/widgets/chat/index.ts @@ -13,8 +13,10 @@ type JSBaseWidgetParams = Parameters[0]; // Explicitly adding ChatConnectorParams back. For some reason // ChatConnectorParams are not inferred when Omit is used with WidgetParams. export type JSChatWidgetParams = Omit & - ChatConnectorParams; -export type ReactChatWidgetParams = ChatProps; + ChatConnectorParams & { renderRefinements?: boolean }; +export type ReactChatWidgetParams = ChatProps & { + renderRefinements?: boolean; +}; type ChatWidgetParams = { javascript: JSChatWidgetParams; diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index 8442a296567..7856b7e979b 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -425,6 +425,154 @@ export function createOptionsTests( 'The message said hello!' ); }); + + test('applies filters from the algolia search tool view all button', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + parts: [ + { + type: `tool-${SearchIndexToolType}`, + toolCallId: '1', + input: { + query: 'test', + facet_filters: [['brand:Apple'], ['category:Laptops']], + }, + state: 'output-available', + output: { + hits: [ + { + objectID: '123', + }, + ], + }, + }, + ], + }, + ], + id: 'chat-id', + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + initialUiState: { + indexName: { + refinementList: { + brand: ['Samsung', 'Apple'], + category: ['Laptops'], + }, + }, + }, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + renderRefinements: true, + }, + react: { + ...createDefaultWidgetParams(chat), + renderRefinements: true, + }, + vue: {}, + }, + }); + + await openChat(act); + + userEvent.click( + document.querySelector( + '.ais-ChatToolSearchIndexCarouselHeaderViewAll' + )! + ); + + await act(async () => { + await wait(0); + }); + + expect(searchClient.search).toHaveBeenCalledTimes(2); + expect(searchClient.search).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + query: 'test', + facetFilters: [['brand:Apple'], ['category:Laptops']], + }), + }), + ]) + ); + }); + + test('does not apply filters when attribute is not in state', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + parts: [ + { + type: `tool-${SearchIndexToolType}`, + toolCallId: '1', + input: { + query: 'test', + facet_filters: [['brand:Apple'], ['category:Laptops']], + }, + state: 'output-available', + output: { + hits: [ + { + objectID: '123', + }, + ], + }, + }, + ], + }, + ], + id: 'chat-id', + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + initialUiState: { + indexName: { + refinementList: { + brand: ['Samsung', 'Apple'], + // category is missing + }, + }, + }, + }, + widgetParams: { + javascript: createDefaultWidgetParams(chat), + react: createDefaultWidgetParams(chat), + vue: {}, + }, + }); + + await openChat(act); + + userEvent.click( + document.querySelector( + '.ais-ChatToolSearchIndexCarouselHeaderViewAll' + )! + ); + + await act(async () => { + await wait(0); + }); + + expect(searchClient.search).toHaveBeenCalledTimes(1); + }); }); }); } From 0b5e33a7bc9a019e86328dc09c4b917e12040312 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 23 Jan 2026 18:25:46 +0000 Subject: [PATCH 11/21] fix lint --- .../chat/__tests__/ChatMessage.test.tsx | 1 + .../src/__tests__/common-widgets.test.tsx | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessage.test.tsx b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessage.test.tsx index c6cd63e4e13..50a46823b2c 100644 --- a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessage.test.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessage.test.tsx @@ -226,6 +226,7 @@ describe('ChatMessage', () => { ), addToolResult: jest.fn(), onToolCall: jest.fn(), + applyFilters: jest.fn(), }, }} onClose={jest.fn()} diff --git a/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx b/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx index 61a26bbc435..be0d3521120 100644 --- a/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx +++ b/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx @@ -620,11 +620,40 @@ const testSetups: TestSetupsMap = { .start(); }, createChatWidgetTests({ instantSearchOptions, widgetParams }) { + const { renderRefinements, ...chatWidgetParams } = widgetParams; + + const refinementsWidgets = []; + if (renderRefinements) { + refinementsWidgets.push( + ...[ + searchBox({ + container: document.body.appendChild(document.createElement('div')), + }), + refinementList({ + container: document.body.appendChild(document.createElement('div')), + attribute: 'brand', + }), + refinementList({ + container: document.body.appendChild(document.createElement('div')), + attribute: 'category', + }), + hierarchicalMenu({ + container: document.body.appendChild(document.createElement('div')), + attributes: [ + 'hierarchicalCategories.lvl0', + 'hierarchicalCategories.lvl1', + ], + }), + ] + ); + } + instantsearch(instantSearchOptions) .addWidgets([ + ...refinementsWidgets, chat({ container: document.body.appendChild(document.createElement('div')), - ...widgetParams, + ...chatWidgetParams, }), ]) .on('error', () => { From ea5af10a9bd8ab24eef3d8d902a66aa880b86153 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 23 Jan 2026 18:27:47 +0000 Subject: [PATCH 12/21] fix bundlesize --- bundlesize.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index b95d2f530a8..23a8fd786c7 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,11 +10,11 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "123.25 kB" + "maxSize": "123.50 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "243.75 kB" + "maxSize": "244 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", From 6d8458c07c994d14f230a01b7bee3a392014b8f9 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 23 Jan 2026 18:48:39 +0000 Subject: [PATCH 13/21] fix test --- .../src/connectors/chat/__tests__/connectChat-test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index 97ce79da3c4..a625be2988d 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -386,6 +386,7 @@ describe('connectChat', () => { testTool: { ...mockTool, addToolResult: expect.any(Function), + applyFilters: expect.any(Function), }, }); }); From 2ae92808d7d184f2d1e7dabf7e9fe5ea37160fba Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Mon, 26 Jan 2026 09:25:16 +0000 Subject: [PATCH 14/21] use generic type --- .../src/components/chat/types.ts | 9 +- .../src/connectors/chat/connectChat.ts | 12 +- .../utils/updateStateFromSearchToolInput.ts | 17 +-- .../src/widgets/chat/chat.tsx | 7 +- .../widgets/chat/tools/SearchIndexTool.tsx | 7 +- tests/common/widgets/chat/options.tsx | 115 ++++++++++++++++++ 6 files changed, 146 insertions(+), 21 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index b09d6f15d0b..4c65a425c86 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -24,13 +24,18 @@ export type SearchToolInput = { facet_filters?: string[][]; }; +export type ApplyFiltersParams = { + query?: string; + facetFilters?: string[][]; +}; + export type ClientSideToolComponentProps = { message: ChatToolMessage; indexUiState: object; setIndexUiState: (state: object) => void; onClose: () => void; addToolResult: AddToolResultWithOutput; - applyFilters: (params: SearchToolInput) => boolean; + applyFilters: (params: ApplyFiltersParams) => boolean; }; export type ClientSideToolComponent = ( @@ -47,7 +52,7 @@ export type ClientSideTool = { addToolResult: AddToolResultWithOutput; } ) => void; - applyFilters: (params: SearchToolInput) => boolean; + applyFilters: (params: ApplyFiltersParams) => boolean; }; export type ClientSideTools = Record; diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index 6eb9f6cc336..9d0930ad89c 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -37,7 +37,6 @@ import type { UserClientSideTool, ClientSideTools, ClientSideTool, - SearchToolInput, } from 'instantsearch-ui-components'; const withUsage = createDocumentationMessageGenerator({ @@ -108,6 +107,11 @@ export type ChatTransport = { transport?: ConstructorParameters[0]; }; +export type ApplyFiltersParams = { + query?: string; + facetFilters?: string[][]; +}; + export type ChatInit = ChatInitWithoutTransport & ChatTransport; @@ -437,10 +441,10 @@ export default (function connectChat( }); } - function applyFilters(inputParam: SearchToolInput): boolean { - if (!helper || !inputParam) return false; + function applyFilters(params: ApplyFiltersParams): boolean { + if (!helper) return false; - return updateStateFromSearchToolInput(inputParam, helper); + return updateStateFromSearchToolInput(params, helper); } const toolsWithAddToolResult: ClientSideTools = {}; diff --git a/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts b/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts index d9fb2df43b9..c4d794d5829 100644 --- a/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts +++ b/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts @@ -1,23 +1,18 @@ import { flat } from './flat'; import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; - -type SearchToolInput = { - query: string; - number_of_results?: number; - facet_filters?: string[][]; -}; +import type { ApplyFiltersParams } from 'instantsearch-ui-components'; export function updateStateFromSearchToolInput( - input: SearchToolInput, + params: ApplyFiltersParams, helper: AlgoliaSearchHelper ) { - if (input.query) { - helper.setQuery(input.query); + if (params.query) { + helper.setQuery(params.query); } - if (input.facet_filters) { - const attributes = flat(input.facet_filters).map((filter) => { + if (params.facetFilters) { + const attributes = flat(params.facetFilters).map((filter) => { const [attribute, value] = filter.split(':'); return { attribute, value }; diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index e6a4892756c..27ace6ad8ab 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -186,7 +186,7 @@ function createCarouselTool< input?: SearchToolInput; hitsPerPage?: number; setIndexUiState: IndexWidget['setIndexUiState']; - applyFilters?: (params: SearchToolInput) => boolean; + applyFilters?: ClientSideToolComponentProps['applyFilters']; indexUiState: IndexUiState; onClose: () => void; getSearchPageURL?: (nextUiState: IndexUiState) => string; @@ -211,7 +211,10 @@ function createCarouselTool< onClick={() => { if (!input || !applyFilters) return; - const success = applyFilters(input); + const success = applyFilters({ + query: input.query, + facetFilters: input.facet_filters, + }); if (success) { onClose(); } diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 7950f1ca22f..efe814343cd 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -125,7 +125,7 @@ function createCarouselTool( hitsPerPage?: number; setIndexUiState: IndexWidget['setIndexUiState']; indexUiState: IndexUiState; - applyFilters: (toolInput: SearchToolInput) => boolean; + applyFilters: ClientSideToolComponentProps['applyFilters']; getSearchPageURL?: (nextUiState: IndexUiState) => string; onClose: () => void; }) { @@ -150,7 +150,10 @@ function createCarouselTool( onClick={() => { if (!input || !applyFilters) return; - const success = applyFilters(input); + const success = applyFilters({ + query: input.query, + facetFilters: input.facet_filters, + }); if (success) { onClose(); } diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index 7856b7e979b..3e6e440a030 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -573,6 +573,121 @@ export function createOptionsTests( expect(searchClient.search).toHaveBeenCalledTimes(1); }); + + test('applies filters for custom tools', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + parts: [ + { + type: 'tool-hello', + toolCallId: '1', + input: { + query: 'test', + facet_filters: [['brand:Apple'], ['category:Laptops']], + }, + state: 'output-available', + output: 'hello', + }, + ], + }, + ], + id: 'chat-id', + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + initialUiState: { + indexName: { + refinementList: { + brand: ['Samsung', 'Apple'], + category: ['Laptops'], + }, + }, + }, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + templates: { + layout: ({ applyFilters }, { html }) => + html``, + }, + }, + }, + renderRefinements: true, + }, + react: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + layoutComponent: ({ applyFilters }) => { + return ( + + ); + }, + }, + }, + renderRefinements: true, + }, + vue: {}, + }, + }); + + await openChat(act); + + userEvent.click(document.querySelector('.ais-ChatToolHelloViewAll')!); + + await act(async () => { + await wait(0); + }); + + expect(searchClient.search).toHaveBeenCalledTimes(2); + expect(searchClient.search).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + query: 'test', + facetFilters: [['brand:Apple'], ['category:Laptops']], + }), + }), + ]) + ); + }); }); }); } From 22f0fe643feec753074f39a53165ec439831e255 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Mon, 26 Jan 2026 09:34:31 +0000 Subject: [PATCH 15/21] revert example change --- examples/react/getting-started/src/App.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/react/getting-started/src/App.tsx b/examples/react/getting-started/src/App.tsx index 7d5ec452742..ad1b9961829 100644 --- a/examples/react/getting-started/src/App.tsx +++ b/examples/react/getting-started/src/App.tsx @@ -2,6 +2,7 @@ import { liteClient as algoliasearch } from 'algoliasearch/lite'; import { Hit } from 'instantsearch.js'; import React from 'react'; import { + Configure, Highlight, Hits, InstantSearch, @@ -44,20 +45,17 @@ export function App() { searchClient={searchClient} indexName="instant_search" insights={true} - routing > +
    - - -
    - +
    From 0dd7facfe7411d880fd07af03bb2ea411ce1e2ff Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Mon, 26 Jan 2026 10:25:25 +0000 Subject: [PATCH 16/21] move search tool to ui components --- .../src/components/Carousel.tsx | 14 +- .../components/chat/tools/SearchIndexTool.tsx | 249 ++++++++++++++++++ .../src/components/index.ts | 1 + .../src/widgets/chat/chat.tsx | 196 +------------- .../src/widgets/chat/search-index-tool.tsx | 61 +++++ .../widgets/chat/tools/SearchIndexTool.tsx | 187 +------------ 6 files changed, 336 insertions(+), 372 deletions(-) create mode 100644 packages/instantsearch-ui-components/src/components/chat/tools/SearchIndexTool.tsx create mode 100644 packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx diff --git a/packages/instantsearch-ui-components/src/components/Carousel.tsx b/packages/instantsearch-ui-components/src/components/Carousel.tsx index 426c024a0a4..9f038fce82e 100644 --- a/packages/instantsearch-ui-components/src/components/Carousel.tsx +++ b/packages/instantsearch-ui-components/src/components/Carousel.tsx @@ -12,6 +12,13 @@ import type { SendEventForHits, } from '../types'; +export type HeaderComponentProps = { + canScrollLeft: boolean; + canScrollRight: boolean; + scrollLeft: () => void; + scrollRight: () => void; +}; + export type CarouselProps< TObject, TComponentProps extends Record = Record @@ -31,12 +38,7 @@ export type CarouselProps< ) => JSX.Element; previousIconComponent?: () => JSX.Element; nextIconComponent?: () => JSX.Element; - headerComponent?: (props: { - canScrollLeft: boolean; - canScrollRight: boolean; - scrollLeft: () => void; - scrollRight: () => void; - }) => JSX.Element; + headerComponent?: (props: HeaderComponentProps) => JSX.Element; showNavigation?: boolean; classNames?: Partial; translations?: Partial; diff --git a/packages/instantsearch-ui-components/src/components/chat/tools/SearchIndexTool.tsx b/packages/instantsearch-ui-components/src/components/chat/tools/SearchIndexTool.tsx new file mode 100644 index 00000000000..0e5a231b474 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/tools/SearchIndexTool.tsx @@ -0,0 +1,249 @@ +/** @jsx createElement */ + +import { createButtonComponent } from '../../Button'; +import { createCarouselComponent, generateCarouselId } from '../../Carousel'; +import { ArrowRightIcon, ChevronLeftIcon, ChevronRightIcon } from '../icons'; + +import type { RecordWithObjectID, Renderer } from '../../../types'; +import type { + CarouselProps, + HeaderComponentProps as CarouselHeaderComponentProps, +} from '../../Carousel'; +import type { ClientSideToolComponentProps } from '../types'; +import type { IndexUiState, IndexWidget } from 'instantsearch.js'; + +type SearchToolInput = { + query: string; + number_of_results?: number; + facet_filters?: string[][]; +}; + +type HeaderProps = { + showViewAll: boolean; + canScrollLeft: boolean; + canScrollRight: boolean; + scrollLeft: () => void; + scrollRight: () => void; + nbHits?: number; + input?: SearchToolInput; + hitsPerPage?: number; + setIndexUiState: IndexWidget['setIndexUiState']; + indexUiState: IndexUiState; + applyFilters: ClientSideToolComponentProps['applyFilters']; + getSearchPageURL?: (nextUiState: IndexUiState) => string; + onClose: () => void; +}; + +export type SearchIndexToolProps = { + useMemo: (factory: () => TType, inputs: readonly unknown[]) => TType; + useRef: (initialValue: TType) => { current: TType }; + useState: ( + initialState: TType + ) => [TType, (newState: TType) => unknown]; + getSearchPageURL?: (nextUiState: IndexUiState) => string; + toolProps: ClientSideToolComponentProps; + itemComponent?: CarouselProps['itemComponent']; + headerComponent?: (props: HeaderProps) => JSX.Element; + headerProps: Pick; +}; + +function createHeaderComponent({ createElement }: Renderer) { + const Button = createButtonComponent({ createElement }); + + return function HeaderComponent({ + showViewAll, + canScrollLeft, + canScrollRight, + scrollLeft, + scrollRight, + nbHits, + input, + hitsPerPage, + applyFilters, + onClose, + }: HeaderProps) { + if ((hitsPerPage ?? 0) < 1) { + return null; + } + + return ( +
    +
    + {nbHits && ( +
    + {hitsPerPage ?? 0} of {nbHits.toLocaleString()} result + {nbHits > 1 ? 's' : ''} +
    + )} + {showViewAll && ( + + )} +
    + + {(hitsPerPage ?? 0) > 2 && ( +
    + + +
    + )} +
    + ); + }; +} + +export function createSearchIndexTool({ + createElement, + Fragment, +}: Renderer) { + const DefaultHeader = createHeaderComponent({ createElement, Fragment }); + const Carousel = createCarouselComponent({ createElement, Fragment }); + + return function SearchIndexTool({ + useMemo, + useRef, + useState, + itemComponent: ItemComponent, + headerComponent: HeaderComponent, + getSearchPageURL, + toolProps: { + message, + indexUiState, + setIndexUiState, + applyFilters, + onClose, + }, + headerProps: { showViewAll }, + }: SearchIndexToolProps) { + const input = message?.input as + | { + query: string; + number_of_results?: number; + } + | undefined; + + const output = message?.output as + | { + hits?: Array>; + nbHits?: number; + } + | undefined; + + const items = output?.hits || []; + + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + + const carouselRefs: Pick< + CarouselProps, + | 'listRef' + | 'nextButtonRef' + | 'previousButtonRef' + | 'carouselIdRef' + | 'canScrollLeft' + | 'canScrollRight' + | 'setCanScrollLeft' + | 'setCanScrollRight' + > = { + listRef: useRef(null), + nextButtonRef: useRef(null), + previousButtonRef: useRef(null), + carouselIdRef: useRef(generateCarouselId()), + canScrollLeft, + canScrollRight, + setCanScrollLeft, + setCanScrollRight, + }; + + const MemoedHeaderComponent = useMemo(() => { + if (HeaderComponent) { + return (props: CarouselHeaderComponentProps) => ( + + ); + } + + return (props: CarouselHeaderComponentProps) => ( + + ); + }, [ + showViewAll, + HeaderComponent, + output?.nbHits, + input, + items.length, + applyFilters, + setIndexUiState, + indexUiState, + getSearchPageURL, + onClose, + ]); + + return ( + {}} + /> + ); + }; +} diff --git a/packages/instantsearch-ui-components/src/components/index.ts b/packages/instantsearch-ui-components/src/components/index.ts index f3b977642c2..0d7688bfd68 100644 --- a/packages/instantsearch-ui-components/src/components/index.ts +++ b/packages/instantsearch-ui-components/src/components/index.ts @@ -9,6 +9,7 @@ export * from './chat/ChatMessageLoader'; export * from './chat/ChatMessageError'; export * from './chat/ChatPrompt'; export * from './chat/ChatToggleButton'; +export * from './chat/tools/SearchIndexTool'; export * from './chat/icons'; export * from './chat/types'; export * from './FrequentlyBoughtTogether'; diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 27ace6ad8ab..efc7a009b86 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -1,14 +1,7 @@ /** @jsx h */ -import { - ArrowRightIcon, - ChevronLeftIcon, - ChevronRightIcon, - createButtonComponent, - createChatComponent, -} from 'instantsearch-ui-components'; +import { createChatComponent } from 'instantsearch-ui-components'; import { Fragment, h, render } from 'preact'; -import { useMemo } from 'preact/hooks'; import TemplateComponent from '../../components/Template/Template'; import connectChat from '../../connectors/chat/connectChat'; @@ -25,7 +18,8 @@ import { getContainerNode, createDocumentationMessageGenerator, } from '../../lib/utils'; -import { carousel } from '../../templates'; + +import { createCarouselTool } from './search-index-tool'; import type { ChatRenderState, @@ -60,7 +54,6 @@ import type { ClientSideToolComponentProps, ClientSideTools, RecordWithObjectID, - SearchToolInput, UserClientSideTool, } from 'instantsearch-ui-components'; import type { ComponentProps } from 'preact'; @@ -77,189 +70,6 @@ function getDefinedProperties(obj: T): Partial { ) as Partial; } -function createCarouselTool< - THit extends RecordWithObjectID = RecordWithObjectID ->( - showViewAll: boolean, - templates: ChatTemplates, - getSearchPageURL?: (nextUiState: IndexUiState) => string -): UserClientSideToolWithTemplate { - const Button = createButtonComponent({ - createElement: h, - }); - - function SearchLayoutComponent({ - message, - indexUiState, - setIndexUiState, - applyFilters, - onClose, - }: ClientSideToolComponentProps) { - const input = message?.input as - | { - query: string; - number_of_results?: number; - } - | undefined; - - const output = message?.output as - | { - hits?: Array>; - nbHits?: number; - } - | undefined; - - const items = output?.hits || []; - - const MemoedHeaderComponent = useMemo(() => { - return ( - props: Omit< - ComponentProps, - | 'nbHits' - | 'query' - | 'hitsPerPage' - | 'setIndexUiState' - | 'indexUiState' - | 'getSearchPageURL' - | 'onClose' - > - ) => ( - - ); - }, [ - items.length, - input, - output?.nbHits, - applyFilters, - setIndexUiState, - indexUiState, - onClose, - ]); - - return carousel({ - showNavigation: false, - templates: { - header: MemoedHeaderComponent, - }, - })({ - items, - templates: { - item: ({ item }) => ( - - ), - }, - sendEvent: () => {}, - }); - } - - function HeaderComponent({ - canScrollLeft, - canScrollRight, - scrollLeft, - scrollRight, - nbHits, - input, - hitsPerPage, - applyFilters, - onClose, - }: { - canScrollLeft: boolean; - canScrollRight: boolean; - scrollLeft: () => void; - scrollRight: () => void; - nbHits?: number; - input?: SearchToolInput; - hitsPerPage?: number; - setIndexUiState: IndexWidget['setIndexUiState']; - applyFilters?: ClientSideToolComponentProps['applyFilters']; - indexUiState: IndexUiState; - onClose: () => void; - getSearchPageURL?: (nextUiState: IndexUiState) => string; - }) { - if ((hitsPerPage ?? 0) < 1) { - return null; - } - - return ( -
    -
    - {nbHits && ( -
    - {hitsPerPage ?? 0} of {nbHits.toLocaleString()} result - {nbHits > 1 ? 's' : ''} -
    - )} - {showViewAll && ( - - )} -
    - - {(hitsPerPage ?? 0) > 2 && ( -
    - - -
    - )} -
    - ); - } - - return { - templates: { layout: SearchLayoutComponent }, - }; -} - function createDefaultTools< THit extends RecordWithObjectID = RecordWithObjectID >( diff --git a/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx b/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx new file mode 100644 index 00000000000..0c68bfa8f78 --- /dev/null +++ b/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx @@ -0,0 +1,61 @@ +/** @jsx h */ + +import { createSearchIndexTool } from 'instantsearch-ui-components'; +import { Fragment, h } from 'preact'; +import { useMemo, useRef, useState } from 'preact/hooks'; + +import TemplateComponent from '../../components/Template/Template'; + +import type { IndexUiState } from '../../types'; +import type { + ChatTemplates, + Tool as UserClientSideToolWithTemplate, +} from './chat'; +import type { + ClientSideToolComponentProps, + RecordWithObjectID, +} from 'instantsearch-ui-components'; + +export function createCarouselTool< + THit extends RecordWithObjectID = RecordWithObjectID +>( + showViewAll: boolean, + templates: ChatTemplates, + getSearchPageURL?: (nextUiState: IndexUiState) => string +): UserClientSideToolWithTemplate { + const SearchLayoutUIComponent = createSearchIndexTool({ + createElement: h, + Fragment, + }); + + const itemComponent = templates.item + ? ({ item }: { item: THit }) => { + return ( + + ); + } + : undefined; + + const SearchLayoutComponent = (toolProps: ClientSideToolComponentProps) => { + return ( + + ); + }; + + return { + templates: { layout: SearchLayoutComponent }, + }; +} diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index efe814343cd..722b8a3ef86 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -1,12 +1,5 @@ -import { - ChevronLeftIcon, - ChevronRightIcon, - ArrowRightIcon, - createButtonComponent, -} from 'instantsearch-ui-components'; -import React, { createElement } from 'react'; - -import { Carousel } from '../../../components'; +import { createSearchIndexTool } from 'instantsearch-ui-components'; +import React, { createElement, Fragment } from 'react'; import type { ClientSideToolComponentProps, @@ -15,185 +8,33 @@ import type { RecordWithObjectID, UserClientSideTool, } from 'instantsearch-ui-components'; -import type { IndexUiState, IndexWidget } from 'instantsearch.js'; -import type { ComponentProps } from 'react'; +import type { IndexUiState } from 'instantsearch.js'; type ItemComponent = RecommendComponentProps['itemComponent']; -type SearchToolInput = { - query: string; - number_of_results?: number; - facet_filters?: string[][]; -}; - function createCarouselTool( showViewAll: boolean, itemComponent?: ItemComponent, getSearchPageURL?: (nextUiState: IndexUiState) => string ): UserClientSideTool { - const Button = createButtonComponent({ + const SearchLayoutUIComponent = createSearchIndexTool({ createElement: createElement as Pragma, + Fragment, }); - function SearchLayoutComponent({ - message, - indexUiState, - setIndexUiState, - applyFilters, - onClose, - }: ClientSideToolComponentProps) { - const input = message?.input as - | { - query: string; - number_of_results?: number; - } - | undefined; - - const output = message?.output as - | { - hits?: Array>; - nbHits?: number; - } - | undefined; - - const items = output?.hits || []; - - const MemoedHeaderComponent = React.useMemo(() => { - return ( - props: Omit< - ComponentProps, - | 'nbHits' - | 'query' - | 'hitsPerPage' - | 'applyFilters' - | 'setIndexUiState' - | 'indexUiState' - | 'getSearchPageURL' - | 'onClose' - > - ) => ( - - ); - }, [ - items.length, - input, - output?.nbHits, - applyFilters, - setIndexUiState, - onClose, - indexUiState, - ]); - + const SearchLayoutComponent = (toolProps: ClientSideToolComponentProps) => { return ( - {}} - showNavigation={false} - headerComponent={MemoedHeaderComponent} + toolProps={toolProps} /> ); - } - - function HeaderComponent({ - canScrollLeft, - canScrollRight, - scrollLeft, - scrollRight, - nbHits, - input, - hitsPerPage, - applyFilters, - onClose, - }: { - canScrollLeft: boolean; - canScrollRight: boolean; - scrollLeft: () => void; - scrollRight: () => void; - nbHits?: number; - input?: SearchToolInput; - hitsPerPage?: number; - setIndexUiState: IndexWidget['setIndexUiState']; - indexUiState: IndexUiState; - applyFilters: ClientSideToolComponentProps['applyFilters']; - getSearchPageURL?: (nextUiState: IndexUiState) => string; - onClose: () => void; - }) { - if ((hitsPerPage ?? 0) < 1) { - return null; - } - - return ( -
    -
    - {nbHits && ( -
    - {hitsPerPage ?? 0} of {nbHits.toLocaleString()} result - {nbHits > 1 ? 's' : ''} -
    - )} - {showViewAll && ( - <> - - - )} -
    - - {(hitsPerPage ?? 0) > 2 && ( -
    - - -
    - )} -
    - ); - } + }; return { layoutComponent: SearchLayoutComponent, From f187961a4e65a5dcc5cc80d87984b908cfece1f0 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Wed, 11 Feb 2026 12:32:03 +0000 Subject: [PATCH 17/21] fix types --- .../src/components/chat/tools/SearchIndexTool.tsx | 6 +++--- packages/instantsearch.js/src/widgets/chat/chat.tsx | 2 -- .../instantsearch.js/src/widgets/chat/search-index-tool.tsx | 4 ++-- .../src/widgets/chat/tools/SearchIndexTool.tsx | 6 ------ 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/tools/SearchIndexTool.tsx b/packages/instantsearch-ui-components/src/components/chat/tools/SearchIndexTool.tsx index 5644039059d..4b3a6a3d078 100644 --- a/packages/instantsearch-ui-components/src/components/chat/tools/SearchIndexTool.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/tools/SearchIndexTool.tsx @@ -10,7 +10,7 @@ import type { HeaderComponentProps as CarouselHeaderComponentProps, } from '../../Carousel'; import type { ClientSideToolComponentProps } from '../types'; -import type { IndexUiState } from 'instantsearch.js'; +import type { SearchParameters } from 'algoliasearch-helper'; type SearchToolInput = { query: string; @@ -28,7 +28,7 @@ type HeaderProps = { input?: SearchToolInput; hitsPerPage?: number; applyFilters: ClientSideToolComponentProps['applyFilters']; - getSearchPageURL?: (nextUiState: IndexUiState) => string; + getSearchPageURL?: (params: SearchParameters) => string; onClose: () => void; }; @@ -38,7 +38,7 @@ export type SearchIndexToolProps = { useState: ( initialState: TType ) => [TType, (newState: TType) => unknown]; - getSearchPageURL?: (nextUiState: IndexUiState) => string; + getSearchPageURL?: (params: SearchParameters) => string; toolProps: ClientSideToolComponentProps; itemComponent?: CarouselProps['itemComponent']; headerComponent?: (props: HeaderProps) => JSX.Element; diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 588b72a8b93..efc7a009b86 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -37,7 +37,6 @@ import type { IndexUiState, IndexWidget, } from '../../types'; -import type { SearchParameters } from 'algoliasearch-helper'; import type { ChatClassNames, ChatHeaderProps, @@ -55,7 +54,6 @@ import type { ClientSideToolComponentProps, ClientSideTools, RecordWithObjectID, - SearchToolInput, UserClientSideTool, } from 'instantsearch-ui-components'; import type { ComponentProps } from 'preact'; diff --git a/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx b/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx index 0c68bfa8f78..d804e14112a 100644 --- a/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx +++ b/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx @@ -6,11 +6,11 @@ import { useMemo, useRef, useState } from 'preact/hooks'; import TemplateComponent from '../../components/Template/Template'; -import type { IndexUiState } from '../../types'; import type { ChatTemplates, Tool as UserClientSideToolWithTemplate, } from './chat'; +import type { SearchParameters } from 'algoliasearch-helper'; import type { ClientSideToolComponentProps, RecordWithObjectID, @@ -21,7 +21,7 @@ export function createCarouselTool< >( showViewAll: boolean, templates: ChatTemplates, - getSearchPageURL?: (nextUiState: IndexUiState) => string + getSearchPageURL?: (params: SearchParameters) => string ): UserClientSideToolWithTemplate { const SearchLayoutUIComponent = createSearchIndexTool({ createElement: h, diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 540d89f64e7..0b1d7e9dd88 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -12,12 +12,6 @@ import type { type ItemComponent = RecommendComponentProps['itemComponent']; -type SearchToolInput = { - query: string; - number_of_results?: number; - facet_filters?: string[][]; -}; - function createCarouselTool( showViewAll: boolean, itemComponent?: ItemComponent, From 1ca78027c7e11de3e150fc93c8e4d9536ef38989 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Mon, 16 Feb 2026 14:53:17 +0000 Subject: [PATCH 18/21] fix test --- .../components/chat/tools/SearchIndexTool.tsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/tools/SearchIndexTool.tsx b/packages/instantsearch-ui-components/src/components/chat/tools/SearchIndexTool.tsx index 4b3a6a3d078..4a924d872a2 100644 --- a/packages/instantsearch-ui-components/src/components/chat/tools/SearchIndexTool.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/tools/SearchIndexTool.tsx @@ -133,23 +133,24 @@ function createHeaderComponent({ createElement }: Renderer) { }; } -export function createSearchIndexTool({ - createElement, - Fragment, -}: Renderer) { +export function createSearchIndexToolComponent< + TObject extends RecordWithObjectID +>({ createElement, Fragment }: Renderer) { const DefaultHeader = createHeaderComponent({ createElement, Fragment }); const Carousel = createCarouselComponent({ createElement, Fragment }); - return function SearchIndexTool({ - useMemo, - useRef, - useState, - itemComponent: ItemComponent, - headerComponent: HeaderComponent, - getSearchPageURL, - toolProps: { message, applyFilters, onClose }, - headerProps: { showViewAll }, - }: SearchIndexToolProps) { + return function SearchIndexTool(userProps: SearchIndexToolProps) { + const { + useMemo, + useRef, + useState, + itemComponent: ItemComponent, + headerComponent: HeaderComponent, + getSearchPageURL, + toolProps: { message, applyFilters, onClose }, + headerProps: { showViewAll }, + } = userProps; + const input = message?.input as | { query: string; From f06eb11276777da71cae2cd61270d79561951e73 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Mon, 16 Feb 2026 15:01:31 +0000 Subject: [PATCH 19/21] fix build --- .../instantsearch.js/src/widgets/chat/search-index-tool.tsx | 4 ++-- .../src/widgets/chat/tools/SearchIndexTool.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx b/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx index d804e14112a..e8aa046f7a1 100644 --- a/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx +++ b/packages/instantsearch.js/src/widgets/chat/search-index-tool.tsx @@ -1,6 +1,6 @@ /** @jsx h */ -import { createSearchIndexTool } from 'instantsearch-ui-components'; +import { createSearchIndexToolComponent } from 'instantsearch-ui-components'; import { Fragment, h } from 'preact'; import { useMemo, useRef, useState } from 'preact/hooks'; @@ -23,7 +23,7 @@ export function createCarouselTool< templates: ChatTemplates, getSearchPageURL?: (params: SearchParameters) => string ): UserClientSideToolWithTemplate { - const SearchLayoutUIComponent = createSearchIndexTool({ + const SearchLayoutUIComponent = createSearchIndexToolComponent({ createElement: h, Fragment, }); diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 0b1d7e9dd88..9f1562ecb5a 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -1,4 +1,4 @@ -import { createSearchIndexTool } from 'instantsearch-ui-components'; +import { createSearchIndexToolComponent } from 'instantsearch-ui-components'; import React, { createElement, Fragment } from 'react'; import type { SearchParameters } from 'algoliasearch-helper'; @@ -17,7 +17,7 @@ function createCarouselTool( itemComponent?: ItemComponent, getSearchPageURL?: (params: SearchParameters) => string ): UserClientSideTool { - const SearchLayoutUIComponent = createSearchIndexTool({ + const SearchLayoutUIComponent = createSearchIndexToolComponent({ createElement: createElement as Pragma, Fragment, }); From cd075913de2925172738c6ff5f256d5535e2737c Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Mon, 16 Feb 2026 15:04:34 +0000 Subject: [PATCH 20/21] update bundlesize --- bundlesize.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index 1a9f3a23318..5b2c643ccba 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,7 +10,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "117.25 kB" + "maxSize": "117.50 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", @@ -22,7 +22,7 @@ }, { "path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js", - "maxSize": "96.75 kB" + "maxSize": "97 kB" }, { "path": "packages/vue-instantsearch/vue2/umd/index.js", From d34ff81cbbafc484c458b79b5e3dba5a49ce02db Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Mon, 16 Feb 2026 15:41:42 +0000 Subject: [PATCH 21/21] remove old code --- .../instantsearch.js/src/lib/utils/index.ts | 1 - .../utils/updateStateFromSearchToolInput.ts | 52 ------------------- 2 files changed, 53 deletions(-) delete mode 100644 packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts diff --git a/packages/instantsearch.js/src/lib/utils/index.ts b/packages/instantsearch.js/src/lib/utils/index.ts index 0b9c9a1ab0a..406ed5619b3 100644 --- a/packages/instantsearch.js/src/lib/utils/index.ts +++ b/packages/instantsearch.js/src/lib/utils/index.ts @@ -50,4 +50,3 @@ export * from './safelyRunOnBrowser'; export * from './serializer'; export * from './toArray'; export * from './uniq'; -export * from './updateStateFromSearchToolInput'; diff --git a/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts b/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts deleted file mode 100644 index c4d794d5829..00000000000 --- a/packages/instantsearch.js/src/lib/utils/updateStateFromSearchToolInput.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { flat } from './flat'; - -import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; -import type { ApplyFiltersParams } from 'instantsearch-ui-components'; - -export function updateStateFromSearchToolInput( - params: ApplyFiltersParams, - helper: AlgoliaSearchHelper -) { - if (params.query) { - helper.setQuery(params.query); - } - - if (params.facetFilters) { - const attributes = flat(params.facetFilters).map((filter) => { - const [attribute, value] = filter.split(':'); - - return { attribute, value }; - }); - - if ( - attributes.some( - ({ attribute }) => - !helper.state.isConjunctiveFacet(attribute) && - !helper.state.isHierarchicalFacet(attribute) && - !helper.state.isDisjunctiveFacet(attribute) - ) - ) { - return false; - } - - attributes.forEach(({ attribute }) => { - helper.clearRefinements(attribute); - }); - - attributes.forEach(({ attribute, value }) => { - const hierarchicalFacet = helper.state.hierarchicalFacets.find( - (facet) => facet.name === attribute - ); - - if (hierarchicalFacet) { - helper.toggleFacetRefinement(hierarchicalFacet.name, value); - } else { - helper.toggleFacetRefinement(attribute, value); - } - }); - } - - helper.search(); - - return true; -}