diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index 54eebff..e766100 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -10,13 +10,18 @@ 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(() => { + return { path: addBlockPath, indexCreated }; + }), onParameterChange: vi.fn(), onCssVariableChange: vi.fn(), onDeleteBlock: vi.fn(), + onMoveBlock: vi.fn(), getExperience: vi.fn(() => { return experience; }), @@ -30,6 +35,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 +55,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 +89,7 @@ describe('describeExperience', () => { }, }, ], + indexName: '', }; const result = describeExperience(experience); @@ -89,6 +101,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 +131,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,21 +196,55 @@ describe('describeExperience', () => { parameters: { agentId: 'agent-1' }, }, ], + indexName: '', }; 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', () => { - 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 +257,7 @@ describe('getTools', () => { parameters: { container: '#search' }, }, ], + indexName: '', }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); @@ -235,8 +273,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 +291,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 +329,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 +366,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 +391,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 +415,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 +448,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 +472,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); @@ -422,7 +496,10 @@ describe('getTools', () => { }); it('accepts container inside parameters instead of top-level', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; const callbacks = createCallbacks(experience); const tools = getTools(callbacks); @@ -437,113 +514,197 @@ describe('getTools', () => { expect(result).toMatchObject({ success: true }); }); - it('skips container and placement inside parameters to avoid duplication', 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); - await tools.add_widget.execute!( - { - type: 'ais.autocomplete', - container: '#search', - placement: 'before', - parameters: { - container: '#other', - placement: 'after', - showRecent: true, - }, - }, + const result = await tools.add_widget.execute!( + { type: 'ais.index', parameters: { indexName: 'products' } }, { 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(callbacks.onAddBlock).toHaveBeenCalledWith('ais.index', undefined); + expect(result).toMatchObject({ + success: true, + type: 'ais.index', + }); + }); + + 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('still calls onAddBlock even when container validation fails', async () => { - const experience: ExperienceApiResponse = { blocks: [] }; + it('does not require container for ais.index widgets', 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.index' }, { toolCallId: 'tc1', messages: [] } ); - // onAddBlock is called before validation — this documents current behavior - expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.autocomplete'); + expect(result).toMatchObject({ success: true }); }); - it('computes the correct index for non-empty experiences', async () => { + it('adds index-dependent widget and passes target_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]); const tools = getTools(callbacks); const result = await tools.add_widget.execute!( - { type: 'ais.chat', container: '#chat' }, - { toolCallId: 'tc2', messages: [] } + { + type: 'ais.autocomplete', + container: '#search', + target_index: 0, + }, + { toolCallId: 'tc1', messages: [] } ); - expect(result).toMatchObject({ index: 1 }); + expect(callbacks.onAddBlock).toHaveBeenCalledWith('ais.autocomplete', 0); + expect(result).toMatchObject({ + success: true, + path: '0.0', + type: 'ais.autocomplete', + }); }); - }); - describe('edit_widget', () => { - it('validates index bounds', async () => { + it('returns error when target_index is not an ais.index block', async () => { const experience: ExperienceApiResponse = { blocks: [ { - type: 'ais.autocomplete', - parameters: { container: '#search' }, + type: 'ais.chat', + parameters: { placement: 'body', agentId: '' }, }, ], + 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.add_widget.execute!( + { + type: 'ais.autocomplete', + container: '#search', + target_index: 0, + }, { toolCallId: 'tc1', messages: [] } ); - expect(result).toMatchObject({ success: false }); - expect(callbacks.onParameterChange).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('not an ais.index block'), + }); + expect(callbacks.onAddBlock).not.toHaveBeenCalled(); }); - it('rejects negative index', async () => { + it('returns error when target_index is out of bounds', async () => { + const experience: ExperienceApiResponse = { + blocks: [], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { + type: 'ais.autocomplete', + 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.autocomplete', container: '#search' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + note: expect.stringContaining('auto-created'), + }); + }); + + it('does not include note when added to existing index', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [], + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience, [0, 0], false); + const tools = getTools(callbacks); + + const result = await tools.add_widget.execute!( + { type: 'ais.autocomplete', container: '#search' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ success: true }); + expect(result).not.toHaveProperty('note'); + }); + }); + + describe('edit_widget', () => { + it('validates path bounds', async () => { const experience: ExperienceApiResponse = { blocks: [ { @@ -551,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: -1, parameters: { container: '#new' } }, + { path: '5', parameters: { container: '#new' } }, { toolCallId: 'tc1', messages: [] } ); @@ -572,12 +734,13 @@ describe('getTools', () => { parameters: { container: '#search', showRecent: false }, }, ], + 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', parameters: { container: '#new', showRecent: true } }, { toolCallId: 'tc1', messages: [] } ); @@ -586,17 +749,52 @@ describe('getTools', () => { applied: expect.arrayContaining(['container', 'showRecent']), }); expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, + [0], 'container', '#new' ); expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, + [0], 'showRecent', true ); }); + it('edits nested widgets by path', 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.edit_widget.execute!( + { path: '0.0', parameters: { container: '#new' } }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(result).toMatchObject({ + success: true, + applied: ['container'], + }); + expect(callbacks.onParameterChange).toHaveBeenCalledWith( + [0, 0], + 'container', + '#new' + ); + }); + it('reports rejected keys for disallowed parameters', async () => { const experience: ExperienceApiResponse = { blocks: [ @@ -605,12 +803,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 +821,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 +869,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 +884,7 @@ describe('getTools', () => { applied: ['placement'], }); expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, + [0], 'placement', 'after' ); @@ -675,13 +901,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 +918,7 @@ describe('getTools', () => { expect(result).toMatchObject({ success: true }); expect(callbacks.onCssVariableChange).toHaveBeenCalledWith( - 0, + [0], 'primary-color-rgb', '#ff0000' ); @@ -708,13 +935,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 +974,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 +998,12 @@ describe('getTools', () => { ]), }); expect(callbacks.onParameterChange).toHaveBeenCalledWith( - 0, + [0], 'showRecent', true ); expect(callbacks.onCssVariableChange).toHaveBeenCalledWith( - 0, + [0], 'primary-color-rgb', '#ff0000' ); @@ -788,12 +1017,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 +1034,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,23 +1070,87 @@ 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.index', + }); + }); + + it('removes a nested child widget', async () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.index', + parameters: { indexName: 'products' }, + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + { + type: 'ais.autocomplete', + parameters: { container: '#box' }, + }, + ], + }, + ], + indexName: '', + }; + const callbacks = createCallbacks(experience); + const tools = getTools(callbacks); + + const result = await tools.remove_widget.execute!( + { path: '0.1' }, + { toolCallId: 'tc1', messages: [] } + ); + + expect(callbacks.onDeleteBlock).toHaveBeenCalledWith([0, 1]); expect(result).toMatchObject({ success: true, removedType: 'ais.autocomplete', }); }); - it('validates index bounds', async () => { + it('rejects malformed paths', async () => { const experience: ExperienceApiResponse = { blocks: [ { @@ -866,20 +1158,78 @@ describe('getTools', () => { 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); + const result = await tools.remove_widget.execute!( - { index: 3 }, + { path: '0' }, { toolCallId: 'tc1', messages: [] } ); - expect(result).toMatchObject({ success: false }); - expect(callbacks.onDeleteBlock).not.toHaveBeenCalled(); + 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 negative index', async () => { + it('rejects top-level paths', async () => { const experience: ExperienceApiResponse = { blocks: [ { @@ -887,59 +1237,112 @@ 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-existent nested path', async () => { const experience: ExperienceApiResponse = { blocks: [ { - type: 'ais.autocomplete', - parameters: { container: '#search' }, + 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: [ + { + 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 +1385,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 +1399,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 +1409,7 @@ describe('describeToolAction', () => { expect( describeToolAction( 'edit_widget', - { index: 2 }, + { path: '2' }, { success: true, applied: [], rejected: ['unknownParam'] } ) ).toBe('Edited widget 2'); @@ -1012,12 +1419,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..272fb37 100644 --- a/packages/toolbar/src/ai/tools.ts +++ b/packages/toolbar/src/ai/tools.ts @@ -1,14 +1,21 @@ 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; }; @@ -38,6 +45,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,6 +71,63 @@ 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(([param]) => { + return param !== 'container' && param !== 'placement'; + }) + .map(([param, val]) => { + return `${param}=${JSON.stringify(val)}`; + }) + .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.'; @@ -68,39 +135,36 @@ export function describeExperience(experience: ExperienceApiResponse): string { 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(', '); - - const paramsSuffix = params ? `: ${params}` : ''; - - return `[${index}] ${label} (${block.type}) [${placementDesc}]${paramsSuffix}`; + 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((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; +} + +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( toolName: string, input: Record | undefined, @@ -124,25 +188,23 @@ 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) { @@ -154,7 +216,7 @@ export function getTools(callbacks: ToolCallbacks) { 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 +226,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 +248,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 +284,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 +329,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 +339,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 +395,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({ - index: z.number().describe('The index of the widget to remove'), + path: z + .string() + .describe('The path of the widget to remove (e.g., "0", "1.0")'), }), - execute: async ({ index }) => { + 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({ + 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 ({ 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..f90d5f5 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,47 @@ export function AddWidgetPopover({ onSelect }: AddWidgetPopoverProps) {
- {Object.entries(WIDGET_TYPES).map(([type, config]) => { - if (!config.enabled) { + {Object.entries(WIDGET_TYPES) + .filter(([type, config]) => { + return !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..23ee27e 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,6 +181,7 @@ export function AiChat({ onParameterChange, onCssVariableChange, onDeleteBlock, + onMoveBlock, }: AiChatProps) { const apiKey = window.__OPENAI_API_KEY__; const [model, setModel] = useState(() => { @@ -203,6 +209,8 @@ export function AiChat({ onCssVariableChangeRef.current = onCssVariableChange; const onDeleteBlockRef = useRef(onDeleteBlock); onDeleteBlockRef.current = onDeleteBlock; + const onMoveBlockRef = useRef(onMoveBlock); + onMoveBlockRef.current = onMoveBlock; const callbacks: ToolCallbacks = useMemo(() => { return { @@ -218,6 +226,9 @@ export function AiChat({ onDeleteBlock: (...args) => { return onDeleteBlockRef.current(...args); }, + onMoveBlock: (...args) => { + return onMoveBlockRef.current(...args); + }, getExperience: () => { return experienceRef.current; }, diff --git a/packages/toolbar/src/components/app.tsx b/packages/toolbar/src/components/app.tsx index e0e3be1..a4922ad 100644 --- a/packages/toolbar/src/components/app.tsx +++ b/packages/toolbar/src/components/app.tsx @@ -3,7 +3,10 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { saveExperience } from '../api'; import { useElementPicker } from '../hooks/use-element-picker'; import type { + AddBlockResult, + BlockPath, Environment, + ExperienceApiBlock, ExperienceApiResponse, SaveState, ToolbarConfig, @@ -22,6 +25,59 @@ const DASHBOARD_BASE: Record = { 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; + } + return -1; +} + +function updateBlockAtPath( + blocks: ExperienceApiBlock[], + path: BlockPath, + updater: (block: ExperienceApiBlock) => ExperienceApiBlock +): ExperienceApiBlock[] { + if (path.length === 1) { + return blocks.map((block, idx) => { + return idx === path[0] ? updater(block) : block; + }); + } + const [parentIdx, childIdx] = path; + return blocks.map((block, idx) => { + return idx === parentIdx + ? { + ...block, + blocks: (block.blocks ?? []).map((child, ci) => { + return ci === childIdx ? updater(child) : child; + }), + } + : block; + }); +} + +function deleteBlockAtPath( + blocks: ExperienceApiBlock[], + path: BlockPath +): ExperienceApiBlock[] { + if (path.length === 1) { + return blocks.filter((_, idx) => { + return idx !== path[0]; + }); + } + const [parentIdx, childIdx] = path; + return blocks.map((block, idx) => { + return idx === parentIdx + ? { + ...block, + blocks: (block.blocks ?? []).filter((_, ci) => { + return ci !== childIdx; + }), + } + : block; + }); +} + export function App({ config, initialExperience }: AppProps) { const [isExpanded, setIsExpanded] = useState(false); const [experience, setExperience] = useState(initialExperience); @@ -44,8 +100,8 @@ export function App({ config, initialExperience }: AppProps) { const updateCssVariablesOnPage = useCallback( ( - blocks: ExperienceApiResponse['blocks'], - blockIndex: number, + blocks: ExperienceApiBlock[], + path: BlockPath, key: string, value: string ) => { @@ -59,24 +115,45 @@ 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 = (items: ExperienceApiBlock[], parentIdx?: number) => { + items.forEach((block, i) => { + const currentPath: BlockPath = + parentIdx !== undefined ? [parentIdx, i] : [i]; + const vars = block.parameters.cssVariables ?? {}; + const isTarget = + currentPath.length === path.length && + currentPath.every((val, idx) => { + return val === path[idx]; + }); + + Object.entries(vars).forEach(([varName, varValue]) => { + if (isTarget && varName === key) { + allVars[`--ais-${varName}`] = value; + } else { + allVars[`--ais-${varName}`] = varValue; + } + }); + + if (block.blocks) { + collectVars(block.blocks, i); + } + }); + }; + + collectVars(blocks); + + style.textContent = `:root { ${Object.entries(allVars) + .map(([prop, val]) => { + return `${prop}: ${val}`; + }) + .join('; ')} }`; }, [] ); - const onPillClick = () => { + const handlePillClick = () => { if (adminApiKey) { setIsExpanded(true); } else { @@ -85,17 +162,15 @@ 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) => { + return { + ...block, + parameters: { ...block.parameters, [key]: value }, + }; }), }; @@ -109,25 +184,23 @@ export function App({ config, initialExperience }: AppProps) { ); const onCssVariableChange = useCallback( - (index: number, key: string, value: string) => { + (path: BlockPath, key: string, value: string) => { setExperience((prev) => { - updateCssVariablesOnPage(prev.blocks, index, key, value); + updateCssVariablesOnPage(prev.blocks, path, key, value); return { ...prev, - blocks: prev.blocks.map((block, blockIdx) => { - return blockIdx === index - ? { - ...block, - parameters: { - ...block.parameters, - cssVariables: { - ...block.parameters.cssVariables, - [key]: value, - }, - }, - } - : block; + blocks: updateBlockAtPath(prev.blocks, path, (block) => { + return { + ...block, + parameters: { + ...block.parameters, + cssVariables: { + ...block.parameters.cssVariables, + [key]: value, + }, + }, + }; }), }; }); @@ -194,13 +267,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 +284,117 @@ 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) => { + return i === targetParentIndex + ? { ...block, blocks: [...(block.blocks ?? []), newBlock] } + : block; + }), + }; + } else { + const lastIndexIdx = findLastIndex(prev.blocks, (bl) => { + return bl.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) => { + return 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((bl, idx) => { + return idx === toParentIndex + ? { ...bl, blocks: [...(bl.blocks ?? []), block] } + : bl; + }), + }; + + scheduleRun(updated); + + return updated; + }); + setIsDirty(true); }, [scheduleRun] ); @@ -309,9 +468,14 @@ export function App({ config, initialExperience }: AppProps) { 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,37 @@ 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..50f1136 --- /dev/null +++ b/packages/toolbar/src/components/index-block-group.tsx @@ -0,0 +1,197 @@ +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 ( +
+ + { + return onToggleExpand(groupKey); + }} + aria-expanded={isOpen} + > +
+
+ {icon && ( +
+ {icon} +
+ )} + Index + {indexName && ( + + {indexName} + + )} +
+
+
+ +
+ + + +
+
+
+ +
+ {/* Index block parameters */} +
+ { + return onParameterChange([parentIndex], key, value); + }} + onCssVariableChange={(key, value) => { + return onCssVariableChange([parentIndex], key, value); + }} + onPickElement={onPickElement} + /> +
+ + {/* Child widgets */} + {(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 */} + { + 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 13cabce..4b3af33 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) => { + return block.type === 'ais.index' + ? count + (block.blocks?.length ?? 0) + : count + 1; + }, 0); + + 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; + 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

@@ -199,31 +266,49 @@ export function Panel({
{experience.blocks.map((block, index) => { + if (block.type === 'ais.index') { + return ( + + ); + } + return ( { - return setExpandedBlock( - expandedBlock === index ? null : index - ); + return handleToggleExpand(String(index)); }} onParameterChange={(key, value) => { - return onParameterChange(index, key, value); + return onParameterChange([index], key, value); }} onCssVariableChange={(key, value) => { - return onCssVariableChange(index, key, value); + return onCssVariableChange([index], key, value); }} onLocate={() => { return onLocate( - block.parameters.container, + block.parameters.container ?? '', block.parameters.placement as string | undefined ); }} onDeleteBlock={() => { - return onDeleteBlock(index); + return onDeleteBlock([index]); }} onPickElement={onPickElement} /> @@ -247,6 +332,7 @@ export function Panel({ onParameterChange={onParameterChange} onCssVariableChange={onCssVariableChange} onDeleteBlock={onDeleteBlock} + onMoveBlock={onMoveBlock} />
)} 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; } 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..338cefb 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: '', @@ -266,6 +285,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 +302,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,