From f0feb7184a534fc4d62ff2f8b69b9c62780f4fef Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:32:16 +0100 Subject: [PATCH 1/4] feat: add `ais.hits` widget support Co-Authored-By: Claude Opus 4.6 --- packages/runtime/src/experiences/types.ts | 7 +- packages/runtime/src/experiences/widget.tsx | 9 + packages/toolbar/__tests__/ai-tools.test.ts | 188 +++++++++++++++++++- packages/toolbar/__tests__/toolbar.test.ts | 10 +- packages/toolbar/src/widget-types.tsx | 43 ++++- 5 files changed, 249 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/experiences/types.ts b/packages/runtime/src/experiences/types.ts index 57b2522..06bc06a 100644 --- a/packages/runtime/src/experiences/types.ts +++ b/packages/runtime/src/experiences/types.ts @@ -4,6 +4,7 @@ import type { Widget, } from 'instantsearch.js/es/types'; import type { ChatWidget } from 'instantsearch.js/es/widgets/chat/chat'; +import type { HitsWidget } from 'instantsearch.js/es/widgets/hits/hits'; export type Environment = 'prod' | 'beta'; @@ -51,5 +52,9 @@ export type ExperienceWidget = Widget & { $$supportedWidgets: { 'ais.chat': SupportedWidget[0]>; 'ais.autocomplete': SupportedWidget; - } & Record<'ais.chat' | 'ais.autocomplete' | (string & {}), SupportedWidget>; + 'ais.hits': SupportedWidget[0]>; + } & Record< + 'ais.chat' | 'ais.autocomplete' | 'ais.hits' | (string & {}), + SupportedWidget + >; }; diff --git a/packages/runtime/src/experiences/widget.tsx b/packages/runtime/src/experiences/widget.tsx index ecae85b..d680d51 100644 --- a/packages/runtime/src/experiences/widget.tsx +++ b/packages/runtime/src/experiences/widget.tsx @@ -4,6 +4,7 @@ import { } from 'instantsearch.js/es/lib/utils'; import { getExperience } from './get-experience'; import chat from 'instantsearch.js/es/widgets/chat/chat'; +import hits from 'instantsearch.js/es/widgets/hits/hits'; import { EXPERIMENTAL_autocomplete } from 'instantsearch.js/es/widgets/autocomplete/autocomplete'; import { renderTemplate, renderTool } from './renderer'; @@ -127,6 +128,14 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { }); }, }, + // TODO: Add support for `templates` (item, empty, banner) + // TODO: Add support for `transformItems` (bucket 3 function) + 'ais.hits': { + widget: hits, + async transformParams(parameters) { + return parameters; + }, + }, }, render: () => {}, dispose: () => {}, diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index e766100..1cf5002 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -37,6 +37,8 @@ describe('describeWidgetTypes', () => { expect(result).toContain('Chat'); expect(result).toContain('ais.index'); expect(result).toContain('Index'); + expect(result).toContain('ais.hits'); + expect(result).toContain('Hits'); }); it('includes widget descriptions and parameter descriptions', () => { @@ -45,6 +47,9 @@ describe('describeWidgetTypes', () => { expect(result).toContain('Parameters:'); expect(result).toContain('showRecent'); expect(result).toContain('recent searches'); + expect(result).toContain('search results'); + expect(result).toContain('escapeHTML'); + expect(result).toContain('XSS'); }); it('includes default placement per widget type', () => { @@ -53,6 +58,7 @@ describe('describeWidgetTypes', () => { 'ais.autocomplete ("Autocomplete", default placement: inside)' ); expect(result).toContain('ais.chat ("Chat", default placement: body)'); + expect(result).toContain('ais.hits ("Hits", default placement: inside)'); }); it('marks index-independent widgets', () => { @@ -62,7 +68,7 @@ describe('describeWidgetTypes', () => { it('excludes disabled widget types', () => { const result = describeWidgetTypes(); - expect(result).not.toContain('ais.hits'); + expect(result).not.toContain('ais.searchBox'); expect(result).not.toContain('ais.pagination'); }); }); @@ -514,6 +520,93 @@ describe('getTools', () => { expect(result).toMatchObject({ success: true }); }); + it('skips container and placement inside parameters to avoid duplication', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + await tools.add_widget.execute!( + { + type: 'ais.autocomplete', + container: '#search', + placement: 'before', + parameters: { + container: '#other', + placement: 'after', + showRecent: true, + }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + // Top-level container and placement should be used + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'container', + '#search' + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'placement', + 'before' + ); + // container and placement inside parameters should be skipped + expect(callbacks.onParameterChange).not.toHaveBeenCalledWith( + [0], + 'container', + '#other' + ); + expect(callbacks.onParameterChange).not.toHaveBeenCalledWith( + [0], + 'placement', + 'after' + ); + // Other params inside parameters should still be applied + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'showRecent', + true + ); + }); + + it('adds hits with default inside placement and container', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { type: 'ais.hits', container: '#hits' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.hits', undefined); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'placement', + 'inside' + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'container', + '#hits' + ); + expect(result).toMatchObject({ + success: true, + path: '0', + type: 'ais.hits', + placement: 'inside', + container: '#hits', + applied: ['placement', 'container'], + rejected: [], + }); + }); + it('adds ais.index widget at top level', async () => { const experience: ExperienceApiResponse = { blocks: [ @@ -1034,6 +1127,99 @@ describe('getTools', () => { }); expect(callbacks.onParameterChange).not.toHaveBeenCalled(); }); + + it('applies a boolean parameter with switch override on hits', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.hits', + parameters: { container: '#hits', escapeHTML: true }, + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { path: '0', parameters: { escapeHTML: false } }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: ['escapeHTML'], + rejected: [], + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'escapeHTML', + false + ); + }); + + it('applies an object parameter (cssClasses) on hits', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.hits', + parameters: { container: '#hits', cssClasses: false }, + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { + path: '0', + parameters: { + cssClasses: { root: 'my-root', item: 'my-item' }, + }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: ['cssClasses'], + rejected: [], + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'cssClasses', + { root: 'my-root', item: 'my-item' } + ); + }); + + it('includes invalid path in bounds error message', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + { + type: 'ais.chat', + parameters: { container: '#chat', placement: 'body' }, + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.edit_widget.execute!( + { path: '5', parameters: { container: '#new' } }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('Invalid path'), + }); + }); }); describe('remove_widget', () => { diff --git a/packages/toolbar/__tests__/toolbar.test.ts b/packages/toolbar/__tests__/toolbar.test.ts index c7b8fdd..538f975 100644 --- a/packages/toolbar/__tests__/toolbar.test.ts +++ b/packages/toolbar/__tests__/toolbar.test.ts @@ -218,18 +218,18 @@ describe('toolbar', () => { }); // Disabled items are rendered as
elements, not