From 498292731da7d645e77bd654379dfdf273b39091 Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Sat, 21 Feb 2026 11:39:45 +0100 Subject: [PATCH 1/4] feat(toolbar): add `ais.index` block support for index-scoped widgets Add nested block UI, auto-wrapping logic, move-between-indices support, and AI tool updates for the new `ais.index` block type. Co-Authored-By: Claude Opus 4.6 --- packages/toolbar/__tests__/ai-tools.test.ts | 717 ++++++++++++------ packages/toolbar/src/ai/system-prompt.ts | 8 +- packages/toolbar/src/ai/tools.ts | 332 +++++--- .../src/components/add-widget-popover.tsx | 67 +- packages/toolbar/src/components/ai-chat.tsx | 103 ++- packages/toolbar/src/components/app.tsx | 311 +++++--- .../toolbar/src/components/block-card.tsx | 40 +- .../toolbar/src/components/block-editor.tsx | 9 +- .../src/components/index-block-group.tsx | 174 +++++ packages/toolbar/src/components/panel.tsx | 161 ++-- packages/toolbar/src/types.ts | 21 +- packages/toolbar/src/widget-types.tsx | 45 +- 12 files changed, 1435 insertions(+), 553 deletions(-) create mode 100644 packages/toolbar/src/components/index-block-group.tsx diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index 54eebff..2515e42 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -10,16 +10,17 @@ import { import type { ExperienceApiResponse } from '../src/types'; function createCallbacks( - experience: ExperienceApiResponse = { blocks: [] } + experience: ExperienceApiResponse = { blocks: [], indexName: '' }, + addBlockPath: [number] | [number, number] = [0], + indexCreated = false ): ToolCallbacks { return { - onAddBlock: vi.fn(), + onAddBlock: vi.fn(() => ({ path: addBlockPath, indexCreated })), onParameterChange: vi.fn(), onCssVariableChange: vi.fn(), onDeleteBlock: vi.fn(), - getExperience: vi.fn(() => { - return experience; - }), + onMoveBlock: vi.fn(), + getExperience: vi.fn(() => experience), }; } @@ -30,6 +31,8 @@ describe('describeWidgetTypes', () => { expect(result).toContain('Autocomplete'); expect(result).toContain('ais.chat'); expect(result).toContain('Chat'); + expect(result).toContain('ais.index'); + expect(result).toContain('Index'); }); it('includes widget descriptions and parameter descriptions', () => { @@ -48,21 +51,25 @@ describe('describeWidgetTypes', () => { expect(result).toContain('ais.chat ("Chat", default placement: body)'); }); + it('marks index-independent widgets', () => { + const result = describeWidgetTypes(); + expect(result).toContain('[index-independent]'); + }); + 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'); }); }); describe('describeExperience', () => { it('returns a message for empty experiences', () => { - const result = describeExperience({ blocks: [] }); + const result = describeExperience({ blocks: [], indexName: '' }); expect(result).toBe('The experience has no widgets yet.'); }); - it('formats blocks with indices and placement', () => { + it('formats blocks with paths and placement', () => { const experience: ExperienceApiResponse = { blocks: [ { @@ -78,6 +85,7 @@ describe('describeExperience', () => { }, }, ], + indexName: '', }; const result = describeExperience(experience); @@ -89,6 +97,7 @@ describe('describeExperience', () => { expect(result).toContain('[1] Chat (ais.chat) [body]'); expect(result).toContain('agentId="agent-1"'); }); + it('falls back to type string for unknown widget types', () => { const experience: ExperienceApiResponse = { blocks: [ @@ -118,71 +127,61 @@ describe('describeExperience', () => { expect(result).not.toContain('[before ]'); }); - it('renders ais.index blocks with fallback label and indexName parameter', () => { + it('formats nested index blocks with child paths', () => { const experience: ExperienceApiResponse = { blocks: [ { type: 'ais.index', - parameters: { container: '', indexName: 'products' }, + parameters: { indexName: 'products' }, + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], }, ], + indexName: '', }; const result = describeExperience(experience); - expect(result).toContain('[0] ais.index (ais.index)'); - expect(result).toContain('indexName="products"'); + expect(result).toContain('[0] Index (ais.index)'); + expect(result).toContain('products'); + expect(result).toContain('[0.0] Autocomplete (ais.autocomplete)'); }); - it('does not describe nested blocks inside ais.index', () => { - // The toolbar type does not support nested blocks, so describeExperience - // only iterates top-level blocks. Nested children inside ais.index are - // invisible to the AI tools layer. This documents the current gap. - const experience = { + it('includes indexId when present on index blocks', () => { + const experience: ExperienceApiResponse = { blocks: [ { type: 'ais.index', - parameters: { container: '', indexName: 'products' }, - blocks: [ - { - type: 'ais.hits', - parameters: { container: '#hits' }, - }, - ], + parameters: { indexName: 'products', indexId: 'main' }, + blocks: [], }, ], - } as ExperienceApiResponse; + indexName: '', + }; const result = describeExperience(experience); - // Only the top-level ais.index block is described - expect(result).toContain('[0] ais.index (ais.index)'); - // The nested ais.hits block is not described - expect(result).not.toContain('ais.hits'); - expect(result).not.toContain('#hits'); + expect(result).toContain('indexName: products'); + expect(result).toContain('indexId: main'); }); - it('renders ais.index alongside regular widgets', () => { + it('omits indexId when not set on index blocks', () => { const experience: ExperienceApiResponse = { blocks: [ - { - type: 'ais.autocomplete', - parameters: { container: '#search' }, - }, { type: 'ais.index', - parameters: { container: '', indexName: 'suggestions' }, - }, - { - type: 'ais.chat', - parameters: { container: '#chat', placement: 'body' }, + parameters: { indexName: 'products' }, + blocks: [], }, ], + indexName: '', }; const result = describeExperience(experience); - expect(result).toContain('[0] Autocomplete (ais.autocomplete)'); - expect(result).toContain('[1] ais.index (ais.index)'); - expect(result).toContain('indexName="suggestions"'); - expect(result).toContain('[2] Chat (ais.chat)'); + expect(result).toContain('indexName: products'); + expect(result).not.toContain('indexId'); }); it('uses default placement from widget config when not in parameters', () => { @@ -193,6 +192,7 @@ describe('describeExperience', () => { parameters: { agentId: 'agent-1' }, }, ], + indexName: '', }; const result = describeExperience(experience); @@ -201,13 +201,14 @@ describe('describeExperience', () => { }); describe('getTools', () => { - it('returns four tools', () => { + it('returns five tools', () => { const tools = getTools(createCallbacks()); expect(Object.keys(tools)).toEqual([ 'get_experience', 'add_widget', 'edit_widget', 'remove_widget', + 'move_widget', ]); }); @@ -220,6 +221,7 @@ describe('getTools', () => { parameters: { container: '#search' }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); @@ -235,8 +237,16 @@ describe('getTools', () => { }); describe('add_widget', () => { - it('calls onAddBlock and onParameterChange for container and placement', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; + it('calls onAddBlock and onParameterChange for index-independent widgets', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], + indexName: '', + }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); @@ -245,31 +255,32 @@ describe('getTools', () => { { toolCallId: 'tc1', messages: [] } ); - expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.autocomplete'); - expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, - 'placement', - 'inside' - ); - expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, - 'container', - '#search' + expect(callbacks.onAddBlock).toHaveBeenCalledWith( + 'ais.autocomplete', + undefined ); expect(result).toMatchObject({ success: true, - index: 0, type: 'ais.autocomplete', placement: 'inside', container: '#search', - applied: ['placement', 'container'], + applied: expect.arrayContaining(['placement', 'container']), rejected: [], }); }); it('applies additional parameters', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; - const callbacks = createCallbacks(experience); + // After onAddBlock is called, getExperience returns the updated state + const afterAdd: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '', showRecent: false }, + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(afterAdd); const tools = getTools(callbacks); await tools.add_widget.execute!( @@ -282,14 +293,22 @@ describe('getTools', () => { ); expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, + [0], 'showRecent', true ); }); it('rejects disallowed parameters', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], + indexName: '', + }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); @@ -311,20 +330,18 @@ describe('getTools', () => { ]), rejected: ['unknownParam'], }); - expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, - 'showRecent', - true - ); - expect(callbacks.onParameterChange).not.toHaveBeenCalledWith( - 0, - 'unknownParam', - expect.anything() - ); }); it('adds widget with body placement and no container', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.chat', + parameters: { placement: 'body', agentId: '' }, + }, + ], + indexName: '', + }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); @@ -338,20 +355,18 @@ describe('getTools', () => { placement: 'body', applied: ['placement'], }); - expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, - 'placement', - 'body' - ); - expect(callbacks.onParameterChange).not.toHaveBeenCalledWith( - 0, - 'container', - expect.anything() - ); }); it('uses default placement from widget config when not specified', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.chat', + parameters: { placement: 'body', agentId: '' }, + }, + ], + indexName: '', + }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); @@ -364,20 +379,27 @@ describe('getTools', () => { success: true, placement: 'body', }); - expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, - 'placement', - 'body' - ); }); it('adds widget with explicit before placement', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], + indexName: '', + }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.add_widget.execute!( - { type: 'ais.autocomplete', placement: 'before', container: '#search' }, + { + type: 'ais.autocomplete', + placement: 'before', + container: '#search', + }, { toolCallId: 'tc1', messages: [] } ); @@ -390,7 +412,15 @@ describe('getTools', () => { }); it('returns error when non-body placement has no container', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], + indexName: '', + }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); @@ -406,7 +436,15 @@ describe('getTools', () => { }); it('returns error when default inside placement has no container', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], + indexName: '', + }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); @@ -421,108 +459,161 @@ describe('getTools', () => { }); }); - it('accepts container inside parameters instead of top-level', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; + it('adds ais.index widget at top level', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [], + }, + ], + indexName: '', + }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); + const result = await tools.add_widget.execute!( + { type: 'ais.index', parameters: { indexName: 'products' } }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.index', undefined); + expect(result).toMatchObject({ + success: true, + type: 'ais.index', + }); + }); + + it('adds index-dependent widget and passes target_index', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [], + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience, [0, 0]); + const tools = getTools(callbacks); + const result = await tools.add_widget.execute!( { - type: 'ais.autocomplete', - parameters: { container: '#search' }, + type: 'ais.searchBox', + container: '#search', + target_index: 0, }, { toolCallId: 'tc1', messages: [] } ); - expect(result).toMatchObject({ success: true }); + expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.searchBox', 0); + expect(result).toMatchObject({ + success: true, + path: '0.0', + type: 'ais.searchBox', + }); }); - it('skips container and placement inside parameters to avoid duplication', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; + it('returns error when target_index is not an ais.index block', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.chat', + parameters: { placement: 'body', agentId: '' }, + }, + ], + indexName: '', + }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); - await tools.add_widget.execute!( + const result = await tools.add_widget.execute!( { - type: 'ais.autocomplete', + type: 'ais.searchBox', container: '#search', - placement: 'before', - parameters: { - container: '#other', - placement: 'after', - showRecent: true, - }, + target_index: 0, }, { 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 - ); + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('not an ais.index block'), + }); + expect(callbacks.onAddBlock).not.toHaveBeenCalled(); }); - it('still calls onAddBlock even when container validation fails', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; + it('returns error when target_index is out of bounds', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); - await tools.add_widget.execute!( - { type: 'ais.autocomplete' }, + const result = await tools.add_widget.execute!( + { + type: 'ais.searchBox', + container: '#search', + target_index: 5, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('not an ais.index block'), + }); + expect(callbacks.onAddBlock).not.toHaveBeenCalled(); + }); + + it('includes note when index block was auto-created', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience, [0, 0], true); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { type: 'ais.searchBox', container: '#search' }, { toolCallId: 'tc1', messages: [] } ); - // onAddBlock is called before validation — this documents current behavior - expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.autocomplete'); + expect(result).toMatchObject({ + success: true, + note: expect.stringContaining('auto-created'), + }); }); - it('computes the correct index for non-empty experiences', async () => { + it('does not include note when added to existing index', async () => { const experience: ExperienceApiResponse = { blocks: [ { - type: 'ais.autocomplete', - parameters: { container: '#search' }, + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [], }, ], + indexName: '', }; - const callbacks = createCallbacks(experience); + const callbacks = createCallbacks(experience, [0, 0], false); const tools = getTools(callbacks); const result = await tools.add_widget.execute!( - { type: 'ais.chat', container: '#chat' }, - { toolCallId: 'tc2', messages: [] } + { type: 'ais.searchBox', container: '#search' }, + { toolCallId: 'tc1', messages: [] } ); - expect(result).toMatchObject({ index: 1 }); + expect(result).toMatchObject({ success: true }); + expect(result).not.toHaveProperty('note'); }); }); describe('edit_widget', () => { - it('validates index bounds', async () => { + it('validates path bounds', async () => { const experience: ExperienceApiResponse = { blocks: [ { @@ -530,12 +621,13 @@ describe('getTools', () => { parameters: { container: '#search' }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.edit_widget.execute!( - { index: 5, parameters: { container: '#new' } }, + { path: '5', parameters: { container: '#new' } }, { toolCallId: 'tc1', messages: [] } ); @@ -543,58 +635,73 @@ describe('getTools', () => { expect(callbacks.onParameterChange).not.toHaveBeenCalled(); }); - it('rejects negative index', async () => { + it('applies allowed parameter changes', async () => { const experience: ExperienceApiResponse = { blocks: [ { type: 'ais.autocomplete', - parameters: { container: '#search' }, + parameters: { container: '#search', showRecent: false }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.edit_widget.execute!( - { index: -1, parameters: { container: '#new' } }, + { path: '0', parameters: { container: '#new', showRecent: true } }, { toolCallId: 'tc1', messages: [] } ); - expect(result).toMatchObject({ success: false }); - expect(callbacks.onParameterChange).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + success: true, + applied: expect.arrayContaining(['container', 'showRecent']), + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'container', + '#new' + ); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0], + 'showRecent', + true + ); }); - it('applies allowed parameter changes', async () => { + it('edits nested widgets by path', async () => { const experience: ExperienceApiResponse = { blocks: [ { - type: 'ais.autocomplete', - parameters: { container: '#search', showRecent: false }, + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.edit_widget.execute!( - { index: 0, parameters: { container: '#new', showRecent: true } }, + { path: '0.0', parameters: { container: '#new' } }, { toolCallId: 'tc1', messages: [] } ); expect(result).toMatchObject({ success: true, - applied: expect.arrayContaining(['container', 'showRecent']), + applied: ['container'], }); expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, + [0, 0], 'container', '#new' ); - expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, - 'showRecent', - true - ); }); it('reports rejected keys for disallowed parameters', async () => { @@ -605,12 +712,13 @@ describe('getTools', () => { parameters: { container: '#search' }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.edit_widget.execute!( - { index: 0, parameters: { container: '#new', unknownParam: 42 } }, + { path: '0', parameters: { container: '#new', unknownParam: 42 } }, { toolCallId: 'tc1', messages: [] } ); @@ -622,20 +730,46 @@ describe('getTools', () => { }); it('returns a clear error for empty experience', async () => { - const callbacks = createCallbacks({ blocks: [] }); + const callbacks = createCallbacks({ blocks: [], indexName: '' }); const tools = getTools(callbacks); const result = await tools.edit_widget.execute!( - { index: 0, parameters: { container: '#new' } }, + { path: '0', parameters: { container: '#new' } }, { toolCallId: 'tc1', messages: [] } ); expect(result).toMatchObject({ success: false, - error: expect.stringContaining('no widgets'), + error: expect.stringContaining('Invalid path'), }); }); + it('rejects malformed paths', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const malformedPaths = ['', '.', 'a.b', '0.0.0', '-1']; + + for (const path of malformedPaths) { + const result = await tools.edit_widget.execute!( + { path, parameters: { container: '#new' } }, + { toolCallId: 'tc1', messages: [] } + ); + expect(result).toMatchObject({ success: false }); + } + + expect(callbacks.onParameterChange).not.toHaveBeenCalled(); + }); + it('allows editing placement', async () => { const experience: ExperienceApiResponse = { blocks: [ @@ -644,12 +778,13 @@ describe('getTools', () => { parameters: { container: '#search', placement: 'inside' }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.edit_widget.execute!( - { index: 0, parameters: { placement: 'after' } }, + { path: '0', parameters: { placement: 'after' } }, { toolCallId: 'tc1', messages: [] } ); @@ -658,7 +793,7 @@ describe('getTools', () => { applied: ['placement'], }); expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, + [0], 'placement', 'after' ); @@ -675,13 +810,14 @@ describe('getTools', () => { }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.edit_widget.execute!( { - index: 0, + path: '0', parameters: { cssVariables: { 'primary-color-rgb': '#ff0000' }, }, @@ -691,7 +827,7 @@ describe('getTools', () => { expect(result).toMatchObject({ success: true }); expect(callbacks.onCssVariableChange).toHaveBeenCalledWith( - 0, + [0], 'primary-color-rgb', '#ff0000' ); @@ -708,13 +844,14 @@ describe('getTools', () => { }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.edit_widget.execute!( { - index: 0, + path: '0', parameters: { cssVariables: { 'primary-color-rgb': '#ff0000', @@ -746,13 +883,14 @@ describe('getTools', () => { }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.edit_widget.execute!( { - index: 0, + path: '0', parameters: { showRecent: true, cssVariables: { 'primary-color-rgb': '#ff0000' }, @@ -769,12 +907,12 @@ describe('getTools', () => { ]), }); expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, + [0], 'showRecent', true ); expect(callbacks.onCssVariableChange).toHaveBeenCalledWith( - 0, + [0], 'primary-color-rgb', '#ff0000' ); @@ -788,12 +926,13 @@ describe('getTools', () => { parameters: { container: '#search' }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.edit_widget.execute!( - { index: 0, parameters: { unknownA: 1, unknownB: 2 } }, + { path: '0', parameters: { unknownA: 1, unknownB: 2 } }, { toolCallId: 'tc1', messages: [] } ); @@ -804,37 +943,35 @@ describe('getTools', () => { }); expect(callbacks.onParameterChange).not.toHaveBeenCalled(); }); + }); - it('includes index range in bounds error message', async () => { + describe('remove_widget', () => { + it('calls onDeleteBlock for valid path', 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!( - { index: 5, parameters: { container: '#new' } }, + const result = await tools.remove_widget.execute!( + { path: '0' }, { toolCallId: 'tc1', messages: [] } ); + expect(callbacks.onDeleteBlock).toHaveBeenCalledWith([0]); expect(result).toMatchObject({ - success: false, - error: expect.stringContaining('indices 0–1'), + success: true, + removedType: 'ais.autocomplete', }); }); - }); - describe('remove_widget', () => { - it('calls onDeleteBlock for valid index', async () => { + it('validates path bounds', async () => { const experience: ExperienceApiResponse = { blocks: [ { @@ -842,44 +979,140 @@ describe('getTools', () => { parameters: { container: '#search' }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.remove_widget.execute!( - { index: 0 }, + { path: '3' }, { toolCallId: 'tc1', messages: [] } ); - expect(callbacks.onDeleteBlock).toHaveBeenCalledWith(0); + expect(result).toMatchObject({ success: false }); + expect(callbacks.onDeleteBlock).not.toHaveBeenCalled(); + }); + + it('removes an index block with children', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.remove_widget.execute!( + { path: '0' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onDeleteBlock).toHaveBeenCalledWith([0]); expect(result).toMatchObject({ success: true, - removedType: 'ais.autocomplete', + removedType: 'ais.index', }); }); - it('validates index bounds', async () => { + it('removes a nested child widget', async () => { const experience: ExperienceApiResponse = { blocks: [ { - type: 'ais.autocomplete', - parameters: { container: '#search' }, + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + { + type: 'ais.searchBox', + parameters: { container: '#box' }, + }, + ], }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); const result = await tools.remove_widget.execute!( - { index: 3 }, + { path: '0.1' }, { toolCallId: 'tc1', messages: [] } ); - expect(result).toMatchObject({ success: false }); - expect(callbacks.onDeleteBlock).not.toHaveBeenCalled(); + expect(callbacks.onDeleteBlock).toHaveBeenCalledWith([0, 1]); + expect(result).toMatchObject({ + success: true, + removedType: 'ais.searchBox', + }); }); - it('rejects negative index', async () => { + it('returns a clear error for empty experience', async () => { + const callbacks = createCallbacks({ blocks: [], indexName: '' }); + const tools = getTools(callbacks); + + const result = await tools.remove_widget.execute!( + { path: '0' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('Invalid path'), + }); + }); + }); + + describe('move_widget', () => { + it('calls onMoveBlock for valid nested path', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], + }, + { + type: 'ais.index', + parameters: { indexName: 'articles' }, + blocks: [], + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.move_widget.execute!( + { path: '0.0', to_index: 1 }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onMoveBlock).toHaveBeenCalledWith([0, 0], 1); + expect(result).toMatchObject({ + success: true, + movedType: 'ais.autocomplete', + }); + }); + + it('rejects top-level paths', async () => { const experience: ExperienceApiResponse = { blocks: [ { @@ -887,59 +1120,81 @@ describe('getTools', () => { parameters: { container: '#search' }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); - const result = await tools.remove_widget.execute!( - { index: -1 }, + const result = await tools.move_widget.execute!( + { path: '0', to_index: 1 }, { toolCallId: 'tc1', messages: [] } ); expect(result).toMatchObject({ success: false }); - expect(callbacks.onDeleteBlock).not.toHaveBeenCalled(); }); - it('returns a clear error for empty experience', async () => { - const callbacks = createCallbacks({ blocks: [] }); + it('rejects out-of-bounds to_index', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); const tools = getTools(callbacks); - const result = await tools.remove_widget.execute!( - { index: 0 }, + const result = await tools.move_widget.execute!( + { path: '0.0', to_index: 99 }, { toolCallId: 'tc1', messages: [] } ); expect(result).toMatchObject({ success: false, - error: expect.stringContaining('no widgets'), + error: expect.stringContaining('not an ais.index block'), }); + expect(callbacks.onMoveBlock).not.toHaveBeenCalled(); }); - it('returns the removed widget type and index', async () => { + it('rejects non-index target', async () => { const experience: ExperienceApiResponse = { blocks: [ { - type: 'ais.autocomplete', - parameters: { container: '#search' }, + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], }, { type: 'ais.chat', - parameters: { container: '#chat', placement: 'body' }, + parameters: { placement: 'body', agentId: '' }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); - const result = await tools.remove_widget.execute!( - { index: 1 }, + const result = await tools.move_widget.execute!( + { path: '0.0', to_index: 1 }, { toolCallId: 'tc1', messages: [] } ); expect(result).toMatchObject({ - success: true, - removedType: 'ais.chat', - removedIndex: 1, + success: false, + error: expect.stringContaining('not an ais.index block'), }); }); }); @@ -982,7 +1237,11 @@ describe('describeToolAction', () => { expect( describeToolAction( 'add_widget', - { type: 'ais.autocomplete', placement: 'before', container: '#search' }, + { + type: 'ais.autocomplete', + placement: 'before', + container: '#search', + }, { success: true } ) ).toBe('Added ais.autocomplete before #search'); @@ -992,7 +1251,7 @@ describe('describeToolAction', () => { expect( describeToolAction( 'edit_widget', - { index: 0 }, + { path: '0' }, { success: true, applied: ['container', 'showRecent'], rejected: [] } ) ).toBe('Edited widget 0 — container, showRecent'); @@ -1002,7 +1261,7 @@ describe('describeToolAction', () => { expect( describeToolAction( 'edit_widget', - { index: 2 }, + { path: '2' }, { success: true, applied: [], rejected: ['unknownParam'] } ) ).toBe('Edited widget 2'); @@ -1012,12 +1271,22 @@ describe('describeToolAction', () => { expect( describeToolAction( 'remove_widget', - { index: 1 }, + { path: '1' }, { success: true, removedType: 'ais.chat' } ) ).toBe('Removed widget 1'); }); + it('describes move_widget', () => { + expect( + describeToolAction( + 'move_widget', + { path: '0.0', to_index: 1 }, + { success: true } + ) + ).toBe('Moved widget 0.0 to index 1'); + }); + it('falls back for unknown tools', () => { expect(describeToolAction('unknown_tool', {}, {})).toBe('Action completed'); }); diff --git a/packages/toolbar/src/ai/system-prompt.ts b/packages/toolbar/src/ai/system-prompt.ts index 95c5cd7..8d644ec 100644 --- a/packages/toolbar/src/ai/system-prompt.ts +++ b/packages/toolbar/src/ai/system-prompt.ts @@ -20,10 +20,10 @@ Each widget type has a default placement listed above. When placement is \`body\ ## Rules -- When calling add_widget or edit_widget, widget-specific settings (like \`agentId\`, \`showRecent\`, \`cssVariables\`) MUST go inside the \`parameters\` field as key-value pairs, NOT as top-level arguments. Only \`type\`, \`container\`, and \`placement\` are top-level for add_widget, only \`index\` is top-level for edit_widget. When adding a widget, include all known parameters in the same call. +- When calling add_widget or edit_widget, widget-specific settings (like \`agentId\`, \`showRecent\`, \`cssVariables\`) MUST go inside the \`parameters\` field as key-value pairs, NOT as top-level arguments. Only \`type\`, \`container\`, \`placement\`, and \`target_index\` are top-level for add_widget, only \`path\` is top-level for edit_widget. When adding a widget, include all known parameters in the same call. - Only modify parameters that are listed as editable for each widget type. -- Keep responses concise and confirm what you did after each action. -- Before editing or removing, ALWAYS call get_experience first to verify current widget indices and state. -- Refer to widgets by their index number from get_experience results. +- Keep responses concise and confirm what you did after each action. Do not explain internal mechanics (index blocks, placements, paths) unless the user asks. Just ask for what you need in plain language (e.g., "Where should I place it? (CSS selector)" instead of explaining the placement system). +- Before editing or removing, ALWAYS call get_experience first to verify current widget paths and state. +- Refer to widgets by their path from get_experience results. - If the user's request is ambiguous, ask for clarification.`; } diff --git a/packages/toolbar/src/ai/tools.ts b/packages/toolbar/src/ai/tools.ts index 82a4adc..85bc22e 100644 --- a/packages/toolbar/src/ai/tools.ts +++ b/packages/toolbar/src/ai/tools.ts @@ -1,21 +1,26 @@ import { tool } from 'ai'; import { z } from 'zod'; -import type { ExperienceApiResponse, Placement } from '../types'; +import type { + AddBlockResult, + BlockPath, + ExperienceApiBlock, + ExperienceApiResponse, + Placement, +} from '../types'; import { WIDGET_TYPES } from '../widget-types'; export type ToolCallbacks = { - onAddBlock: (type: string) => void; - onParameterChange: (index: number, key: string, value: unknown) => void; - onCssVariableChange: (index: number, key: string, value: string) => void; - onDeleteBlock: (index: number) => void; + onAddBlock: (type: string, targetParentIndex?: number) => AddBlockResult; + onParameterChange: (path: BlockPath, key: string, value: unknown) => void; + onCssVariableChange: (path: BlockPath, key: string, value: string) => void; + onDeleteBlock: (path: BlockPath) => void; + onMoveBlock: (fromPath: BlockPath, toParentIndex: number) => void; getExperience: () => ExperienceApiResponse; }; function getEnabledTypes() { - return Object.entries(WIDGET_TYPES).filter(([, config]) => { - return config.enabled; - }); + return Object.entries(WIDGET_TYPES).filter(([, config]) => config.enabled); } export function describeWidgetTypes(): string { @@ -27,9 +32,9 @@ export function describeWidgetTypes(): string { return enabled .map(([key, config]) => { - const paramKeys = Object.keys(config.defaultParameters).filter((k) => { - return k !== 'container' && k !== 'placement'; - }); + const paramKeys = Object.keys(config.defaultParameters).filter( + (k) => k !== 'container' && k !== 'placement' + ); const overrideKeys = Object.keys(config.fieldOverrides ?? {}); const allKeys = [...new Set([...paramKeys, ...overrideKeys])]; @@ -38,6 +43,9 @@ export function describeWidgetTypes(): string { if (config.description) { desc += `: ${config.description}`; } + if (config.indexIndependent) { + desc += ' [index-independent]'; + } if (allKeys.length > 0) { const paramLines = allKeys.map((k) => { @@ -61,44 +69,87 @@ export function describeWidgetTypes(): string { .join('\n'); } +function describeBlock( + block: ExperienceApiBlock, + path: string, + indent: string = '' +): string { + const config = WIDGET_TYPES[block.type]; + const label = config?.label ?? block.type; + + if (block.type === 'ais.index') { + const indexName = block.parameters.indexName || '(unnamed)'; + const indexId = block.parameters.indexId; + const children = block.blocks ?? []; + let desc = `${indent}[${path}] Index (ais.index) — indexName: ${indexName}`; + if (indexId) { + desc += `, indexId: ${indexId}`; + } + + if (children.length === 0) { + desc += ' (empty)'; + } else { + children.forEach((child, j) => { + desc += '\n' + describeBlock(child, `${path}.${j}`, indent + ' '); + }); + } + + return desc; + } + + const placement: Placement = + block.parameters.placement ?? + (config?.defaultParameters.placement as Placement | undefined) ?? + 'inside'; + const container = block.parameters.container as string | undefined; + + let placementDesc: string; + if (placement === 'body') { + placementDesc = 'body'; + } else if (container) { + placementDesc = `${placement} ${container}`; + } else { + placementDesc = placement; + } + + const params = Object.entries(block.parameters) + .filter(([k]) => k !== 'container' && k !== 'placement') + .map(([k, v]) => `${k}=${JSON.stringify(v)}`) + .join(', '); + + const paramsSuffix = params ? `: ${params}` : ''; + + return `${indent}[${path}] ${label} (${block.type}) [${placementDesc}]${paramsSuffix}`; +} + export function describeExperience(experience: ExperienceApiResponse): string { if (experience.blocks.length === 0) { return 'The experience has no widgets yet.'; } return experience.blocks - .map((block, index) => { - const config = WIDGET_TYPES[block.type]; - const label = config?.label ?? block.type; - const placement: Placement = - block.parameters.placement ?? - (config?.defaultParameters.placement as Placement | undefined) ?? - 'inside'; - const container = block.parameters.container as string | undefined; - - let placementDesc: string; - if (placement === 'body') { - placementDesc = 'body'; - } else if (container) { - placementDesc = `${placement} ${container}`; - } else { - placementDesc = placement; - } - - const params = Object.entries(block.parameters) - .filter(([param]) => { - return param !== 'container' && param !== 'placement'; - }) - .map(([param, val]) => { - return `${param}=${JSON.stringify(val)}`; - }) - .join(', '); + .map((block, index) => describeBlock(block, String(index))) + .join('\n'); +} - const paramsSuffix = params ? `: ${params}` : ''; +function parsePath(path: string): BlockPath | null { + if (!path || path.startsWith('.') || path.endsWith('.')) return null; + const parts = path.split('.').map(Number); + if (parts.some((n) => isNaN(n) || n < 0)) return null; + if (parts.length === 1) return [parts[0]] as BlockPath; + if (parts.length === 2) return [parts[0], parts[1]] as BlockPath; + return null; +} - return `[${index}] ${label} (${block.type}) [${placementDesc}]${paramsSuffix}`; - }) - .join('\n'); +function resolveBlock( + experience: ExperienceApiResponse, + path: BlockPath +): ExperienceApiBlock | null { + if (path.length === 1) { + return experience.blocks[path[0]] ?? null; + } + const [parentIdx, childIdx] = path; + return experience.blocks[parentIdx]?.blocks?.[childIdx] ?? null; } export function describeToolAction( @@ -124,37 +175,33 @@ export function describeToolAction( } case 'edit_widget': { const applied = output?.applied; - const desc = `Edited widget ${input?.index ?? ''}`; + const desc = `Edited widget ${input?.path ?? ''}`; if (Array.isArray(applied) && applied.length > 0) { return `${desc} — ${applied.join(', ')}`; } return desc; } case 'remove_widget': - return `Removed widget ${input?.index ?? ''}`; + return `Removed widget ${input?.path ?? ''}`; + case 'move_widget': + return `Moved widget ${input?.path ?? ''} to index ${input?.to_index ?? ''}`; default: return 'Action completed'; } } -function boundsError(index: number, count: number): string { - if (count === 0) { - return `Invalid index ${index}. The experience has no widgets.`; - } - - return `Invalid index ${index}. Experience has ${count} widget(s) (indices 0–${count - 1}).`; +function boundsError(path: string): string { + return `Invalid path "${path}". Use get_experience to see valid widget paths.`; } export function getTools(callbacks: ToolCallbacks) { - const enabledKeys = getEnabledTypes().map(([key]) => { - return key; - }); + const enabledKeys = getEnabledTypes().map(([key]) => key); const typeEnum = z.enum(enabledKeys as [string, ...string[]]); return { get_experience: tool({ description: - 'Get the current experience state, including all widgets and their parameters.', + 'Get the current experience state, including all widgets and their parameters. Widgets are addressed by path (e.g., "0" for top-level, "1.0" for first child of index block 1).', inputSchema: z.object({}), execute: async () => { const experience = callbacks.getExperience(); @@ -164,14 +211,14 @@ export function getTools(callbacks: ToolCallbacks) { add_widget: tool({ description: - 'Add a new widget to the experience. When placement is "body", no container is needed. For other placements, a container CSS selector is required.', + 'Add a new widget to the experience. Index-dependent widgets (all except ais.chat, ais.autocomplete, ais.index) are automatically placed inside an ais.index block. When placement is "body", no container is needed. For other placements, a container CSS selector is required.', inputSchema: z.object({ type: typeEnum.describe('The widget type to add'), container: z .string() .optional() .describe( - 'CSS selector for the container element (required unless placement is "body")' + 'CSS selector for the container element (required unless placement is "body" or type is "ais.index")' ), placement: z .enum< @@ -186,29 +233,32 @@ export function getTools(callbacks: ToolCallbacks) { .record(z.unknown()) .optional() .describe('Additional parameters for the widget'), + target_index: z + .number() + .optional() + .describe( + 'Index of the ais.index block to add this widget to. Only for index-dependent widgets. If omitted, uses the last index block or auto-creates one.' + ), }), - execute: async ({ type, container, placement, parameters }) => { - const experience = callbacks.getExperience(); - const newIndex = experience.blocks.length; - + execute: async ({ + type, + container, + placement, + parameters, + target_index, + }) => { const config = WIDGET_TYPES[type]; - const allowedKeys = new Set([ - ...Object.keys(config?.defaultParameters ?? {}), - ...Object.keys(config?.fieldOverrides ?? {}), - 'placement', - ]); + // Validate container before mutating state const effectivePlacement: Placement = - placement ?? - (config?.defaultParameters.placement as Placement | undefined) ?? - 'inside'; - - callbacks.onAddBlock(type); - - const applied: string[] = []; - const rejected: string[] = []; + type === 'ais.index' + ? 'inside' + : (placement ?? + (config?.defaultParameters.placement as Placement | undefined) ?? + 'inside'); if ( + type !== 'ais.index' && effectivePlacement !== 'body' && !container && !parameters?.container @@ -219,11 +269,44 @@ export function getTools(callbacks: ToolCallbacks) { }; } - callbacks.onParameterChange(newIndex, 'placement', effectivePlacement); + if (target_index !== undefined) { + const experience = callbacks.getExperience(); + const targetBlock = experience.blocks[target_index]; + if (!targetBlock || targetBlock.type !== 'ais.index') { + return { + success: false, + error: `Target index ${target_index} is not an ais.index block.`, + }; + } + } + + const result = callbacks.onAddBlock(type, target_index); + const newPath = result.path; + + if (type === 'ais.index') { + if (parameters) { + for (const [key, value] of Object.entries(parameters)) { + callbacks.onParameterChange(newPath, key, value); + } + } + + return { success: true, path: newPath.join('.'), type }; + } + + const allowedKeys = new Set([ + ...Object.keys(config?.defaultParameters ?? {}), + ...Object.keys(config?.fieldOverrides ?? {}), + 'placement', + ]); + + const applied: string[] = []; + const rejected: string[] = []; + + callbacks.onParameterChange(newPath, 'placement', effectivePlacement); applied.push('placement'); if (container) { - callbacks.onParameterChange(newIndex, 'container', container); + callbacks.onParameterChange(newPath, 'container', container); applied.push('container'); } @@ -231,7 +314,7 @@ export function getTools(callbacks: ToolCallbacks) { for (const [key, value] of Object.entries(parameters)) { if (key === 'container' || key === 'placement') continue; if (allowedKeys.has(key)) { - callbacks.onParameterChange(newIndex, key, value); + callbacks.onParameterChange(newPath, key, value); applied.push(key); } else { rejected.push(key); @@ -241,36 +324,43 @@ export function getTools(callbacks: ToolCallbacks) { return { success: true, - index: newIndex, + path: newPath.join('.'), type, placement: effectivePlacement, container, applied, rejected, + ...(result.indexCreated && { + note: `An ais.index block was auto-created at path ${newPath[0]} but has no indexName. Ask the user which Algolia index to target, then use edit_widget to set it.`, + }), }; }, }), edit_widget: tool({ description: - 'Edit parameters of an existing widget. Only modify allowed parameters.', + 'Edit parameters of an existing widget. Address widgets by path (e.g., "0" for top-level, "1.0" for first child of index block 1).', inputSchema: z.object({ - index: z.number().describe('The index of the widget to edit'), + path: z + .string() + .describe('The path of the widget to edit (e.g., "0", "1.0")'), parameters: z .record(z.unknown()) .describe('Parameters to update (key-value pairs)'), }), - execute: async ({ index, parameters }) => { + execute: async ({ path, parameters }) => { const experience = callbacks.getExperience(); + const blockPath = parsePath(path); - if (index < 0 || index >= experience.blocks.length) { - return { - success: false, - error: boundsError(index, experience.blocks.length), - }; + if (!blockPath) { + return { success: false, error: boundsError(path) }; + } + + const block = resolveBlock(experience, blockPath); + if (!block) { + return { success: false, error: boundsError(path) }; } - const block = experience.blocks[index]!; const config = WIDGET_TYPES[block.type]; const allowedKeys = new Set([ ...Object.keys(config?.defaultParameters ?? {}), @@ -290,40 +380,90 @@ export function getTools(callbacks: ToolCallbacks) { for (const [cssKey, cssValue] of Object.entries( value as Record )) { - callbacks.onCssVariableChange(index, cssKey, cssValue); + callbacks.onCssVariableChange(blockPath, cssKey, cssValue); applied.push(`cssVariables.${cssKey}`); } } else if (allowedKeys.has(key)) { - callbacks.onParameterChange(index, key, value); + callbacks.onParameterChange(blockPath, key, value); applied.push(key); } else { rejected.push(key); } } - return { success: true, index, applied, rejected }; + return { success: true, path, applied, rejected }; }, }), remove_widget: tool({ - description: 'Remove a widget from the experience by its index.', + description: + 'Remove a widget from the experience by its path (e.g., "0" for top-level, "1.0" for first child of index block 1).', + inputSchema: z.object({ + path: z + .string() + .describe('The path of the widget to remove (e.g., "0", "1.0")'), + }), + execute: async ({ path }) => { + const experience = callbacks.getExperience(); + const blockPath = parsePath(path); + + if (!blockPath) { + return { success: false, error: boundsError(path) }; + } + + const block = resolveBlock(experience, blockPath); + if (!block) { + return { success: false, error: boundsError(path) }; + } + + callbacks.onDeleteBlock(blockPath); + + return { success: true, removedType: block.type, removedPath: path }; + }, + }), + + move_widget: tool({ + description: + 'Move a widget from one ais.index block to another. The widget must be inside an index block (path has two parts, e.g., "1.0").', inputSchema: z.object({ - index: z.number().describe('The index of the widget to remove'), + path: z + .string() + .describe( + 'The path of the widget to move (e.g., "1.0" for first child of index block 1)' + ), + to_index: z + .number() + .describe( + 'The top-level index of the target ais.index block to move the widget to' + ), }), - execute: async ({ index }) => { + execute: async ({ path, to_index }) => { const experience = callbacks.getExperience(); + const blockPath = parsePath(path); + + if (!blockPath || blockPath.length !== 2) { + return { + success: false, + error: `Invalid path "${path}". Only nested widgets (e.g., "1.0") can be moved between index blocks.`, + }; + } + + const block = resolveBlock(experience, blockPath); + if (!block) { + return { success: false, error: boundsError(path) }; + } - if (index < 0 || index >= experience.blocks.length) { + const targetBlock = experience.blocks[to_index]; + if (!targetBlock || targetBlock.type !== 'ais.index') { return { success: false, - error: boundsError(index, experience.blocks.length), + error: `Target index ${to_index} is not an ais.index block.`, }; } - const block = experience.blocks[index]!; - callbacks.onDeleteBlock(index); + callbacks.onMoveBlock(blockPath, to_index); - return { success: true, removedType: block.type, removedIndex: index }; + return { success: true, movedType: block.type, from: path, to_index }; }, }), }; diff --git a/packages/toolbar/src/components/add-widget-popover.tsx b/packages/toolbar/src/components/add-widget-popover.tsx index 82c0ffd..a938158 100644 --- a/packages/toolbar/src/components/add-widget-popover.tsx +++ b/packages/toolbar/src/components/add-widget-popover.tsx @@ -6,9 +6,10 @@ import { CollapsibleContent } from './ui/collapsible'; type AddWidgetPopoverProps = { onSelect: (type: string) => void; + filter?: (type: string, config: (typeof WIDGET_TYPES)[string]) => boolean; }; -export function AddWidgetPopover({ onSelect }: AddWidgetPopoverProps) { +export function AddWidgetPopover({ onSelect, filter }: AddWidgetPopoverProps) { const [open, setOpen] = useState(false); return ( @@ -38,43 +39,45 @@ export function AddWidgetPopover({ onSelect }: AddWidgetPopoverProps) {
- {Object.entries(WIDGET_TYPES).map(([type, config]) => { - if (!config.enabled) { + {Object.entries(WIDGET_TYPES) + .filter(([type, config]) => !filter || filter(type, config)) + .map(([type, config]) => { + if (!config.enabled) { + return ( +
+
+ {config.icon} +
+ + {config.label} + + + Coming Soon + +
+ ); + } + return ( -
{ + onSelect(type); + setOpen(false); + }} > -
+
{config.icon}
- - {config.label} - - - Coming Soon - -
+ {config.label} + ); - } - - return ( - - ); - })} + })}
diff --git a/packages/toolbar/src/components/ai-chat.tsx b/packages/toolbar/src/components/ai-chat.tsx index a4e5dfc..ca02846 100644 --- a/packages/toolbar/src/components/ai-chat.tsx +++ b/packages/toolbar/src/components/ai-chat.tsx @@ -5,7 +5,11 @@ import { Marked } from 'marked'; import { Fragment } from 'preact'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; -import type { ExperienceApiResponse } from '../types'; +import type { + AddBlockResult, + BlockPath, + ExperienceApiResponse, +} from '../types'; import { buildSystemPrompt } from '../ai/system-prompt'; import { describeToolAction, getTools, type ToolCallbacks } from '../ai/tools'; import { Alert, AlertDescription, AlertTitle } from './ui/alert'; @@ -164,10 +168,11 @@ function ModelPicker({ type AiChatProps = { experience: ExperienceApiResponse; - onAddBlock: (type: string) => void; - onParameterChange: (index: number, key: string, value: unknown) => void; - onCssVariableChange: (index: number, key: string, value: string) => void; - onDeleteBlock: (index: number) => void; + onAddBlock: (type: string, targetParentIndex?: number) => AddBlockResult; + onParameterChange: (path: BlockPath, key: string, value: unknown) => void; + onCssVariableChange: (path: BlockPath, key: string, value: string) => void; + onDeleteBlock: (path: BlockPath) => void; + onMoveBlock: (fromPath: BlockPath, toParentIndex: number) => void; }; export function AiChat({ @@ -176,17 +181,12 @@ export function AiChat({ onParameterChange, onCssVariableChange, onDeleteBlock, + onMoveBlock, }: AiChatProps) { const apiKey = window.__OPENAI_API_KEY__; const [model, setModel] = useState(() => { const stored = localStorage.getItem(MODEL_STORAGE_KEY); - if ( - stored && - MODELS.some((entry) => { - return entry.id === stored; - }) - ) - return stored; + if (stored && MODELS.some((m) => m.id === stored)) return stored; return DEFAULT_MODEL; }); const [modelPickerOpen, setModelPickerOpen] = useState(false); @@ -203,26 +203,20 @@ export function AiChat({ onCssVariableChangeRef.current = onCssVariableChange; const onDeleteBlockRef = useRef(onDeleteBlock); onDeleteBlockRef.current = onDeleteBlock; - - const callbacks: ToolCallbacks = useMemo(() => { - return { - onAddBlock: (...args) => { - return onAddBlockRef.current(...args); - }, - onParameterChange: (...args) => { - return onParameterChangeRef.current(...args); - }, - onCssVariableChange: (...args) => { - return onCssVariableChangeRef.current(...args); - }, - onDeleteBlock: (...args) => { - return onDeleteBlockRef.current(...args); - }, - getExperience: () => { - return experienceRef.current; - }, - }; - }, []); + const onMoveBlockRef = useRef(onMoveBlock); + onMoveBlockRef.current = onMoveBlock; + + const callbacks: ToolCallbacks = useMemo( + () => ({ + onAddBlock: (...args) => onAddBlockRef.current(...args), + onParameterChange: (...args) => onParameterChangeRef.current(...args), + onCssVariableChange: (...args) => onCssVariableChangeRef.current(...args), + onDeleteBlock: (...args) => onDeleteBlockRef.current(...args), + onMoveBlock: (...args) => onMoveBlockRef.current(...args), + getExperience: () => experienceRef.current, + }), + [] + ); const transport = useMemo(() => { if (!apiKey) { @@ -252,16 +246,15 @@ export function AiChat({ const parsed = JSON.parse(stored); if ( !Array.isArray(parsed) || - !parsed.every((msg: unknown) => { - return ( - typeof msg === 'object' && - msg !== null && - 'id' in msg && - 'role' in msg && - 'parts' in msg && - Array.isArray((msg as { parts: unknown }).parts) - ); - }) + !parsed.every( + (m: unknown) => + typeof m === 'object' && + m !== null && + 'id' in m && + 'role' in m && + 'parts' in m && + Array.isArray((m as { parts: unknown }).parts) + ) ) { sessionStorage.removeItem(STORAGE_KEY); @@ -343,15 +336,15 @@ export function AiChat({ Ask me to add, edit, or remove widgets from your experience. )} - {messages.map((message) => { - return message.role === 'user' ? ( + {messages.map((message) => + message.role === 'user' ? (
- {message.parts.map((part, index) => { - return part.type === 'text' ? ( + {message.parts.map((part, index) => + part.type === 'text' ? (
{part.text}
- ) : null; - })} + ) : null + )}
) : ( @@ -404,8 +397,8 @@ export function AiChat({ return null; })} - ); - })} + ) + )} {isStreaming && (
@@ -430,9 +423,9 @@ export function AiChat({ {/* Input */}
{ - event.preventDefault(); - const form = event.currentTarget; + onSubmit={(e) => { + e.preventDefault(); + const form = e.currentTarget; const input = form.elements.namedItem( 'message' ) as HTMLInputElement; @@ -440,9 +433,7 @@ export function AiChat({ if (!text || isStreaming) return; chat.sendMessage({ text }); input.value = ''; - requestAnimationFrame(() => { - return inputRef.current?.focus(); - }); + requestAnimationFrame(() => inputRef.current?.focus()); }} > = { prod: 'https://dashboard.algolia.com', }; +function findLastIndex(arr: T[], predicate: (item: T) => boolean): number { + for (let i = arr.length - 1; i >= 0; i--) { + if (predicate(arr[i]!)) return i; + } + return -1; +} + +function updateBlockAtPath( + blocks: ExperienceApiBlock[], + path: BlockPath, + updater: (block: ExperienceApiBlock) => ExperienceApiBlock +): ExperienceApiBlock[] { + if (path.length === 1) { + return blocks.map((b, i) => (i === path[0] ? updater(b) : b)); + } + const [parentIdx, childIdx] = path; + return blocks.map((b, i) => + i === parentIdx + ? { + ...b, + blocks: (b.blocks ?? []).map((child, j) => + j === childIdx ? updater(child) : child + ), + } + : b + ); +} + +function deleteBlockAtPath( + blocks: ExperienceApiBlock[], + path: BlockPath +): ExperienceApiBlock[] { + if (path.length === 1) { + return blocks.filter((_, i) => i !== path[0]); + } + const [parentIdx, childIdx] = path; + return blocks.map((b, i) => + i === parentIdx + ? { ...b, blocks: (b.blocks ?? []).filter((_, j) => j !== childIdx) } + : b + ); +} + export function App({ config, initialExperience }: AppProps) { const [isExpanded, setIsExpanded] = useState(false); const [experience, setExperience] = useState(initialExperience); const [isDirty, setIsDirty] = useState(false); const [saveState, setSaveState] = useState('idle'); const [toast, setToast] = useState(null); - const [adminApiKey, setAdminApiKey] = useState(() => { - return sessionStorage.getItem(`experiences.${config.experienceId}.key`); - }); + const [adminApiKey, setAdminApiKey] = useState(() => + sessionStorage.getItem(`experiences.${config.experienceId}.key`) + ); const debounceRef = useRef>(); const picker = useElementPicker(); @@ -43,12 +89,7 @@ export function App({ config, initialExperience }: AppProps) { }, []); const updateCssVariablesOnPage = useCallback( - ( - blocks: ExperienceApiResponse['blocks'], - blockIndex: number, - key: string, - value: string - ) => { + (path: BlockPath, key: string, value: string) => { const existingStyle = document.querySelector( 'style[data-algolia-experiences-toolbar]' ); @@ -59,24 +100,44 @@ export function App({ config, initialExperience }: AppProps) { document.head.appendChild(style); } - const declarations: string[] = []; - - for (let blockIdx = 0; blockIdx < blocks.length; blockIdx++) { - const vars = blocks[blockIdx]!.parameters.cssVariables ?? {}; - - for (const [varName, varValue] of Object.entries(vars)) { - const resolved = - blockIdx === blockIndex && varName === key ? value : varValue; - declarations.push(`--ais-${varName}: ${resolved}`); - } - } - - style.textContent = `:root { ${declarations.join('; ')} }`; + const allVars: Record = {}; + + const collectVars = ( + blocks: ExperienceApiBlock[], + parentIdx?: number + ) => { + blocks.forEach((block, i) => { + const currentPath: BlockPath = + parentIdx !== undefined ? [parentIdx, i] : [i]; + const vars = block.parameters.cssVariables ?? {}; + const isTarget = + currentPath.length === path.length && + currentPath.every((v, idx) => v === path[idx]); + + Object.entries(vars).forEach(([k, v]) => { + if (isTarget && k === key) { + allVars[`--ais-${k}`] = value; + } else { + allVars[`--ais-${k}`] = v; + } + }); + + if (block.blocks) { + collectVars(block.blocks, i); + } + }); + }; + + collectVars(experience.blocks); + + style.textContent = `:root { ${Object.entries(allVars) + .map(([k, v]) => `${k}: ${v}`) + .join('; ')} }`; }, [] ); - const onPillClick = () => { + const handlePillClick = () => { if (adminApiKey) { setIsExpanded(true); } else { @@ -85,18 +146,14 @@ export function App({ config, initialExperience }: AppProps) { }; const onParameterChange = useCallback( - (index: number, key: string, value: unknown) => { + (path: BlockPath, key: string, value: unknown) => { setExperience((prev) => { const updated = { ...prev, - blocks: prev.blocks.map((block, blockIdx) => { - return blockIdx === index - ? { - ...block, - parameters: { ...block.parameters, [key]: value }, - } - : block; - }), + blocks: updateBlockAtPath(prev.blocks, path, (block) => ({ + ...block, + parameters: { ...block.parameters, [key]: value }, + })), }; scheduleRun(updated); @@ -109,28 +166,22 @@ export function App({ config, initialExperience }: AppProps) { ); const onCssVariableChange = useCallback( - (index: number, key: string, value: string) => { - setExperience((prev) => { - updateCssVariablesOnPage(prev.blocks, index, key, value); - - return { - ...prev, - blocks: prev.blocks.map((block, blockIdx) => { - return blockIdx === index - ? { - ...block, - parameters: { - ...block.parameters, - cssVariables: { - ...block.parameters.cssVariables, - [key]: value, - }, - }, - } - : block; - }), - }; - }); + (path: BlockPath, key: string, value: string) => { + updateCssVariablesOnPage(path, key, value); + + setExperience((prev) => ({ + ...prev, + blocks: updateBlockAtPath(prev.blocks, path, (block) => ({ + ...block, + parameters: { + ...block.parameters, + cssVariables: { + ...(block.parameters.cssVariables ?? {}), + [key]: value, + }, + }, + })), + })); setIsDirty(true); }, @@ -175,9 +226,7 @@ export function App({ config, initialExperience }: AppProps) { overlay.style.cssText = `position:fixed;top:${target.top}px;left:${target.left}px;width:${target.width}px;height:${target.height}px;border:2px solid #003dff;background:rgba(0,61,255,0.08);border-radius:4px;pointer-events:none;z-index:2147483646`; document.body.appendChild(overlay); - const removeOverlay = () => { - return overlay.remove(); - }; + const removeOverlay = () => overlay.remove(); const animation = overlay.animate( [ { opacity: 1, offset: 0 }, @@ -194,13 +243,11 @@ export function App({ config, initialExperience }: AppProps) { ); const onDeleteBlock = useCallback( - (index: number) => { + (path: BlockPath) => { setExperience((value) => { const updated = { ...value, - blocks: value.blocks.filter((_, i) => { - return i !== index; - }), + blocks: deleteBlockAtPath(value.blocks, path), }; scheduleRun(updated); @@ -213,29 +260,118 @@ export function App({ config, initialExperience }: AppProps) { ); const onAddBlock = useCallback( - (type: string) => { - setExperience((value) => { - const updated = { - ...value, - blocks: [ - ...value.blocks, - { - type, - parameters: { - ...(WIDGET_TYPES[type]?.defaultParameters ?? { - container: '', - }), - }, - }, - ], + (type: string, targetParentIndex?: number): AddBlockResult => { + const config = WIDGET_TYPES[type]; + const isIndexIndependent = config?.indexIndependent ?? false; + + // Compute path and indexCreated from the current state synchronously, + // so the return value is always correct even in async contexts where + // the setExperience updater may be batched. + let result: AddBlockResult; + + setExperience((prev) => { + const newBlock: ExperienceApiBlock = { + type, + parameters: { + ...(config?.defaultParameters ?? { container: '' }), + }, }; + let updated: ExperienceApiResponse; + + if (isIndexIndependent || type === 'ais.index') { + result = { path: [prev.blocks.length], indexCreated: false }; + updated = { ...prev, blocks: [...prev.blocks, newBlock] }; + } else if (targetParentIndex !== undefined) { + const childIdx = prev.blocks[targetParentIndex]?.blocks?.length ?? 0; + result = { + path: [targetParentIndex, childIdx], + indexCreated: false, + }; + updated = { + ...prev, + blocks: prev.blocks.map((block, i) => + i === targetParentIndex + ? { ...block, blocks: [...(block.blocks ?? []), newBlock] } + : block + ), + }; + } else { + const lastIndexIdx = findLastIndex( + prev.blocks, + (b) => b.type === 'ais.index' + ); + + if (lastIndexIdx === -1) { + result = { + path: [prev.blocks.length, 0], + indexCreated: true, + }; + updated = { + ...prev, + blocks: [ + ...prev.blocks, + { + type: 'ais.index', + parameters: { indexName: '', indexId: '' }, + blocks: [newBlock], + }, + ], + }; + } else { + const childIdx = prev.blocks[lastIndexIdx]?.blocks?.length ?? 0; + result = { + path: [lastIndexIdx, childIdx], + indexCreated: false, + }; + updated = { + ...prev, + blocks: prev.blocks.map((block, i) => + i === lastIndexIdx + ? { ...block, blocks: [...(block.blocks ?? []), newBlock] } + : block + ), + }; + } + } + scheduleRun(updated); return updated; }); setIsDirty(true); + + return result!; + }, + [scheduleRun] + ); + + const onMoveBlock = useCallback( + (fromPath: BlockPath, toParentIndex: number) => { + setExperience((prev) => { + if (fromPath.length !== 2) return prev; + const [srcParent, srcChild] = fromPath; + if (srcParent === toParentIndex) return prev; + + const block = prev.blocks[srcParent]?.blocks?.[srcChild]; + if (!block) return prev; + + const withRemoved = deleteBlockAtPath(prev.blocks, fromPath); + const updated = { + ...prev, + blocks: withRemoved.map((b, i) => + i === toParentIndex + ? { ...b, blocks: [...(b.blocks ?? []), block] } + : b + ), + }; + + scheduleRun(updated); + + return updated; + }); + setIsDirty(true); }, [scheduleRun] ); @@ -253,9 +389,7 @@ export function App({ config, initialExperience }: AppProps) { setIsDirty(false); setSaveState('saved'); - setTimeout(() => { - return setSaveState('idle'); - }, 2000); + setTimeout(() => setSaveState('idle'), 2000); } catch (err) { setSaveState('idle'); setToast(err instanceof Error ? err.message : 'Failed to save.'); @@ -267,13 +401,9 @@ export function App({ config, initialExperience }: AppProps) { return; } - const timer = setTimeout(() => { - return setToast(null); - }, 4000); + const timer = setTimeout(() => setToast(null), 4000); - return () => { - return clearTimeout(timer); - }; + return () => clearTimeout(timer); }, [toast]); useEffect(() => { @@ -300,18 +430,21 @@ export function App({ config, initialExperience }: AppProps) { dirty={isDirty} saveState={saveState} open={isExpanded} - onClose={() => { - return setIsExpanded(false); - }} + onClose={() => setIsExpanded(false)} onSave={onSave} onParameterChange={onParameterChange} onCssVariableChange={onCssVariableChange} onLocate={onLocate} onDeleteBlock={onDeleteBlock} onAddBlock={onAddBlock} + onMoveBlock={onMoveBlock} onPickElement={picker.startPicking} /> - + {toast && (
void; onDeleteBlock: () => void; onPickElement: (callback: (selector: string) => void) => void; + indexBlocks?: Array<{ index: number; block: ExperienceApiBlock }>; + parentIndex?: number; + onMoveToIndex?: (toParentIndex: number) => void; }; const PLACEMENT_LABELS: Record = { @@ -61,6 +67,9 @@ export function BlockCard({ onLocate, onDeleteBlock, onPickElement, + indexBlocks, + parentIndex, + onMoveToIndex, }: BlockCardProps) { const widgetType = WIDGET_TYPES[type]; const label = widgetType?.label ?? type; @@ -173,6 +182,35 @@ export function BlockCard({ + {indexBlocks && + indexBlocks.length > 1 && + onMoveToIndex && + parentIndex !== undefined && ( +
+ +
+ )} { return key in parameters; @@ -32,7 +34,12 @@ export function BlockEditor({ : Object.keys(parameters); return ( -
+
{paramKeys.map((key) => { if (key === 'placement') { return null; diff --git a/packages/toolbar/src/components/index-block-group.tsx b/packages/toolbar/src/components/index-block-group.tsx new file mode 100644 index 0000000..e3edb83 --- /dev/null +++ b/packages/toolbar/src/components/index-block-group.tsx @@ -0,0 +1,174 @@ +import type { BlockPath, ExperienceApiBlock } from '../types'; +import { WIDGET_TYPES } from '../widget-types'; +import { AddWidgetPopover } from './add-widget-popover'; +import { BlockCard } from './block-card'; +import { BlockEditor } from './block-editor'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from './ui/collapsible'; + +type IndexBlockGroupProps = { + block: ExperienceApiBlock; + parentIndex: number; + expandedBlock: string | null; + indexBlocks: Array<{ index: number; block: ExperienceApiBlock }>; + onToggleExpand: (key: string) => void; + onParameterChange: (path: BlockPath, key: string, value: unknown) => void; + onCssVariableChange: (path: BlockPath, key: string, value: string) => void; + onLocate: (container: string, placement: string | undefined) => void; + onDeleteBlock: (path: BlockPath) => void; + onAddBlock: (type: string, targetParentIndex?: number) => void; + onMoveBlock: (fromPath: BlockPath, toParentIndex: number) => void; + onPickElement: (callback: (selector: string) => void) => void; +}; + +export function IndexBlockGroup({ + block, + parentIndex, + expandedBlock, + indexBlocks, + onToggleExpand, + onParameterChange, + onCssVariableChange, + onLocate, + onDeleteBlock, + onAddBlock, + onMoveBlock, + onPickElement, +}: IndexBlockGroupProps) { + const groupKey = String(parentIndex); + const isOpen = + expandedBlock?.startsWith(`${groupKey}.`) || expandedBlock === groupKey; + const indexName = (block.parameters.indexName as string) || ''; + const Icon = WIDGET_TYPES['ais.index']?.icon; + + return ( +
+ + onToggleExpand(groupKey)} + aria-expanded={isOpen} + > +
+
+ {Icon && ( +
+ +
+ )} + Index + {indexName && ( + + {indexName} + + )} +
+
+
+ +
+ + + +
+
+
+ +
+ {/* Index block parameters */} +
+ + onParameterChange([parentIndex], key, value) + } + onCssVariableChange={(key, value) => + onCssVariableChange([parentIndex], key, value) + } + onPickElement={onPickElement} + /> +
+ + {/* Child widgets */} + {(block.blocks ?? []).map((child, childIndex) => ( + onToggleExpand(`${parentIndex}.${childIndex}`)} + onParameterChange={(key, value) => + onParameterChange([parentIndex, childIndex], key, value) + } + onCssVariableChange={(key, value) => + onCssVariableChange([parentIndex, childIndex], key, value) + } + onLocate={() => + onLocate( + child.parameters.container ?? '', + child.parameters.placement as string | undefined + ) + } + onDeleteBlock={() => onDeleteBlock([parentIndex, childIndex])} + onPickElement={onPickElement} + indexBlocks={indexBlocks} + parentIndex={parentIndex} + onMoveToIndex={(toParentIndex) => + onMoveBlock([parentIndex, childIndex], toParentIndex) + } + /> + ))} + + {/* Scoped add widget */} + onAddBlock(type, parentIndex)} + filter={(type) => type !== 'ais.index'} + /> +
+
+
+
+ ); +} diff --git a/packages/toolbar/src/components/panel.tsx b/packages/toolbar/src/components/panel.tsx index 13cabce..537dbbf 100644 --- a/packages/toolbar/src/components/panel.tsx +++ b/packages/toolbar/src/components/panel.tsx @@ -1,8 +1,14 @@ -import { useEffect, useRef, useState } from 'preact/hooks'; -import type { ExperienceApiResponse, SaveState } from '../types'; +import { useMemo, useEffect, useRef, useState } from 'preact/hooks'; +import type { + AddBlockResult, + BlockPath, + ExperienceApiResponse, + SaveState, +} from '../types'; import { AddWidgetPopover } from './add-widget-popover'; import { AiChat } from './ai-chat'; import { BlockCard } from './block-card'; +import { IndexBlockGroup } from './index-block-group'; import { Button } from './ui/button'; import { TabsList, TabsTrigger, TabsContent } from './ui/tabs'; @@ -13,11 +19,12 @@ type PanelProps = { open: boolean; onClose: () => void; onSave: () => void; - onParameterChange: (index: number, key: string, value: unknown) => void; - onCssVariableChange: (index: number, key: string, value: string) => void; + onParameterChange: (path: BlockPath, key: string, value: unknown) => void; + onCssVariableChange: (path: BlockPath, key: string, value: string) => void; onLocate: (container: string, placement: string | undefined) => void; - onDeleteBlock: (index: number) => void; - onAddBlock: (type: string) => void; + onDeleteBlock: (path: BlockPath) => void; + onAddBlock: (type: string, targetParentIndex?: number) => AddBlockResult; + onMoveBlock: (fromPath: BlockPath, toParentIndex: number) => void; onPickElement: (callback: (selector: string) => void) => void; }; @@ -35,17 +42,63 @@ export function Panel({ onLocate, onDeleteBlock, onAddBlock, + onMoveBlock, onPickElement, }: PanelProps) { const [tab, setTab] = useState('manual'); const [aiMounted, setAiMounted] = useState(false); - const [expandedBlock, setExpandedBlock] = useState(null); - const prevBlockCount = useRef(experience.blocks.length); + const [expandedBlock, setExpandedBlock] = useState(null); + const prevBlocksRef = useRef(experience.blocks); - if (experience.blocks.length > prevBlockCount.current) { - setExpandedBlock(experience.blocks.length - 1); - } - prevBlockCount.current = experience.blocks.length; + const widgetCount = experience.blocks.reduce( + (count, block) => + block.type === 'ais.index' + ? count + (block.blocks?.length ?? 0) + : count + 1, + 0 + ); + + const indexBlocks = useMemo( + () => + experience.blocks + .map((block, index) => ({ index, block })) + .filter(({ block }) => block.type === 'ais.index'), + [experience.blocks] + ); + + useEffect(() => { + const prev = prevBlocksRef.current; + const curr = experience.blocks; + prevBlocksRef.current = curr; + + if (curr.length > prev.length) { + const lastIdx = curr.length - 1; + const lastBlock = curr[lastIdx]!; + + if ( + lastBlock.type === 'ais.index' && + (lastBlock.blocks?.length ?? 0) > 0 + ) { + setExpandedBlock(`${lastIdx}.${lastBlock.blocks!.length - 1}`); + } else { + setExpandedBlock(String(lastIdx)); + } + } else { + for (let i = 0; i < curr.length; i++) { + const currBlock = curr[i]!; + const prevBlock = prev[i]; + if ( + currBlock.type === 'ais.index' && + prevBlock?.type === 'ais.index' && + (currBlock.blocks?.length ?? 0) > (prevBlock.blocks?.length ?? 0) + ) { + const childIdx = currBlock.blocks!.length - 1; + setExpandedBlock(`${i}.${childIdx}`); + break; + } + } + } + }, [experience.blocks]); useEffect(() => { if (tab === 'ai') { @@ -53,6 +106,20 @@ export function Panel({ } }, [tab]); + const handleToggleExpand = (key: string) => { + const isChild = key.indexOf('.') !== -1; + + if (expandedBlock === key) { + // Closing a child widget should keep the parent index group open + setExpandedBlock(isChild ? key.slice(0, key.indexOf('.')) : null); + } else if (!isChild && expandedBlock?.startsWith(`${key}.`)) { + // Clicking parent heading while a child is expanded — collapse everything + setExpandedBlock(null); + } else { + setExpandedBlock(key); + } + }; + return (

Algolia Experiences

- {experience.blocks.length} widget - {experience.blocks.length !== 1 ? 's' : ''} configured + {widgetCount} widget + {widgetCount !== 1 ? 's' : ''} configured

@@ -149,9 +216,7 @@ export function Panel({ { - return setTab('manual'); - }} + onClick={() => setTab('manual')} > Manual - { - return setTab('ai'); - }} - > + setTab('ai')}>
{experience.blocks.map((block, index) => { + if (block.type === 'ais.index') { + return ( + + ); + } + return ( { - return setExpandedBlock( - expandedBlock === index ? null : index - ); - }} - onParameterChange={(key, value) => { - return onParameterChange(index, key, value); - }} - onCssVariableChange={(key, value) => { - return onCssVariableChange(index, key, value); - }} - onLocate={() => { - return onLocate( - block.parameters.container, + open={expandedBlock === String(index)} + onToggle={() => handleToggleExpand(String(index))} + onParameterChange={(key, value) => + onParameterChange([index], key, value) + } + onCssVariableChange={(key, value) => + onCssVariableChange([index], key, value) + } + onLocate={() => + onLocate( + block.parameters.container ?? '', block.parameters.placement as string | undefined - ); - }} - onDeleteBlock={() => { - return onDeleteBlock(index); - }} + ) + } + onDeleteBlock={() => onDeleteBlock([index])} onPickElement={onPickElement} /> ); @@ -247,6 +321,7 @@ export function Panel({ onParameterChange={onParameterChange} onCssVariableChange={onCssVariableChange} onDeleteBlock={onDeleteBlock} + onMoveBlock={onMoveBlock} />
)} diff --git a/packages/toolbar/src/types.ts b/packages/toolbar/src/types.ts index 996e078..73f00eb 100644 --- a/packages/toolbar/src/types.ts +++ b/packages/toolbar/src/types.ts @@ -5,17 +5,28 @@ export type Placement = 'inside' | 'before' | 'after' | 'replace' | 'body'; export type SaveState = 'idle' | 'saving' | 'saved'; export type ExperienceApiBlockParameters = { - container: string; + container?: string; placement?: Placement; cssVariables?: Record; indexName?: string; + indexId?: string; } & Record; +export type ExperienceApiBlock = { + type: string; + parameters: ExperienceApiBlockParameters; + blocks?: ExperienceApiBlock[]; +}; + +export type BlockPath = [number] | [number, number]; + +export type AddBlockResult = { + path: BlockPath; + indexCreated: boolean; +}; + export type ExperienceApiResponse = { - blocks: Array<{ - type: string; - parameters: ExperienceApiBlockParameters; - }>; + blocks: ExperienceApiBlock[]; indexName: string; }; diff --git a/packages/toolbar/src/widget-types.tsx b/packages/toolbar/src/widget-types.tsx index d1067ef..c35357b 100644 --- a/packages/toolbar/src/widget-types.tsx +++ b/packages/toolbar/src/widget-types.tsx @@ -15,7 +15,9 @@ export type WidgetTypeConfig = { description?: string; icon: JSX.Element; enabled: boolean; + indexIndependent?: boolean; defaultParameters: ExperienceApiBlockParameters; + columns?: number; fieldOrder?: string[]; fieldOverrides?: Record; paramLabels?: Record; @@ -51,6 +53,22 @@ const CHAT_ICON = ( ); +const DATABASE_ICON = ( + + + + + +); + const GRID_ICON = ( = { description: 'A search-as-you-type dropdown that shows results, suggestions, and recent searches as the user types.', enabled: true, + indexIndependent: true, icon: SEARCH_ICON, defaultParameters: { container: '', @@ -238,7 +257,6 @@ export const WIDGET_TYPES: Record = { showSuggestions: { type: 'object', label: 'Suggestions', - // oxlint-disable-next-line id-length defaultValue: { indexName: '', searchPageUrl: '', q: 'q' }, fields: [ { key: 'indexName', label: 'Index Name' }, @@ -266,6 +284,7 @@ export const WIDGET_TYPES: Record = { description: 'A conversational AI chat widget powered by an Algolia Agent Studio agent.', enabled: true, + indexIndependent: true, icon: CHAT_ICON, defaultParameters: { container: '', @@ -282,6 +301,28 @@ export const WIDGET_TYPES: Record = { agentId: 'The ID of the Algolia Agent Studio agent to power the chat.', }, }, + 'ais.index': { + label: 'Index', + description: + 'Scopes child widgets to a specific Algolia index. Required for search widgets.', + enabled: true, + indexIndependent: true, + icon: DATABASE_ICON, + defaultParameters: { + indexName: '', + indexId: '', + }, + columns: 2, + paramLabels: { + indexName: 'Index Name', + indexId: 'Index ID', + }, + paramDescriptions: { + indexName: 'The Algolia index or composition ID to search.', + indexId: + 'Optional identifier when using multiple indices with the same name.', + }, + }, 'ais.hits': { label: 'Hits', enabled: false, @@ -292,7 +333,7 @@ export const WIDGET_TYPES: Record = { }, 'ais.searchBox': { label: 'Search Box', - enabled: false, + enabled: true, icon: SEARCH_ICON, defaultParameters: { container: '', From 7cc13b2818e92ea0f0a2b4789b01622c671a6ca2 Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Sat, 21 Feb 2026 11:45:53 +0100 Subject: [PATCH 2/4] fix(toolbar): disable ais.searchBox test widget Co-Authored-By: Claude Opus 4.6 --- packages/toolbar/__tests__/ai-tools.test.ts | 26 +-- packages/toolbar/src/ai/tools.ts | 33 +++- .../src/components/add-widget-popover.tsx | 4 +- packages/toolbar/src/components/ai-chat.tsx | 86 ++++++---- packages/toolbar/src/components/app.tsx | 155 +++++++++++------- .../toolbar/src/components/block-card.tsx | 18 +- .../src/components/index-block-group.tsx | 101 +++++++----- packages/toolbar/src/components/panel.tsx | 67 ++++---- packages/toolbar/src/widget-types.tsx | 3 +- 9 files changed, 300 insertions(+), 193 deletions(-) diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index 2515e42..95faed7 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -15,12 +15,16 @@ function createCallbacks( indexCreated = false ): ToolCallbacks { return { - onAddBlock: vi.fn(() => ({ path: addBlockPath, indexCreated })), + onAddBlock: vi.fn(() => { + return { path: addBlockPath, indexCreated }; + }), onParameterChange: vi.fn(), onCssVariableChange: vi.fn(), onDeleteBlock: vi.fn(), onMoveBlock: vi.fn(), - getExperience: vi.fn(() => experience), + getExperience: vi.fn(() => { + return experience; + }), }; } @@ -501,18 +505,18 @@ describe('getTools', () => { const result = await tools.add_widget.execute!( { - type: 'ais.searchBox', + type: 'ais.autocomplete', container: '#search', target_index: 0, }, { toolCallId: 'tc1', messages: [] } ); - expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.searchBox', 0); + expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.autocomplete', 0); expect(result).toMatchObject({ success: true, path: '0.0', - type: 'ais.searchBox', + type: 'ais.autocomplete', }); }); @@ -531,7 +535,7 @@ describe('getTools', () => { const result = await tools.add_widget.execute!( { - type: 'ais.searchBox', + type: 'ais.autocomplete', container: '#search', target_index: 0, }, @@ -555,7 +559,7 @@ describe('getTools', () => { const result = await tools.add_widget.execute!( { - type: 'ais.searchBox', + type: 'ais.autocomplete', container: '#search', target_index: 5, }, @@ -578,7 +582,7 @@ describe('getTools', () => { const tools = getTools(callbacks); const result = await tools.add_widget.execute!( - { type: 'ais.searchBox', container: '#search' }, + { type: 'ais.autocomplete', container: '#search' }, { toolCallId: 'tc1', messages: [] } ); @@ -603,7 +607,7 @@ describe('getTools', () => { const tools = getTools(callbacks); const result = await tools.add_widget.execute!( - { type: 'ais.searchBox', container: '#search' }, + { type: 'ais.autocomplete', container: '#search' }, { toolCallId: 'tc1', messages: [] } ); @@ -1036,7 +1040,7 @@ describe('getTools', () => { parameters: { container: '#search' }, }, { - type: 'ais.searchBox', + type: 'ais.autocomplete', parameters: { container: '#box' }, }, ], @@ -1055,7 +1059,7 @@ describe('getTools', () => { expect(callbacks.onDeleteBlock).toHaveBeenCalledWith([0, 1]); expect(result).toMatchObject({ success: true, - removedType: 'ais.searchBox', + removedType: 'ais.autocomplete', }); }); diff --git a/packages/toolbar/src/ai/tools.ts b/packages/toolbar/src/ai/tools.ts index 85bc22e..272fb37 100644 --- a/packages/toolbar/src/ai/tools.ts +++ b/packages/toolbar/src/ai/tools.ts @@ -20,7 +20,9 @@ export type ToolCallbacks = { }; function getEnabledTypes() { - return Object.entries(WIDGET_TYPES).filter(([, config]) => config.enabled); + return Object.entries(WIDGET_TYPES).filter(([, config]) => { + return config.enabled; + }); } export function describeWidgetTypes(): string { @@ -32,9 +34,9 @@ export function describeWidgetTypes(): string { return enabled .map(([key, config]) => { - const paramKeys = Object.keys(config.defaultParameters).filter( - (k) => k !== 'container' && k !== 'placement' - ); + const paramKeys = Object.keys(config.defaultParameters).filter((k) => { + return k !== 'container' && k !== 'placement'; + }); const overrideKeys = Object.keys(config.fieldOverrides ?? {}); const allKeys = [...new Set([...paramKeys, ...overrideKeys])]; @@ -113,8 +115,12 @@ function describeBlock( } const params = Object.entries(block.parameters) - .filter(([k]) => k !== 'container' && k !== 'placement') - .map(([k, v]) => `${k}=${JSON.stringify(v)}`) + .filter(([param]) => { + return param !== 'container' && param !== 'placement'; + }) + .map(([param, val]) => { + return `${param}=${JSON.stringify(val)}`; + }) .join(', '); const paramsSuffix = params ? `: ${params}` : ''; @@ -128,14 +134,21 @@ export function describeExperience(experience: ExperienceApiResponse): string { } return experience.blocks - .map((block, index) => describeBlock(block, String(index))) + .map((block, index) => { + return describeBlock(block, String(index)); + }) .join('\n'); } function parsePath(path: string): BlockPath | null { if (!path || path.startsWith('.') || path.endsWith('.')) return null; const parts = path.split('.').map(Number); - if (parts.some((n) => isNaN(n) || n < 0)) return null; + if ( + parts.some((num) => { + return isNaN(num) || num < 0; + }) + ) + return null; if (parts.length === 1) return [parts[0]] as BlockPath; if (parts.length === 2) return [parts[0], parts[1]] as BlockPath; return null; @@ -195,7 +208,9 @@ function boundsError(path: string): string { } export function getTools(callbacks: ToolCallbacks) { - const enabledKeys = getEnabledTypes().map(([key]) => key); + const enabledKeys = getEnabledTypes().map(([key]) => { + return key; + }); const typeEnum = z.enum(enabledKeys as [string, ...string[]]); return { diff --git a/packages/toolbar/src/components/add-widget-popover.tsx b/packages/toolbar/src/components/add-widget-popover.tsx index a938158..f90d5f5 100644 --- a/packages/toolbar/src/components/add-widget-popover.tsx +++ b/packages/toolbar/src/components/add-widget-popover.tsx @@ -40,7 +40,9 @@ export function AddWidgetPopover({ onSelect, filter }: AddWidgetPopoverProps) {
{Object.entries(WIDGET_TYPES) - .filter(([type, config]) => !filter || filter(type, config)) + .filter(([type, config]) => { + return !filter || filter(type, config); + }) .map(([type, config]) => { if (!config.enabled) { return ( diff --git a/packages/toolbar/src/components/ai-chat.tsx b/packages/toolbar/src/components/ai-chat.tsx index ca02846..23ee27e 100644 --- a/packages/toolbar/src/components/ai-chat.tsx +++ b/packages/toolbar/src/components/ai-chat.tsx @@ -186,7 +186,13 @@ export function AiChat({ const apiKey = window.__OPENAI_API_KEY__; const [model, setModel] = useState(() => { const stored = localStorage.getItem(MODEL_STORAGE_KEY); - if (stored && MODELS.some((m) => m.id === stored)) return stored; + if ( + stored && + MODELS.some((entry) => { + return entry.id === stored; + }) + ) + return stored; return DEFAULT_MODEL; }); const [modelPickerOpen, setModelPickerOpen] = useState(false); @@ -206,17 +212,28 @@ export function AiChat({ const onMoveBlockRef = useRef(onMoveBlock); onMoveBlockRef.current = onMoveBlock; - const callbacks: ToolCallbacks = useMemo( - () => ({ - onAddBlock: (...args) => onAddBlockRef.current(...args), - onParameterChange: (...args) => onParameterChangeRef.current(...args), - onCssVariableChange: (...args) => onCssVariableChangeRef.current(...args), - onDeleteBlock: (...args) => onDeleteBlockRef.current(...args), - onMoveBlock: (...args) => onMoveBlockRef.current(...args), - getExperience: () => experienceRef.current, - }), - [] - ); + const callbacks: ToolCallbacks = useMemo(() => { + return { + onAddBlock: (...args) => { + return onAddBlockRef.current(...args); + }, + onParameterChange: (...args) => { + return onParameterChangeRef.current(...args); + }, + onCssVariableChange: (...args) => { + return onCssVariableChangeRef.current(...args); + }, + onDeleteBlock: (...args) => { + return onDeleteBlockRef.current(...args); + }, + onMoveBlock: (...args) => { + return onMoveBlockRef.current(...args); + }, + getExperience: () => { + return experienceRef.current; + }, + }; + }, []); const transport = useMemo(() => { if (!apiKey) { @@ -246,15 +263,16 @@ export function AiChat({ const parsed = JSON.parse(stored); if ( !Array.isArray(parsed) || - !parsed.every( - (m: unknown) => - typeof m === 'object' && - m !== null && - 'id' in m && - 'role' in m && - 'parts' in m && - Array.isArray((m as { parts: unknown }).parts) - ) + !parsed.every((msg: unknown) => { + return ( + typeof msg === 'object' && + msg !== null && + 'id' in msg && + 'role' in msg && + 'parts' in msg && + Array.isArray((msg as { parts: unknown }).parts) + ); + }) ) { sessionStorage.removeItem(STORAGE_KEY); @@ -336,15 +354,15 @@ export function AiChat({ Ask me to add, edit, or remove widgets from your experience.
)} - {messages.map((message) => - message.role === 'user' ? ( + {messages.map((message) => { + return message.role === 'user' ? (
- {message.parts.map((part, index) => - part.type === 'text' ? ( + {message.parts.map((part, index) => { + return part.type === 'text' ? (
{part.text}
- ) : null - )} + ) : null; + })}
) : ( @@ -397,8 +415,8 @@ export function AiChat({ return null; })} - ) - )} + ); + })} {isStreaming && (
@@ -423,9 +441,9 @@ export function AiChat({ {/* Input */}
{ - e.preventDefault(); - const form = e.currentTarget; + onSubmit={(event) => { + event.preventDefault(); + const form = event.currentTarget; const input = form.elements.namedItem( 'message' ) as HTMLInputElement; @@ -433,7 +451,9 @@ export function AiChat({ if (!text || isStreaming) return; chat.sendMessage({ text }); input.value = ''; - requestAnimationFrame(() => inputRef.current?.focus()); + requestAnimationFrame(() => { + return inputRef.current?.focus(); + }); }} > = { prod: 'https://dashboard.algolia.com', }; +// oxlint-disable-next-line id-length function findLastIndex(arr: T[], predicate: (item: T) => boolean): number { for (let i = arr.length - 1; i >= 0; i--) { if (predicate(arr[i]!)) return i; @@ -38,19 +39,21 @@ function updateBlockAtPath( updater: (block: ExperienceApiBlock) => ExperienceApiBlock ): ExperienceApiBlock[] { if (path.length === 1) { - return blocks.map((b, i) => (i === path[0] ? updater(b) : b)); + return blocks.map((block, idx) => { + return idx === path[0] ? updater(block) : block; + }); } const [parentIdx, childIdx] = path; - return blocks.map((b, i) => - i === parentIdx + return blocks.map((block, idx) => { + return idx === parentIdx ? { - ...b, - blocks: (b.blocks ?? []).map((child, j) => - j === childIdx ? updater(child) : child - ), + ...block, + blocks: (block.blocks ?? []).map((child, ci) => { + return ci === childIdx ? updater(child) : child; + }), } - : b - ); + : block; + }); } function deleteBlockAtPath( @@ -58,14 +61,21 @@ function deleteBlockAtPath( path: BlockPath ): ExperienceApiBlock[] { if (path.length === 1) { - return blocks.filter((_, i) => i !== path[0]); + return blocks.filter((_, idx) => { + return idx !== path[0]; + }); } const [parentIdx, childIdx] = path; - return blocks.map((b, i) => - i === parentIdx - ? { ...b, blocks: (b.blocks ?? []).filter((_, j) => j !== childIdx) } - : b - ); + return blocks.map((block, idx) => { + return idx === parentIdx + ? { + ...block, + blocks: (block.blocks ?? []).filter((_, ci) => { + return ci !== childIdx; + }), + } + : block; + }); } export function App({ config, initialExperience }: AppProps) { @@ -74,9 +84,9 @@ export function App({ config, initialExperience }: AppProps) { const [isDirty, setIsDirty] = useState(false); const [saveState, setSaveState] = useState('idle'); const [toast, setToast] = useState(null); - const [adminApiKey, setAdminApiKey] = useState(() => - sessionStorage.getItem(`experiences.${config.experienceId}.key`) - ); + const [adminApiKey, setAdminApiKey] = useState(() => { + return sessionStorage.getItem(`experiences.${config.experienceId}.key`); + }); const debounceRef = useRef>(); const picker = useElementPicker(); @@ -112,13 +122,15 @@ export function App({ config, initialExperience }: AppProps) { const vars = block.parameters.cssVariables ?? {}; const isTarget = currentPath.length === path.length && - currentPath.every((v, idx) => v === path[idx]); + currentPath.every((val, idx) => { + return val === path[idx]; + }); - Object.entries(vars).forEach(([k, v]) => { - if (isTarget && k === key) { - allVars[`--ais-${k}`] = value; + Object.entries(vars).forEach(([varName, varValue]) => { + if (isTarget && varName === key) { + allVars[`--ais-${varName}`] = value; } else { - allVars[`--ais-${k}`] = v; + allVars[`--ais-${varName}`] = varValue; } }); @@ -131,7 +143,9 @@ export function App({ config, initialExperience }: AppProps) { collectVars(experience.blocks); style.textContent = `:root { ${Object.entries(allVars) - .map(([k, v]) => `${k}: ${v}`) + .map(([prop, val]) => { + return `${prop}: ${val}`; + }) .join('; ')} }`; }, [] @@ -150,10 +164,12 @@ export function App({ config, initialExperience }: AppProps) { setExperience((prev) => { const updated = { ...prev, - blocks: updateBlockAtPath(prev.blocks, path, (block) => ({ - ...block, - parameters: { ...block.parameters, [key]: value }, - })), + blocks: updateBlockAtPath(prev.blocks, path, (block) => { + return { + ...block, + parameters: { ...block.parameters, [key]: value }, + }; + }), }; scheduleRun(updated); @@ -169,19 +185,23 @@ export function App({ config, initialExperience }: AppProps) { (path: BlockPath, key: string, value: string) => { updateCssVariablesOnPage(path, key, value); - setExperience((prev) => ({ - ...prev, - blocks: updateBlockAtPath(prev.blocks, path, (block) => ({ - ...block, - parameters: { - ...block.parameters, - cssVariables: { - ...(block.parameters.cssVariables ?? {}), - [key]: value, - }, - }, - })), - })); + setExperience((prev) => { + return { + ...prev, + blocks: updateBlockAtPath(prev.blocks, path, (block) => { + return { + ...block, + parameters: { + ...block.parameters, + cssVariables: { + ...block.parameters.cssVariables, + [key]: value, + }, + }, + }; + }), + }; + }); setIsDirty(true); }, @@ -226,7 +246,9 @@ export function App({ config, initialExperience }: AppProps) { overlay.style.cssText = `position:fixed;top:${target.top}px;left:${target.left}px;width:${target.width}px;height:${target.height}px;border:2px solid #003dff;background:rgba(0,61,255,0.08);border-radius:4px;pointer-events:none;z-index:2147483646`; document.body.appendChild(overlay); - const removeOverlay = () => overlay.remove(); + const removeOverlay = () => { + return overlay.remove(); + }; const animation = overlay.animate( [ { opacity: 1, offset: 0 }, @@ -290,17 +312,16 @@ export function App({ config, initialExperience }: AppProps) { }; updated = { ...prev, - blocks: prev.blocks.map((block, i) => - i === targetParentIndex + blocks: prev.blocks.map((block, i) => { + return i === targetParentIndex ? { ...block, blocks: [...(block.blocks ?? []), newBlock] } - : block - ), + : block; + }), }; } else { - const lastIndexIdx = findLastIndex( - prev.blocks, - (b) => b.type === 'ais.index' - ); + const lastIndexIdx = findLastIndex(prev.blocks, (bl) => { + return bl.type === 'ais.index'; + }); if (lastIndexIdx === -1) { result = { @@ -326,11 +347,11 @@ export function App({ config, initialExperience }: AppProps) { }; updated = { ...prev, - blocks: prev.blocks.map((block, i) => - i === lastIndexIdx + blocks: prev.blocks.map((block, i) => { + return i === lastIndexIdx ? { ...block, blocks: [...(block.blocks ?? []), newBlock] } - : block - ), + : block; + }), }; } } @@ -360,11 +381,11 @@ export function App({ config, initialExperience }: AppProps) { const withRemoved = deleteBlockAtPath(prev.blocks, fromPath); const updated = { ...prev, - blocks: withRemoved.map((b, i) => - i === toParentIndex - ? { ...b, blocks: [...(b.blocks ?? []), block] } - : b - ), + blocks: withRemoved.map((bl, idx) => { + return idx === toParentIndex + ? { ...bl, blocks: [...(bl.blocks ?? []), block] } + : bl; + }), }; scheduleRun(updated); @@ -389,7 +410,9 @@ export function App({ config, initialExperience }: AppProps) { setIsDirty(false); setSaveState('saved'); - setTimeout(() => setSaveState('idle'), 2000); + setTimeout(() => { + return setSaveState('idle'); + }, 2000); } catch (err) { setSaveState('idle'); setToast(err instanceof Error ? err.message : 'Failed to save.'); @@ -401,9 +424,13 @@ export function App({ config, initialExperience }: AppProps) { return; } - const timer = setTimeout(() => setToast(null), 4000); + const timer = setTimeout(() => { + return setToast(null); + }, 4000); - return () => clearTimeout(timer); + return () => { + return clearTimeout(timer); + }; }, [toast]); useEffect(() => { @@ -430,7 +457,9 @@ export function App({ config, initialExperience }: AppProps) { dirty={isDirty} saveState={saveState} open={isExpanded} - onClose={() => setIsExpanded(false)} + onClose={() => { + return setIsExpanded(false); + }} onSave={onSave} onParameterChange={onParameterChange} onCssVariableChange={onCssVariableChange} diff --git a/packages/toolbar/src/components/block-card.tsx b/packages/toolbar/src/components/block-card.tsx index 0d0b058..68c37fd 100644 --- a/packages/toolbar/src/components/block-card.tsx +++ b/packages/toolbar/src/components/block-card.tsx @@ -192,21 +192,23 @@ export function BlockCard({
diff --git a/packages/toolbar/src/components/index-block-group.tsx b/packages/toolbar/src/components/index-block-group.tsx index e3edb83..4625440 100644 --- a/packages/toolbar/src/components/index-block-group.tsx +++ b/packages/toolbar/src/components/index-block-group.tsx @@ -51,7 +51,9 @@ export function IndexBlockGroup({ onToggleExpand(groupKey)} + onClick={() => { + return onToggleExpand(groupKey); + }} aria-expanded={isOpen} >
@@ -80,8 +82,8 @@ export function IndexBlockGroup({ class="text-muted-foreground hover:text-destructive rounded p-0.5 transition-colors outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px]" aria-label="Delete index block" title="Delete index block" - onClick={(e) => { - e.stopPropagation(); + onClick={(event) => { + event.stopPropagation(); onDeleteBlock([parentIndex]); }} > @@ -121,50 +123,71 @@ export function IndexBlockGroup({ - onParameterChange([parentIndex], key, value) - } - onCssVariableChange={(key, value) => - onCssVariableChange([parentIndex], key, value) - } + onParameterChange={(key, value) => { + return onParameterChange([parentIndex], key, value); + }} + onCssVariableChange={(key, value) => { + return onCssVariableChange([parentIndex], key, value); + }} onPickElement={onPickElement} />
{/* Child widgets */} - {(block.blocks ?? []).map((child, childIndex) => ( - onToggleExpand(`${parentIndex}.${childIndex}`)} - onParameterChange={(key, value) => - onParameterChange([parentIndex, childIndex], key, value) - } - onCssVariableChange={(key, value) => - onCssVariableChange([parentIndex, childIndex], key, value) - } - onLocate={() => - onLocate( - child.parameters.container ?? '', - child.parameters.placement as string | undefined - ) - } - onDeleteBlock={() => onDeleteBlock([parentIndex, childIndex])} - onPickElement={onPickElement} - indexBlocks={indexBlocks} - parentIndex={parentIndex} - onMoveToIndex={(toParentIndex) => - onMoveBlock([parentIndex, childIndex], toParentIndex) - } - /> - ))} + {(block.blocks ?? []).map((child, childIndex) => { + return ( + { + return onToggleExpand(`${parentIndex}.${childIndex}`); + }} + onParameterChange={(key, value) => { + return onParameterChange( + [parentIndex, childIndex], + key, + value + ); + }} + onCssVariableChange={(key, value) => { + return onCssVariableChange( + [parentIndex, childIndex], + key, + value + ); + }} + onLocate={() => { + return onLocate( + child.parameters.container ?? '', + child.parameters.placement as string | undefined + ); + }} + onDeleteBlock={() => { + return onDeleteBlock([parentIndex, childIndex]); + }} + onPickElement={onPickElement} + indexBlocks={indexBlocks} + parentIndex={parentIndex} + onMoveToIndex={(toParentIndex) => { + return onMoveBlock( + [parentIndex, childIndex], + toParentIndex + ); + }} + /> + ); + })} {/* Scoped add widget */} onAddBlock(type, parentIndex)} - filter={(type) => type !== 'ais.index'} + onSelect={(type) => { + return onAddBlock(type, parentIndex); + }} + filter={(type) => { + return type !== 'ais.index'; + }} />
diff --git a/packages/toolbar/src/components/panel.tsx b/packages/toolbar/src/components/panel.tsx index 537dbbf..4b3af33 100644 --- a/packages/toolbar/src/components/panel.tsx +++ b/packages/toolbar/src/components/panel.tsx @@ -50,21 +50,21 @@ export function Panel({ const [expandedBlock, setExpandedBlock] = useState(null); const prevBlocksRef = useRef(experience.blocks); - const widgetCount = experience.blocks.reduce( - (count, block) => - block.type === 'ais.index' - ? count + (block.blocks?.length ?? 0) - : count + 1, - 0 - ); + const widgetCount = experience.blocks.reduce((count, block) => { + return block.type === 'ais.index' + ? count + (block.blocks?.length ?? 0) + : count + 1; + }, 0); - const indexBlocks = useMemo( - () => - experience.blocks - .map((block, index) => ({ index, block })) - .filter(({ block }) => block.type === 'ais.index'), - [experience.blocks] - ); + const indexBlocks = useMemo(() => { + return experience.blocks + .map((block, index) => { + return { index, block }; + }) + .filter(({ block }) => { + return block.type === 'ais.index'; + }); + }, [experience.blocks]); useEffect(() => { const prev = prevBlocksRef.current; @@ -216,7 +216,9 @@ export function Panel({ setTab('manual')} + onClick={() => { + return setTab('manual'); + }} > Manual - setTab('ai')}> + { + return setTab('ai'); + }} + > handleToggleExpand(String(index))} - onParameterChange={(key, value) => - onParameterChange([index], key, value) - } - onCssVariableChange={(key, value) => - onCssVariableChange([index], key, value) - } - onLocate={() => - onLocate( + onToggle={() => { + return handleToggleExpand(String(index)); + }} + onParameterChange={(key, value) => { + return onParameterChange([index], key, value); + }} + onCssVariableChange={(key, value) => { + return onCssVariableChange([index], key, value); + }} + onLocate={() => { + return onLocate( block.parameters.container ?? '', block.parameters.placement as string | undefined - ) - } - onDeleteBlock={() => onDeleteBlock([index])} + ); + }} + onDeleteBlock={() => { + return onDeleteBlock([index]); + }} onPickElement={onPickElement} /> ); diff --git a/packages/toolbar/src/widget-types.tsx b/packages/toolbar/src/widget-types.tsx index c35357b..338cefb 100644 --- a/packages/toolbar/src/widget-types.tsx +++ b/packages/toolbar/src/widget-types.tsx @@ -257,6 +257,7 @@ export const WIDGET_TYPES: Record = { showSuggestions: { type: 'object', label: 'Suggestions', + // oxlint-disable-next-line id-length defaultValue: { indexName: '', searchPageUrl: '', q: 'q' }, fields: [ { key: 'indexName', label: 'Index Name' }, @@ -333,7 +334,7 @@ export const WIDGET_TYPES: Record = { }, 'ais.searchBox': { label: 'Search Box', - enabled: true, + enabled: false, icon: SEARCH_ICON, defaultParameters: { container: '', From 454516c8ddbeb88f0ab4c9cfb96db77f64b0aed8 Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:33:08 +0100 Subject: [PATCH 3/4] fix(toolbar): align index-block-group with JSX.Element icon type and fix stale closure - Render `icon` as JSX element (`{icon}`) instead of component (``) - Pass `blocks` as parameter to `updateCssVariablesOnPage` to avoid stale closure over `experience` state Co-Authored-By: Claude Opus 4.6 --- packages/toolbar/src/components/app.tsx | 20 ++++++++++--------- .../src/components/index-block-group.tsx | 6 +++--- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/toolbar/src/components/app.tsx b/packages/toolbar/src/components/app.tsx index 329faab..a4922ad 100644 --- a/packages/toolbar/src/components/app.tsx +++ b/packages/toolbar/src/components/app.tsx @@ -99,7 +99,12 @@ export function App({ config, initialExperience }: AppProps) { }, []); const updateCssVariablesOnPage = useCallback( - (path: BlockPath, key: string, value: string) => { + ( + blocks: ExperienceApiBlock[], + path: BlockPath, + key: string, + value: string + ) => { const existingStyle = document.querySelector( 'style[data-algolia-experiences-toolbar]' ); @@ -112,11 +117,8 @@ export function App({ config, initialExperience }: AppProps) { const allVars: Record = {}; - const collectVars = ( - blocks: ExperienceApiBlock[], - parentIdx?: number - ) => { - blocks.forEach((block, i) => { + const collectVars = (items: ExperienceApiBlock[], parentIdx?: number) => { + items.forEach((block, i) => { const currentPath: BlockPath = parentIdx !== undefined ? [parentIdx, i] : [i]; const vars = block.parameters.cssVariables ?? {}; @@ -140,7 +142,7 @@ export function App({ config, initialExperience }: AppProps) { }); }; - collectVars(experience.blocks); + collectVars(blocks); style.textContent = `:root { ${Object.entries(allVars) .map(([prop, val]) => { @@ -183,9 +185,9 @@ export function App({ config, initialExperience }: AppProps) { const onCssVariableChange = useCallback( (path: BlockPath, key: string, value: string) => { - updateCssVariablesOnPage(path, key, value); - setExperience((prev) => { + updateCssVariablesOnPage(prev.blocks, path, key, value); + return { ...prev, blocks: updateBlockAtPath(prev.blocks, path, (block) => { diff --git a/packages/toolbar/src/components/index-block-group.tsx b/packages/toolbar/src/components/index-block-group.tsx index 4625440..50f1136 100644 --- a/packages/toolbar/src/components/index-block-group.tsx +++ b/packages/toolbar/src/components/index-block-group.tsx @@ -44,7 +44,7 @@ export function IndexBlockGroup({ const isOpen = expandedBlock?.startsWith(`${groupKey}.`) || expandedBlock === groupKey; const indexName = (block.parameters.indexName as string) || ''; - const Icon = WIDGET_TYPES['ais.index']?.icon; + const icon = WIDGET_TYPES['ais.index']?.icon; return (
@@ -58,9 +58,9 @@ export function IndexBlockGroup({ >
- {Icon && ( + {icon && (
- + {icon}
)} Index From 1f687c0ee68ab21543c3377a584bee8038144371 Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:18:03 +0100 Subject: [PATCH 4/4] test(toolbar): add missing test coverage for ais.index tools Add tests for edge cases discovered during audit: - describeExperience: empty index blocks, unnamed index blocks - add_widget: container-in-parameters fallback, ais.index without params, ais.index doesn't require container - remove_widget: malformed path rejection - move_widget: non-existent nested path rejection Also fix Prettier formatting in toolbar.css. Co-Authored-By: Claude Opus 4.6 --- packages/toolbar/__tests__/ai-tools.test.ts | 144 ++++++++++++++++++++ packages/toolbar/src/toolbar.css | 3 +- 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index 95faed7..e766100 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -202,6 +202,38 @@ describe('describeExperience', () => { const result = describeExperience(experience); expect(result).toContain('[0] Chat (ais.chat) [body]'); }); + + it('shows empty index block with "(empty)" marker', () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [], + }, + ], + indexName: '', + }; + + const result = describeExperience(experience); + expect(result).toContain('(empty)'); + }); + + it('shows "(unnamed)" when index block has no indexName', () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.index', + parameters: {}, + blocks: [], + }, + ], + indexName: '', + }; + + const result = describeExperience(experience); + expect(result).toContain('(unnamed)'); + }); }); describe('getTools', () => { @@ -463,6 +495,25 @@ describe('getTools', () => { }); }); + it('accepts container inside parameters instead of top-level', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ success: true }); + }); + it('adds ais.index widget at top level', async () => { const experience: ExperienceApiResponse = { blocks: [ @@ -489,6 +540,42 @@ describe('getTools', () => { }); }); + it('adds ais.index widget without parameters', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { type: 'ais.index' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.index', undefined); + expect(result).toMatchObject({ + success: true, + type: 'ais.index', + }); + }); + + it('does not require container for ais.index widgets', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { type: 'ais.index' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ success: true }); + }); + it('adds index-dependent widget and passes target_index', async () => { const experience: ExperienceApiResponse = { blocks: [ @@ -1063,6 +1150,32 @@ describe('getTools', () => { }); }); + it('rejects malformed paths', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const malformedPaths = ['', '.', 'a.b', '0.0.0', '-1']; + + for (const path of malformedPaths) { + const result = await tools.remove_widget.execute!( + { path }, + { toolCallId: 'tc1', messages: [] } + ); + expect(result).toMatchObject({ success: false }); + } + + expect(callbacks.onDeleteBlock).not.toHaveBeenCalled(); + }); + it('returns a clear error for empty experience', async () => { const callbacks = createCallbacks({ blocks: [], indexName: '' }); const tools = getTools(callbacks); @@ -1168,6 +1281,37 @@ describe('getTools', () => { expect(callbacks.onMoveBlock).not.toHaveBeenCalled(); }); + it('rejects non-existent nested path', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [], + }, + { + type: 'ais.index', + parameters: { indexName: 'articles' }, + blocks: [], + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.move_widget.execute!( + { path: '0.5', to_index: 1 }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('Invalid path'), + }); + expect(callbacks.onMoveBlock).not.toHaveBeenCalled(); + }); + it('rejects non-index target', async () => { const experience: ExperienceApiResponse = { blocks: [ diff --git a/packages/toolbar/src/toolbar.css b/packages/toolbar/src/toolbar.css index a3d36f2..5408d7e 100644 --- a/packages/toolbar/src/toolbar.css +++ b/packages/toolbar/src/toolbar.css @@ -127,7 +127,8 @@ background: none; padding: 0; } - ul, ol { + ul, + ol { margin: 0.25em 0; padding-left: 1.25em; }