@@ -42,14 +76,30 @@ export function createChatMessageLoaderComponent({
- {translations.loaderText && (
-
- {translations.loaderText}
+ {caption && (
+
+ {caption}
)}
-
+
)
+ : undefined
+ }
+ >
diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx
new file mode 100644
index 00000000000..a27a8362e37
--- /dev/null
+++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx
@@ -0,0 +1,138 @@
+/** @jsx createElement */
+
+import { cx } from '../../lib';
+
+import { BrainIcon, ChevronDownIcon } from './icons';
+
+import type { ComponentProps, Renderer } from '../../types';
+import type { ChatMessageBase, ReasoningUIPart } from './types';
+
+export type ChatMessageReasoningVisibility =
+ | 'collapsed'
+ | 'expanded'
+ | 'auto'
+ | 'hidden';
+
+export type ChatMessageReasoningTranslations = {
+ /**
+ * Header label for the reasoning panel. Defaults to "Reasoning" to match
+ * the Agent Studio dashboard.
+ */
+ title: string;
+ /** Accessible label for the disclosure toggle. */
+ toggleLabel: string;
+};
+
+export type ChatMessageReasoningClassNames = {
+ root: string | string[];
+ header: string | string[];
+ icon: string | string[];
+ label: string | string[];
+ chevron: string | string[];
+ body: string | string[];
+ text: string | string[];
+};
+
+export type ChatMessageReasoningProps = ComponentProps<'details'> & {
+ /** The message whose `reasoning` parts should be rendered. */
+ message: ChatMessageBase;
+ /**
+ * Visibility strategy.
+ * - `collapsed` (default): closed, user can expand — mirrors the dashboard.
+ * - `expanded`: always open.
+ * - `auto`: open while streaming, collapsible afterwards.
+ * - `hidden`: render nothing even if reasoning parts exist.
+ */
+ visibility?: ChatMessageReasoningVisibility;
+ classNames?: Partial
;
+ translations?: Partial;
+};
+
+function getReasoningParts(message: ChatMessageBase): ReasoningUIPart[] {
+ return message.parts.filter(
+ (p): p is ReasoningUIPart => p.type === 'reasoning'
+ );
+}
+
+export function createChatMessageReasoningComponent({
+ createElement,
+}: Renderer) {
+ return function ChatMessageReasoning(userProps: ChatMessageReasoningProps) {
+ const {
+ message,
+ visibility = 'collapsed',
+ classNames = {},
+ translations: userTranslations,
+ ...props
+ } = userProps;
+
+ if (visibility === 'hidden') return null;
+
+ const reasoningParts = getReasoningParts(message);
+ if (reasoningParts.length === 0) return null;
+
+ const translations: Required = {
+ title: 'Reasoning',
+ toggleLabel: 'Toggle reasoning',
+ ...userTranslations,
+ };
+
+ const streaming = reasoningParts.some((part) => part.state === 'streaming');
+
+ // Native `` handles the open/closed state (and the `[open]`
+ // attribute the CSS keys off) with zero JS. We only force `open` for the
+ // `expanded` strategy and while streaming under `auto`; otherwise the
+ // element is uncontrolled so the user can freely toggle it.
+ const forceOpen =
+ visibility === 'expanded' || (visibility === 'auto' && streaming);
+
+ const cssClasses: ChatMessageReasoningClassNames = {
+ root: cx(
+ 'ais-ChatMessageReasoning',
+ streaming && 'ais-ChatMessageReasoning--streaming',
+ classNames.root
+ ),
+ header: cx('ais-ChatMessageReasoning-header', classNames.header),
+ icon: cx('ais-ChatMessageReasoning-icon', classNames.icon),
+ label: cx('ais-ChatMessageReasoning-label', classNames.label),
+ chevron: cx('ais-ChatMessageReasoning-chevron', classNames.chevron),
+ body: cx('ais-ChatMessageReasoning-body', classNames.body),
+ text: cx('ais-ChatMessageReasoning-text', classNames.text),
+ };
+
+ return (
+
+
+
+
+
+ {translations.title}
+
+
+
+
+
+
+ {reasoningParts.map((part, index) => (
+
+ {part.text}
+
+ ))}
+
+
+ );
+ };
+}
diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx
index 27539b9b53b..1a40a704572 100644
--- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx
+++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx
@@ -8,6 +8,11 @@ import {
isPartText,
isPartTool,
} from '../../lib/utils/chat';
+import {
+ getReasoningContext,
+ summarizeReasoning,
+ type ReasoningSummarizer,
+} from '../../lib/utils/reasoning';
import { createButtonComponent } from '../Button';
import { createChatMessageComponent } from './ChatMessage';
@@ -38,6 +43,11 @@ import type {
} from './ChatMessage';
import type { ChatMessageErrorProps } from './ChatMessageError';
import type { ChatMessageLoaderProps } from './ChatMessageLoader';
+import type {
+ ChatMessageReasoningClassNames,
+ ChatMessageReasoningTranslations,
+ ChatMessageReasoningVisibility,
+} from './ChatMessageReasoning';
import type {
ChatEmptyProps,
ChatLayoutOwnProps,
@@ -241,6 +251,31 @@ export type ChatMessagesProps<
* Map of message IDs to their feedback state.
*/
feedbackState?: Record;
+ /**
+ * Whether to render reasoning UI parts. Off by default.
+ * Forwarded to every {@link ChatMessageProps.showReasoning}, and also
+ * controls whether the loader caption is replaced with a live
+ * substitute label derived from the reasoning stream.
+ */
+ showReasoning?: boolean;
+ /**
+ * Visibility strategy for the reasoning panel. Default: `collapsed`.
+ */
+ reasoningVisibility?: ChatMessageReasoningVisibility;
+ /**
+ * Optional override for the reasoning summarizer.
+ */
+ reasoningSummarizer?: ReasoningSummarizer;
+ /**
+ * Injectable strings for the reasoning panel. Forwarded to every message's
+ * ``.
+ */
+ reasoningTranslations?: Partial;
+ /**
+ * Optional class names for the reasoning panel. Forwarded to every message's
+ * ``.
+ */
+ reasoningClassNames?: Partial;
};
const copyToClipboard = (message: ChatMessageBase) => {
@@ -298,6 +333,10 @@ function createDefaultMessageComponent<
messageTranslations,
translations,
suggestionsElement,
+ showReasoning,
+ reasoningVisibility,
+ reasoningTranslations,
+ reasoningClassNames,
}: {
key: string;
message: TMessage;
@@ -317,6 +356,10 @@ function createDefaultMessageComponent<
classNames?: Partial;
messageTranslations?: Partial;
suggestionsElement?: VNode;
+ showReasoning?: boolean;
+ reasoningVisibility?: ChatMessageReasoningVisibility;
+ reasoningTranslations?: Partial;
+ reasoningClassNames?: Partial;
}) {
const defaultAssistantActions: ChatMessageActionProps[] = [
...(hasTextContent(message)
@@ -401,6 +444,10 @@ function createDefaultMessageComponent<
classNames={classNames}
translations={messageTranslations}
suggestionsElement={suggestionsElement}
+ showReasoning={showReasoning}
+ reasoningVisibility={reasoningVisibility}
+ reasoningTranslations={reasoningTranslations}
+ reasoningClassNames={reasoningClassNames}
{...messageProps}
/>
);
@@ -464,6 +511,11 @@ export function createChatMessagesComponent({
suggestionsElement,
onFeedback,
feedbackState,
+ showReasoning = false,
+ reasoningVisibility = 'collapsed',
+ reasoningSummarizer,
+ reasoningTranslations,
+ reasoningClassNames,
...props
} = userProps;
@@ -496,6 +548,28 @@ export function createChatMessagesComponent({
const lastPart = lastMessage?.parts?.[lastMessage.parts.length - 1];
const showLoader = getShowLoader(status, lastPart, tools);
+ // Derive a live substitute label for the loader from the in-flight
+ // assistant message. Off when reasoning rendering is opted-out.
+ let reasoningPreview: string | undefined;
+ if (
+ showReasoning &&
+ lastMessage &&
+ lastMessage.role === 'assistant'
+ ) {
+ const summarizer = reasoningSummarizer || summarizeReasoning;
+ const ctx = getReasoningContext(lastMessage);
+ if (ctx.text || ctx.lastToolCall) {
+ const summary = summarizer(ctx.text, {
+ message: lastMessage,
+ lastToolCall: ctx.lastToolCall,
+ streaming: ctx.streaming,
+ });
+ if (!summary.redact) {
+ reasoningPreview = summary.label;
+ }
+ }
+ }
+
const showEmpty =
messages.length === 0 && !showLoader && !isClearing && status !== 'error';
@@ -555,6 +629,10 @@ export function createChatMessagesComponent({
translations={translations}
classNames={messageClassNames}
messageTranslations={messageTranslations}
+ showReasoning={showReasoning}
+ reasoningVisibility={reasoningVisibility}
+ reasoningTranslations={reasoningTranslations}
+ reasoningClassNames={reasoningClassNames}
suggestionsElement={
status === 'ready' &&
message.role === 'assistant' &&
@@ -568,6 +646,7 @@ export function createChatMessagesComponent({
{showLoader && (
)}
diff --git a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessageReasoning.test.tsx b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessageReasoning.test.tsx
new file mode 100644
index 00000000000..691a6b3d570
--- /dev/null
+++ b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessageReasoning.test.tsx
@@ -0,0 +1,126 @@
+/**
+ * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts
+ */
+/** @jsx createElement */
+import { render } from '@testing-library/preact';
+import { Fragment, createElement } from 'preact';
+
+import { createChatMessageReasoningComponent } from '../ChatMessageReasoning';
+
+import type { ChatMessageBase } from '../types';
+
+const ChatMessageReasoning = createChatMessageReasoningComponent({
+ createElement,
+ Fragment,
+});
+
+function messageWith(
+ parts: ChatMessageBase['parts'],
+ overrides: Partial = {}
+): ChatMessageBase {
+ return { id: '1', role: 'assistant', parts, ...overrides };
+}
+
+describe('ChatMessageReasoning', () => {
+ test('renders nothing when there are no reasoning parts', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ test('renders nothing when visibility is hidden', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ test('renders a collapsible disclosure with the default "Reasoning" title', () => {
+ const { container } = render(
+
+ );
+ const details = container.querySelector('details.ais-ChatMessageReasoning');
+ expect(details).toBeInTheDocument();
+ // Collapsed by default.
+ expect(details).not.toHaveAttribute('open');
+ expect(
+ container.querySelector('.ais-ChatMessageReasoning-label')
+ ).toHaveTextContent('Reasoning');
+ // Brain icon is present.
+ expect(
+ container.querySelector('.ais-ChatMessageReasoning-icon svg')
+ ).toBeInTheDocument();
+ });
+
+ test('renders the raw reasoning body text', () => {
+ const { container } = render(
+
+ );
+ expect(
+ container.querySelector('.ais-ChatMessageReasoning-text')
+ ).toHaveTextContent('comparing the options');
+ });
+
+ test('is open when visibility is expanded', () => {
+ const { container } = render(
+
+ );
+ expect(
+ container.querySelector('.ais-ChatMessageReasoning')
+ ).toHaveAttribute('open');
+ });
+
+ test('is open while streaming under the auto strategy', () => {
+ const { container } = render(
+
+ );
+ const details = container.querySelector('.ais-ChatMessageReasoning');
+ expect(details).toHaveAttribute('open');
+ expect(details).toHaveClass('ais-ChatMessageReasoning--streaming');
+ });
+
+ test('accepts injectable title and toggle label', () => {
+ const { container, getByLabelText } = render(
+
+ );
+ expect(
+ container.querySelector('.ais-ChatMessageReasoning-label')
+ ).toHaveTextContent('Raisonnement');
+ expect(getByLabelText('Basculer le raisonnement')).toBeInTheDocument();
+ });
+
+ test('applies injectable class names', () => {
+ const { container } = render(
+
+ );
+ expect(container.querySelector('.my-root')).toBeInTheDocument();
+ expect(container.querySelector('.my-text')).toBeInTheDocument();
+ });
+});
diff --git a/packages/instantsearch-ui-components/src/components/chat/icons.tsx b/packages/instantsearch-ui-components/src/components/chat/icons.tsx
index f3715cad8d0..22b18c3a071 100644
--- a/packages/instantsearch-ui-components/src/components/chat/icons.tsx
+++ b/packages/instantsearch-ui-components/src/components/chat/icons.tsx
@@ -294,3 +294,29 @@ export function ChevronRightIcon({ createElement }: IconProps) {
);
}
+
+export function BrainIcon({ createElement }: IconProps) {
+ // Lucide `brain` icon — kept in sync with the dashboard (Agent Studio) so the
+ // reasoning panel reads identically across surfaces.
+ return (
+
+ );
+}
diff --git a/packages/instantsearch-ui-components/src/components/index.ts b/packages/instantsearch-ui-components/src/components/index.ts
index aaa5df4b9e6..6dadf0ebcaf 100644
--- a/packages/instantsearch-ui-components/src/components/index.ts
+++ b/packages/instantsearch-ui-components/src/components/index.ts
@@ -7,6 +7,7 @@ export * from './chat/ChatInlineLayout';
export * from './chat/ChatSidePanelLayout';
export * from './chat/ChatHeader';
export * from './chat/ChatMessage';
+export * from './chat/ChatMessageReasoning';
export * from './chat/ChatMessages';
export * from './chat/ChatMessageLoader';
export * from './chat/ChatMessageError';
diff --git a/packages/instantsearch-ui-components/src/lib/utils/__tests__/reasoning-test.ts b/packages/instantsearch-ui-components/src/lib/utils/__tests__/reasoning-test.ts
new file mode 100644
index 00000000000..dbccda1f640
--- /dev/null
+++ b/packages/instantsearch-ui-components/src/lib/utils/__tests__/reasoning-test.ts
@@ -0,0 +1,135 @@
+import {
+ categorizeFromToolCall,
+ categorizeReasoning,
+ getReasoningContext,
+ summarizeReasoning,
+} from '../reasoning';
+
+import type { ChatMessageBase } from '../../../components/chat/types';
+
+describe('categorizeReasoning', () => {
+ test('returns the thinking fallback for empty input', () => {
+ expect(categorizeReasoning('')).toEqual({
+ label: 'Thinking\u2026',
+ category: 'thinking',
+ });
+ expect(categorizeReasoning(' ')).toEqual({
+ label: 'Thinking\u2026',
+ category: 'thinking',
+ });
+ });
+
+ test('matches keyword rules with the documented precedence', () => {
+ expect(categorizeReasoning('comparing the price and budget').category).toBe(
+ 'pricing'
+ );
+ expect(categorizeReasoning('narrow down by category').category).toBe(
+ 'filtering'
+ );
+ expect(categorizeReasoning('let me search the catalogue').category).toBe(
+ 'searching'
+ );
+ expect(categorizeReasoning('I will plan the next step').category).toBe(
+ 'planning'
+ );
+ });
+
+ test('flags PII/secrets for redaction', () => {
+ const summary = categorizeReasoning('the api_key is sk-1234, searching');
+ expect(summary.redact).toBe(true);
+ });
+
+ test('inspects content that falls within the last 2KB of the buffer', () => {
+ const longPrefix = 'x'.repeat(4000);
+ const summary = categorizeReasoning(`${longPrefix} the price is too high`);
+ expect(summary.category).toBe('pricing');
+ });
+
+ test('ignores content older than the last 2KB of the buffer', () => {
+ const stalePricing = 'the price is high ';
+ const recentFiller = 'x'.repeat(4000);
+ const summary = categorizeReasoning(`${stalePricing}${recentFiller}`);
+ expect(summary.category).toBe('thinking');
+ });
+});
+
+describe('categorizeFromToolCall', () => {
+ test('derives a label from the search index tool input', () => {
+ expect(
+ categorizeFromToolCall({
+ type: 'tool-algolia_search_index',
+ input: { indexName: 'products' },
+ })
+ ).toEqual({ category: 'tool', label: 'Searching the products index' });
+ });
+
+ test('falls back to a generic label without an index name', () => {
+ expect(
+ categorizeFromToolCall({ type: 'tool-algolia_search_index' })
+ ).toEqual({ category: 'tool', label: 'Searching the catalogue' });
+ });
+
+ test('humanizes unknown tool names', () => {
+ expect(categorizeFromToolCall({ type: 'tool-do_a_thing' })).toEqual({
+ category: 'tool',
+ label: 'Running Do A Thing',
+ });
+ });
+});
+
+describe('summarizeReasoning', () => {
+ const message: ChatMessageBase = { id: '1', role: 'assistant', parts: [] };
+
+ test('tier 1: uses the first sentence of a short server summary', () => {
+ const summary = summarizeReasoning(
+ 'Comparing the top matches. Then ranking them.',
+ { message, streaming: false }
+ );
+ expect(summary.label).toBe('Comparing the top matches.');
+ });
+
+ test('tier 2: prefers the in-flight tool call when streaming', () => {
+ const summary = summarizeReasoning('some very long reasoning '.repeat(50), {
+ message,
+ streaming: true,
+ lastToolCall: {
+ type: 'tool-algolia_search_index',
+ input: { indexName: 'products' },
+ } as unknown as ChatMessageBase['parts'][number] & {
+ type: `tool-${string}`;
+ },
+ });
+ expect(summary.label).toBe('Searching the products index');
+ });
+
+ test('tier 3: heuristic categorizer for long streaming text', () => {
+ const summary = summarizeReasoning('narrowing down by category '.repeat(50), {
+ message,
+ streaming: true,
+ });
+ expect(summary.category).toBe('filtering');
+ });
+});
+
+describe('getReasoningContext', () => {
+ test('concatenates reasoning parts and tracks streaming + last tool', () => {
+ const message: ChatMessageBase = {
+ id: '1',
+ role: 'assistant',
+ parts: [
+ { type: 'reasoning', text: 'first ' },
+ {
+ type: 'tool-algolia_search_index',
+ input: { indexName: 'products' },
+ } as unknown as ChatMessageBase['parts'][number],
+ { type: 'reasoning', text: 'second', state: 'streaming' },
+ ],
+ };
+
+ const ctx = getReasoningContext(message);
+
+ expect(ctx.text).toBe('first second');
+ expect(ctx.streaming).toBe(true);
+ expect(ctx.lastToolCall?.type).toBe('tool-algolia_search_index');
+ });
+});
diff --git a/packages/instantsearch-ui-components/src/lib/utils/index.ts b/packages/instantsearch-ui-components/src/lib/utils/index.ts
index 977595d5629..1deb089e2a8 100644
--- a/packages/instantsearch-ui-components/src/lib/utils/index.ts
+++ b/packages/instantsearch-ui-components/src/lib/utils/index.ts
@@ -1,4 +1,5 @@
export { getFacetFiltersFromToolInput } from './chat';
export * from './find';
export * from './promptSuggestions';
+export * from './reasoning';
export * from './startsWith';
diff --git a/packages/instantsearch-ui-components/src/lib/utils/reasoning.ts b/packages/instantsearch-ui-components/src/lib/utils/reasoning.ts
new file mode 100644
index 00000000000..27f5f736821
--- /dev/null
+++ b/packages/instantsearch-ui-components/src/lib/utils/reasoning.ts
@@ -0,0 +1,220 @@
+/**
+ * Reasoning categorization utilities.
+ *
+ * Modern reasoning models (GPT-5/o-series, Claude with extended/adaptive
+ * thinking, Gemini 2.5+, DeepSeek-R1-class) emit a stream of internal
+ * chain-of-thought (or, where supported, a server-side summary).
+ *
+ * Showing that stream verbatim is overwhelming and sometimes unsafe.
+ * We project it onto a single short "substitute label" that is friendly,
+ * stable, and refreshed at most every ~600ms by the consumer.
+ *
+ * The strategy is intentionally tiny: zero dependencies, synchronous,
+ * predictable. Apps can replace it wholesale by passing a custom
+ * {@link ReasoningSummarizer}.
+ */
+
+import { startsWith } from './startsWith';
+
+import type { ChatMessageBase } from '../../components/chat/types';
+
+export type ReasoningCategory =
+ | 'thinking'
+ | 'searching'
+ | 'filtering'
+ | 'ranking'
+ | 'pricing'
+ | 'attributes'
+ | 'summarising'
+ | 'images'
+ | 'planning'
+ | 'tool';
+
+export type ReasoningSummary = {
+ /** Short user-facing label, e.g. "Searching the catalogue". */
+ label: string;
+ /** Coarse category for telemetry/styling. */
+ category: ReasoningCategory;
+ /**
+ * If true, the host should not display the raw reasoning body
+ * (used when the categorizer detects PII / prompt fragments).
+ */
+ redact?: boolean;
+};
+
+export type ReasoningSummarizerContext = {
+ /** The full message currently being rendered. */
+ message: ChatMessageBase;
+ /** The most recent assistant tool call, if any. */
+ lastToolCall?: ChatMessageBase['parts'][number] & { type: `tool-${string}` };
+ /** Truthy when the latest reasoning part is still streaming. */
+ streaming?: boolean;
+};
+
+export type ReasoningSummarizer = (
+ reasoningText: string,
+ ctx: ReasoningSummarizerContext
+) => ReasoningSummary;
+
+const PII_PATTERN =
+ /\b(?:api[_-]?key|secret|bearer\s+[a-z0-9._-]+|sk-[a-z0-9]{20,}|ssn|passport|credit\s*card)\b/i;
+
+const KEYWORD_RULES: Array<{
+ test: RegExp;
+ category: ReasoningCategory;
+ label: string;
+}> = [
+ // ordering matters: more specific first
+ { test: /\b(price|budget|cheap|expensive|cost|usd|eur)\b/i, category: 'pricing', label: 'Looking at price ranges' },
+ { test: /\b(image|photo|picture|visual|thumbnail)\b/i, category: 'images', label: 'Looking at product images' },
+ { test: /\b(filter|narrow|refin|facet|category)\b/i, category: 'filtering', label: 'Narrowing down filters' },
+ { test: /\b(rank|sort|order|rerank|relevan(t|ce))\b/i, category: 'ranking', label: 'Comparing top matches' },
+ { test: /\b(size|colou?r|brand|material|fabric|fit)\b/i, category: 'attributes', label: 'Reading product attributes' },
+ { test: /\b(summari[sz]e|conclude|recommend|suggest|propose)\b/i, category: 'summarising', label: 'Summarising the results' },
+ { test: /\b(search|look(?:ing)?\s+up|find(?:ing)?|query|catalog(?:ue)?)\b/i, category: 'searching', label: 'Searching the catalogue' },
+ { test: /\b(plan|strategy|approach|let me think|i should|i'll|i will)\b/i, category: 'planning', label: 'Planning the next step' },
+];
+
+/**
+ * Heuristic categorizer. Runs over the **last 2 KB** of the buffer only,
+ * so it stays cheap as the reasoning stream grows.
+ */
+export function categorizeReasoning(reasoningText: string): ReasoningSummary {
+ const tail = reasoningText.length > 2048
+ ? reasoningText.slice(-2048)
+ : reasoningText;
+
+ if (!tail.trim()) {
+ return { label: 'Thinking\u2026', category: 'thinking' };
+ }
+
+ const redact = PII_PATTERN.test(tail);
+
+ for (const rule of KEYWORD_RULES) {
+ if (rule.test.test(tail)) {
+ return { label: rule.label, category: rule.category, redact };
+ }
+ }
+
+ return { label: 'Thinking\u2026', category: 'thinking', redact };
+}
+
+/**
+ * Tier 2 categorizer: derive a label from the most recent tool call.
+ * Returns null when no useful inference can be made.
+ */
+export function categorizeFromToolCall(
+ part: { type: `tool-${string}`; input?: unknown }
+): ReasoningSummary | null {
+ const toolName = part.type.slice('tool-'.length);
+
+ if (startsWith(toolName, 'algolia_search_index')) {
+ const indexName = readStringProp(part.input, 'indexName');
+ return {
+ category: 'tool',
+ label: indexName
+ ? `Searching the ${truncate(indexName, 28)} index`
+ : 'Searching the catalogue',
+ };
+ }
+
+ if (startsWith(toolName, 'algolia_recommend')) {
+ return { category: 'tool', label: 'Looking at related products' };
+ }
+
+ if (toolName === 'apply_filters' || startsWith(toolName, 'apply_filters')) {
+ return { category: 'filtering', label: 'Narrowing down filters' };
+ }
+
+ if (startsWith(toolName, 'compare')) {
+ return { category: 'ranking', label: 'Comparing top matches' };
+ }
+
+ return { category: 'tool', label: `Running ${humanize(toolName)}` };
+}
+
+/**
+ * Top-level summarizer that walks the three tiers described in the RFC:
+ *
+ * 1. Server-side summary already present in the reasoning stream.
+ * (We accept any reasoning text shorter than `serverSummaryThreshold`
+ * as evidence that the server pre-summarized.)
+ * 2. Tool-call inference, if a tool is currently in flight.
+ * 3. Heuristic regex categorizer over the last ~2 KB of the buffer.
+ */
+export function summarizeReasoning(
+ reasoningText: string,
+ ctx: ReasoningSummarizerContext,
+ options: { serverSummaryThreshold?: number } = {}
+): ReasoningSummary {
+ const threshold = options.serverSummaryThreshold ?? 280;
+
+ // Tier 1: short server-side summary - use the first sentence verbatim.
+ const trimmed = reasoningText.trim();
+ if (trimmed && trimmed.length <= threshold && !ctx.streaming) {
+ // Grab up to the first sentence terminator. Avoids lookbehind so the
+ // utility stays compatible with older JS engines.
+ const sentenceMatch = trimmed.match(/^[\s\S]*?[.!?](?:\s|$)/);
+ const firstSentence = (sentenceMatch ? sentenceMatch[0] : trimmed).trim();
+ return {
+ label: truncate(firstSentence, 64),
+ category: inferCategoryFromText(firstSentence),
+ };
+ }
+
+ // Tier 2: tool-call inference.
+ if (ctx.lastToolCall) {
+ const fromTool = categorizeFromToolCall(ctx.lastToolCall);
+ if (fromTool) return fromTool;
+ }
+
+ // Tier 3: heuristic.
+ return categorizeReasoning(reasoningText);
+}
+
+function inferCategoryFromText(text: string): ReasoningCategory {
+ return categorizeReasoning(text).category;
+}
+
+function readStringProp(input: unknown, key: string): string | undefined {
+ if (input && typeof input === 'object' && !Array.isArray(input)) {
+ const value = (input as Record)[key];
+ return typeof value === 'string' ? value : undefined;
+ }
+ return undefined;
+}
+
+function truncate(value: string, max: number): string {
+ return value.length > max ? `${value.slice(0, max - 1)}\u2026` : value;
+}
+
+function humanize(toolName: string): string {
+ return toolName
+ .replace(/[_-]+/g, ' ')
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+/**
+ * Pull the current reasoning text and most recent tool call from a message.
+ * Convenience for callers that only have the {@link ChatMessageBase}.
+ */
+export function getReasoningContext(
+ message: ChatMessageBase
+): { text: string; lastToolCall?: ReasoningSummarizerContext['lastToolCall']; streaming: boolean } {
+ let text = '';
+ let streaming = false;
+ let lastToolCall: ReasoningSummarizerContext['lastToolCall'] | undefined;
+
+ for (const part of message.parts) {
+ if (part.type === 'reasoning') {
+ text += part.text;
+ if (part.state === 'streaming') {
+ streaming = true;
+ }
+ } else if (startsWith(part.type, 'tool-')) {
+ lastToolCall = part as ReasoningSummarizerContext['lastToolCall'];
+ }
+ }
+
+ return { text, lastToolCall, streaming };
+}
diff --git a/packages/instantsearch.css/src/components/chat.scss b/packages/instantsearch.css/src/components/chat.scss
index dc3b268e6de..2671710c313 100644
--- a/packages/instantsearch.css/src/components/chat.scss
+++ b/packages/instantsearch.css/src/components/chat.scss
@@ -12,6 +12,7 @@
@use 'chat/chat-messages';
@use 'chat/chat-message';
@use 'chat/chat-message-loader';
+@use 'chat/chat-message-reasoning';
@use 'chat/chat-greeting';
@use 'chat/chat-prompt';
@use 'chat/chat-tool-results';
diff --git a/packages/instantsearch.css/src/components/chat/_chat-message-loader.scss b/packages/instantsearch.css/src/components/chat/_chat-message-loader.scss
index 9abfa6b69a8..d9b84de428d 100644
--- a/packages/instantsearch.css/src/components/chat/_chat-message-loader.scss
+++ b/packages/instantsearch.css/src/components/chat/_chat-message-loader.scss
@@ -60,6 +60,29 @@
width: 40%;
}
}
+
+ // Animate the caption every time the key changes (i.e. when the
+ // substitute label is updated). Pairs with `key={caption}` in the
+ // ChatMessageLoader component.
+ &.ais-ChatMessageLoader--withPreview .ais-ChatMessageLoader-text {
+ animation: ais-chat-loader-text-in 280ms ease-out, ais-chat-loader-text 3s linear infinite;
+ animation-delay: 0s, 0.5s;
+ }
+
+ // Progress hairline: turn the second skeleton bar into a thin,
+ // filled progress line driven by the `--ais-loader-progress` CSS var.
+ &.ais-ChatMessageLoader--withPreview .ais-ChatMessageLoader-skeletonItem:nth-child(2) {
+ height: 2px;
+ background: linear-gradient(
+ to right,
+ rgba(var(--ais-primary-color-rgb), 0.85) 0%,
+ rgba(var(--ais-primary-color-rgb), 0.85) var(--ais-loader-progress, 0%),
+ rgba(var(--ais-muted-color-rgb), 0.2) var(--ais-loader-progress, 0%),
+ rgba(var(--ais-muted-color-rgb), 0.2) 100%
+ );
+ transition: background 280ms ease-out;
+ animation: none;
+ }
}
@keyframes ais-chat-loader-spinner {
@@ -93,3 +116,14 @@
background-position: 250% 0%;
}
}
+
+@keyframes ais-chat-loader-text-in {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss b/packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss
new file mode 100644
index 00000000000..792569a927c
--- /dev/null
+++ b/packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss
@@ -0,0 +1,135 @@
+// Reasoning disclosure — visually aligned with the Agent Studio dashboard
+// (Satellite `ChatContextAccordion`): a bordered, collapsible panel with a
+// brain icon, a static "Reasoning" caption that pulses while streaming, a
+// chevron that rotates on open, and a muted body holding the raw reasoning.
+.ais-ChatMessageReasoning {
+ margin-block-end: calc(var(--ais-spacing) * 0.5);
+ border: 1px solid rgba(var(--ais-muted-color-rgb), 0.25);
+ border-radius: var(--ais-border-radius-md);
+ background-color: var(--ais-background-color);
+ overflow: hidden;
+}
+
+.ais-ChatMessageReasoning-header {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ gap: calc(var(--ais-spacing) * 0.375);
+ width: 100%;
+ padding: calc(var(--ais-spacing) * 0.375) calc(var(--ais-spacing) * 0.5);
+ cursor: pointer;
+ list-style: none;
+ user-select: none;
+
+ // Remove the native disclosure triangle across engines.
+ &::-webkit-details-marker {
+ display: none;
+ }
+ &::marker {
+ content: '';
+ }
+
+ &:hover {
+ background-color: rgba(var(--ais-muted-color-rgb), 0.06);
+ }
+
+ &:focus-visible {
+ outline: 2px solid rgba(var(--ais-primary-color-rgb), 0.5);
+ outline-offset: -2px;
+ }
+}
+
+.ais-ChatMessageReasoning-icon {
+ display: inline-flex;
+ width: calc(var(--ais-icon-size) * 0.8);
+ height: calc(var(--ais-icon-size) * 0.8);
+ color: rgba(var(--ais-muted-color-rgb), 0.9);
+ flex-shrink: 0;
+
+ svg {
+ width: 100%;
+ height: 100%;
+ }
+}
+
+.ais-ChatMessageReasoning-label {
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: rgba(var(--ais-muted-color-rgb), 0.95);
+ font-size: calc(var(--ais-spacing) * 0.8125);
+}
+
+// While the model is still emitting this reasoning, run the same shimmer the
+// loader caption uses so the label reads as "live".
+.ais-ChatMessageReasoning--streaming .ais-ChatMessageReasoning-label {
+ text-fill-color: transparent;
+ -webkit-text-fill-color: transparent;
+ background: rgba(var(--ais-muted-color-rgb), var(--ais-muted-color-alpha))
+ linear-gradient(
+ to right,
+ rgba(var(--ais-muted-color-rgb), var(--ais-muted-color-alpha)) 0%,
+ rgba(var(--ais-text-color-rgb), 0.9) 40%,
+ rgba(var(--ais-text-color-rgb), 0.9) 60%,
+ rgba(var(--ais-muted-color-rgb), var(--ais-muted-color-alpha)) 100%
+ );
+ -webkit-background-clip: text;
+ background-clip: text;
+ background-repeat: no-repeat;
+ background-size: 50% 200%;
+ background-position: -100% 0;
+
+ animation: ais-chat-loader-text 3s linear infinite;
+}
+
+.ais-ChatMessageReasoning-chevron {
+ display: inline-flex;
+ width: calc(var(--ais-icon-size) * 0.85);
+ height: calc(var(--ais-icon-size) * 0.85);
+ color: rgba(var(--ais-muted-color-rgb), 0.9);
+ transition: transform var(--ais-transition-duration)
+ var(--ais-transition-timing-function);
+ flex-shrink: 0;
+
+ svg {
+ width: 100%;
+ height: 100%;
+ }
+}
+
+// `` reflects the open state natively, so the chevron rotates on user
+// toggle with no JS involved.
+.ais-ChatMessageReasoning[open] .ais-ChatMessageReasoning-chevron {
+ transform: rotate(180deg);
+}
+
+.ais-ChatMessageReasoning-body {
+ padding: calc(var(--ais-spacing) * 0.5) calc(var(--ais-spacing) * 0.75);
+ border-block-start: 1px solid rgba(var(--ais-muted-color-rgb), 0.2);
+ background-color: rgba(var(--ais-muted-color-rgb), 0.06);
+ color: rgba(var(--ais-text-color-rgb), 0.7);
+ font-size: calc(var(--ais-spacing) * 0.8125);
+ line-height: 1.5;
+ max-height: 9rem;
+ overflow-y: auto;
+}
+
+.ais-ChatMessageReasoning-text {
+ margin: 0;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+
+ & + & {
+ margin-block-start: calc(var(--ais-spacing) * 0.5);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ais-ChatMessageReasoning-label,
+ .ais-ChatMessageReasoning-chevron {
+ animation: none !important;
+ transition: none !important;
+ }
+}
diff --git a/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx b/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx
index dbd68676e55..3f335c19aca 100644
--- a/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx
+++ b/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx
@@ -294,4 +294,114 @@ describe('chat', () => {
expect(document.activeElement).toBe(textareaAfter);
});
});
+
+ describe('reasoning', () => {
+ const reasoningMessages = [
+ {
+ id: 'assistant-message-id',
+ role: 'assistant' as const,
+ parts: [
+ {
+ type: 'reasoning' as const,
+ text: 'Looking at product images and comparing them.',
+ },
+ { type: 'text' as const, text: 'Here are some options.' },
+ ],
+ },
+ ];
+
+ test('does not render the reasoning panel by default', async () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const search = instantsearch({
+ indexName: 'indexName',
+ searchClient: createSearchClient(),
+ });
+
+ search.addWidgets([
+ chat({
+ container,
+ agentId: 'test-agent-id',
+ disableTriggerValidation: true,
+ messages: reasoningMessages,
+ }),
+ ]);
+
+ search.start();
+ await wait(0);
+
+ expect(
+ container.querySelector('.ais-ChatMessageReasoning')
+ ).not.toBeInTheDocument();
+ });
+
+ test('renders the reasoning panel when `showReasoning` is enabled', async () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const search = instantsearch({
+ indexName: 'indexName',
+ searchClient: createSearchClient(),
+ });
+
+ search.addWidgets([
+ chat({
+ container,
+ agentId: 'test-agent-id',
+ disableTriggerValidation: true,
+ showReasoning: true,
+ reasoningVisibility: 'expanded',
+ messages: reasoningMessages,
+ }),
+ ]);
+
+ search.start();
+ await wait(0);
+
+ expect(
+ container.querySelector('.ais-ChatMessageReasoning')
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector('.ais-ChatMessageReasoning-text')
+ ).toHaveTextContent('Looking at product images and comparing them.');
+ });
+
+ test('forwards injectable reasoning label strings from templates', async () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const search = instantsearch({
+ indexName: 'indexName',
+ searchClient: createSearchClient(),
+ });
+
+ search.addWidgets([
+ chat({
+ container,
+ agentId: 'test-agent-id',
+ disableTriggerValidation: true,
+ showReasoning: true,
+ reasoningVisibility: 'expanded',
+ messages: reasoningMessages,
+ templates: {
+ messages: {
+ reasoningTitleText: 'Raisonnement',
+ reasoningToggleLabelText: 'Basculer le raisonnement',
+ },
+ },
+ }),
+ ]);
+
+ search.start();
+ await wait(0);
+
+ expect(
+ container.querySelector('.ais-ChatMessageReasoning-label')
+ ).toHaveTextContent('Raisonnement');
+ expect(
+ container.querySelector('.ais-ChatMessageReasoning-header')
+ ).toHaveAttribute('aria-label', 'Basculer le raisonnement');
+ });
+ });
});
diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx
index d8a489c739d..9c89d5f61b4 100644
--- a/packages/instantsearch.js/src/widgets/chat/chat.tsx
+++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx
@@ -59,6 +59,8 @@ import type {
ChatMessageActionProps,
ChatMessageBase,
ChatMessageErrorProps,
+ ChatMessageReasoningTranslations,
+ ChatMessageReasoningVisibility,
ChatEmptyProps,
ChatMessageLoaderProps,
ChatMessageProps,
@@ -69,6 +71,7 @@ import type {
ClientSideToolComponentProps,
ClientSideTools,
RecordWithObjectID,
+ ReasoningSummarizer,
SearchToolInput,
UserClientSideTool,
} from 'instantsearch-ui-components';
@@ -352,6 +355,10 @@ type ChatWrapperProps = {
};
translations: Partial;
messageTranslations: Partial;
+ reasoningTranslations: Partial;
+ showReasoning: boolean;
+ reasoningVisibility?: ChatMessageReasoningVisibility;
+ reasoningSummarizer?: ReasoningSummarizer;
sendMessage: ChatLayoutOwnProps['sendMessage'];
setInput: (input: string) => void;
};
@@ -466,6 +473,10 @@ function ChatWrapper({
userMessageProps: messagesProps.userMessageProps,
translations: messagesProps.translations,
messageTranslations: messagesProps.messageTranslations,
+ reasoningTranslations: messagesProps.reasoningTranslations,
+ showReasoning: messagesProps.showReasoning,
+ reasoningVisibility: messagesProps.reasoningVisibility,
+ reasoningSummarizer: messagesProps.reasoningSummarizer,
sendMessage: messagesProps.sendMessage,
setInput: messagesProps.setInput,
}}
@@ -501,6 +512,9 @@ const createRenderer = ({
containerNode,
templates,
tools,
+ showReasoning,
+ reasoningVisibility,
+ reasoningSummarizer,
}: {
containerNode: HTMLElement;
cssClasses: ChatCSSClasses;
@@ -509,6 +523,9 @@ const createRenderer = ({
};
templates: ChatTemplates;
tools: UserClientSideToolsWithTemplate;
+ showReasoning: boolean;
+ reasoningVisibility?: ChatMessageReasoningVisibility;
+ reasoningSummarizer?: ReasoningSummarizer;
}): Renderer> => {
const state = createLocalState();
const promptRef = { current: null as HTMLTextAreaElement | null };
@@ -831,6 +848,12 @@ const createRenderer = ({
regenerateLabel: templates.messages?.regenerateLabelText,
});
+ const reasoningTranslations: Partial =
+ getDefinedProperties({
+ title: templates.messages?.reasoningTitleText,
+ toggleLabel: templates.messages?.reasoningToggleLabelText,
+ });
+
assistantMessageTemplateRef.current = prepareTemplateProps({
defaultTemplates: {} as unknown as NonNullable<
Required['assistantMessage']>
@@ -923,6 +946,10 @@ const createRenderer = ({
},
translations: messagesTranslations,
messageTranslations,
+ reasoningTranslations,
+ showReasoning,
+ reasoningVisibility,
+ reasoningSummarizer,
sendMessage,
setInput,
}}
@@ -1081,6 +1108,14 @@ export type ChatTemplates = BaseHit> =
* Label for the regenerate action
*/
regenerateLabelText?: string;
+ /**
+ * Header label for the reasoning panel. Defaults to "Reasoning".
+ */
+ reasoningTitleText?: string;
+ /**
+ * Accessible label for the reasoning panel disclosure toggle.
+ */
+ reasoningToggleLabelText?: string;
}>;
/**
@@ -1222,6 +1257,29 @@ type ChatWidgetParams = {
* Disable validation that requires either `chatTrigger` or AI mode.
*/
disableTriggerValidation?: boolean;
+
+ /**
+ * Render `reasoning` message parts (extended thinking / server-side
+ * reasoning summaries) via the reasoning panel. Off by default — enable it
+ * when the backend emits reasoning parts and you want to surface them.
+ */
+ showReasoning?: boolean;
+
+ /**
+ * Visibility strategy for the reasoning panel.
+ * - `collapsed` (default): closed, user can expand.
+ * - `expanded`: always open.
+ * - `auto`: open while streaming, collapsible afterwards.
+ * - `hidden`: never render reasoning even if parts exist.
+ */
+ reasoningVisibility?: ChatMessageReasoningVisibility;
+
+ /**
+ * Override the substitute-label computation for the live loader caption
+ * (the "Searching the catalogue…" text shown while the model thinks).
+ * Defaults to the built-in heuristic summarizer.
+ */
+ reasoningSummarizer?: ReasoningSummarizer;
};
export type ChatWidget = WidgetFactory<
@@ -1247,6 +1305,9 @@ export default (function chat<
tools: userTools,
getSearchPageURL,
disableTriggerValidation = false,
+ showReasoning = false,
+ reasoningVisibility,
+ reasoningSummarizer,
...options
} = widgetParams || {};
@@ -1281,6 +1342,9 @@ export default (function chat<
renderState: {},
templates,
tools,
+ showReasoning,
+ reasoningVisibility,
+ reasoningSummarizer,
});
const makeWidget = connectChat(specializedRenderer, () =>
diff --git a/packages/react-instantsearch/src/widgets/Chat.tsx b/packages/react-instantsearch/src/widgets/Chat.tsx
index 62b1e022ea2..80396865ece 100644
--- a/packages/react-instantsearch/src/widgets/Chat.tsx
+++ b/packages/react-instantsearch/src/widgets/Chat.tsx
@@ -37,6 +37,8 @@ import type {
Pragma,
ChatProps as ChatUiProps,
ChatLayoutOwnProps,
+ ChatMessageReasoningVisibility,
+ ReasoningSummarizer,
RecommendComponentProps,
RecordWithObjectID,
UserClientSideTool,
@@ -150,11 +152,26 @@ export type ChatProps = Omit<
userMessageLeadingComponent?: ChatMessageProps['leadingComponent'];
userMessageFooterComponent?: ChatMessageProps['footerComponent'];
suggestionsComponent?: ChatUiProps['suggestionsComponent'];
+ /**
+ * Render `reasoning` message parts (extended thinking / server-side
+ * reasoning summaries) via the reasoning panel. Off by default.
+ */
+ showReasoning?: boolean;
+ /**
+ * Visibility strategy for the reasoning panel. Default: `collapsed`.
+ */
+ reasoningVisibility?: ChatMessageReasoningVisibility;
+ /**
+ * Override the substitute-label computation for the live loader caption
+ * (the "Searching the catalogue…" text shown while the model thinks).
+ */
+ reasoningSummarizer?: ReasoningSummarizer;
translations?: Partial<{
prompt: ChatUiProps['promptProps']['translations'];
header: ChatUiProps['headerProps']['translations'];
message: ChatUiProps['messagesProps']['messageTranslations'];
messages: ChatUiProps['messagesProps']['translations'];
+ reasoning: ChatUiProps['messagesProps']['reasoningTranslations'];
}>;
};
@@ -197,6 +214,9 @@ function ChatInner<
title,
getSearchPageURL,
disableTriggerValidation = false,
+ showReasoning,
+ reasoningVisibility,
+ reasoningSummarizer,
...props
}: ChatProps,
ref: React.ForwardedRef
@@ -206,6 +226,7 @@ function ChatInner<
header: headerTranslations,
message: messageTranslations,
messages: messagesTranslations,
+ reasoning: reasoningTranslations,
} = translations;
const { indexUiState, setIndexUiState } = useInstantSearch();
@@ -353,6 +374,10 @@ function ChatInner<
},
translations: messagesTranslations,
messageTranslations,
+ reasoningTranslations,
+ showReasoning,
+ reasoningVisibility,
+ reasoningSummarizer,
...messagesProps,
error,
}}