From 43e9d36bb91522fbcddd66bd61d89b2d65fa5ac2 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 31 Mar 2026 10:21:33 +0200 Subject: [PATCH 01/23] WIP: streaming tools --- examples/react/getting-started/src/App.tsx | 213 ++++++++++++++++++ .../src/components/chat/types.ts | 2 + .../chat/__tests__/connectChat-test.ts | 59 +++++ .../src/lib/ai-lite/abstract-chat.ts | 156 ++++++++++++- .../instantsearch.js/src/lib/ai-lite/types.ts | 2 + .../widgets/chat/tools/SearchIndexTool.tsx | 37 +++ 6 files changed, 467 insertions(+), 2 deletions(-) diff --git a/examples/react/getting-started/src/App.tsx b/examples/react/getting-started/src/App.tsx index cef7ca85bc4..706e3b8a249 100644 --- a/examples/react/getting-started/src/App.tsx +++ b/examples/react/getting-started/src/App.tsx @@ -27,6 +27,216 @@ const searchClient = algoliasearch( '6be0576ff61c053d5f9a3225e2a90f76' ); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const TOOL_OUTPUT_STEP_DELAY_MS = 900; + +const normalizeToolOutputEvent = (event: any): any => { + if (event?.type !== 'tool-output-available' || !event.output) { + return event; + } + + const output = event.output as Record; + + if (Array.isArray(output.hits)) { + return event; + } + + if ( + Array.isArray(output.results) && + output.results.length > 0 && + Array.isArray(output.results[0]?.hits) + ) { + const hits = output.results[0].hits; + return { + ...event, + output: { + ...output, + hits, + nbHits: output.nbHits ?? hits.length, + }, + }; + } + + return event; +}; + +const getProgressiveToolOutputEvents = (event: any): any[] => { + if (event?.type !== 'tool-output-available' || !event.output) { + return []; + } + + const output = event.output as Record; + + if (Array.isArray(output.hits) && output.hits.length > 1) { + return output.hits.slice(0, -1).map((_: any, index: number) => ({ + ...event, + preliminary: true, + output: { + ...output, + hits: output.hits.slice(0, index + 1), + }, + })); + } + + if (Array.isArray(output.results) && output.results.length > 0) { + const firstResultWithHitsIndex = output.results.findIndex( + (result: any) => Array.isArray(result?.hits) && result.hits.length > 1 + ); + + if (firstResultWithHitsIndex === -1) { + return []; + } + + const hits = output.results[firstResultWithHitsIndex].hits; + return hits.slice(0, -1).map((_: any, index: number) => ({ + ...event, + preliminary: true, + output: { + ...output, + results: output.results.map((result: any, resultIndex: number) => + resultIndex === firstResultWithHitsIndex + ? { + ...result, + hits: hits.slice(0, index + 1), + } + : result + ), + }, + })); + } + + return []; +}; + +const delayedChatFetch: typeof fetch = async (input, init) => { + const response = await fetch(input, init); + + const isEventStream = + response.headers.get('content-type')?.includes('text/event-stream') ?? + false; + + if (!response.body || !isEventStream) { + return response; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let hasInjectedOutputDelay = false; + let pendingText = ''; + + const delayedBody = new ReadableStream({ + async start(controller) { + const emitLine = async (line: string): Promise => { + if (!line.startsWith('data: ')) { + controller.enqueue(encoder.encode(line)); + return; + } + + const payload = line.slice(6).trim(); + if (payload === '[DONE]') { + controller.enqueue(encoder.encode(line)); + return; + } + + let parsedEvent: any; + try { + parsedEvent = JSON.parse(payload); + } catch { + controller.enqueue(encoder.encode(line)); + return; + } + + if ( + !hasInjectedOutputDelay && + parsedEvent?.type === 'tool-output-available' + ) { + const normalizedToolOutputEvent = + normalizeToolOutputEvent(parsedEvent); + const progressiveEvents = getProgressiveToolOutputEvents( + normalizedToolOutputEvent + ); + + if (progressiveEvents.length > 0) { + hasInjectedOutputDelay = true; + + const emitProgressiveOutput = (index: number): Promise => { + if (index >= progressiveEvents.length) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify(normalizedToolOutputEvent)}\n` + ) + ); + controller.enqueue(encoder.encode('\n')); + return Promise.resolve(); + } + + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify(progressiveEvents[index])}\n` + ) + ); + controller.enqueue(encoder.encode('\n')); + + return sleep(TOOL_OUTPUT_STEP_DELAY_MS).then(() => + emitProgressiveOutput(index + 1) + ); + }; + + return emitProgressiveOutput(0); + } + } + + controller.enqueue(encoder.encode(line)); + }; + + const processPendingLines = (): Promise => { + const lineBreakIndex = pendingText.indexOf('\n'); + if (lineBreakIndex === -1) { + return Promise.resolve(); + } + + const line = pendingText.slice(0, lineBreakIndex + 1); + pendingText = pendingText.slice(lineBreakIndex + 1); + + return emitLine(line).then(() => processPendingLines()); + }; + + const pump = (): Promise => + reader.read().then(({ done, value }) => { + if (done) { + if (pendingText) { + controller.enqueue(encoder.encode(pendingText)); + } + controller.close(); + return Promise.resolve(); + } + + if (!value) { + return pump(); + } + + pendingText += decoder.decode(value, { stream: true }); + return processPendingLines().then(() => pump()); + }); + + try { + await pump(); + } catch (error) { + controller.error(error); + } finally { + reader.releaseLock(); + } + }, + }); + + return new Response(delayedBody, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +}; + export function App() { return (
@@ -94,6 +304,9 @@ export function App() {
diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index e1c22139831..558aabf4f31 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -121,6 +121,7 @@ export type ToolUIPart = ValueOf<{ | { state: 'input-streaming'; input: DeepPartial | undefined; + rawInput?: string; providerExecuted?: boolean; output?: never; errorText?: never; @@ -165,6 +166,7 @@ export type DynamicToolUIPart = { | { state: 'input-streaming'; input: unknown | undefined; + rawInput?: string; output?: never; errorText?: never; } diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index 6aaa828df54..4b96c248648 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -443,6 +443,65 @@ data: [DONE]`, ); }); }); + + it('streams tool input parts from tool-input-delta without tool-input-available', async () => { + const { widget } = getInitializedWidget({ + agentId: undefined, + transport: { + fetch: () => + Promise.resolve( + new Response( + `data: {"type": "start", "messageId": "test-id"} + +data: {"type": "start-step"} + +data: {"type": "tool-input-start", "toolCallId": "call_1", "toolName": "displayResults"} + +data: {"type": "tool-input-delta", "toolCallId": "call_1", "toolName": "displayResults", "inputDelta": "{}"} + +data: {"type": "finish-step"} + +data: {"type": "finish"} + +data: [DONE]`, + { + headers: { 'Content-Type': 'text/event-stream' }, + } + ) + ), + }, + }); + + const { chatInstance } = widget; + + await chatInstance.sendMessage({ + id: 'message-id', + role: 'user', + parts: [{ type: 'text', text: 'Show me product groups' }], + }); + + await waitFor(() => { + const lastMessage = chatInstance.messages[chatInstance.messages.length - 1]; + expect(lastMessage?.role).toBe('assistant'); + + const toolPart = lastMessage?.parts.find( + (part) => + 'type' in part && + part.type === 'tool-displayResults' && + 'toolCallId' in part && + part.toolCallId === 'call_1' + ) as + | { + state: string; + rawInput?: string; + input?: Record; + } + | undefined; + + expect(toolPart?.state).toBe('input-streaming'); + expect(toolPart?.input).toEqual({}); + }); + }); }); describe('transport configuration', () => { diff --git a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts index d31307101e2..fda337b2700 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts @@ -26,6 +26,96 @@ type ActiveResponse = { stream?: ReadableStream; }; +const tryParseJson = (value: string): unknown | undefined => { + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +const repairPartialJson = (value: string): string => { + let repaired = value.trim(); + + if (!repaired) { + return repaired; + } + + let inString = false; + let isEscaped = false; + const stack: Array<'{' | '['> = []; + + for (let index = 0; index < repaired.length; index++) { + const char = repaired[index]; + if (inString) { + if (isEscaped) { + isEscaped = false; + } else if (char === '\\') { + isEscaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === '{' || char === '[') { + stack.push(char); + continue; + } + + if (char === '}' && stack[stack.length - 1] === '{') { + stack.pop(); + continue; + } + + if (char === ']' && stack[stack.length - 1] === '[') { + stack.pop(); + } + } + + if (inString && !isEscaped) { + repaired += '"'; + } + + repaired = repaired.replace(/,\s*$/u, ''); + + if (stack.length > 0) { + repaired += stack + .reverse() + .map((opening) => (opening === '{' ? '}' : ']')) + .join(''); + } + + return repaired.replace(/,\s*([}\]])/gu, '$1'); +}; + +const parseToolInputDelta = ( + accumulatedRawInput: string, + fallbackInput: unknown +): unknown => { + const normalized = accumulatedRawInput.trim(); + if (!normalized) { + return fallbackInput; + } + + const directParsed = tryParseJson(normalized); + if (directParsed !== undefined) { + return directParsed; + } + + const repairedParsed = tryParseJson(repairPartialJson(normalized)); + if (repairedParsed !== undefined) { + return repairedParsed; + } + + return fallbackInput; +}; + /** * Abstract base class for chat implementations. */ @@ -437,6 +527,7 @@ export abstract class AbstractChat { // Track current text/reasoning part state let currentTextPartId: string | undefined; let currentReasoningPartId: string | undefined; + const toolRawInputByCallId: Record = {}; // Promise chain for handling tool calls that return promises let pendingToolCall: Promise = Promise.resolve(); @@ -623,11 +714,21 @@ export abstract class AbstractChat { case 'tool-input-start': { if (!currentMessage) break; + const initialRawInput = + typeof chunk.input === 'string' + ? chunk.input + : chunk.input !== undefined + ? JSON.stringify(chunk.input) + : ''; + + toolRawInputByCallId[chunk.toolCallId] = initialRawInput; + const toolPart = { type: `tool-${chunk.toolName}` as const, toolCallId: chunk.toolCallId, state: 'input-streaming' as const, input: chunk.input, + rawInput: initialRawInput || undefined, providerExecuted: chunk.providerExecuted, }; @@ -640,14 +741,61 @@ export abstract class AbstractChat { } case 'tool-input-delta': { - // Tool input streaming - we'd need to parse partial JSON - // For now, we'll wait for tool-input-available + if (!currentMessage) break; + + const toolIndex = currentMessage.parts.findIndex( + (p) => 'toolCallId' in p && p.toolCallId === chunk.toolCallId + ); + + const existingPart = + toolIndex >= 0 + ? (currentMessage.parts[toolIndex] as any) + : null; + const previousRawInput = + existingPart?.rawInput ?? + toolRawInputByCallId[chunk.toolCallId] ?? + ''; + const nextRawInput = `${previousRawInput}${chunk.inputDelta}`; + toolRawInputByCallId[chunk.toolCallId] = nextRawInput; + + const parsedInput = parseToolInputDelta( + nextRawInput, + existingPart?.input + ); + + const nextToolPart = { + ...(existingPart ?? { + type: `tool-${chunk.toolName}` as const, + toolCallId: chunk.toolCallId, + }), + state: 'input-streaming' as const, + input: parsedInput, + rawInput: nextRawInput, + }; + + if (toolIndex >= 0) { + const updatedParts = [...currentMessage.parts]; + updatedParts[toolIndex] = nextToolPart; + currentMessage = { + ...currentMessage, + parts: updatedParts, + } as TUIMessage; + } else { + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, nextToolPart], + } as TUIMessage; + } + + this.state.replaceMessage(currentMessageIndex, currentMessage); break; } case 'tool-input-available': { if (!currentMessage) break; + delete toolRawInputByCallId[chunk.toolCallId]; + // Find existing tool part or create new one const existingIndex = currentMessage.parts.findIndex( (p) => 'toolCallId' in p && p.toolCallId === chunk.toolCallId @@ -702,6 +850,8 @@ export abstract class AbstractChat { ); if (toolIndex >= 0) { + delete toolRawInputByCallId[chunk.toolCallId]; + const updatedParts = [...currentMessage.parts]; const existingPart = updatedParts[toolIndex] as any; updatedParts[toolIndex] = { @@ -728,6 +878,8 @@ export abstract class AbstractChat { ); if (toolIndex >= 0) { + delete toolRawInputByCallId[chunk.toolCallId]; + const updatedParts = [...currentMessage.parts]; const existingPart = updatedParts[toolIndex] as any; updatedParts[toolIndex] = { diff --git a/packages/instantsearch.js/src/lib/ai-lite/types.ts b/packages/instantsearch.js/src/lib/ai-lite/types.ts index a317953ae42..fdbe9f3825a 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/types.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/types.ts @@ -84,6 +84,7 @@ export type ToolUIPart = ValueOf<{ | { state: 'input-streaming'; input: DeepPartial | undefined; + rawInput?: string; providerExecuted?: boolean; output?: never; errorText?: never; @@ -125,6 +126,7 @@ export type DynamicToolUIPart = { | { state: 'input-streaming'; input: unknown | undefined; + rawInput?: string; output?: never; errorText?: never; } diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 0aebcd66d59..9e14e24cd45 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -44,6 +44,7 @@ function createCarouselTool( | { query: string; number_of_results?: number; + facet_filters?: string[][]; } | undefined; @@ -56,6 +57,42 @@ function createCarouselTool( const items = output?.hits || []; + const appliedStreamingInputRef = React.useRef(''); + + React.useEffect(() => { + if ( + message?.state !== 'input-streaming' && + message?.state !== 'input-available' + ) { + return; + } + + if (!input) { + return; + } + + const hasQuery = input.query.trim().length > 0; + const hasFacetFilters = (input.facet_filters?.length ?? 0) > 0; + if (!hasQuery && !hasFacetFilters) { + return; + } + + const inputSignature = JSON.stringify({ + query: input.query, + facet_filters: input.facet_filters, + }); + + if (appliedStreamingInputRef.current === inputSignature) { + return; + } + + appliedStreamingInputRef.current = inputSignature; + applyFilters({ + query: input.query, + facetFilters: input.facet_filters, + }); + }, [applyFilters, input, message?.state]); + const MemoedHeaderComponent = React.useMemo(() => { return ( props: Omit< From 8355da7dd1bd9a98d8ba238756204aa4cc44d784 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Thu, 2 Apr 2026 10:24:13 +0100 Subject: [PATCH 02/23] fix delta key --- .../src/components/chat/ChatMessages.tsx | 3 +- .../src/lib/utils/chat.ts | 8 +++++ .../chat/__tests__/connectChat-test.ts | 2 +- .../src/lib/ai-lite/abstract-chat.ts | 2 +- .../instantsearch.js/src/lib/ai-lite/types.ts | 2 +- .../widgets/chat/tools/SearchIndexTool.tsx | 36 ------------------- 6 files changed, 13 insertions(+), 40 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index 873e3839f45..3bdb4b937a8 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -5,6 +5,7 @@ import { getTextContent, hasTextContent, isPartText, + isPartTool, } from '../../lib/utils/chat'; import { createButtonComponent } from '../Button'; @@ -328,7 +329,7 @@ export function createChatMessagesComponent({ const isWaitingForResponse = status === 'submitted'; const isStreamingWithNoContent = status === 'streaming' && !lastPart; const isStreamingNonTextContent = - status === 'streaming' && lastPart && !isPartText(lastPart); + status === 'streaming' && lastPart && !(isPartText(lastPart) || isPartTool(lastPart)); const showLoader = isWaitingForResponse || diff --git a/packages/instantsearch-ui-components/src/lib/utils/chat.ts b/packages/instantsearch-ui-components/src/lib/utils/chat.ts index 0e170dd5009..64ef730d57d 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/chat.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/chat.ts @@ -1,3 +1,5 @@ +import { startsWith } from './startsWith'; + import type { ChatMessageBase } from '../../components'; export const getTextContent = (message: ChatMessageBase) => { @@ -15,3 +17,9 @@ export const isPartText = ( ): part is Extract => { return part.type === 'text'; }; + +export const isPartTool = ( + part: ChatMessageBase['parts'][number] +): part is Extract => { + return startsWith(part.type, 'tool-'); +}; diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index 4b96c248648..7e9ff485613 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -457,7 +457,7 @@ data: {"type": "start-step"} data: {"type": "tool-input-start", "toolCallId": "call_1", "toolName": "displayResults"} -data: {"type": "tool-input-delta", "toolCallId": "call_1", "toolName": "displayResults", "inputDelta": "{}"} +data: {"type": "tool-input-delta", "toolCallId": "call_1", "toolName": "displayResults", "inputTextDelta": "{}"} data: {"type": "finish-step"} diff --git a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts index fda337b2700..ff0dd4db310 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts @@ -755,7 +755,7 @@ export abstract class AbstractChat { existingPart?.rawInput ?? toolRawInputByCallId[chunk.toolCallId] ?? ''; - const nextRawInput = `${previousRawInput}${chunk.inputDelta}`; + const nextRawInput = `${previousRawInput}${chunk.inputTextDelta}`; toolRawInputByCallId[chunk.toolCallId] = nextRawInput; const parsedInput = parseToolInputDelta( diff --git a/packages/instantsearch.js/src/lib/ai-lite/types.ts b/packages/instantsearch.js/src/lib/ai-lite/types.ts index fdbe9f3825a..daf60dc86be 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/types.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/types.ts @@ -269,7 +269,7 @@ export type UIMessageChunk< type: 'tool-input-delta'; toolName: string; toolCallId: string; - inputDelta: string; + inputTextDelta: string; } | { type: 'tool-output-available'; diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 9e14e24cd45..33bfd686404 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -57,42 +57,6 @@ function createCarouselTool( const items = output?.hits || []; - const appliedStreamingInputRef = React.useRef(''); - - React.useEffect(() => { - if ( - message?.state !== 'input-streaming' && - message?.state !== 'input-available' - ) { - return; - } - - if (!input) { - return; - } - - const hasQuery = input.query.trim().length > 0; - const hasFacetFilters = (input.facet_filters?.length ?? 0) > 0; - if (!hasQuery && !hasFacetFilters) { - return; - } - - const inputSignature = JSON.stringify({ - query: input.query, - facet_filters: input.facet_filters, - }); - - if (appliedStreamingInputRef.current === inputSignature) { - return; - } - - appliedStreamingInputRef.current = inputSignature; - applyFilters({ - query: input.query, - facetFilters: input.facet_filters, - }); - }, [applyFilters, input, message?.state]); - const MemoedHeaderComponent = React.useMemo(() => { return ( props: Omit< From 929f2c08f1441fe0f845e8d1233f175758c34e0c Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Thu, 2 Apr 2026 14:39:18 +0100 Subject: [PATCH 03/23] fix loader --- .../src/components/chat/ChatMessages.tsx | 4 ++-- .../src/lib/utils/chat.ts | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index 3bdb4b937a8..2dc307a3860 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -5,7 +5,7 @@ import { getTextContent, hasTextContent, isPartText, - isPartTool, + isToolPartActivelyRendering, } from '../../lib/utils/chat'; import { createButtonComponent } from '../Button'; @@ -329,7 +329,7 @@ export function createChatMessagesComponent({ const isWaitingForResponse = status === 'submitted'; const isStreamingWithNoContent = status === 'streaming' && !lastPart; const isStreamingNonTextContent = - status === 'streaming' && lastPart && !(isPartText(lastPart) || isPartTool(lastPart)); + status === 'streaming' && lastPart && !(isPartText(lastPart) || isToolPartActivelyRendering(lastPart)); const showLoader = isWaitingForResponse || diff --git a/packages/instantsearch-ui-components/src/lib/utils/chat.ts b/packages/instantsearch-ui-components/src/lib/utils/chat.ts index 64ef730d57d..55da19f4294 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/chat.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/chat.ts @@ -1,6 +1,7 @@ import { startsWith } from './startsWith'; import type { ChatMessageBase } from '../../components'; +import type { ChatToolMessage } from '../../components/chat/types'; export const getTextContent = (message: ChatMessageBase) => { return message.parts @@ -20,6 +21,17 @@ export const isPartText = ( export const isPartTool = ( part: ChatMessageBase['parts'][number] -): part is Extract => { +): part is ChatToolMessage => { return startsWith(part.type, 'tool-'); }; + +export const isToolPartActivelyRendering = ( + part: ChatMessageBase['parts'][number] +): boolean => { + return ( + isPartTool(part) && + (part.state === 'input-streaming' || + part.state === 'output-available' || + part.state === 'output-error') + ); +}; From 80c4486d4a56371c021192ec9622dfde63cab10b Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Thu, 2 Apr 2026 14:39:29 +0100 Subject: [PATCH 04/23] update tests --- tests/common/widgets/chat/options.tsx | 53 +++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index 123a2bacaea..978c495e131 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -380,7 +380,7 @@ export function createOptionsTests( ).toBeInTheDocument(); }); - test('shows loader during streaming when last part is a tool without output', async () => { + test('does not show loader during streaming when last part is a tool with streaming input', async () => { const searchClient = createSearchClient(); const chat = new Chat({}); @@ -422,12 +422,59 @@ export function createOptionsTests( await wait(0); }); + expect( + document.querySelector('.ais-ChatMessageLoader') + ).not.toBeInTheDocument(); + }); + + test('shows loader during streaming when last part is a tool with input available', async () => { + const searchClient = createSearchClient(); + const chat = new Chat({}); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: createDefaultWidgetParams(chat), + react: createDefaultWidgetParams(chat), + vue: {}, + }, + }); + + await openChat(act); + + await act(async () => { + chat._state.messages = [ + { + id: '1', + role: 'user', + parts: [{ type: 'text', text: 'Hello' }], + }, + { + id: '2', + role: 'assistant', + parts: [ + { + type: `tool-${SearchIndexToolType}`, + toolCallId: '1', + state: 'input-available', + input: { query: 'shoes' }, + }, + ], + }, + ] as any; + chat._state.status = 'streaming'; + await wait(0); + }); + expect( document.querySelector('.ais-ChatMessageLoader') ).toBeInTheDocument(); }); - test('shows loader during streaming when last part is a tool with output', async () => { + test('does not show loader during streaming when last part is a tool with output', async () => { const searchClient = createSearchClient(); const chat = new Chat({}); @@ -473,7 +520,7 @@ export function createOptionsTests( expect( document.querySelector('.ais-ChatMessageLoader') - ).toBeInTheDocument(); + ).not.toBeInTheDocument(); }); test('does not show loader during streaming when last part is text', async () => { From fa6af667a2d9815744d1b410a59251449489dcd5 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Sun, 5 Apr 2026 16:57:00 +0100 Subject: [PATCH 05/23] add loader state to search tool --- .../src/widgets/chat/tools/SearchIndexTool.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 33bfd686404..dff950ee152 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -82,6 +82,14 @@ function createCarouselTool( ); }, [items.length, input, output?.nbHits, applyFilters, onClose]); + if (message.state === 'input-streaming') { + return ( +
+ Searching{input?.query ? ` for "${input.query}"` : ''}… +
+ ); + } + return ( Date: Tue, 7 Apr 2026 12:17:09 +0100 Subject: [PATCH 06/23] add loader component prop --- .../src/components/chat/ChatMessage.tsx | 6 ++++++ .../src/components/chat/ChatMessages.tsx | 4 ++++ .../src/components/chat/types.ts | 1 + 3 files changed, 11 insertions(+) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index 21cc65c0f23..a3c03e7f28f 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -135,6 +135,10 @@ export type ChatMessageProps = ComponentProps<'article'> & { * Array of tools available for the assistant (for tool messages) */ tools: ClientSideTools; + /** + * Loader component passed to tool layout components + */ + loaderComponent?: (props: Record) => JSX.Element; /** * Optional suggestions element */ @@ -171,6 +175,7 @@ export function createChatMessageComponent({ createElement }: Renderer) { indexUiState, setIndexUiState, onClose, + loaderComponent: LoaderComponent, translations: userTranslations, suggestionsElement, ...props @@ -253,6 +258,7 @@ export function createChatMessageComponent({ createElement }: Renderer) { addToolResult={boundAddToolResult} applyFilters={tool.applyFilters} onClose={onClose} + loaderComponent={LoaderComponent} /> ); diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index 2dc307a3860..913a241c93e 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -192,6 +192,7 @@ function createDefaultMessageComponent< onReload, onClose, actionsComponent, + loaderComponent, classNames, messageTranslations, translations, @@ -208,6 +209,7 @@ function createDefaultMessageComponent< onReload: (messageId?: string) => void; onClose: () => void; actionsComponent?: ChatMessageProps['actionsComponent']; + loaderComponent?: ChatMessageProps['loaderComponent']; translations: ChatMessagesTranslations; classNames?: Partial; messageTranslations?: Partial; @@ -247,6 +249,7 @@ function createDefaultMessageComponent< onClose={onClose} actions={defaultActions} actionsComponent={actionsComponent} + loaderComponent={loaderComponent} data-role={message.role} classNames={classNames} translations={messageTranslations} @@ -376,6 +379,7 @@ export function createChatMessagesComponent({ setIndexUiState={setIndexUiState} onReload={onReload} actionsComponent={ActionsComponent} + loaderComponent={DefaultLoader} onClose={onClose} translations={translations} classNames={messageClassNames} diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index 558aabf4f31..72be161bfb0 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -466,6 +466,7 @@ export type ClientSideToolComponentProps = { onClose: () => void; addToolResult: AddToolResultWithOutput; applyFilters: (params: ApplyFiltersParams) => SearchParameters; + loaderComponent?: (props: Record) => JSX.Element; }; export type ClientSideToolComponent = ( From 68bc15b690e7fb049cfe0396cd655cb09fbe967d Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Tue, 7 Apr 2026 12:44:07 +0100 Subject: [PATCH 07/23] add js template type --- .../src/components/chat/ChatMessage.tsx | 3 ++- .../src/components/chat/types.ts | 3 ++- .../instantsearch.js/src/widgets/chat/chat.tsx | 14 +++++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index a3c03e7f28f..95d757c6761 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -7,6 +7,7 @@ import { createButtonComponent } from '../Button'; import { MenuIcon } from './icons'; import type { ComponentProps, Renderer, VNode } from '../../types'; +import type { ChatMessageLoaderProps } from './ChatMessageLoader'; import type { AddToolResultWithOutput, ChatMessageBase, @@ -138,7 +139,7 @@ export type ChatMessageProps = ComponentProps<'article'> & { /** * Loader component passed to tool layout components */ - loaderComponent?: (props: Record) => JSX.Element; + loaderComponent?: (props: ChatMessageLoaderProps) => JSX.Element; /** * Optional suggestions element */ diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index 72be161bfb0..23ac43e4a4e 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -1,3 +1,4 @@ +import type { ChatMessageLoaderProps } from './ChatMessageLoader'; import type { SearchParameters } from 'algoliasearch-helper'; export type ChatStatus = 'ready' | 'submitted' | 'streaming' | 'error'; @@ -466,7 +467,7 @@ export type ClientSideToolComponentProps = { onClose: () => void; addToolResult: AddToolResultWithOutput; applyFilters: (params: ApplyFiltersParams) => SearchParameters; - loaderComponent?: (props: Record) => JSX.Element; + loaderComponent?: (props: ChatMessageLoaderProps) => JSX.Element; }; export type ClientSideToolComponent = ( diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 00d9cc5d8bd..cfd75fe2157 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -506,6 +506,17 @@ const createRenderer = ({ widgetTool = tools[SearchIndexToolType]; } + const loaderComponent = widgetTool?.templates?.loader + ? () => ( + + ) + : undefined; + toolsForUi[key] = { ...connectorTool, ...(widgetTool?.templates?.layout && { @@ -517,7 +528,7 @@ const createRenderer = ({ templates={widgetTool.templates} rootTagName="fragment" templateKey="layout" - data={layoutComponentProps} + data={{ ...layoutComponentProps, loader: loaderComponent }} /> ); }, @@ -898,6 +909,7 @@ const createRenderer = ({ export type UserClientSideToolTemplates = Partial<{ layout: TemplateWithBindEvent; + loader: TemplateWithBindEvent; }>; type UserClientSideToolWithTemplate = Omit< From d431b2f0bfa0d40d2ca2e940ccc52fc7f015f4c4 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Tue, 7 Apr 2026 13:51:43 +0100 Subject: [PATCH 08/23] update types --- .../src/components/chat/ChatMessage.tsx | 4 +-- .../src/components/chat/ChatMessages.tsx | 2 +- .../src/components/chat/types.ts | 2 +- .../src/widgets/chat/chat.tsx | 29 +++++++++---------- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index 1dc37ce6807..43245e75321 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -139,7 +139,7 @@ export type ChatMessageProps = ComponentProps<'article'> & { /** * Loader component passed to tool layout components */ - loaderComponent?: (props: ChatMessageLoaderProps) => JSX.Element; + loaderComponent: (props: ChatMessageLoaderProps) => JSX.Element; /** * Optional suggestions element */ @@ -258,7 +258,7 @@ export function createChatMessageComponent({ createElement }: Renderer) { setIndexUiState={setIndexUiState} addToolResult={boundAddToolResult} applyFilters={tool.applyFilters} - sendEvent={tool.sendEvent || (() => {})} + sendEvent={tool.sendEvent || (() => { })} onClose={onClose} loaderComponent={LoaderComponent} /> diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index f9db8dfdc60..eed345ac039 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -245,7 +245,7 @@ function createDefaultMessageComponent< onFeedback?: (messageId: string, vote: 0 | 1) => void; feedbackState?: Record; actionsComponent?: ChatMessageProps['actionsComponent']; - loaderComponent?: ChatMessageProps['loaderComponent']; + loaderComponent: ChatMessageProps['loaderComponent']; translations: ChatMessagesTranslations; classNames?: Partial; messageTranslations?: Partial; diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index 282f10be94a..11da99b048f 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -488,7 +488,7 @@ export type ClientSideToolComponentProps = { onClose: () => void; addToolResult: AddToolResultWithOutput; applyFilters: (params: ApplyFiltersParams) => SearchParameters; - loaderComponent?: (props: ChatMessageLoaderProps) => JSX.Element; + loaderComponent: (props: ChatMessageLoaderProps) => JSX.Element; sendEvent: SendEventForHits; }; diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index c148610d0dc..35adc789417 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -97,7 +97,7 @@ function createCarouselTool< applyFilters, onClose, sendEvent, - }: ClientSideToolComponentProps) { + }: ClientSideToolTemplateData) { const input = message?.input as | { query: string; @@ -533,29 +533,22 @@ const createRenderer = ({ widgetTool = tools[SearchIndexToolType]; } - const loaderComponent = widgetTool?.templates?.loader - ? () => ( - - ) - : undefined; - toolsForUi[key] = { ...connectorTool, ...(widgetTool?.templates?.layout && { layoutComponent: ( layoutComponentProps: ClientSideToolComponentProps ) => { + const { loaderComponent: Loader, ...restProps } = layoutComponentProps; return ( , + }} /> ); }, @@ -973,9 +966,15 @@ const createRenderer = ({ }; }; +export type ClientSideToolTemplateData = Omit< + ClientSideToolComponentProps, + 'loaderComponent' +> & { + loader: () => JSX.Element; +}; + export type UserClientSideToolTemplates = Partial<{ - layout: TemplateWithBindEvent; - loader: TemplateWithBindEvent; + layout: TemplateWithBindEvent; }>; type UserClientSideToolWithTemplate = Omit< From 609b7dd58e6ed8466e477b13e47d3d331c3573b7 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Tue, 7 Apr 2026 13:54:21 +0100 Subject: [PATCH 09/23] add test --- tests/common/widgets/chat/options.tsx | 154 ++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index abd0be5efed..cb93ecc49e7 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -1030,6 +1030,160 @@ export function createOptionsTests( ); }); + test('passes loaderComponent to tool layout components', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + parts: [ + { + type: 'tool-hello', + toolCallId: '1', + input: { text: 'hello' }, + state: 'output-available', + output: 'hello', + }, + ], + }, + ], + id: 'chat-id', + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + templates: { + layout: ({ loader }: any, { html }: any) => + html`
+ ${loader ? loader() : null} +
`, + }, + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + layoutComponent: ({ + loaderComponent: Loader, + }: { + loaderComponent?: (props: any) => React.ReactElement; + }) => ( +
+ {Loader ? : null} +
+ ), + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + expect( + document.querySelector('#tool-with-loader') + ).toBeInTheDocument(); + expect( + document.querySelector( + '#tool-with-loader .ais-ChatMessageLoader' + ) + ).toBeInTheDocument(); + }); + + test('loaderComponent renders custom loader when messagesLoaderComponent is provided', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + parts: [ + { + type: 'tool-hello', + toolCallId: '1', + input: { text: 'hello' }, + state: 'output-available', + output: 'hello', + }, + ], + }, + ], + id: 'chat-id', + }); + + const CustomLoader = () => ( +
Loading...
+ ); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + templates: { + messages: { + loader: '
Loading...
', + }, + }, + tools: { + hello: { + templates: { + layout: ({ loader }: any, { html }: any) => + html`
+ ${loader ? loader() : null} +
`, + }, + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + messagesLoaderComponent: CustomLoader, + tools: { + hello: { + layoutComponent: ({ + loaderComponent: Loader, + }: { + loaderComponent?: (props: any) => React.ReactElement; + }) => ( +
+ {Loader ? : null} +
+ ), + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + expect( + document.querySelector('#tool-custom-loader') + ).toBeInTheDocument(); + expect( + document.querySelector('#tool-custom-loader .custom-loader') + ).toBeInTheDocument(); + }); + test('shows actions for assistant messages when status is ready', async () => { const searchClient = createSearchClient(); From 2aa21f6adf344618ced7bebc04ae1fcf18353467 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Tue, 7 Apr 2026 14:36:44 +0100 Subject: [PATCH 10/23] fix lint --- .../src/components/chat/__tests__/ChatMessage.test.tsx | 6 ++++++ .../widgets/chat/tools/__tests__/SearchIndexTool.test.tsx | 2 ++ 2 files changed, 8 insertions(+) diff --git a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessage.test.tsx b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessage.test.tsx index 1dcc0e2440c..2a58ca706e1 100644 --- a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessage.test.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessage.test.tsx @@ -21,6 +21,7 @@ describe('ChatMessage', () => { message={{ role: 'user', id: '1', parts: [] }} status="ready" tools={{}} + loaderComponent={jest.fn()} onClose={jest.fn()} /> ); @@ -66,6 +67,7 @@ describe('ChatMessage', () => { actions: 'actions', }} tools={{}} + loaderComponent={jest.fn()} onClose={jest.fn()} /> ); @@ -104,6 +106,7 @@ describe('ChatMessage', () => { }} status="ready" tools={{}} + loaderComponent={jest.fn()} onClose={jest.fn()} /> { }} status="ready" tools={{}} + loaderComponent={jest.fn()} onClose={jest.fn()} /> { }} status="ready" tools={{}} + loaderComponent={jest.fn()} onClose={jest.fn()} /> @@ -235,6 +240,7 @@ describe('ChatMessage', () => { applyFilters: jest.fn(), }, }} + loaderComponent={jest.fn()} onClose={jest.fn()} /> ); diff --git a/packages/react-instantsearch/src/widgets/chat/tools/__tests__/SearchIndexTool.test.tsx b/packages/react-instantsearch/src/widgets/chat/tools/__tests__/SearchIndexTool.test.tsx index ea1b1f30399..12c68583424 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/__tests__/SearchIndexTool.test.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/__tests__/SearchIndexTool.test.tsx @@ -47,6 +47,7 @@ describe('createCarouselTool', () => { indexUiState={{}} addToolResult={jest.fn()} setIndexUiState={jest.fn()} + loaderComponent={jest.fn()} sendEvent={jest.fn()} /> ); @@ -81,6 +82,7 @@ describe('createCarouselTool', () => { indexUiState={{}} addToolResult={jest.fn()} setIndexUiState={jest.fn()} + loaderComponent={jest.fn()} sendEvent={jest.fn()} /> ); From 82f571f27a06372d5e9941e0c86c8b6b167b0be5 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Tue, 7 Apr 2026 14:42:20 +0100 Subject: [PATCH 11/23] update bundlesize --- bundlesize.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index 7572a04caff..a1bc9fb5a58 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -14,7 +14,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "251.25 kB" + "maxSize": "252.75 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", From 7e1d9f4a98fef94ba8be03e201cd7387c6fd8539 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Tue, 7 Apr 2026 14:46:40 +0100 Subject: [PATCH 12/23] revert example --- examples/react/getting-started/src/App.tsx | 213 --------------------- 1 file changed, 213 deletions(-) diff --git a/examples/react/getting-started/src/App.tsx b/examples/react/getting-started/src/App.tsx index d60d2b2770b..7602bfab9a0 100644 --- a/examples/react/getting-started/src/App.tsx +++ b/examples/react/getting-started/src/App.tsx @@ -27,216 +27,6 @@ const searchClient = algoliasearch( '6be0576ff61c053d5f9a3225e2a90f76' ); -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -const TOOL_OUTPUT_STEP_DELAY_MS = 900; - -const normalizeToolOutputEvent = (event: any): any => { - if (event?.type !== 'tool-output-available' || !event.output) { - return event; - } - - const output = event.output as Record; - - if (Array.isArray(output.hits)) { - return event; - } - - if ( - Array.isArray(output.results) && - output.results.length > 0 && - Array.isArray(output.results[0]?.hits) - ) { - const hits = output.results[0].hits; - return { - ...event, - output: { - ...output, - hits, - nbHits: output.nbHits ?? hits.length, - }, - }; - } - - return event; -}; - -const getProgressiveToolOutputEvents = (event: any): any[] => { - if (event?.type !== 'tool-output-available' || !event.output) { - return []; - } - - const output = event.output as Record; - - if (Array.isArray(output.hits) && output.hits.length > 1) { - return output.hits.slice(0, -1).map((_: any, index: number) => ({ - ...event, - preliminary: true, - output: { - ...output, - hits: output.hits.slice(0, index + 1), - }, - })); - } - - if (Array.isArray(output.results) && output.results.length > 0) { - const firstResultWithHitsIndex = output.results.findIndex( - (result: any) => Array.isArray(result?.hits) && result.hits.length > 1 - ); - - if (firstResultWithHitsIndex === -1) { - return []; - } - - const hits = output.results[firstResultWithHitsIndex].hits; - return hits.slice(0, -1).map((_: any, index: number) => ({ - ...event, - preliminary: true, - output: { - ...output, - results: output.results.map((result: any, resultIndex: number) => - resultIndex === firstResultWithHitsIndex - ? { - ...result, - hits: hits.slice(0, index + 1), - } - : result - ), - }, - })); - } - - return []; -}; - -const delayedChatFetch: typeof fetch = async (input, init) => { - const response = await fetch(input, init); - - const isEventStream = - response.headers.get('content-type')?.includes('text/event-stream') ?? - false; - - if (!response.body || !isEventStream) { - return response; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - let hasInjectedOutputDelay = false; - let pendingText = ''; - - const delayedBody = new ReadableStream({ - async start(controller) { - const emitLine = async (line: string): Promise => { - if (!line.startsWith('data: ')) { - controller.enqueue(encoder.encode(line)); - return; - } - - const payload = line.slice(6).trim(); - if (payload === '[DONE]') { - controller.enqueue(encoder.encode(line)); - return; - } - - let parsedEvent: any; - try { - parsedEvent = JSON.parse(payload); - } catch { - controller.enqueue(encoder.encode(line)); - return; - } - - if ( - !hasInjectedOutputDelay && - parsedEvent?.type === 'tool-output-available' - ) { - const normalizedToolOutputEvent = - normalizeToolOutputEvent(parsedEvent); - const progressiveEvents = getProgressiveToolOutputEvents( - normalizedToolOutputEvent - ); - - if (progressiveEvents.length > 0) { - hasInjectedOutputDelay = true; - - const emitProgressiveOutput = (index: number): Promise => { - if (index >= progressiveEvents.length) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify(normalizedToolOutputEvent)}\n` - ) - ); - controller.enqueue(encoder.encode('\n')); - return Promise.resolve(); - } - - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify(progressiveEvents[index])}\n` - ) - ); - controller.enqueue(encoder.encode('\n')); - - return sleep(TOOL_OUTPUT_STEP_DELAY_MS).then(() => - emitProgressiveOutput(index + 1) - ); - }; - - return emitProgressiveOutput(0); - } - } - - controller.enqueue(encoder.encode(line)); - }; - - const processPendingLines = (): Promise => { - const lineBreakIndex = pendingText.indexOf('\n'); - if (lineBreakIndex === -1) { - return Promise.resolve(); - } - - const line = pendingText.slice(0, lineBreakIndex + 1); - pendingText = pendingText.slice(lineBreakIndex + 1); - - return emitLine(line).then(() => processPendingLines()); - }; - - const pump = (): Promise => - reader.read().then(({ done, value }) => { - if (done) { - if (pendingText) { - controller.enqueue(encoder.encode(pendingText)); - } - controller.close(); - return Promise.resolve(); - } - - if (!value) { - return pump(); - } - - pendingText += decoder.decode(value, { stream: true }); - return processPendingLines().then(() => pump()); - }); - - try { - await pump(); - } catch (error) { - controller.error(error); - } finally { - reader.releaseLock(); - } - }, - }); - - return new Response(delayedBody, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); -}; - export function App() { return (
@@ -305,9 +95,6 @@ export function App() { agentId="eedef238-5468-470d-bc37-f99fa741bd25" feedback={true} itemComponent={ItemComponent} - transport={{ - fetch: delayedChatFetch, - }} />
From dc5a32bb3dcc8335e7acb96c37662637289e0d8f Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Wed, 8 Apr 2026 14:10:59 +0100 Subject: [PATCH 13/23] add option to show loader for input streaming --- .../src/components/chat/ChatMessage.tsx | 9 +++++++ .../src/components/chat/ChatMessageLoader.tsx | 2 +- .../src/components/chat/ChatMessages.tsx | 25 +++++++++++++++++-- .../src/components/chat/types.ts | 1 + 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index 43245e75321..ede99d83dfe 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -243,6 +243,15 @@ export function createChatMessageComponent({ createElement }: Renderer) { toolCallId: toolMessage.toolCallId, }); + const showInlineLoader = + tool.showLoaderDuringStreaming && + toolMessage.state === 'input-streaming' && + LoaderComponent; + + if (showInlineLoader) { + return null; + } + if (!ToolLayoutComponent) { return null; } diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx index 32ce1590a31..db193f9e59a 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx @@ -24,7 +24,7 @@ export function createChatMessageLoaderComponent({ return function ChatMessageLoader(userProps: ChatMessageLoaderProps) { const { translations: userTranslations, ...props } = userProps; const translations: Required = { - loaderText: 'Thinking...', + loaderText: '', ...userTranslations, }; diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index eed345ac039..298cd6083e8 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -1,10 +1,12 @@ /** @jsx createElement */ import { cx } from '../../lib'; +import { startsWith } from '../../lib'; import { getTextContent, hasTextContent, isPartText, + isPartTool, isToolPartActivelyRendering, } from '../../lib/utils/chat'; import { createButtonComponent } from '../Button'; @@ -31,7 +33,7 @@ import type { } from './ChatMessage'; import type { ChatMessageErrorProps } from './ChatMessageError'; import type { ChatMessageLoaderProps } from './ChatMessageLoader'; -import type { ChatMessageBase, ChatStatus, ClientSideTools } from './types'; +import type { ChatMessageBase, ChatStatus, ClientSideTool, ClientSideTools } from './types'; export type ChatMessagesTranslations = { /** @@ -417,13 +419,32 @@ export function createChatMessagesComponent({ const lastPart = lastMessage?.parts?.[lastMessage.parts.length - 1]; const isWaitingForResponse = status === 'submitted'; const isStreamingWithNoContent = status === 'streaming' && !lastPart; + + // Check if the last tool part has showLoaderDuringStreaming enabled and is in input-streaming state + // if so, it renders nothing inline and we should show the global loader instead + const isToolDeferringToGlobalLoader = + lastPart && + isPartTool(lastPart) && + lastPart.state === 'input-streaming' && + (() => { + const toolName = lastPart.type.replace('tool-', ''); + let tool: ClientSideTool | undefined = tools[toolName]; + if (!tool) { + tool = Object.entries(tools).find(([key]) => + startsWith(toolName, `${key}_`) + )?.[1]; + } + return tool?.showLoaderDuringStreaming; + })(); + const isStreamingNonTextContent = status === 'streaming' && lastPart && !(isPartText(lastPart) || isToolPartActivelyRendering(lastPart)); const showLoader = isWaitingForResponse || isStreamingWithNoContent || - isStreamingNonTextContent; + isStreamingNonTextContent || + isToolDeferringToGlobalLoader; const DefaultMessage = MessageComponent || DefaultMessageComponent; const DefaultLoader = LoaderComponent || DefaultLoaderComponent; diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index 11da99b048f..f9f699a2378 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -498,6 +498,7 @@ export type ClientSideToolComponent = ( export type ClientSideTool = { layoutComponent?: ClientSideToolComponent; + showLoaderDuringStreaming?: boolean; addToolResult: AddToolResult; sendEvent?: SendEventForHits; onToolCall?: ( From 108af757b45b18a0e276f949329f0cabf0a19939 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Wed, 8 Apr 2026 14:48:35 +0100 Subject: [PATCH 14/23] update bundlesize --- bundlesize.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index 453e6767773..3c3bbddb037 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,11 +10,11 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "121.25 kB" + "maxSize": "122 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "253 kB" + "maxSize": "254.25 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", From 6cdad46ff6d67cb3f244b41b1e3661496a5932c1 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Wed, 8 Apr 2026 14:52:53 +0100 Subject: [PATCH 15/23] fix indents --- .../src/components/chat/types.ts | 134 +++++++++--------- 1 file changed, 66 insertions(+), 68 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index f9f699a2378..bdbe76e02ca 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -121,39 +121,38 @@ export type ToolUIPart = ValueOf<{ toolCallId: string; } & ( | { - state: 'input-streaming'; - input: DeepPartial | undefined; - rawInput?: string; - providerExecuted?: boolean; - output?: never; - errorText?: never; - } + state: 'input-streaming'; + input: DeepPartial | undefined; + providerExecuted?: boolean; + output?: never; + errorText?: never; + } | { - state: 'input-available'; - input: TTools[NAME]['input']; - providerExecuted?: boolean; - output?: never; - errorText?: never; - callProviderMetadata?: ProviderMetadata; - } + state: 'input-available'; + input: TTools[NAME]['input']; + providerExecuted?: boolean; + output?: never; + errorText?: never; + callProviderMetadata?: ProviderMetadata; + } | { - state: 'output-available'; - input: TTools[NAME]['input']; - output: TTools[NAME]['output']; - errorText?: never; - providerExecuted?: boolean; - callProviderMetadata?: ProviderMetadata; - preliminary?: boolean; - } + state: 'output-available'; + input: TTools[NAME]['input']; + output: TTools[NAME]['output']; + errorText?: never; + providerExecuted?: boolean; + callProviderMetadata?: ProviderMetadata; + preliminary?: boolean; + } | { - state: 'output-error'; - input: TTools[NAME]['input'] | undefined; - rawInput?: unknown; - output?: never; - errorText: string; - providerExecuted?: boolean; - callProviderMetadata?: ProviderMetadata; - } + state: 'output-error'; + input: TTools[NAME]['input'] | undefined; + rawInput?: unknown; + output?: never; + errorText: string; + providerExecuted?: boolean; + callProviderMetadata?: ProviderMetadata; + } ); }>; @@ -165,21 +164,20 @@ export type DynamicToolUIPart = { toolName: string; toolCallId: string; } & ( - | { + | { state: 'input-streaming'; input: unknown | undefined; - rawInput?: string; output?: never; errorText?: never; } - | { + | { state: 'input-available'; input: unknown; output?: never; errorText?: never; callProviderMetadata?: ProviderMetadata; } - | { + | { state: 'output-available'; input: unknown; output: unknown; @@ -187,14 +185,14 @@ export type DynamicToolUIPart = { callProviderMetadata?: ProviderMetadata; preliminary?: boolean; } - | { + | { state: 'output-error'; input: unknown; output?: never; errorText: string; callProviderMetadata?: ProviderMetadata; } - ); +); /** * All possible message part types. @@ -297,23 +295,23 @@ export type ChatOnErrorCallback = (error: Error) => void; */ export type InferUIMessageToolCall = | ValueOf<{ - [NAME in keyof InferUIMessageTools]: { - toolName: NAME & string; + [NAME in keyof InferUIMessageTools]: { + toolName: NAME & string; + toolCallId: string; + input: InferUIMessageTools[NAME] extends { + input: infer INPUT; + } + ? INPUT + : never; + dynamic?: false; + }; + }> + | { + toolName: string; toolCallId: string; - input: InferUIMessageTools[NAME] extends { - input: infer INPUT; - } - ? INPUT - : never; - dynamic?: false; + input: unknown; + dynamic: true; }; - }> - | { - toolName: string; - toolCallId: string; - input: unknown; - dynamic: true; - }; /** * Optional callback function that is invoked when a tool call is received. @@ -401,25 +399,25 @@ export interface AbstractChat { sendMessage: ( message?: | (Omit & { - id?: TUIMessage['id']; - role?: TUIMessage['role']; - text?: never; - files?: never; - messageId?: string; - }) + id?: TUIMessage['id']; + role?: TUIMessage['role']; + text?: never; + files?: never; + messageId?: string; + }) | { - text: string; - files?: FileList | FileUIPart[]; - metadata?: InferUIMessageMetadata; - parts?: never; - messageId?: string; - } + text: string; + files?: FileList | FileUIPart[]; + metadata?: InferUIMessageMetadata; + parts?: never; + messageId?: string; + } | { - files: FileList | FileUIPart[]; - metadata?: InferUIMessageMetadata; - parts?: never; - messageId?: string; - }, + files: FileList | FileUIPart[]; + metadata?: InferUIMessageMetadata; + parts?: never; + messageId?: string; + }, options?: { headers?: Record | Headers; body?: object } ) => Promise; From 08721f1c0e5fa12581852ce5f2775084838a3822 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Wed, 8 Apr 2026 15:40:10 +0100 Subject: [PATCH 16/23] remove not needed code --- .../src/components/chat/ChatMessage.tsx | 6 ++++-- .../src/components/chat/ChatMessages.tsx | 3 +-- .../src/lib/utils/chat.ts | 13 +------------ 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index ede99d83dfe..e07745c4f59 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -243,12 +243,14 @@ export function createChatMessageComponent({ createElement }: Renderer) { toolCallId: toolMessage.toolCallId, }); - const showInlineLoader = + const showBaseLoader = tool.showLoaderDuringStreaming && toolMessage.state === 'input-streaming' && LoaderComponent; - if (showInlineLoader) { + // If the tool is still streaming and has indicated to show the base loader, + // we don't render the tool layout component. + if (showBaseLoader) { return null; } diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index 298cd6083e8..869c26b5ccb 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -7,7 +7,6 @@ import { hasTextContent, isPartText, isPartTool, - isToolPartActivelyRendering, } from '../../lib/utils/chat'; import { createButtonComponent } from '../Button'; @@ -438,7 +437,7 @@ export function createChatMessagesComponent({ })(); const isStreamingNonTextContent = - status === 'streaming' && lastPart && !(isPartText(lastPart) || isToolPartActivelyRendering(lastPart)); + status === 'streaming' && lastPart && !isPartText(lastPart); const showLoader = isWaitingForResponse || diff --git a/packages/instantsearch-ui-components/src/lib/utils/chat.ts b/packages/instantsearch-ui-components/src/lib/utils/chat.ts index 55da19f4294..a9883402f46 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/chat.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/chat.ts @@ -23,15 +23,4 @@ export const isPartTool = ( part: ChatMessageBase['parts'][number] ): part is ChatToolMessage => { return startsWith(part.type, 'tool-'); -}; - -export const isToolPartActivelyRendering = ( - part: ChatMessageBase['parts'][number] -): boolean => { - return ( - isPartTool(part) && - (part.state === 'input-streaming' || - part.state === 'output-available' || - part.state === 'output-error') - ); -}; +}; \ No newline at end of file From 2098ffa89d82430821e410414117a4097a2a8588 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Wed, 8 Apr 2026 15:43:32 +0100 Subject: [PATCH 17/23] update search tool loading indicator --- packages/instantsearch.js/src/widgets/chat/chat.tsx | 8 ++++++++ .../src/widgets/chat/tools/SearchIndexTool.tsx | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 35adc789417..6920bb8b7e1 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -143,6 +143,14 @@ function createCarouselTool< ); }, [items.length, input, output?.nbHits, applyFilters, onClose]); + if (message.state === 'input-streaming') { + return ( +
+ Searching… +
+ ); + } + return carousel({ showNavigation: false, templates: { diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 89d6ac1a62c..154b8eb7604 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -96,7 +96,7 @@ function createCarouselTool( if (message.state === 'input-streaming') { return (
- Searching{input?.query ? ` for "${input.query}"` : ''}… + Searching…
); } From 2d97af6381be68b881f79c5f10a411f82ec2cf6f Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Thu, 9 Apr 2026 10:45:06 +0100 Subject: [PATCH 18/23] default input streaming to false --- .../src/components/chat/ChatMessage.tsx | 10 +++------- .../src/components/chat/ChatMessages.tsx | 9 ++++++--- .../src/connectors/chat/connectChat.ts | 1 + packages/instantsearch.js/src/widgets/chat/chat.tsx | 1 + .../src/widgets/chat/tools/SearchIndexTool.tsx | 1 + tests/common/widgets/chat/options.tsx | 6 +++--- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index e07745c4f59..9ad5eb6a907 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -243,14 +243,10 @@ export function createChatMessageComponent({ createElement }: Renderer) { toolCallId: toolMessage.toolCallId, }); - const showBaseLoader = - tool.showLoaderDuringStreaming && + if ( toolMessage.state === 'input-streaming' && - LoaderComponent; - - // If the tool is still streaming and has indicated to show the base loader, - // we don't render the tool layout component. - if (showBaseLoader) { + tool.showLoaderDuringStreaming + ) { return null; } diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index 869c26b5ccb..24d10e1f50a 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -419,8 +419,8 @@ export function createChatMessagesComponent({ const isWaitingForResponse = status === 'submitted'; const isStreamingWithNoContent = status === 'streaming' && !lastPart; - // Check if the last tool part has showLoaderDuringStreaming enabled and is in input-streaming state - // if so, it renders nothing inline and we should show the global loader instead + // Check if the tool opted out of the global loader during input-streaming + // via showLoaderDuringStreaming: false. const isToolDeferringToGlobalLoader = lastPart && isPartTool(lastPart) && @@ -437,7 +437,10 @@ export function createChatMessagesComponent({ })(); const isStreamingNonTextContent = - status === 'streaming' && lastPart && !isPartText(lastPart); + status === 'streaming' && + lastPart && + !isPartText(lastPart) && + !(isPartTool(lastPart) && lastPart.state === 'output-available'); const showLoader = isWaitingForResponse || diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index 9d88d3cce56..c9c18c5c39a 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -599,6 +599,7 @@ export default (function connectChat( const toolsWithAddToolResult: ClientSideTools = {}; Object.entries(tools).forEach(([key, tool]) => { const toolWithAddToolResult: ClientSideTool = { + showLoaderDuringStreaming: true, ...tool, addToolResult: _chatInstance.addToolResult, applyFilters, diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 6920bb8b7e1..f5be7f1ebd5 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -265,6 +265,7 @@ function createCarouselTool< return { templates: { layout: SearchLayoutComponent }, + showLoaderDuringStreaming: false, }; } diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 154b8eb7604..406de1e806e 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -205,6 +205,7 @@ function createCarouselTool( return { layoutComponent: SearchLayoutComponent, + showLoaderDuringStreaming: false, }; } diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index 5e2ce103614..71e4f73a07d 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -385,7 +385,7 @@ export function createOptionsTests( ).toBeInTheDocument(); }); - test('does not show loader during streaming when last part is a tool with streaming input', async () => { + test('shows loader during streaming when last part is a tool with streaming input', async () => { const searchClient = createSearchClient(); const chat = new Chat({}); @@ -415,7 +415,7 @@ export function createOptionsTests( role: 'assistant', parts: [ { - type: `tool-${SearchIndexToolType}`, + type: 'tool-some_tool', toolCallId: '1', state: 'input-streaming', input: undefined, @@ -429,7 +429,7 @@ export function createOptionsTests( expect( document.querySelector('.ais-ChatMessageLoader') - ).not.toBeInTheDocument(); + ).toBeInTheDocument(); }); test('shows loader during streaming when last part is a tool with input available', async () => { From 7c919206cff404a3eeb47f93adb067bbd3812472 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 10 Apr 2026 01:48:15 +0100 Subject: [PATCH 19/23] move loader logic --- .../src/components/chat/ChatMessages.tsx | 60 +++++++++---------- .../src/lib/utils/chat.ts | 22 ++++++- 2 files changed, 47 insertions(+), 35 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index 24d10e1f50a..2f53483ace8 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -1,8 +1,8 @@ /** @jsx createElement */ import { cx } from '../../lib'; -import { startsWith } from '../../lib'; import { + findTool, getTextContent, hasTextContent, isPartText, @@ -32,7 +32,7 @@ import type { } from './ChatMessage'; import type { ChatMessageErrorProps } from './ChatMessageError'; import type { ChatMessageLoaderProps } from './ChatMessageLoader'; -import type { ChatMessageBase, ChatStatus, ClientSideTool, ClientSideTools } from './types'; +import type { ChatMessageBase, ChatStatus, ClientSideTools } from './types'; export type ChatMessagesTranslations = { /** @@ -416,37 +416,7 @@ export function createChatMessagesComponent({ const lastMessage = messages[messages.length - 1]; const lastPart = lastMessage?.parts?.[lastMessage.parts.length - 1]; - const isWaitingForResponse = status === 'submitted'; - const isStreamingWithNoContent = status === 'streaming' && !lastPart; - - // Check if the tool opted out of the global loader during input-streaming - // via showLoaderDuringStreaming: false. - const isToolDeferringToGlobalLoader = - lastPart && - isPartTool(lastPart) && - lastPart.state === 'input-streaming' && - (() => { - const toolName = lastPart.type.replace('tool-', ''); - let tool: ClientSideTool | undefined = tools[toolName]; - if (!tool) { - tool = Object.entries(tools).find(([key]) => - startsWith(toolName, `${key}_`) - )?.[1]; - } - return tool?.showLoaderDuringStreaming; - })(); - - const isStreamingNonTextContent = - status === 'streaming' && - lastPart && - !isPartText(lastPart) && - !(isPartTool(lastPart) && lastPart.state === 'output-available'); - - const showLoader = - isWaitingForResponse || - isStreamingWithNoContent || - isStreamingNonTextContent || - isToolDeferringToGlobalLoader; + const showLoader = getShowLoader(status, lastPart, tools); const DefaultMessage = MessageComponent || DefaultMessageComponent; const DefaultLoader = LoaderComponent || DefaultLoaderComponent; @@ -534,3 +504,27 @@ export function createChatMessagesComponent({ ); }; } + +const getShowLoader = ( + status: ChatStatus, + lastPart: ChatMessageBase['parts'][number] | undefined, + tools: ClientSideTools +): boolean => { + if (status !== 'submitted' && status !== 'streaming') return false; + if (status === 'submitted') return true; + + if (!lastPart) return true; + if (isPartText(lastPart)) return false; + + if (isPartTool(lastPart)) { + if (lastPart.state === 'output-available') return false; + if (lastPart.state === 'input-streaming') { + const tool = findTool(lastPart.type, tools); + return tool?.showLoaderDuringStreaming !== false; + } + return true; + } + + return true; +}; + diff --git a/packages/instantsearch-ui-components/src/lib/utils/chat.ts b/packages/instantsearch-ui-components/src/lib/utils/chat.ts index a9883402f46..abf89a61454 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/chat.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/chat.ts @@ -1,7 +1,11 @@ import { startsWith } from './startsWith'; import type { ChatMessageBase } from '../../components'; -import type { ChatToolMessage } from '../../components/chat/types'; +import type { + ChatToolMessage, + ClientSideTool, + ClientSideTools, +} from '../../components/chat/types'; export const getTextContent = (message: ChatMessageBase) => { return message.parts @@ -23,4 +27,18 @@ export const isPartTool = ( part: ChatMessageBase['parts'][number] ): part is ChatToolMessage => { return startsWith(part.type, 'tool-'); -}; \ No newline at end of file +}; + +export const findTool = ( + partType: string, + tools: ClientSideTools +): ClientSideTool | undefined => { + const toolName = partType.replace('tool-', ''); + let tool: ClientSideTool | undefined = tools[toolName]; + if (!tool) { + tool = Object.entries(tools).find(([key]) => + startsWith(toolName, `${key}_`) + )?.[1]; + } + return tool; +}; From aed2537bd227c20305af383e34ba47b6680cf8a1 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 10 Apr 2026 01:48:44 +0100 Subject: [PATCH 20/23] remove search tool loading text --- packages/instantsearch.js/src/widgets/chat/chat.tsx | 9 --------- .../src/widgets/chat/tools/SearchIndexTool.tsx | 9 --------- 2 files changed, 18 deletions(-) diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index f5be7f1ebd5..35adc789417 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -143,14 +143,6 @@ function createCarouselTool< ); }, [items.length, input, output?.nbHits, applyFilters, onClose]); - if (message.state === 'input-streaming') { - return ( -
- Searching… -
- ); - } - return carousel({ showNavigation: false, templates: { @@ -265,7 +257,6 @@ function createCarouselTool< return { templates: { layout: SearchLayoutComponent }, - showLoaderDuringStreaming: false, }; } diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 406de1e806e..67adad96fb7 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -93,14 +93,6 @@ function createCarouselTool( ); }, [items.length, input, output?.nbHits, applyFilters, onClose]); - if (message.state === 'input-streaming') { - return ( -
- Searching… -
- ); - } - return ( ( return { layoutComponent: SearchLayoutComponent, - showLoaderDuringStreaming: false, }; } From cdea719574d1fc219e00e592469e173545824fbd Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 10 Apr 2026 09:30:41 +0100 Subject: [PATCH 21/23] optionally skip json repair --- .../chat/__tests__/connectChat-test.ts | 130 ++++++++++++++++++ .../src/connectors/chat/connectChat.ts | 8 ++ .../src/lib/ai-lite/abstract-chat.ts | 17 ++- .../instantsearch.js/src/lib/ai-lite/types.ts | 1 + 4 files changed, 152 insertions(+), 4 deletions(-) diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index e75b0832e2f..0e4a9ca43d9 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -447,6 +447,7 @@ describe('connectChat', () => { const renderState = getRenderState(); expect(renderState.tools).toEqual({ testTool: { + showLoaderDuringStreaming: true, ...mockTool, addToolResult: expect.any(Function), applyFilters: expect.any(Function), @@ -565,6 +566,135 @@ data: [DONE]`, expect(toolPart?.input).toEqual({}); }); }); + + it('skips JSON repair for tools with showLoaderDuringStreaming enabled (default)', async () => { + const { widget } = getInitializedWidget({ + agentId: undefined, + tools: { + myTool: {}, + }, + transport: { + fetch: () => + Promise.resolve( + new Response( + `data: {"type": "start", "messageId": "test-id"} + +data: {"type": "start-step"} + +data: {"type": "tool-input-start", "toolCallId": "call_1", "toolName": "myTool"} + +data: {"type": "tool-input-delta", "toolCallId": "call_1", "toolName": "myTool", "inputTextDelta": "{\\"query\\": \\"sho"} + +data: {"type": "finish-step"} + +data: {"type": "finish"} + +data: [DONE]`, + { + headers: { 'Content-Type': 'text/event-stream' }, + } + ) + ), + }, + }); + + const { chatInstance } = widget; + + await chatInstance.sendMessage({ + id: 'message-id', + role: 'user', + parts: [{ type: 'text', text: 'search' }], + }); + + await waitFor(() => { + const lastMessage = + chatInstance.messages[chatInstance.messages.length - 1]; + const toolPart = lastMessage?.parts.find( + (part) => + 'type' in part && + part.type === 'tool-myTool' && + 'toolCallId' in part && + part.toolCallId === 'call_1' + ) as + | { + state: string; + rawInput?: string; + input?: unknown; + } + | undefined; + + expect(toolPart?.state).toBe('input-streaming'); + // Input is not repaired since showLoaderDuringStreaming defaults to true + expect(toolPart?.input).toBeUndefined(); + // Raw input is still accumulated + expect(toolPart?.rawInput).toBe('{"query": "sho'); + }); + }); + + it('repairs JSON for tools with showLoaderDuringStreaming set to false', async () => { + const { widget } = getInitializedWidget({ + agentId: undefined, + tools: { + myTool: { + showLoaderDuringStreaming: false, + }, + }, + transport: { + fetch: () => + Promise.resolve( + new Response( + `data: {"type": "start", "messageId": "test-id"} + +data: {"type": "start-step"} + +data: {"type": "tool-input-start", "toolCallId": "call_1", "toolName": "myTool"} + +data: {"type": "tool-input-delta", "toolCallId": "call_1", "toolName": "myTool", "inputTextDelta": "{\\"query\\": \\"sho"} + +data: {"type": "finish-step"} + +data: {"type": "finish"} + +data: [DONE]`, + { + headers: { 'Content-Type': 'text/event-stream' }, + } + ) + ), + }, + }); + + const { chatInstance } = widget; + + await chatInstance.sendMessage({ + id: 'message-id', + role: 'user', + parts: [{ type: 'text', text: 'search' }], + }); + + await waitFor(() => { + const lastMessage = + chatInstance.messages[chatInstance.messages.length - 1]; + const toolPart = lastMessage?.parts.find( + (part) => + 'type' in part && + part.type === 'tool-myTool' && + 'toolCallId' in part && + part.toolCallId === 'call_1' + ) as + | { + state: string; + rawInput?: string; + input?: unknown; + } + | undefined; + + expect(toolPart?.state).toBe('input-streaming'); + // Input is repaired since showLoaderDuringStreaming is false + expect(toolPart?.input).toEqual({ query: 'sho' }); + expect(toolPart?.rawInput).toBe('{"query": "sho'); + }); + }); }); describe('transport configuration', () => { diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index c9c18c5c39a..250d95ba4cb 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -420,6 +420,14 @@ export default (function connectChat( ...options, transport, sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + shouldRepairToolInput(toolName) { + let tool = tools[toolName]; + if (!tool && toolName.startsWith(`${SearchIndexToolType}_`)) { + tool = tools[SearchIndexToolType]; + } + if (!tool) return true; + return tool.showLoaderDuringStreaming === false; + }, onToolCall({ toolCall }) { let tool = tools[toolCall.toolName]; diff --git a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts index ff0dd4db310..4cbdb794ea1 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts @@ -132,6 +132,7 @@ export abstract class AbstractChat { private sendAutomaticallyWhen?: (options: { messages: TUIMessage[]; }) => boolean | PromiseLike; + private shouldRepairToolInput?: (toolName: string) => boolean; private activeResponse: ActiveResponse | null = null; private jobExecutor = new SerialJobExecutor(); @@ -146,6 +147,7 @@ export abstract class AbstractChat { onFinish, onData, sendAutomaticallyWhen, + shouldRepairToolInput, }: Omit, 'messages'> & { state: ChatState; }) { @@ -158,6 +160,7 @@ export abstract class AbstractChat { this.onFinish = onFinish; this.onData = onData; this.sendAutomaticallyWhen = sendAutomaticallyWhen; + this.shouldRepairToolInput = shouldRepairToolInput; } /** @@ -758,10 +761,16 @@ export abstract class AbstractChat { const nextRawInput = `${previousRawInput}${chunk.inputTextDelta}`; toolRawInputByCallId[chunk.toolCallId] = nextRawInput; - const parsedInput = parseToolInputDelta( - nextRawInput, - existingPart?.input - ); + const toolName = + chunk.toolName ?? + existingPart?.type?.replace('tool-', ''); + const shouldRepair = + toolName + ? (this.shouldRepairToolInput?.(toolName) ?? true) + : true; + const parsedInput = shouldRepair + ? parseToolInputDelta(nextRawInput, existingPart?.input) + : existingPart?.input; const nextToolPart = { ...(existingPart ?? { diff --git a/packages/instantsearch.js/src/lib/ai-lite/types.ts b/packages/instantsearch.js/src/lib/ai-lite/types.ts index daf60dc86be..12d83e7896d 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/types.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/types.ts @@ -434,6 +434,7 @@ export interface ChatInit { sendAutomaticallyWhen?: (options: { messages: UI_MESSAGE[]; }) => boolean | PromiseLike; + shouldRepairToolInput?: (toolName: string) => boolean; } export type CreateUIMessage = Omit< From e16c8c4f68619f24859be38b3f26cf9fe12cf45b Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 10 Apr 2026 09:36:21 +0100 Subject: [PATCH 22/23] update bundlesize --- bundlesize.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index 1dfc56e30ea..666b80171d4 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,11 +10,11 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "121.8 kB" + "maxSize": "122.75 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "253.4 kB" + "maxSize": "255 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", From 60788da133b6a8f089b43c568413c023927de058 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 10 Apr 2026 09:57:34 +0100 Subject: [PATCH 23/23] rename flag --- .../src/components/chat/ChatMessage.tsx | 2 +- .../src/components/chat/ChatMessages.tsx | 2 +- .../src/components/chat/types.ts | 2 +- .../src/connectors/chat/__tests__/connectChat-test.ts | 11 +++++------ .../src/connectors/chat/connectChat.ts | 3 +-- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index 9ad5eb6a907..fcfc64b74a7 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -245,7 +245,7 @@ export function createChatMessageComponent({ createElement }: Renderer) { if ( toolMessage.state === 'input-streaming' && - tool.showLoaderDuringStreaming + !tool.streamInput ) { return null; } diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index 2f53483ace8..ef97805c3b0 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -520,7 +520,7 @@ const getShowLoader = ( if (lastPart.state === 'output-available') return false; if (lastPart.state === 'input-streaming') { const tool = findTool(lastPart.type, tools); - return tool?.showLoaderDuringStreaming !== false; + return !tool?.streamInput; } return true; } diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index bdbe76e02ca..37f42e3691d 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -496,7 +496,7 @@ export type ClientSideToolComponent = ( export type ClientSideTool = { layoutComponent?: ClientSideToolComponent; - showLoaderDuringStreaming?: boolean; + streamInput?: boolean; addToolResult: AddToolResult; sendEvent?: SendEventForHits; onToolCall?: ( diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index 0e4a9ca43d9..aff5df16586 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -447,7 +447,6 @@ describe('connectChat', () => { const renderState = getRenderState(); expect(renderState.tools).toEqual({ testTool: { - showLoaderDuringStreaming: true, ...mockTool, addToolResult: expect.any(Function), applyFilters: expect.any(Function), @@ -567,7 +566,7 @@ data: [DONE]`, }); }); - it('skips JSON repair for tools with showLoaderDuringStreaming enabled (default)', async () => { + it('skips JSON repair for tools without streamInput (default)', async () => { const { widget } = getInitializedWidget({ agentId: undefined, tools: { @@ -624,19 +623,19 @@ data: [DONE]`, | undefined; expect(toolPart?.state).toBe('input-streaming'); - // Input is not repaired since showLoaderDuringStreaming defaults to true + // Input is not repaired since streamInput is not set (default) expect(toolPart?.input).toBeUndefined(); // Raw input is still accumulated expect(toolPart?.rawInput).toBe('{"query": "sho'); }); }); - it('repairs JSON for tools with showLoaderDuringStreaming set to false', async () => { + it('repairs JSON for tools with streamInput set to true', async () => { const { widget } = getInitializedWidget({ agentId: undefined, tools: { myTool: { - showLoaderDuringStreaming: false, + streamInput: true, }, }, transport: { @@ -690,7 +689,7 @@ data: [DONE]`, | undefined; expect(toolPart?.state).toBe('input-streaming'); - // Input is repaired since showLoaderDuringStreaming is false + // Input is repaired since streamInput is true expect(toolPart?.input).toEqual({ query: 'sho' }); expect(toolPart?.rawInput).toBe('{"query": "sho'); }); diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index 250d95ba4cb..2e493e4c3cf 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -426,7 +426,7 @@ export default (function connectChat( tool = tools[SearchIndexToolType]; } if (!tool) return true; - return tool.showLoaderDuringStreaming === false; + return Boolean(tool.streamInput); }, onToolCall({ toolCall }) { let tool = tools[toolCall.toolName]; @@ -607,7 +607,6 @@ export default (function connectChat( const toolsWithAddToolResult: ClientSideTools = {}; Object.entries(tools).forEach(([key, tool]) => { const toolWithAddToolResult: ClientSideTool = { - showLoaderDuringStreaming: true, ...tool, addToolResult: _chatInstance.addToolResult, applyFilters,