From dc3101f5a47e0d516045657b2d61d6090006e941 Mon Sep 17 00:00:00 2001 From: Andras Date: Thu, 7 May 2026 09:10:36 +0300 Subject: [PATCH 1/7] [POC] Reasoning POC --- .../src/components/chat/ChatMessage.tsx | 54 +- .../src/components/chat/ChatMessageLoader.tsx | 64 +- .../components/chat/ChatMessageReasoning.tsx | 203 +++++ .../src/components/chat/ChatMessages.tsx | 59 ++ .../src/components/chat/icons.tsx | 18 + .../src/components/index.ts | 1 + .../src/lib/utils/index.ts | 1 + .../src/lib/utils/reasoning.ts | 217 +++++ .../src/components/chat.scss | 1 + .../components/chat/_chat-message-loader.scss | 34 + .../chat/_chat-message-reasoning.scss | 147 ++++ poc/reasoning/README.md | 41 + poc/reasoning/index.html | 751 ++++++++++++++++++ 13 files changed, 1583 insertions(+), 8 deletions(-) create mode 100644 packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx create mode 100644 packages/instantsearch-ui-components/src/lib/utils/reasoning.ts create mode 100644 packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss create mode 100644 poc/reasoning/README.md create mode 100644 poc/reasoning/index.html diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index d03a0e4c1e0..e62bc1435ab 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -2,8 +2,13 @@ import { compiler } from 'markdown-to-jsx'; import { cx, startsWith } from '../../lib'; +import type { ReasoningSummarizer } from '../../lib/utils/reasoning'; import { createButtonComponent } from '../Button'; +import { + createChatMessageReasoningComponent, + type ChatMessageReasoningVisibility, +} from './ChatMessageReasoning'; import { MenuIcon } from './icons'; import type { ComponentProps, Renderer, VNode } from '../../types'; @@ -139,6 +144,25 @@ export type ChatMessageProps = ComponentProps<'article'> & { * Optional suggestions element */ suggestionsElement?: VNode; + /** + * Whether to render `reasoning` UI parts via ``. + * Off by default - enable explicitly when the backend emits reasoning + * summaries / extended thinking and you want to surface them. + */ + showReasoning?: boolean; + /** + * Visibility strategy for the reasoning panel. + * - `auto` (default): open while streaming, collapse when done. + * - `expanded`: always open. + * - `collapsed`: always closed. + * - `hidden`: do not render reasoning even if parts exist. + */ + reasoningVisibility?: ChatMessageReasoningVisibility; + /** + * Optional override for the substitute label computation. + * @see {@link summarizeReasoning} for the default 3-tier strategy. + */ + reasoningSummarizer?: ReasoningSummarizer; /** * Optional class names */ @@ -152,8 +176,15 @@ export type ChatMessageProps = ComponentProps<'article'> & { // Keep in sync with packages/instantsearch.js/src/lib/chat/index.ts const SearchIndexToolType = 'algolia_search_index'; -export function createChatMessageComponent({ createElement }: Renderer) { +export function createChatMessageComponent({ + createElement, + Fragment, +}: Renderer) { const Button = createButtonComponent({ createElement }); + const ChatMessageReasoning = createChatMessageReasoningComponent({ + createElement, + Fragment, + }); return function ChatMessage(userProps: ChatMessageProps) { const { @@ -173,6 +204,9 @@ export function createChatMessageComponent({ createElement }: Renderer) { onClose, translations: userTranslations, suggestionsElement, + showReasoning = false, + reasoningVisibility = 'auto', + reasoningSummarizer, ...props } = userProps; @@ -203,6 +237,10 @@ export function createChatMessageComponent({ createElement }: Renderer) { footer: cx('ais-ChatMessage-footer', classNames.footer), }; + const firstReasoningIndex = showReasoning + ? message.parts.findIndex((p) => p.type === 'reasoning') + : -1; + function renderMessagePart( part: ChatMessageBase['parts'][number], index: number @@ -210,6 +248,20 @@ export function createChatMessageComponent({ createElement }: Renderer) { if (part.type === 'step-start') { return null; } + if (part.type === 'reasoning') { + if (!showReasoning) return null; + // Render one reasoning panel total, anchored at the first reasoning + // part. Subsequent reasoning parts are absorbed by that panel. + if (index !== firstReasoningIndex) return null; + return ( + + ); + } if (part.type === 'text') { if ( part.text.startsWith('') && diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx index db193f9e59a..384971b14fc 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx @@ -1,12 +1,15 @@ /** @jsx createElement */ +import { cx } from '../../lib'; + import { LoadingSpinnerIcon } from './icons'; import type { ComponentProps, Renderer } from '../../types'; export type ChatMessageLoaderTranslations = { /** - * Text to display in the loader + * Static text to display in the loader. Used as a fallback when no + * `reasoningPreview` is provided. */ loaderText?: string; }; @@ -16,22 +19,53 @@ export type ChatMessageLoaderProps = ComponentProps<'article'> & { * Translations for loader component texts */ translations?: Partial; + /** + * Live substitute label for the current reasoning step, e.g. + * "Searching the catalogue". Takes precedence over `loaderText` when set. + */ + reasoningPreview?: string; + /** + * Wall-clock time the model has been thinking, in ms. Drives the + * progress hairline; when omitted, the hairline is hidden. + */ + elapsedMs?: number; + /** + * Optimistic upper bound for the thinking time, in ms. Used to compute + * the progress ratio. Defaults to 8000 ms. + */ + expectedMs?: number; }; export function createChatMessageLoaderComponent({ createElement, }: Pick) { return function ChatMessageLoader(userProps: ChatMessageLoaderProps) { - const { translations: userTranslations, ...props } = userProps; + const { + translations: userTranslations, + reasoningPreview, + elapsedMs, + expectedMs = 8000, + ...props + } = userProps; const translations: Required = { loaderText: '', ...userTranslations, }; + const caption = reasoningPreview || translations.loaderText; + const hasProgress = typeof elapsedMs === 'number' && elapsedMs > 0; + const progress = hasProgress + ? Math.max(0, Math.min(1, (elapsedMs as number) / expectedMs)) + : 0; + return (
@@ -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..f410ab79cd3 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx @@ -0,0 +1,203 @@ +/** @jsx createElement */ + +import { cx } from '../../lib'; +import { + getReasoningContext, + summarizeReasoning, + type ReasoningSummarizer, + type ReasoningSummary, +} from '../../lib/utils/reasoning'; + +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 = { + /** Static fallback header label, used when the summarizer returns nothing. */ + thinkingLabel: string; + /** Aria label for the toggle button. */ + toggleLabel: string; + /** Optional prefix for the elapsed time, e.g. "Thought for". */ + elapsedPrefix: string; + /** Locale-friendly seconds suffix, e.g. "s". */ + elapsedSuffix: string; +}; + +export type ChatMessageReasoningClassNames = { + root: string | string[]; + header: string | string[]; + icon: string | string[]; + label: string | string[]; + timer: string | string[]; + chevron: string | string[]; + body: string | string[]; + text: string | string[]; +}; + +export type ChatMessageReasoningProps = ComponentProps<'section'> & { + /** The full message - used by the summarizer to reach tool calls etc. */ + message: ChatMessageBase; + /** Visibility strategy. Defaults to "auto" (open while streaming). */ + visibility?: ChatMessageReasoningVisibility; + /** Optional override for the substitute label computation. */ + summarizer?: ReasoningSummarizer; + /** + * ms since the reasoning started. The component will format and display it. + * If omitted, no timer is shown. + */ + elapsedMs?: number; + /** + * Called whenever the substitute label changes. Lets the parent (e.g. the + * loader) mirror the same label without recomputing it. + */ + onSummaryChange?: (summary: ReasoningSummary) => void; + classNames?: Partial; + translations?: Partial; +}; + +function getReasoningParts(message: ChatMessageBase): ReasoningUIPart[] { + return message.parts.filter( + (p): p is ReasoningUIPart => p.type === 'reasoning' + ); +} + +function formatElapsed(ms: number): string { + if (ms < 1000) return `${Math.max(0, Math.round(ms / 100) / 10).toFixed(1)}`; + return `${(ms / 1000).toFixed(1)}`; +} + +export function createChatMessageReasoningComponent({ + createElement, + Fragment, +}: Renderer) { + return function ChatMessageReasoning(userProps: ChatMessageReasoningProps) { + const { + message, + visibility = 'auto', + summarizer = summarizeReasoning, + elapsedMs, + onSummaryChange, + classNames = {}, + translations: userTranslations, + ...props + } = userProps; + + if (visibility === 'hidden') return null; + + const reasoningParts = getReasoningParts(message); + if (reasoningParts.length === 0) return null; + + const translations: Required = { + thinkingLabel: 'Thinking\u2026', + toggleLabel: 'Toggle reasoning', + elapsedPrefix: 'Thought for', + elapsedSuffix: 's', + ...userTranslations, + }; + + const ctx = getReasoningContext(message); + const summary = summarizer(ctx.text, { + message, + lastToolCall: ctx.lastToolCall, + streaming: ctx.streaming, + }); + + if (onSummaryChange) onSummaryChange(summary); + + // Visibility resolution. + // - auto: open while streaming, collapse on done. + // - expanded: always open. + // - collapsed: always closed. + const open = + visibility === 'expanded' || + (visibility === 'auto' && ctx.streaming); + + const cssClasses: ChatMessageReasoningClassNames = { + root: cx( + 'ais-ChatMessageReasoning', + ctx.streaming && 'ais-ChatMessageReasoning--streaming', + open && 'ais-ChatMessageReasoning--open', + summary.redact && 'ais-ChatMessageReasoning--redacted', + `ais-ChatMessageReasoning--${summary.category}`, + classNames.root + ), + header: cx('ais-ChatMessageReasoning-header', classNames.header), + icon: cx('ais-ChatMessageReasoning-icon', classNames.icon), + label: cx('ais-ChatMessageReasoning-label', classNames.label), + timer: cx('ais-ChatMessageReasoning-timer', classNames.timer), + chevron: cx('ais-ChatMessageReasoning-chevron', classNames.chevron), + body: cx('ais-ChatMessageReasoning-body', classNames.body), + text: cx('ais-ChatMessageReasoning-text', classNames.text), + }; + + const showTimer = typeof elapsedMs === 'number' && elapsedMs > 0; + const bodyId = `ais-reasoning-body-${message.id}`; + + return ( +
+ + + +
+ ); + }; +} diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index 5a908c1e23b..0fb45c768d3 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'; @@ -30,6 +35,7 @@ import type { ChatMessageClassNames, ChatMessageTranslations, } from './ChatMessage'; +import type { ChatMessageReasoningVisibility } from './ChatMessageReasoning'; import type { ChatMessageErrorProps } from './ChatMessageError'; import type { ChatMessageLoaderProps } from './ChatMessageLoader'; import type { ChatEmptyProps, ChatLayoutOwnProps, ChatMessageBase, ChatStatus, ClientSideTools } from './types'; @@ -215,6 +221,21 @@ 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: `auto`. + */ + reasoningVisibility?: ChatMessageReasoningVisibility; + /** + * Optional override for the reasoning summarizer. + */ + reasoningSummarizer?: ReasoningSummarizer; }; const copyToClipboard = (message: ChatMessageBase) => { @@ -243,6 +264,9 @@ function createDefaultMessageComponent< messageTranslations, translations, suggestionsElement, + showReasoning, + reasoningVisibility, + reasoningSummarizer, }: { key: string; message: TMessage; @@ -261,6 +285,9 @@ function createDefaultMessageComponent< classNames?: Partial; messageTranslations?: Partial; suggestionsElement?: VNode; + showReasoning?: boolean; + reasoningVisibility?: ChatMessageReasoningVisibility; + reasoningSummarizer?: ReasoningSummarizer; }) { const defaultAssistantActions: ChatMessageActionProps[] = [ ...(hasTextContent(message) @@ -344,6 +371,9 @@ function createDefaultMessageComponent< classNames={classNames} translations={messageTranslations} suggestionsElement={suggestionsElement} + showReasoning={showReasoning} + reasoningVisibility={reasoningVisibility} + reasoningSummarizer={reasoningSummarizer} {...messageProps} /> ); @@ -398,6 +428,9 @@ export function createChatMessagesComponent({ suggestionsElement, onFeedback, feedbackState, + showReasoning = false, + reasoningVisibility = 'auto', + reasoningSummarizer, ...props } = userProps; @@ -430,6 +463,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'; @@ -488,6 +543,9 @@ export function createChatMessagesComponent({ translations={translations} classNames={messageClassNames} messageTranslations={messageTranslations} + showReasoning={showReasoning} + reasoningVisibility={reasoningVisibility} + reasoningSummarizer={reasoningSummarizer} suggestionsElement={ status === 'ready' && message.role === 'assistant' && @@ -501,6 +559,7 @@ export function createChatMessagesComponent({ {showLoader && ( )} diff --git a/packages/instantsearch-ui-components/src/components/chat/icons.tsx b/packages/instantsearch-ui-components/src/components/chat/icons.tsx index f3715cad8d0..71abbb8448f 100644 --- a/packages/instantsearch-ui-components/src/components/chat/icons.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/icons.tsx @@ -294,3 +294,21 @@ export function ChevronRightIcon({ createElement }: IconProps) { ); } + +export function BrainIcon({ createElement }: IconProps) { + 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/index.ts b/packages/instantsearch-ui-components/src/lib/utils/index.ts index 764893a9869..f274e59c180 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/index.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/index.ts @@ -1,3 +1,4 @@ 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..780b35ad036 --- /dev/null +++ b/packages/instantsearch-ui-components/src/lib/utils/reasoning.ts @@ -0,0 +1,217 @@ +/** + * 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) { + const firstSentence = trimmed.split(/(?<=[.!?])\s+/, 1)[0]; + 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 8ea8ce01076..34be52fa94d 100644 --- a/packages/instantsearch.css/src/components/chat.scss +++ b/packages/instantsearch.css/src/components/chat.scss @@ -11,6 +11,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-carousel'; 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..14d5dee493e --- /dev/null +++ b/packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss @@ -0,0 +1,147 @@ +.ais-ChatMessageReasoning { + display: flex; + flex-direction: column; + gap: calc(var(--ais-spacing) * 0.25); + margin-block-end: calc(var(--ais-spacing) * 0.5); + border: 1px solid rgba(var(--ais-muted-color-rgb), 0.2); + border-radius: var(--ais-border-radius-md); + background-color: rgba(var(--ais-muted-color-rgb), 0.04); + font-size: calc(var(--ais-spacing) * 0.8125); + color: rgba(var(--ais-text-color-rgb), 0.85); + overflow: hidden; +} + +.ais-ChatMessageReasoning-header { + all: unset; + box-sizing: border-box; + display: flex; + align-items: center; + gap: calc(var(--ais-spacing) * 0.5); + width: 100%; + padding: calc(var(--ais-spacing) * 0.5) calc(var(--ais-spacing) * 0.75); + cursor: pointer; + font-weight: var(--ais-font-weight-medium); + + &: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.85); + height: calc(var(--ais-icon-size) * 0.85); + color: rgba(var(--ais-primary-color-rgb), 0.85); + flex-shrink: 0; + + svg { + width: 100%; + height: 100%; + } +} + +.ais-ChatMessageReasoning--streaming .ais-ChatMessageReasoning-icon { + animation: ais-reasoning-pulse 1.6s ease-in-out infinite; +} + +.ais-ChatMessageReasoning-label { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// Reuse the loader's text shimmer while streaming - it tells the user the +// label is "live" and not a stale title. +.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-timer { + flex-shrink: 0; + color: rgba(var(--ais-muted-color-rgb), 0.9); + font-variant-numeric: tabular-nums; + font-size: calc(var(--ais-spacing) * 0.75); +} + +.ais-ChatMessageReasoning-chevron { + display: inline-flex; + width: calc(var(--ais-icon-size) * 0.7); + height: calc(var(--ais-icon-size) * 0.7); + 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%; + } +} + +.ais-ChatMessageReasoning--open .ais-ChatMessageReasoning-chevron { + transform: rotate(180deg); +} + +.ais-ChatMessageReasoning-body { + padding: 0 calc(var(--ais-spacing) * 0.75) + calc(var(--ais-spacing) * 0.75) calc(var(--ais-spacing) * 0.75); + border-block-start: 1px solid rgba(var(--ais-muted-color-rgb), 0.15); + color: rgba(var(--ais-text-color-rgb), 0.6); + line-height: 1.5; + font-style: italic; + max-height: 18rem; + overflow-y: auto; + + p { + margin: calc(var(--ais-spacing) * 0.5) 0 0 0; + + &:first-child { + margin-block-start: calc(var(--ais-spacing) * 0.5); + } + + animation: ais-reasoning-line-in 320ms ease-out; + } +} + +.ais-ChatMessageReasoning[data-state='closed'] .ais-ChatMessageReasoning-body { + display: none; +} + +@keyframes ais-reasoning-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.55; transform: scale(0.9); } +} + +@keyframes ais-reasoning-line-in { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (prefers-reduced-motion: reduce) { + .ais-ChatMessageReasoning-icon, + .ais-ChatMessageReasoning-label, + .ais-ChatMessageReasoning-chevron, + .ais-ChatMessageReasoning-body p { + animation: none !important; + transition: none !important; + } +} diff --git a/poc/reasoning/README.md b/poc/reasoning/README.md new file mode 100644 index 00000000000..2ac2dddfb91 --- /dev/null +++ b/poc/reasoning/README.md @@ -0,0 +1,41 @@ +# POC — Reasoning content rendering + +Self-contained proof-of-concept for [`specs/rfcs/0001-reasoning-content.md`](../../specs/rfcs/0001-reasoning-content.md). + +## What's in here + +- `index.html` — a single-file demo. Open it in any browser. Click **Run scenario** to play a mocked AI-SDK 5 stream that emits reasoning deltas, tool calls and final text. +- The HTML inlines the **same class names** (`ais-ChatMessageReasoning*`, `ais-ChatMessageLoader*`) and the **same `summarizeReasoning` logic** that ship in: + - `packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx` + - `packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx` + - `packages/instantsearch-ui-components/src/lib/utils/reasoning.ts` + - `packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss` + +## Running it + +```bash +open /Users/ferencz.andras/Documents/GitHub/instantsearch/poc/reasoning/index.html +# or +npx serve /Users/ferencz.andras/Documents/GitHub/instantsearch/poc/reasoning +``` + +No build step needed. + +## Things to look at + +1. Toggle **showReasoning** off → the same stream falls back to today's behaviour: a generic "…" loader, no thinking panel. +2. Toggle **showReasoning** on → the loader caption tracks the current substitute label live; the reasoning panel auto-opens while streaming and collapses on done with a "Thought for N s" timer. +3. Switch **scenario** to *Raw chain-of-thought (DeepSeek-R1-style)* — notice the body redacts itself when the categorizer detects an API-key pattern. +4. Try **visibility = expanded / collapsed / hidden** — covers the three deployment policies described in the RFC. + +## Mapping the demo back to production + +| In the demo (`index.html`) | In the package | +|----------------------------------|-------------------------------------------------------------------------------------------------| +| `summarizeReasoning()` | `lib/utils/reasoning.ts` → `summarizeReasoning` | +| `categorizeReasoning()` | `lib/utils/reasoning.ts` → `categorizeReasoning` | +| `categorizeFromToolCall()` | `lib/utils/reasoning.ts` → `categorizeFromToolCall` | +| `renderReasoning()` | `components/chat/ChatMessageReasoning.tsx` | +| `renderLoader()` | `components/chat/ChatMessageLoader.tsx` | +| `applyChunk()` | `instantsearch.js/src/lib/ai-lite/abstract-chat.ts` (`processStreamWithCallbacks`) — production | +| Mocked SCENARIOS | Real `data: { type: "reasoning-start" \| "reasoning-delta" \| ... }` SSE events | diff --git a/poc/reasoning/index.html b/poc/reasoning/index.html new file mode 100644 index 00000000000..498566f9ec7 --- /dev/null +++ b/poc/reasoning/index.html @@ -0,0 +1,751 @@ + + + + + POC — Reasoning content rendering (InstantSearch chat) + + + + +
+

RFC 0001 — Reasoning content rendering

+

+ Standalone POC. Click Run scenario to mock a streamed + assistant reply with reasoning, tool calls and final text. Toggle + showReasoning to see what the same stream looks like with + reasoning rendering off (today's behaviour). +

+ +
+ + + + + +
+ +
+ +

+ The label in the loader and reasoning header is computed by + summarizeReasoning() — the exact same function that lives in + packages/instantsearch-ui-components/src/lib/utils/reasoning.ts. + Inlined here so you can tweak it live. +

+
+ + + + From 4e18b06c9be9e45e4a58e7dc13e23257ec735957 Mon Sep 17 00:00:00 2001 From: Andras Date: Mon, 6 Jul 2026 12:49:39 +0300 Subject: [PATCH 2/7] feat: Reasoning --- .../src/components/chat/ChatMessage.tsx | 18 +- .../src/components/chat/ChatMessageLoader.tsx | 2 +- .../components/chat/ChatMessageReasoning.tsx | 2 +- .../src/components/chat/ChatMessages.tsx | 26 +- .../__tests__/ChatMessageReasoning.test.tsx | 129 +++ .../src/lib/utils/__tests__/reasoning-test.ts | 135 ++++ .../src/widgets/chat/__tests__/chat.test.tsx | 106 +++ .../src/widgets/chat/chat.tsx | 74 ++ .../react-instantsearch/src/widgets/Chat.tsx | 25 + poc/reasoning/README.md | 41 - poc/reasoning/index.html | 751 ------------------ 11 files changed, 513 insertions(+), 796 deletions(-) create mode 100644 packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessageReasoning.test.tsx create mode 100644 packages/instantsearch-ui-components/src/lib/utils/__tests__/reasoning-test.ts delete mode 100644 poc/reasoning/README.md delete mode 100644 poc/reasoning/index.html diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index b3d679ec963..cb0579987fa 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -2,15 +2,17 @@ import { compiler } from 'markdown-to-jsx'; import { cx, startsWith } from '../../lib'; -import type { ReasoningSummarizer } from '../../lib/utils/reasoning'; import { createButtonComponent } from '../Button'; import { createChatMessageReasoningComponent, + type ChatMessageReasoningClassNames, + type ChatMessageReasoningTranslations, type ChatMessageReasoningVisibility, } from './ChatMessageReasoning'; import { MenuIcon } from './icons'; +import type { ReasoningSummarizer } from '../../lib/utils/reasoning'; import type { ComponentProps, Renderer, VNode } from '../../types'; import type { AddToolResultWithOutput, @@ -169,6 +171,16 @@ export type ChatMessageProps = ComponentProps<'article'> & { * @see {@link summarizeReasoning} for the default 3-tier strategy. */ reasoningSummarizer?: ReasoningSummarizer; + /** + * Injectable strings for the reasoning panel (header labels, toggle label, + * elapsed time prefix/suffix). Forwarded to ``. + */ + reasoningTranslations?: Partial; + /** + * Optional class names for the reasoning panel. Forwarded to + * ``. + */ + reasoningClassNames?: Partial; /** * Optional class names */ @@ -225,6 +237,8 @@ export function createChatMessageComponent({ showReasoning = false, reasoningVisibility = 'auto', reasoningSummarizer, + reasoningTranslations, + reasoningClassNames, parseMarkdown = true, ...props } = userProps; @@ -278,6 +292,8 @@ export function createChatMessageComponent({ message={message} visibility={reasoningVisibility} summarizer={reasoningSummarizer} + translations={reasoningTranslations} + classNames={reasoningClassNames} /> ); } diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx index 384971b14fc..1845abaf177 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx @@ -55,7 +55,7 @@ export function createChatMessageLoaderComponent({ const caption = reasoningPreview || translations.loaderText; const hasProgress = typeof elapsedMs === 'number' && elapsedMs > 0; const progress = hasProgress - ? Math.max(0, Math.min(1, (elapsedMs as number) / expectedMs)) + ? Math.max(0, Math.min(1, (elapsedMs) / expectedMs)) : 0; return ( diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx index 03c27664229..63baef5542b 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx @@ -164,7 +164,7 @@ export function createChatMessageReasoningComponent({ {showTimer && ( {translations.elapsedPrefix}{' '} - {formatElapsed(elapsedMs as number)} + {formatElapsed(elapsedMs)} {translations.elapsedSuffix} )} diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index ae6b19d551b..35fb2219709 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -41,9 +41,13 @@ import type { ChatMessageClassNames, ChatMessageTranslations, } from './ChatMessage'; -import type { ChatMessageReasoningVisibility } from './ChatMessageReasoning'; import type { ChatMessageErrorProps } from './ChatMessageError'; import type { ChatMessageLoaderProps } from './ChatMessageLoader'; +import type { + ChatMessageReasoningClassNames, + ChatMessageReasoningTranslations, + ChatMessageReasoningVisibility, +} from './ChatMessageReasoning'; import type { ChatEmptyProps, ChatLayoutOwnProps, @@ -262,6 +266,16 @@ export type ChatMessagesProps< * 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) => { @@ -322,6 +336,8 @@ function createDefaultMessageComponent< showReasoning, reasoningVisibility, reasoningSummarizer, + reasoningTranslations, + reasoningClassNames, }: { key: string; message: TMessage; @@ -344,6 +360,8 @@ function createDefaultMessageComponent< showReasoning?: boolean; reasoningVisibility?: ChatMessageReasoningVisibility; reasoningSummarizer?: ReasoningSummarizer; + reasoningTranslations?: Partial; + reasoningClassNames?: Partial; }) { const defaultAssistantActions: ChatMessageActionProps[] = [ ...(hasTextContent(message) @@ -431,6 +449,8 @@ function createDefaultMessageComponent< showReasoning={showReasoning} reasoningVisibility={reasoningVisibility} reasoningSummarizer={reasoningSummarizer} + reasoningTranslations={reasoningTranslations} + reasoningClassNames={reasoningClassNames} {...messageProps} /> ); @@ -497,6 +517,8 @@ export function createChatMessagesComponent({ showReasoning = false, reasoningVisibility = 'auto', reasoningSummarizer, + reasoningTranslations, + reasoningClassNames, ...props } = userProps; @@ -613,6 +635,8 @@ export function createChatMessagesComponent({ showReasoning={showReasoning} reasoningVisibility={reasoningVisibility} reasoningSummarizer={reasoningSummarizer} + reasoningTranslations={reasoningTranslations} + reasoningClassNames={reasoningClassNames} suggestionsElement={ status === 'ready' && message.role === 'assistant' && 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..e397394e420 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessageReasoning.test.tsx @@ -0,0 +1,129 @@ +/** + * @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 the reasoning body text', () => { + const { container } = render( + + ); + expect( + container.querySelector('.ais-ChatMessageReasoning-text') + ).toHaveTextContent('comparing the options'); + }); + + test('accepts an injectable toggle label and elapsed time strings', () => { + const { getByRole, getByText } = render( + + ); + expect( + getByRole('button', { name: 'Basculer le raisonnement' }) + ).toBeInTheDocument(); + expect(getByText(/Réflexion pendant/)).toBeInTheDocument(); + expect(getByText(/secondes/)).toBeInTheDocument(); + }); + + test('falls back to the injectable thinking label when the summarizer returns no label', () => { + const { getByText } = render( + ({ label: '', category: 'thinking' })} + translations={{ thinkingLabel: 'Réflexion en cours' }} + /> + ); + expect(getByText('Réflexion en cours')).toBeInTheDocument(); + }); + + test('applies injectable class names', () => { + const { container } = render( + + ); + expect(container.querySelector('.my-root')).toBeInTheDocument(); + expect(container.querySelector('.my-text')).toBeInTheDocument(); + }); + + test('redacts the body when the summarizer flags it', () => { + const { getByText, queryByText } = render( + + ); + expect(getByText('(reasoning redacted for privacy)')).toBeInTheDocument(); + expect(queryByText(/sk-01234567890123456789/)).not.toBeInTheDocument(); + }); + + test('uses a custom summarizer when provided', () => { + const summarizer = jest.fn(() => ({ + label: 'Custom label', + category: 'thinking' as const, + })); + const { getByText } = render( + + ); + expect(summarizer).toHaveBeenCalled(); + expect(getByText('Custom label')).toBeInTheDocument(); + }); +}); 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.js/src/widgets/chat/__tests__/chat.test.tsx b/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx index dbd68676e55..9dbc9e58914 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,110 @@ 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: { + reasoningToggleLabelText: 'Basculer le raisonnement', + }, + }, + }), + ]); + + search.start(); + await wait(0); + + expect( + screen.getByRole('button', { name: 'Basculer le raisonnement' }) + ).toBeInTheDocument(); + }); + }); }); diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index d8a489c739d..da434088e5f 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,14 @@ const createRenderer = ({ regenerateLabel: templates.messages?.regenerateLabelText, }); + const reasoningTranslations: Partial = + getDefinedProperties({ + thinkingLabel: templates.messages?.reasoningThinkingLabelText, + toggleLabel: templates.messages?.reasoningToggleLabelText, + elapsedPrefix: templates.messages?.reasoningElapsedPrefixText, + elapsedSuffix: templates.messages?.reasoningElapsedSuffixText, + }); + assistantMessageTemplateRef.current = prepareTemplateProps({ defaultTemplates: {} as unknown as NonNullable< Required['assistantMessage']> @@ -923,6 +948,10 @@ const createRenderer = ({ }, translations: messagesTranslations, messageTranslations, + reasoningTranslations, + showReasoning, + reasoningVisibility, + reasoningSummarizer, sendMessage, setInput, }} @@ -1081,6 +1110,23 @@ export type ChatTemplates = BaseHit> = * Label for the regenerate action */ regenerateLabelText?: string; + /** + * Fallback header label for the reasoning panel, shown when the + * summarizer produces no substitute label. Defaults to "Thinking…". + */ + reasoningThinkingLabelText?: string; + /** + * Accessible label for the reasoning panel toggle button. + */ + reasoningToggleLabelText?: string; + /** + * Prefix shown before the reasoning elapsed time, e.g. "Thought for". + */ + reasoningElapsedPrefixText?: string; + /** + * Suffix shown after the reasoning elapsed time, e.g. "s". + */ + reasoningElapsedSuffixText?: string; }>; /** @@ -1222,6 +1268,28 @@ 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. + * - `auto` (default): open while streaming, collapse when done. + * - `expanded`: always open. + * - `collapsed`: always closed. + * - `hidden`: never render reasoning even if parts exist. + */ + reasoningVisibility?: ChatMessageReasoningVisibility; + + /** + * Override the substitute-label computation for the reasoning panel and the + * live loader caption. Defaults to the built-in heuristic summarizer. + */ + reasoningSummarizer?: ReasoningSummarizer; }; export type ChatWidget = WidgetFactory< @@ -1247,6 +1315,9 @@ export default (function chat< tools: userTools, getSearchPageURL, disableTriggerValidation = false, + showReasoning = false, + reasoningVisibility, + reasoningSummarizer, ...options } = widgetParams || {}; @@ -1281,6 +1352,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..04e694cc0ba 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: `auto`. + */ + reasoningVisibility?: ChatMessageReasoningVisibility; + /** + * Override the substitute-label computation for the reasoning panel and + * the live loader caption. + */ + 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, }} diff --git a/poc/reasoning/README.md b/poc/reasoning/README.md deleted file mode 100644 index 2ac2dddfb91..00000000000 --- a/poc/reasoning/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# POC — Reasoning content rendering - -Self-contained proof-of-concept for [`specs/rfcs/0001-reasoning-content.md`](../../specs/rfcs/0001-reasoning-content.md). - -## What's in here - -- `index.html` — a single-file demo. Open it in any browser. Click **Run scenario** to play a mocked AI-SDK 5 stream that emits reasoning deltas, tool calls and final text. -- The HTML inlines the **same class names** (`ais-ChatMessageReasoning*`, `ais-ChatMessageLoader*`) and the **same `summarizeReasoning` logic** that ship in: - - `packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx` - - `packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx` - - `packages/instantsearch-ui-components/src/lib/utils/reasoning.ts` - - `packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss` - -## Running it - -```bash -open /Users/ferencz.andras/Documents/GitHub/instantsearch/poc/reasoning/index.html -# or -npx serve /Users/ferencz.andras/Documents/GitHub/instantsearch/poc/reasoning -``` - -No build step needed. - -## Things to look at - -1. Toggle **showReasoning** off → the same stream falls back to today's behaviour: a generic "…" loader, no thinking panel. -2. Toggle **showReasoning** on → the loader caption tracks the current substitute label live; the reasoning panel auto-opens while streaming and collapses on done with a "Thought for N s" timer. -3. Switch **scenario** to *Raw chain-of-thought (DeepSeek-R1-style)* — notice the body redacts itself when the categorizer detects an API-key pattern. -4. Try **visibility = expanded / collapsed / hidden** — covers the three deployment policies described in the RFC. - -## Mapping the demo back to production - -| In the demo (`index.html`) | In the package | -|----------------------------------|-------------------------------------------------------------------------------------------------| -| `summarizeReasoning()` | `lib/utils/reasoning.ts` → `summarizeReasoning` | -| `categorizeReasoning()` | `lib/utils/reasoning.ts` → `categorizeReasoning` | -| `categorizeFromToolCall()` | `lib/utils/reasoning.ts` → `categorizeFromToolCall` | -| `renderReasoning()` | `components/chat/ChatMessageReasoning.tsx` | -| `renderLoader()` | `components/chat/ChatMessageLoader.tsx` | -| `applyChunk()` | `instantsearch.js/src/lib/ai-lite/abstract-chat.ts` (`processStreamWithCallbacks`) — production | -| Mocked SCENARIOS | Real `data: { type: "reasoning-start" \| "reasoning-delta" \| ... }` SSE events | diff --git a/poc/reasoning/index.html b/poc/reasoning/index.html deleted file mode 100644 index 498566f9ec7..00000000000 --- a/poc/reasoning/index.html +++ /dev/null @@ -1,751 +0,0 @@ - - - - - POC — Reasoning content rendering (InstantSearch chat) - - - - -
-

RFC 0001 — Reasoning content rendering

-

- Standalone POC. Click Run scenario to mock a streamed - assistant reply with reasoning, tool calls and final text. Toggle - showReasoning to see what the same stream looks like with - reasoning rendering off (today's behaviour). -

- -
- - - - - -
- -
- -

- The label in the loader and reasoning header is computed by - summarizeReasoning() — the exact same function that lives in - packages/instantsearch-ui-components/src/lib/utils/reasoning.ts. - Inlined here so you can tweak it live. -

-
- - - - From 5520a6804c71ba0c0c474ee82f20d93fd8e050c7 Mon Sep 17 00:00:00 2001 From: Andras Date: Mon, 6 Jul 2026 15:32:35 +0300 Subject: [PATCH 3/7] feat: Reasoning --- .../src/components/chat/ChatMessage.tsx | 18 +-- .../components/chat/ChatMessageReasoning.tsx | 145 +++++------------- .../src/components/chat/ChatMessages.tsx | 6 +- .../__tests__/ChatMessageReasoning.test.tsx | 103 ++++++------- .../src/components/chat/icons.tsx | 14 +- .../chat/_chat-message-reasoning.scss | 108 ++++++------- .../src/widgets/chat/__tests__/chat.test.tsx | 8 +- .../src/widgets/chat/chat.tsx | 19 +-- 8 files changed, 164 insertions(+), 257 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index cb0579987fa..a05bae5f2a6 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -12,7 +12,6 @@ import { } from './ChatMessageReasoning'; import { MenuIcon } from './icons'; -import type { ReasoningSummarizer } from '../../lib/utils/reasoning'; import type { ComponentProps, Renderer, VNode } from '../../types'; import type { AddToolResultWithOutput, @@ -160,20 +159,15 @@ export type ChatMessageProps = ComponentProps<'article'> & { showReasoning?: boolean; /** * Visibility strategy for the reasoning panel. - * - `auto` (default): open while streaming, collapse when done. + * - `collapsed` (default): closed, user can expand. * - `expanded`: always open. - * - `collapsed`: always closed. + * - `auto`: open while streaming, collapsible afterwards. * - `hidden`: do not render reasoning even if parts exist. */ reasoningVisibility?: ChatMessageReasoningVisibility; /** - * Optional override for the substitute label computation. - * @see {@link summarizeReasoning} for the default 3-tier strategy. - */ - reasoningSummarizer?: ReasoningSummarizer; - /** - * Injectable strings for the reasoning panel (header labels, toggle label, - * elapsed time prefix/suffix). Forwarded to ``. + * Injectable strings for the reasoning panel (title, toggle label). + * Forwarded to ``. */ reasoningTranslations?: Partial; /** @@ -235,8 +229,7 @@ export function createChatMessageComponent({ translations: userTranslations, suggestionsElement, showReasoning = false, - reasoningVisibility = 'auto', - reasoningSummarizer, + reasoningVisibility = 'collapsed', reasoningTranslations, reasoningClassNames, parseMarkdown = true, @@ -291,7 +284,6 @@ export function createChatMessageComponent({ key={`${message.id}-reasoning`} message={message} visibility={reasoningVisibility} - summarizer={reasoningSummarizer} translations={reasoningTranslations} classNames={reasoningClassNames} /> diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx index 63baef5542b..f310bb4521e 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx @@ -1,12 +1,6 @@ /** @jsx createElement */ import { cx } from '../../lib'; -import { - getReasoningContext, - summarizeReasoning, - type ReasoningSummarizer, - type ReasoningSummary, -} from '../../lib/utils/reasoning'; import { BrainIcon, ChevronDownIcon } from './icons'; @@ -20,14 +14,13 @@ export type ChatMessageReasoningVisibility = | 'hidden'; export type ChatMessageReasoningTranslations = { - /** Static fallback header label, used when the summarizer returns nothing. */ - thinkingLabel: string; - /** Aria label for the toggle button. */ + /** + * 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; - /** Optional prefix for the elapsed time, e.g. "Thought for". */ - elapsedPrefix: string; - /** Locale-friendly seconds suffix, e.g. "s". */ - elapsedSuffix: string; }; export type ChatMessageReasoningClassNames = { @@ -35,29 +28,22 @@ export type ChatMessageReasoningClassNames = { header: string | string[]; icon: string | string[]; label: string | string[]; - timer: string | string[]; chevron: string | string[]; body: string | string[]; text: string | string[]; }; -export type ChatMessageReasoningProps = ComponentProps<'section'> & { - /** The full message - used by the summarizer to reach tool calls etc. */ +export type ChatMessageReasoningProps = ComponentProps<'details'> & { + /** The message whose `reasoning` parts should be rendered. */ message: ChatMessageBase; - /** Visibility strategy. Defaults to "auto" (open while streaming). */ - visibility?: ChatMessageReasoningVisibility; - /** Optional override for the substitute label computation. */ - summarizer?: ReasoningSummarizer; /** - * ms since the reasoning started. The component will format and display it. - * If omitted, no timer is shown. + * 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. */ - elapsedMs?: number; - /** - * Called whenever the substitute label changes. Lets the parent (e.g. the - * loader) mirror the same label without recomputing it. - */ - onSummaryChange?: (summary: ReasoningSummary) => void; + visibility?: ChatMessageReasoningVisibility; classNames?: Partial; translations?: Partial; }; @@ -68,21 +54,13 @@ function getReasoningParts(message: ChatMessageBase): ReasoningUIPart[] { ); } -function formatElapsed(ms: number): string { - if (ms < 1000) return `${Math.max(0, Math.round(ms / 100) / 10).toFixed(1)}`; - return `${(ms / 1000).toFixed(1)}`; -} - export function createChatMessageReasoningComponent({ createElement, }: Renderer) { return function ChatMessageReasoning(userProps: ChatMessageReasoningProps) { const { message, - visibility = 'auto', - summarizer = summarizeReasoning, - elapsedMs, - onSummaryChange, + visibility = 'collapsed', classNames = {}, translations: userTranslations, ...props @@ -94,109 +72,64 @@ export function createChatMessageReasoningComponent({ if (reasoningParts.length === 0) return null; const translations: Required = { - thinkingLabel: 'Thinking\u2026', + title: 'Reasoning', toggleLabel: 'Toggle reasoning', - elapsedPrefix: 'Thought for', - elapsedSuffix: 's', ...userTranslations, }; - const ctx = getReasoningContext(message); - const summary = summarizer(ctx.text, { - message, - lastToolCall: ctx.lastToolCall, - streaming: ctx.streaming, - }); - - if (onSummaryChange) onSummaryChange(summary); + const streaming = reasoningParts.some((part) => part.state === 'streaming'); - // Visibility resolution. - // - auto: open while streaming, collapse on done. - // - expanded: always open. - // - collapsed: always closed. - const open = - visibility === 'expanded' || - (visibility === 'auto' && ctx.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', - ctx.streaming && 'ais-ChatMessageReasoning--streaming', - open && 'ais-ChatMessageReasoning--open', - summary.redact && 'ais-ChatMessageReasoning--redacted', - `ais-ChatMessageReasoning--${summary.category}`, + 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), - timer: cx('ais-ChatMessageReasoning-timer', classNames.timer), chevron: cx('ais-ChatMessageReasoning-chevron', classNames.chevron), body: cx('ais-ChatMessageReasoning-body', classNames.body), text: cx('ais-ChatMessageReasoning-text', classNames.text), }; - const showTimer = typeof elapsedMs === 'number' && elapsedMs > 0; - const bodyId = `ais-reasoning-body-${message.id}`; - return ( -
- - -
+
); }; } diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index 35fb2219709..61964a17fd2 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -335,7 +335,6 @@ function createDefaultMessageComponent< suggestionsElement, showReasoning, reasoningVisibility, - reasoningSummarizer, reasoningTranslations, reasoningClassNames, }: { @@ -359,7 +358,6 @@ function createDefaultMessageComponent< suggestionsElement?: VNode; showReasoning?: boolean; reasoningVisibility?: ChatMessageReasoningVisibility; - reasoningSummarizer?: ReasoningSummarizer; reasoningTranslations?: Partial; reasoningClassNames?: Partial; }) { @@ -448,7 +446,6 @@ function createDefaultMessageComponent< suggestionsElement={suggestionsElement} showReasoning={showReasoning} reasoningVisibility={reasoningVisibility} - reasoningSummarizer={reasoningSummarizer} reasoningTranslations={reasoningTranslations} reasoningClassNames={reasoningClassNames} {...messageProps} @@ -515,7 +512,7 @@ export function createChatMessagesComponent({ onFeedback, feedbackState, showReasoning = false, - reasoningVisibility = 'auto', + reasoningVisibility = 'collapsed', reasoningSummarizer, reasoningTranslations, reasoningClassNames, @@ -634,7 +631,6 @@ export function createChatMessagesComponent({ messageTranslations={messageTranslations} showReasoning={showReasoning} reasoningVisibility={reasoningVisibility} - reasoningSummarizer={reasoningSummarizer} reasoningTranslations={reasoningTranslations} reasoningClassNames={reasoningClassNames} suggestionsElement={ 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 index e397394e420..691a6b3d570 100644 --- a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessageReasoning.test.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessageReasoning.test.tsx @@ -39,13 +39,31 @@ describe('ChatMessageReasoning', () => { expect(container.firstChild).toBeNull(); }); - test('renders the reasoning body text', () => { + 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( @@ -53,77 +71,56 @@ describe('ChatMessageReasoning', () => { ).toHaveTextContent('comparing the options'); }); - test('accepts an injectable toggle label and elapsed time strings', () => { - const { getByRole, getByText } = render( + test('is open when visibility is expanded', () => { + const { container } = render( ); expect( - getByRole('button', { name: 'Basculer le raisonnement' }) - ).toBeInTheDocument(); - expect(getByText(/Réflexion pendant/)).toBeInTheDocument(); - expect(getByText(/secondes/)).toBeInTheDocument(); + 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('falls back to the injectable thinking label when the summarizer returns no label', () => { - const { getByText } = render( + test('accepts injectable title and toggle label', () => { + const { container, getByLabelText } = render( ({ label: '', category: 'thinking' })} - translations={{ thinkingLabel: 'Réflexion en cours' }} + translations={{ + title: 'Raisonnement', + toggleLabel: 'Basculer le raisonnement', + }} /> ); - expect(getByText('Réflexion en cours')).toBeInTheDocument(); + 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(); }); - - test('redacts the body when the summarizer flags it', () => { - const { getByText, queryByText } = render( - - ); - expect(getByText('(reasoning redacted for privacy)')).toBeInTheDocument(); - expect(queryByText(/sk-01234567890123456789/)).not.toBeInTheDocument(); - }); - - test('uses a custom summarizer when provided', () => { - const summarizer = jest.fn(() => ({ - label: 'Custom label', - category: 'thinking' as const, - })); - const { getByText } = render( - - ); - expect(summarizer).toHaveBeenCalled(); - expect(getByText('Custom label')).toBeInTheDocument(); - }); }); diff --git a/packages/instantsearch-ui-components/src/components/chat/icons.tsx b/packages/instantsearch-ui-components/src/components/chat/icons.tsx index 71abbb8448f..22b18c3a071 100644 --- a/packages/instantsearch-ui-components/src/components/chat/icons.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/icons.tsx @@ -296,19 +296,27 @@ 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.css/src/components/chat/_chat-message-reasoning.scss b/packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss index 14d5dee493e..792569a927c 100644 --- a/packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss +++ b/packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss @@ -1,26 +1,37 @@ +// 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 { - display: flex; - flex-direction: column; - gap: calc(var(--ais-spacing) * 0.25); margin-block-end: calc(var(--ais-spacing) * 0.5); - border: 1px solid rgba(var(--ais-muted-color-rgb), 0.2); + border: 1px solid rgba(var(--ais-muted-color-rgb), 0.25); border-radius: var(--ais-border-radius-md); - background-color: rgba(var(--ais-muted-color-rgb), 0.04); - font-size: calc(var(--ais-spacing) * 0.8125); - color: rgba(var(--ais-text-color-rgb), 0.85); + background-color: var(--ais-background-color); overflow: hidden; } .ais-ChatMessageReasoning-header { - all: unset; box-sizing: border-box; display: flex; align-items: center; - gap: calc(var(--ais-spacing) * 0.5); + gap: calc(var(--ais-spacing) * 0.375); width: 100%; - padding: calc(var(--ais-spacing) * 0.5) calc(var(--ais-spacing) * 0.75); + padding: calc(var(--ais-spacing) * 0.375) calc(var(--ais-spacing) * 0.5); cursor: pointer; - font-weight: var(--ais-font-weight-medium); + 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); @@ -30,9 +41,9 @@ .ais-ChatMessageReasoning-icon { display: inline-flex; - width: calc(var(--ais-icon-size) * 0.85); - height: calc(var(--ais-icon-size) * 0.85); - color: rgba(var(--ais-primary-color-rgb), 0.85); + 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 { @@ -41,20 +52,18 @@ } } -.ais-ChatMessageReasoning--streaming .ais-ChatMessageReasoning-icon { - animation: ais-reasoning-pulse 1.6s ease-in-out infinite; -} - .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); } -// Reuse the loader's text shimmer while streaming - it tells the user the -// label is "live" and not a stale title. +// 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; @@ -75,17 +84,10 @@ animation: ais-chat-loader-text 3s linear infinite; } -.ais-ChatMessageReasoning-timer { - flex-shrink: 0; - color: rgba(var(--ais-muted-color-rgb), 0.9); - font-variant-numeric: tabular-nums; - font-size: calc(var(--ais-spacing) * 0.75); -} - .ais-ChatMessageReasoning-chevron { display: inline-flex; - width: calc(var(--ais-icon-size) * 0.7); - height: calc(var(--ais-icon-size) * 0.7); + 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); @@ -97,50 +99,36 @@ } } -.ais-ChatMessageReasoning--open .ais-ChatMessageReasoning-chevron { +// `
` 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: 0 calc(var(--ais-spacing) * 0.75) - calc(var(--ais-spacing) * 0.75) calc(var(--ais-spacing) * 0.75); - border-block-start: 1px solid rgba(var(--ais-muted-color-rgb), 0.15); - color: rgba(var(--ais-text-color-rgb), 0.6); + 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; - font-style: italic; - max-height: 18rem; + max-height: 9rem; overflow-y: auto; - - p { - margin: calc(var(--ais-spacing) * 0.5) 0 0 0; - - &:first-child { - margin-block-start: calc(var(--ais-spacing) * 0.5); - } - - animation: ais-reasoning-line-in 320ms ease-out; - } } -.ais-ChatMessageReasoning[data-state='closed'] .ais-ChatMessageReasoning-body { - display: none; -} +.ais-ChatMessageReasoning-text { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; -@keyframes ais-reasoning-pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.55; transform: scale(0.9); } -} - -@keyframes ais-reasoning-line-in { - from { opacity: 0; transform: translateY(2px); } - to { opacity: 1; transform: translateY(0); } + & + & { + margin-block-start: calc(var(--ais-spacing) * 0.5); + } } @media (prefers-reduced-motion: reduce) { - .ais-ChatMessageReasoning-icon, .ais-ChatMessageReasoning-label, - .ais-ChatMessageReasoning-chevron, - .ais-ChatMessageReasoning-body p { + .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 9dbc9e58914..3f335c19aca 100644 --- a/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx +++ b/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx @@ -386,6 +386,7 @@ describe('chat', () => { messages: reasoningMessages, templates: { messages: { + reasoningTitleText: 'Raisonnement', reasoningToggleLabelText: 'Basculer le raisonnement', }, }, @@ -396,8 +397,11 @@ describe('chat', () => { await wait(0); expect( - screen.getByRole('button', { name: 'Basculer le raisonnement' }) - ).toBeInTheDocument(); + 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 da434088e5f..2544763a4cd 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -850,10 +850,8 @@ const createRenderer = ({ const reasoningTranslations: Partial = getDefinedProperties({ - thinkingLabel: templates.messages?.reasoningThinkingLabelText, + title: templates.messages?.reasoningTitleText, toggleLabel: templates.messages?.reasoningToggleLabelText, - elapsedPrefix: templates.messages?.reasoningElapsedPrefixText, - elapsedSuffix: templates.messages?.reasoningElapsedSuffixText, }); assistantMessageTemplateRef.current = prepareTemplateProps({ @@ -1111,22 +1109,13 @@ export type ChatTemplates = BaseHit> = */ regenerateLabelText?: string; /** - * Fallback header label for the reasoning panel, shown when the - * summarizer produces no substitute label. Defaults to "Thinking…". + * Header label for the reasoning panel. Defaults to "Reasoning". */ - reasoningThinkingLabelText?: string; + reasoningTitleText?: string; /** - * Accessible label for the reasoning panel toggle button. + * Accessible label for the reasoning panel disclosure toggle. */ reasoningToggleLabelText?: string; - /** - * Prefix shown before the reasoning elapsed time, e.g. "Thought for". - */ - reasoningElapsedPrefixText?: string; - /** - * Suffix shown after the reasoning elapsed time, e.g. "s". - */ - reasoningElapsedSuffixText?: string; }>; /** From 8dc5377292e10141fe8d036a1ca58447038cf117 Mon Sep 17 00:00:00 2001 From: Andras Date: Mon, 6 Jul 2026 15:36:05 +0300 Subject: [PATCH 4/7] Update App.tsx --- examples/react/getting-started/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/react/getting-started/src/App.tsx b/examples/react/getting-started/src/App.tsx index 36973875179..312252a60e3 100644 --- a/examples/react/getting-started/src/App.tsx +++ b/examples/react/getting-started/src/App.tsx @@ -96,6 +96,7 @@ export function App() { agentId="eedef238-5468-470d-bc37-f99fa741bd25" feedback={true} itemComponent={ItemComponent} + showReasoning /> From 333f86ea23c81415a3752908d510c32d4f45da94 Mon Sep 17 00:00:00 2001 From: Andras Date: Mon, 6 Jul 2026 15:55:27 +0300 Subject: [PATCH 5/7] Final Fixes --- packages/instantsearch.js/src/widgets/chat/chat.tsx | 9 +++++---- packages/react-instantsearch/src/widgets/Chat.tsx | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 2544763a4cd..9c89d5f61b4 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -1267,16 +1267,17 @@ type ChatWidgetParams = { /** * Visibility strategy for the reasoning panel. - * - `auto` (default): open while streaming, collapse when done. + * - `collapsed` (default): closed, user can expand. * - `expanded`: always open. - * - `collapsed`: always closed. + * - `auto`: open while streaming, collapsible afterwards. * - `hidden`: never render reasoning even if parts exist. */ reasoningVisibility?: ChatMessageReasoningVisibility; /** - * Override the substitute-label computation for the reasoning panel and the - * live loader caption. Defaults to the built-in heuristic summarizer. + * 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; }; diff --git a/packages/react-instantsearch/src/widgets/Chat.tsx b/packages/react-instantsearch/src/widgets/Chat.tsx index 04e694cc0ba..80396865ece 100644 --- a/packages/react-instantsearch/src/widgets/Chat.tsx +++ b/packages/react-instantsearch/src/widgets/Chat.tsx @@ -158,12 +158,12 @@ export type ChatProps = Omit< */ showReasoning?: boolean; /** - * Visibility strategy for the reasoning panel. Default: `auto`. + * Visibility strategy for the reasoning panel. Default: `collapsed`. */ reasoningVisibility?: ChatMessageReasoningVisibility; /** - * Override the substitute-label computation for the reasoning panel and - * the live loader caption. + * Override the substitute-label computation for the live loader caption + * (the "Searching the catalogue…" text shown while the model thinks). */ reasoningSummarizer?: ReasoningSummarizer; translations?: Partial<{ From 959cba44cccc74ec50fe5fee94b9e7b1e8ca5fd3 Mon Sep 17 00:00:00 2001 From: Andras Date: Mon, 6 Jul 2026 16:05:53 +0300 Subject: [PATCH 6/7] Update bundlesize.config.json --- bundlesize.config.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index 996b4865233..3153e701d0e 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,11 +10,11 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "130 kB" + "maxSize": "133 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "275 kB" + "maxSize": "280 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", @@ -22,7 +22,7 @@ }, { "path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js", - "maxSize": "102.5 kB" + "maxSize": "105.5 kB" }, { "path": "packages/vue-instantsearch/vue2/umd/index.js", @@ -42,11 +42,11 @@ }, { "path": "./packages/instantsearch.css/themes/algolia.css", - "maxSize": "10.75 kB" + "maxSize": "11.25 kB" }, { "path": "./packages/instantsearch.css/themes/algolia-min.css", - "maxSize": "10 kB" + "maxSize": "10.5 kB" }, { "path": "./packages/instantsearch.css/themes/reset.css", @@ -58,27 +58,27 @@ }, { "path": "./packages/instantsearch.css/themes/nova.css", - "maxSize": "11 kB" + "maxSize": "11.5 kB" }, { "path": "./packages/instantsearch.css/themes/nova-min.css", - "maxSize": "10.25 kB" + "maxSize": "10.75 kB" }, { "path": "./packages/instantsearch.css/themes/satellite.css", - "maxSize": "11.75 kB" + "maxSize": "12.25 kB" }, { "path": "./packages/instantsearch.css/themes/satellite-min.css", - "maxSize": "11 kB" + "maxSize": "11.5 kB" }, { "path": "./packages/instantsearch.css/components/chat.css", - "maxSize": "6.25 kB" + "maxSize": "6.75 kB" }, { "path": "./packages/instantsearch.css/components/chat-min.css", - "maxSize": "5.75 kB" + "maxSize": "6.25 kB" }, { "path": "./packages/instantsearch.css/components/autocomplete.css", From a12dec28f76de96d106f1e225d5129696cdbe7a4 Mon Sep 17 00:00:00 2001 From: Andras Date: Mon, 6 Jul 2026 18:28:47 +0300 Subject: [PATCH 7/7] Copilot Code review fixes --- .../src/components/chat/ChatMessageReasoning.tsx | 5 ++++- .../src/components/chat/ChatMessages.tsx | 2 +- .../instantsearch-ui-components/src/lib/utils/reasoning.ts | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx index f310bb4521e..a27a8362e37 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx @@ -104,7 +104,10 @@ export function createChatMessageReasoningComponent({