1
0
Fork 0

feat(agentStudio): surface thread depth error (#2877)

* feat(agentStudio): surface thread depth error

* feat(agentStudio): new conversation fixes

* feat(agentStudio): Sidepanel improvements

* feat(agentStudio): ussage of service error message
This commit is contained in:
Felipe Bermudez 2026-04-14 11:36:54 -05:00 committed by GitHub
parent b686914cd2
commit cf42ab519b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 623 additions and 92 deletions

View file

@ -47,6 +47,7 @@
"@rollup/plugin-terser": "0.4.4",
"@stylistic/eslint-plugin": "2.13.0",
"@testing-library/dom": "10.4.0",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "8.20.0",

View file

@ -54,6 +54,7 @@ export interface DocSearchContext {
isModalActive: boolean;
onAskAiToggle: OnAskAiToggle;
initialAskAiMessage: InitialAskAiMessage | undefined;
clearInitialAskAiMessage: () => void;
registerView: (view: View) => void;
isHybridModeSupported: boolean;
}
@ -183,6 +184,10 @@ function DocSearchInner(
[setDocsearchState, setInitialQuery],
);
const clearInitialAskAiMessage = React.useCallback((): void => {
setInitialAskAiMessage(undefined);
}, []);
const registerView = React.useCallback(
(view: View): void => {
if (registeredViews.has(view)) return;
@ -246,6 +251,7 @@ function DocSearchInner(
isModalActive,
onAskAiToggle,
initialAskAiMessage,
clearInitialAskAiMessage,
registerView,
isHybridModeSupported,
}),
@ -260,6 +266,7 @@ function DocSearchInner(
isModalActive,
onAskAiToggle,
initialAskAiMessage,
clearInitialAskAiMessage,
registerView,
isHybridModeSupported,
],

View file

@ -87,7 +87,8 @@
background: transparent;
border: 0;
color: var(--docsearch-text-color);
flex: 1;
flex: 1 1 0%;
min-width: 0;
font: inherit;
font-size: 1.2em;
font-weight: 300;
@ -95,12 +96,16 @@
outline: none;
padding-block-start: 0px;
padding-inline-start: 8px;
width: 80%;
line-height: 1.4;
resize: none;
overflow-y: hidden; /* js toggles to auto when exceeding max */
}
.DocSearch-Input {
overflow-x: hidden;
text-overflow: ellipsis;
}
.DocSearch-Input::placeholder {
color: var(--docsearch-muted-color);
opacity: 1; /* Firefox */
@ -114,7 +119,8 @@
}
.DocSearch-Actions {
width: var(--docsearch-actions-width);
flex: 0 0 auto;
width: auto;
height: var(--docsearch-actions-height);
display: flex;
align-items: center;
@ -929,6 +935,10 @@ assistive tech users */
width: 100%;
}
.DocSearch-AskAiScreen-Error--ThreadDepth .DocSearch-AskAiScreen-Error-Title {
margin-bottom: 6px;
}
@keyframes slideDown {
from {
opacity: 0;

View file

@ -19,12 +19,16 @@
--docsearch-sidepanel-hit-highlight-color: var(--docsearch-hit-highlight-color);
--docsearch-sidepanel-button-background: var(--docsearch-sidepanel-background);
--docsearch-sidepanel-button-background-dark: var(--docsearch-sidepanel-background);
--docsearch-sidepanel-thread-depth-banner-bg: #fff1f2;
--docsearch-sidepanel-thread-depth-banner-border: #fecdd3;
}
html[data-theme='dark'] {
--docsearch-sidepanel-text-base: var(--docsearch-text-color);
--docsearch-sidepanel-primary-disabled: rgba(1, 45, 186, 0.60);
--docsearch-sidepanel-button-background-dark: rgba(4, 4, 8, 1);
--docsearch-sidepanel-thread-depth-banner-bg: rgb(239 83 80 / 12%);
--docsearch-sidepanel-thread-depth-banner-border: rgb(254 205 211 / 35%);
}
.DocSearch-SidepanelButton {
@ -623,6 +627,45 @@ html[data-theme="dark"] .DocSearch-Sidepanel-Prompt--stop:hover {
flex-direction: column;
}
@keyframes docsearch-sidepanel-thread-depth-banner-enter {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.DocSearch-Sidepanel .DocSearch-Sidepanel-ThreadDepthBanner {
display: flex;
flex-direction: column;
margin: 0;
flex-shrink: 0;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background-color: var(--docsearch-sidepanel-thread-depth-banner-bg);
border: 1px solid var(--docsearch-sidepanel-thread-depth-banner-border);
box-sizing: border-box;
color: var(--docsearch-sidepanel-text-base);
font-size: 0.75rem;
line-height: 1rem;
font-weight: 400;
width: 100%;
animation: docsearch-sidepanel-thread-depth-banner-enter 0.3s ease-out;
}
.DocSearch-Sidepanel .DocSearch-Sidepanel-ThreadDepthBanner p {
margin: 0;
}
.DocSearch-Sidepanel .DocSearch-Sidepanel-ThreadDepthBanner-apiMessage {
font-weight: 700;
margin-bottom: 0.5rem;
}
@media screen and (min-width: 769px) {
.DocSearch-Sidepanel-ConversationScreen {
padding: 1rem 0;

View file

@ -9,7 +9,13 @@ import type { StoredSearchPlugin } from './stored-searches';
import { ToolCall } from './ToolCall';
import type { InternalDocSearchHit, StoredAskAiState } from './types';
import type { AIMessage } from './types/AskiAi';
import { extractLinksFromMessage, getMessageContent, isThreadDepthError } from './utils/ai';
import {
extractLinksFromMessage,
filterExchangesForThreadDepthError,
getMessageContent,
getThreadDepthErrorUserFacingMessage,
isThreadDepthError,
} from './utils/ai';
import { groupConsecutiveToolResults } from './utils/groupConsecutiveToolResults';
export type AskAiScreenTranslations = Partial<{
@ -378,6 +384,8 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
return status === 'error' && isThreadDepthError(askAiError);
}, [status, askAiError]);
const threadDepthApiMessage = useMemo(() => getThreadDepthErrorUserFacingMessage(askAiError), [askAiError]);
// Group messages into exchanges (user + assistant pairs)
const exchanges: Exchange[] = useMemo(() => {
const grouped: Exchange[] = [];
@ -392,17 +400,7 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
}
}
// If there's a thread depth error, remove the last exchange (the one that triggered the error)
// We only want to show successful exchanges
if (hasThreadDepthError && grouped.length > 0) {
// Check if the last exchange has no assistant message (failed to complete)
const lastExchange = grouped[grouped.length - 1];
if (!lastExchange.assistantMessage) {
grouped.pop();
}
}
return grouped;
return filterExchangesForThreadDepthError(grouped, hasThreadDepthError);
}, [messages, hasThreadDepthError]);
const handleSearchQueryClick = (query: string): void => {
@ -419,6 +417,9 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
{showThreadDepthError && (
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error DocSearch-AskAiScreen-Error--ThreadDepth">
<div className="DocSearch-AskAiScreen-Error-Content">
{threadDepthApiMessage ? (
<p className="DocSearch-AskAiScreen-Error-Title">{threadDepthApiMessage}</p>
) : null}
<p>
{threadDepthExceededMessage}{' '}
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={props.onNewConversation}>

View file

@ -398,16 +398,26 @@ export function DocSearchModal({
const [stoppedStream, setStoppedStream] = React.useState(false);
const { messages, status, setMessages, sendMessage, stopAskAiStreaming, askAiError, sendFeedback, conversations } =
useAskAi({
assistantId: askAiConfigurationId,
apiKey: askAiConfig?.apiKey || apiKey,
appId: askAiConfig?.appId || appId,
indexName: askAiConfig?.indexName || defaultIndexName,
searchParameters: askAiSearchParameters,
useStagingEnv: askAiUseStagingEnv,
agentStudio,
});
const {
messages,
status,
sendMessage,
stopAskAiStreaming,
askAiError,
sendFeedback,
conversations,
clearError,
resetAskAiAbortScope,
resetAskAiChatSession,
} = useAskAi({
assistantId: askAiConfigurationId,
apiKey: askAiConfig?.apiKey || apiKey,
appId: askAiConfig?.appId || appId,
indexName: askAiConfig?.indexName || defaultIndexName,
searchParameters: askAiSearchParameters,
useStagingEnv: askAiUseStagingEnv,
agentStudio,
});
const prevStatus = React.useRef(status);
React.useEffect(() => {
@ -423,9 +433,12 @@ export function DocSearchModal({
};
}
for (const part of messages[0].parts) {
if (part.type === 'text') {
conversations.add(buildDummyAskAiHit(part.text, messages));
const first = messages[0];
if (first?.parts) {
for (const part of first.parts) {
if (part.type === 'text') {
conversations.add(buildDummyAskAiHit(part.text, messages));
}
}
}
}
@ -533,6 +546,8 @@ export function DocSearchModal({
if (isHybridModeSupported) return;
setStoppedStream(false);
resetAskAiAbortScope();
clearError();
const messageOptions: ChatRequestOptions = {};
@ -571,7 +586,16 @@ export function DocSearchModal({
autocompleteRef.current.setQuery('');
}
},
[onAskAiToggle, interceptAskAiEvent, sendMessage, askAiState, setAskAiState, isHybridModeSupported],
[
onAskAiToggle,
interceptAskAiEvent,
askAiState,
setAskAiState,
isHybridModeSupported,
clearError,
resetAskAiAbortScope,
sendMessage,
],
);
// feedback handler
@ -623,7 +647,10 @@ export function DocSearchModal({
},
onSelect({ item }): void {
if (item.messages) {
setMessages(item.messages as any);
resetAskAiChatSession({
kind: 'setMessages',
messages: item.messages as AIMessage[],
});
onAskAiToggle(true);
}
},
@ -793,14 +820,18 @@ export function DocSearchModal({
};
}, []);
// Refresh the autocomplete results when ask ai is toggled off
// helps return to the previous ac state and start screen
// Refresh autocomplete and rotate the chat session only when Ask AI is turned off — not on every
// mount while inactive. `clearError` from `useChat` can change when `chatSessionId` changes; listing
// it (and re-running `resetAskAiChatSession` on that) caused an infinite update loop.
const prevIsAskAiActiveRef = React.useRef(isAskAiActive);
React.useEffect(() => {
if (!isAskAiActive) {
if (prevIsAskAiActiveRef.current && !isAskAiActive) {
autocomplete.refresh();
setMessages([]);
clearError();
resetAskAiChatSession();
}
}, [isAskAiActive, autocomplete, setMessages]);
prevIsAskAiActiveRef.current = isAskAiActive;
}, [isAskAiActive, autocomplete, clearError, resetAskAiChatSession]);
// Track external state in order to manage internal askAiState
React.useEffect(() => {
@ -814,7 +845,8 @@ export function DocSearchModal({
};
const handleNewConversation = (): void => {
setMessages([]);
clearError();
resetAskAiChatSession();
setAskAiState('new-conversation');
};
@ -913,7 +945,10 @@ export function DocSearchModal({
if (item.type === 'askAI' && item.query) {
// if the item is askAI and the anchor is stored
if (item.anchor === 'stored' && 'messages' in item) {
setMessages(item.messages as any);
resetAskAiChatSession({
kind: 'setMessages',
messages: item.messages as AIMessage[],
});
const initialMessage: InitialAskAiMessage = {
query: item.query,
messageId: (item.messages as StoredAskAiMessage[])[0].id,

View file

@ -115,13 +115,21 @@ function DocSearchSidepanelComp({
panel: { portalContainer, ...panelProps } = {},
...rootProps
}: DocSearchSidepanelProps): JSX.Element {
const { docsearchState, setDocsearchState, keyboardShortcuts, registerView, initialAskAiMessage } = useDocSearch();
const {
docsearchState,
setDocsearchState,
keyboardShortcuts,
registerView,
initialAskAiMessage,
clearInitialAskAiMessage,
} = useDocSearch();
const toggleSidepanelState = React.useCallback(() => {
setDocsearchState(docsearchState === 'sidepanel' ? 'ready' : 'sidepanel');
}, [docsearchState, setDocsearchState]);
const handleClose = (): void => {
clearInitialAskAiMessage();
setDocsearchState('ready');
};

View file

@ -9,7 +9,7 @@ import type { StoredSearchPlugin } from '../stored-searches';
import { ToolCall, type ToolCallTranslations } from '../ToolCall';
import type { StoredAskAiState } from '../types';
import { isAIToolPart, type AIMessage } from '../types/AskiAi';
import { extractLinksFromMessage, getMessageContent } from '../utils/ai';
import { extractLinksFromMessage, getMessageContent, isThreadDepthError } from '../utils/ai';
import { groupConsecutiveToolResults } from '../utils/groupConsecutiveToolResults';
import { AggregatedSearchBlock } from './AggregatedSearchBlock';
@ -60,7 +60,15 @@ export type ConversationScreenTranslations = Partial<
/**
* Error title shown if there is an error while chatting.
*/
errorTitleText;
errorTitleText: string;
/**
* Message shown when thread depth limit is exceeded (AI-217).
*/
threadDepthExceededMessage: string;
/**
* Button label to start a new conversation after a thread depth error.
*/
startNewConversationButtonText: string;
}
>;
@ -105,6 +113,8 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
errorTitleText = 'Chat error',
} = translations;
const isThreadDepth = isThreadDepthError(streamError);
const assistantContent = useMemo(() => getMessageContent(assistantMessage), [assistantMessage]);
const userContent = useMemo(() => getMessageContent(userMessage), [userMessage]);
@ -130,7 +140,7 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
</div>
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
<div className="DocSearch-AskAiScreen-MessageContent">
{status === 'error' && streamError && isLastExchange && (
{status === 'error' && streamError && isLastExchange && !isThreadDepth && (
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error">
<AlertIcon />
<div className="DocSearch-AskAiScreen-Error-Content">

View file

@ -22,8 +22,30 @@ export type PromptFormTranslations = Partial<{
* Disclaimer text displayed beneath the prompt form.
**/
promptDisclaimerText: string;
/**
* Visually hidden label text (`aria-labelledby`); usually keyboard hints for the textarea.
**/
promptLabelText: string;
/**
* Accessible name for the textarea (`aria-label`).
**/
promptAriaLabelText: string;
/**
* Placeholder when the conversation hit the thread depth limit (AI-217).
**/
threadDepthErrorPlaceholder: string;
/**
* Message shown in the thread-depth banner above the prompt (AI-217).
**/
threadDepthExceededMessage: string;
/**
* Button label in the thread-depth banner to start a new conversation.
**/
startNewConversationButtonText: string;
/**
* Trailing sentence fragment after the button in the thread-depth banner (e.g. "to continue.").
**/
threadDepthBannerContinueText: string;
}>;
type Props = {
@ -32,12 +54,27 @@ type Props = {
translations?: PromptFormTranslations;
onSend: (prompt: string) => void;
onStopStreaming: () => void;
showThreadDepthBanner: boolean;
threadDepthApiMessage?: string;
onStartNewConversation: () => void;
};
const MAX_PROMPT_ROWS = 8;
export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
({ exchanges, isStreaming, translations = {}, onSend, onStopStreaming }, ref): JSX.Element => {
(
{
exchanges,
isStreaming,
translations = {},
onSend,
onStopStreaming,
showThreadDepthBanner,
threadDepthApiMessage,
onStartNewConversation,
},
ref,
): JSX.Element => {
const isMobile = useIsMobile();
const [userPrompt, setUserPrompt] = React.useState('');
const promptRef = React.useRef<HTMLTextAreaElement>(null);
@ -51,6 +88,10 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
promptDisclaimerText = 'Answers are generated with AI which can make mistakes.',
promptLabelText = 'Press Enter to send, or Shift and Enter for new line.',
promptAriaLabelText = 'Prompt input',
threadDepthErrorPlaceholder = 'Conversation limit reached',
threadDepthExceededMessage = 'This conversation is now closed to keep responses accurate.',
startNewConversationButtonText = 'Start a new conversation',
threadDepthBannerContinueText = 'to continue.',
} = translations;
const managePromptHeight = (): void => {
@ -74,7 +115,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
};
const handleSend = (): void => {
if (isStreaming) return;
if (isStreaming || showThreadDepthBanner) return;
const prompt = userPrompt.trim();
@ -93,7 +134,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
// Allow Enter to work normally (new line) when streaming
if (isStreaming) return;
if (isStreaming || showThreadDepthBanner) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@ -108,7 +149,9 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
let promptPlaceholder = promptPlaceholderText;
if (isStreaming) {
if (showThreadDepthBanner) {
promptPlaceholder = threadDepthErrorPlaceholder;
} else if (isStreaming) {
promptPlaceholder = promptAnsweringText;
} else if (exchanges.length > 0) {
promptPlaceholder = promptAskAnotherQuestionText;
@ -116,12 +159,26 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
return (
<div className="DocSearch-Sidepanel-Prompt">
{showThreadDepthBanner ? (
<div className="DocSearch-Sidepanel-ThreadDepthBanner">
{threadDepthApiMessage ? (
<p className="DocSearch-Sidepanel-ThreadDepthBanner-apiMessage">{threadDepthApiMessage}</p>
) : null}
<p>
{threadDepthExceededMessage}{' '}
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={onStartNewConversation}>
{startNewConversationButtonText}
</button>{' '}
{threadDepthBannerContinueText}
</p>
</div>
) : null}
<form
className="DocSearch-Sidepanel-Prompt--form"
onSubmit={(e) => {
e.preventDefault();
if (isStreaming) return;
if (isStreaming || showThreadDepthBanner) return;
handleSend();
}}
@ -136,6 +193,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
autoComplete="off"
translate="no"
rows={isMobile ? 1 : 2}
disabled={showThreadDepthBanner}
onKeyDown={handleKeyDown}
onInput={managePromptHeight}
onChange={(e) => setUserPrompt(e.target.value)}
@ -154,7 +212,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
<StopIcon />
</button>
)}
{!isStreaming && (
{!isStreaming && !showThreadDepthBanner && (
<button
type="submit"
aria-label="Send question"

View file

@ -9,7 +9,12 @@ import { useAskAi } from '../useAskAi';
import { useIsMobile } from '../useIsMobile';
import { useSearchClient } from '../useSearchClient';
import { useSuggestedQuestions } from '../useSuggestedQuestions';
import { buildDummyAskAiHit } from '../utils/ai';
import {
buildDummyAskAiHit,
filterExchangesForThreadDepthError,
getThreadDepthErrorUserFacingMessage,
isThreadDepthError,
} from '../utils/ai';
import { ConversationHistoryScreen } from './ConversationHistoryScreen';
import type { ConversationScreenTranslations } from './ConversationScreen';
@ -182,7 +187,8 @@ function SidepanelInner(
stopAskAiStreaming,
isStreaming,
exchanges,
setMessages,
clearError,
resetAskAiChatSession,
conversations,
messages,
sendFeedback,
@ -203,31 +209,62 @@ function SidepanelInner(
searchClient,
});
const hasThreadDepthError = React.useMemo(
() => status === 'error' && isThreadDepthError(askAiError),
[status, askAiError],
);
const displayExchanges = React.useMemo(
() => filterExchangesForThreadDepthError(exchanges, hasThreadDepthError),
[exchanges, hasThreadDepthError],
);
const showThreadDepthBanner =
sidepanelState === 'conversation' && hasThreadDepthError && messages.some((m) => m.role === 'assistant');
const threadDepthApiMessage = React.useMemo(() => getThreadDepthErrorUserFacingMessage(askAiError), [askAiError]);
const promptFormTranslations = React.useMemo(
() => ({
...translations.promptForm,
...(translations.conversationScreen?.threadDepthExceededMessage !== undefined
? { threadDepthExceededMessage: translations.conversationScreen.threadDepthExceededMessage }
: {}),
...(translations.conversationScreen?.startNewConversationButtonText !== undefined
? { startNewConversationButtonText: translations.conversationScreen.startNewConversationButtonText }
: {}),
}),
[translations.promptForm, translations.conversationScreen],
);
const prevStatus = React.useRef(status);
const handleSend = (prompt: string): void => {
setStoppedStreaming(false);
clearError();
sendMessage({ text: prompt });
setSidepanelState('conversation');
};
const handleStartNewConversation = (): void => {
setMessages([]);
clearError();
resetAskAiChatSession();
setSidepanelState('new-conversation');
};
const handleSelectQuestion = (question: SuggestedQuestionHit): void => {
setStoppedStreaming(false);
setMessages([]);
sendMessage(
{ text: question.question },
{
clearError();
resetAskAiChatSession({
kind: 'sendText',
text: question.question,
requestOptions: {
body: {
suggestedQuestionId: question.objectID,
},
},
);
});
setSidepanelState('conversation');
};
@ -238,15 +275,16 @@ function SidepanelInner(
const handleSelectConversation = React.useCallback(
(conversation: StoredAskAiState): void => {
clearError();
if (conversation.messages) {
setMessages(conversation.messages);
resetAskAiChatSession({ kind: 'setMessages', messages: conversation.messages });
} else if (conversation.query) {
sendMessage({ text: conversation.query });
resetAskAiChatSession({ kind: 'sendText', text: conversation.query });
}
setSidepanelState('conversation');
},
[sendMessage, setMessages],
[clearError, resetAskAiChatSession],
);
useManageSidepanelLayout({
@ -288,9 +326,12 @@ function SidepanelInner(
};
}
for (const part of messages[0].parts) {
if (part.type === 'text') {
conversations.add(buildDummyAskAiHit(part.text, messages));
const first = messages[0];
if (first?.parts) {
for (const part of first.parts) {
if (part.type === 'text') {
conversations.add(buildDummyAskAiHit(part.text, messages));
}
}
}
}
@ -315,34 +356,57 @@ function SidepanelInner(
};
}, []);
// Only re-run when `initialMessage` changes. Other handlers (e.g. `sendMessage`) can change every
// render; listing them here was re-firing the effect and repeatedly clearing the chat.
const initialMessageHandlingRef = React.useRef({
clearError,
resetAskAiChatSession,
conversations,
handleSelectConversation,
});
initialMessageHandlingRef.current = {
clearError,
resetAskAiChatSession,
conversations,
handleSelectConversation,
};
React.useEffect(() => {
if (!initialMessage) return;
const {
clearError: clr,
resetAskAiChatSession: resetSession,
conversations: convs,
handleSelectConversation: selectConv,
} = initialMessageHandlingRef.current;
let selectedConversation: StoredAskAiState | undefined;
if (initialMessage.messageId) {
selectedConversation = conversations.getConversation?.(initialMessage.messageId);
selectedConversation = convs.getConversation?.(initialMessage.messageId);
}
if (selectedConversation) {
handleSelectConversation(selectedConversation);
selectConv(selectedConversation);
} else {
setMessages([]);
sendMessage(
{
text: initialMessage.query,
},
clr();
resetSession(
initialMessage.suggestedQuestionId
? {
body: {
suggestedQuestionId: initialMessage.suggestedQuestionId,
kind: 'sendText',
text: initialMessage.query,
requestOptions: {
body: {
suggestedQuestionId: initialMessage.suggestedQuestionId,
},
},
}
: {},
: { kind: 'sendText', text: initialMessage.query },
);
setSidepanelState('conversation');
}
}, [initialMessage, sendMessage, conversations, handleSelectConversation, setMessages]);
}, [initialMessage]);
// Autofocus the prompt input when the sidepanel opens and blur it when
// it closes. Disabled on mobile because focusing the textarea triggers the
@ -373,7 +437,7 @@ function SidepanelInner(
<aside id="docsearch-sidepanel" className={`DocSearch-Sidepanel ${sidepanelState}`}>
<SidepanelHeader
sidepanelState={sidepanelState}
exchanges={exchanges}
exchanges={displayExchanges}
setSidepanelState={setSidepanelState}
hasConversations={conversations.getAll().length > 0}
isStreaming={isStreaming}
@ -391,7 +455,7 @@ function SidepanelInner(
)}
{sidepanelState === 'conversation' && (
<ConversationScreen
exchanges={exchanges}
exchanges={displayExchanges}
status={status}
conversations={conversations}
handleFeedback={sendFeedback}
@ -406,10 +470,13 @@ function SidepanelInner(
</div>
<PromptForm
ref={promptInputRef}
exchanges={exchanges}
exchanges={displayExchanges}
isStreaming={isStreaming}
translations={translations.promptForm}
showThreadDepthBanner={showThreadDepthBanner}
threadDepthApiMessage={threadDepthApiMessage}
translations={promptFormTranslations}
onSend={handleSend}
onStartNewConversation={handleStartNewConversation}
onStopStreaming={handleStopStreaming}
/>
<footer className="DocSearch-Sidepanel-Footer">

View file

@ -1,4 +1,4 @@
import { render } from '@testing-library/react';
import { render, within } from '@testing-library/react';
import type { UIMessage } from 'ai';
import React from 'react';
import { describe, it, expect } from 'vitest';
@ -42,4 +42,44 @@ describe('AskAiScreen', () => {
expect(getByText('oh no')).toBeInTheDocument();
});
it('surfaces thread depth (AI-217) with a banner and hides the generic chat error', () => {
const messages: UIMessage[] = [
{
id: '1',
role: 'user',
parts: [{ type: 'text', text: 'first' }],
},
{
id: '2',
role: 'assistant',
parts: [{ type: 'text', text: 'answer' }],
},
{
id: '3',
role: 'user',
parts: [{ type: 'text', text: 'follow-up' }],
},
];
const onNewConversation = (): void => {};
const { container, getByText } = render(
<AskAiScreen
{...baseProps}
messages={messages}
status="error"
askAiError={new Error('AI-217 - Thread depth exceeded')}
onNewConversation={onNewConversation}
/>,
);
expect(
getByText('This conversation is now closed to keep responses accurate.', { exact: false }),
).toBeInTheDocument();
expect(getByText('Start a new conversation')).toBeInTheDocument();
// Bound queries from `render()` use `baseElement` (often `document.body`), so a prior test can still
// match. Restrict to this instance's root so we only assert on this tree.
expect(within(container).queryByText('Chat error')).not.toBeInTheDocument();
});
});

View file

@ -1,3 +1,9 @@
export {
filterExchangesForThreadDepthError,
getThreadDepthErrorUserFacingMessage,
isThreadDepthError,
} from './utils/ai';
export * from './DocSearch';
export * from './DocSearchButton';
export * from './DocSearchModal';

View file

@ -1,7 +1,9 @@
import type { UseChatHelpers } from '@ai-sdk/react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
import { useCallback, useMemo, useRef } from 'react';
import type { ChatRequestOptions } from 'ai';
import { DefaultChatTransport, generateId, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MutableRefObject } from 'react';
import {
agentStudioBaseUrl,
@ -20,6 +22,21 @@ import type { AgentStudioSearchParameters, AskAiSearchParameters, StoredAskAiSta
type UseChat = UseChatHelpers<AIMessage>;
/**
* After rotating `useChat` `id`, run this on the **new** chat (see `resetAskAiChatSession`).
*/
export type AskAiPendingAfterSessionReset =
| {
kind: 'sendUserMessage';
message: {
role: 'user';
parts: Array<{ type: 'text'; text: string }>;
};
requestOptions?: ChatRequestOptions;
}
| { kind: 'sendText'; text: string; requestOptions?: ChatRequestOptions }
| { kind: 'setMessages'; messages: AIMessage[] };
type UseAskAiParams = {
assistantId?: string | null;
apiKey: string;
@ -44,6 +61,9 @@ type UseAskAiReturn = {
status: UseChat['status'];
sendMessage: UseChat['sendMessage'];
setMessages: UseChat['setMessages'];
clearError: UseChat['clearError'];
resetAskAiAbortScope: () => void;
resetAskAiChatSession: (pending?: AskAiPendingAfterSessionReset) => void;
stopAskAiStreaming: UseChat['stop'];
askAiError?: Error;
isStreaming: boolean;
@ -80,10 +100,10 @@ const getAskAiTransport = ({
indexName,
searchParameters,
appId,
abortController,
abortControllerRef,
useStagingEnv,
}: Pick<UseAskAiParams, 'apiKey' | 'appId' | 'assistantId' | 'indexName' | 'searchParameters' | 'useStagingEnv'> & {
abortController: AbortController;
abortControllerRef: MutableRefObject<AbortController>;
}): DefaultChatTransport<AIMessage> => {
return new DefaultChatTransport({
api: useStagingEnv ? BETA_ASK_AI_API_URL : ASK_AI_API_URL,
@ -94,7 +114,7 @@ const getAskAiTransport = ({
const token = await getValidToken({
assistantId,
abortSignal: abortController.signal,
abortSignal: abortControllerRef.current.signal,
useStagingEnv,
});
@ -113,6 +133,10 @@ const getAskAiTransport = ({
export const useAskAi: UseAskAi = ({ assistantId, apiKey, appId, indexName, useStagingEnv = false, ...params }) => {
const abortControllerRef = useRef(new AbortController());
const [chatSessionId, setChatSessionId] = useState(() => generateId());
const pendingAfterChatSessionResetRef = useRef<AskAiPendingAfterSessionReset | null>(null);
const sendMessageRef = useRef<UseChat['sendMessage'] | null>(null);
const setMessagesRef = useRef<UseChat['setMessages'] | null>(null);
const askAiTransport = useMemo(
() =>
@ -129,16 +153,25 @@ export const useAskAi: UseAskAi = ({ assistantId, apiKey, appId, indexName, useS
appId,
indexName,
searchParameters: params.searchParameters,
abortController: abortControllerRef.current,
abortControllerRef,
useStagingEnv,
}),
[apiKey, appId, assistantId, indexName, useStagingEnv, params],
);
const { messages, sendMessage, status, setMessages, error, stop } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
transport: askAiTransport,
});
const chatOptions = useMemo(
() => ({
id: chatSessionId,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
transport: askAiTransport,
}),
[chatSessionId, askAiTransport],
);
const { messages, sendMessage, status, setMessages, error, stop, clearError } = useChat(chatOptions);
sendMessageRef.current = sendMessage;
setMessagesRef.current = setMessages;
const conversations = useRef(
createStoredConversations<StoredAskAiState>({
@ -175,9 +208,48 @@ export const useAskAi: UseAskAi = ({ assistantId, apiKey, appId, indexName, useS
[assistantId, params.agentStudio, appId, apiKey, useStagingEnv, conversations],
);
const resetAskAiAbortScope = useCallback((): void => {
abortControllerRef.current.abort();
abortControllerRef.current = new AbortController();
}, []);
const resetAskAiChatSession = useCallback(
(pending?: AskAiPendingAfterSessionReset): void => {
resetAskAiAbortScope();
pendingAfterChatSessionResetRef.current = pending ?? null;
setChatSessionId(generateId());
},
[resetAskAiAbortScope],
);
useEffect(() => {
const pending = pendingAfterChatSessionResetRef.current;
if (pending === null) return;
const send = sendMessageRef.current;
const setMsgs = setMessagesRef.current;
if (pending.kind === 'sendText') {
if (!send) return;
pendingAfterChatSessionResetRef.current = null;
send({ text: pending.text }, pending.requestOptions ?? {});
return;
}
if (pending.kind === 'sendUserMessage') {
if (!send) return;
pendingAfterChatSessionResetRef.current = null;
send(pending.message, pending.requestOptions ?? {});
return;
}
if (!setMsgs) return;
pendingAfterChatSessionResetRef.current = null;
setMsgs(pending.messages);
}, [chatSessionId]);
const onStopStreaming = async (): Promise<void> => {
abortControllerRef.current.abort();
await stop();
abortControllerRef.current = new AbortController();
};
const exchanges = useMemo(() => {
@ -212,6 +284,9 @@ export const useAskAi: UseAskAi = ({ assistantId, apiKey, appId, indexName, useS
sendMessage,
status,
setMessages,
clearError,
resetAskAiAbortScope,
resetAskAiChatSession,
askAiError,
stopAskAiStreaming: onStopStreaming,
isStreaming,

View file

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import type { AIMessage } from '../../types/AskiAi';
import { filterExchangesForThreadDepthError, getThreadDepthErrorUserFacingMessage, isThreadDepthError } from '../ai';
describe('isThreadDepthError', () => {
it('detects AI-217 in message (any case)', () => {
expect(isThreadDepthError(new Error('ai-217: limit'))).toBe(true);
expect(isThreadDepthError(new Error('prefix AI-217 suffix'))).toBe(true);
});
it('detects thread depth phrasing without code', () => {
expect(isThreadDepthError(new Error('Thread depth exceeded'))).toBe(true);
expect(isThreadDepthError(new Error('thread depth limit reached'))).toBe(true);
});
it('detects AI-217 in JSON-shaped error bodies', () => {
expect(isThreadDepthError(new Error(JSON.stringify({ code: 'AI-217', message: 'Too deep' })))).toBe(true);
expect(isThreadDepthError(new Error(JSON.stringify({ errorCode: 'ai-217' })))).toBe(true);
});
it('returns false for unrelated errors', () => {
expect(isThreadDepthError(undefined)).toBe(false);
expect(isThreadDepthError(new Error('Network failed'))).toBe(false);
expect(isThreadDepthError(new Error(JSON.stringify({ code: 'AI-214' })))).toBe(false);
});
});
describe('filterExchangesForThreadDepthError', () => {
const userOnly = (id: string): { id: string; userMessage: AIMessage; assistantMessage: null } => ({
id,
userMessage: { id: `${id}-u`, role: 'user', parts: [{ type: 'text', text: 'q' }] },
assistantMessage: null,
});
const withAssistant = (id: string): { id: string; userMessage: AIMessage; assistantMessage: AIMessage } => ({
id,
userMessage: { id: `${id}-u`, role: 'user', parts: [{ type: 'text', text: 'q' }] },
assistantMessage: { id: `${id}-a`, role: 'assistant', parts: [{ type: 'text', text: 'a' }] },
});
it('keeps trailing user-only exchange when thread depth failed so the last prompt stays visible', () => {
const exchanges = [withAssistant('1'), userOnly('2')];
expect(filterExchangesForThreadDepthError(exchanges, true)).toEqual(exchanges);
});
it('leaves exchanges unchanged when not a thread depth error', () => {
const exchanges = [withAssistant('1'), userOnly('2')];
expect(filterExchangesForThreadDepthError(exchanges, false)).toEqual(exchanges);
});
it('does not remove exchanges when an assistant reply exists', () => {
const exchanges = [withAssistant('1'), withAssistant('2')];
expect(filterExchangesForThreadDepthError(exchanges, true)).toEqual(exchanges);
});
});
describe('getThreadDepthErrorUserFacingMessage', () => {
it('returns nested message from JSON-shaped thread depth errors', () => {
const body = JSON.stringify({
message: 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.',
});
expect(getThreadDepthErrorUserFacingMessage(new Error(body))).toBe(
'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.',
);
});
it('returns undefined when not a thread depth error', () => {
expect(getThreadDepthErrorUserFacingMessage(new Error('Network failed'))).toBeUndefined();
});
});

View file

@ -99,11 +99,74 @@ export const buildDummyAskAiHit = (query: string, messages: AIMessage[]): Stored
export const getMessageContent = (message: AIMessage | null): TextUIPart | undefined =>
message?.parts.find((part) => part.type === 'text');
type ExchangeWithOptionalAssistant = {
assistantMessage: AIMessage | null;
};
/**
* Helper function to check if error is a thread depth error (AI-217).
* Pass-through: keep all exchanges when thread depth fails so the last user message stays visible
* (there is often no assistant reply for that turn).
*/
export function filterExchangesForThreadDepthError<T extends ExchangeWithOptionalAssistant>(
exchanges: T[],
_hasThreadDepthError: boolean,
): T[] {
return exchanges;
}
function threadDepthFromPlainText(message: string): boolean {
if (!message) return false;
if (message.toUpperCase().includes('AI-217')) return true;
return /thread\s+depth/i.test(message);
}
function messageLooksLikeThreadDepth(message: string): boolean {
if (threadDepthFromPlainText(message)) return true;
try {
const parsed = JSON.parse(message) as {
code?: string;
errorCode?: string;
message?: string;
};
const code = parsed.code ?? parsed.errorCode;
if (typeof code === 'string' && code.toUpperCase() === 'AI-217') {
return true;
}
const nested = typeof parsed.message === 'string' ? parsed.message : '';
return threadDepthFromPlainText(nested);
} catch {
return false;
}
}
/**
* Whether the error is thread depth exceeded (AI-217), including JSON-shaped Agent Studio payloads.
*/
export function isThreadDepthError(error?: Error): boolean {
if (!error) return false;
return error.message?.includes('AI-217') || false;
return messageLooksLikeThreadDepth(error.message ?? '');
}
/**
* Prefer the API `message` field when the error body is JSON; otherwise the thrown message string.
* Only meaningful when {@link isThreadDepthError} is true.
*/
export function getThreadDepthErrorUserFacingMessage(error?: Error): string | undefined {
if (!error || !isThreadDepthError(error)) return undefined;
const raw = error.message ?? '';
try {
const parsed = JSON.parse(raw) as { message?: string };
if (typeof parsed.message === 'string' && parsed.message.trim() !== '') {
return parsed.message.trim();
}
} catch {
// not JSON — fall through
}
const trimmed = raw.trim();
return trimmed !== '' ? trimmed : undefined;
}

View file

@ -13,15 +13,23 @@ export type SidepanelProps = DocSearchSidepanelProps['panel'] &
SidepanelSearchParameters;
export function Sidepanel({ portalContainer, ...props }: SidepanelProps): JSX.Element {
const { docsearchState, setDocsearchState, keyboardShortcuts, registerView, initialAskAiMessage } = useDocSearch();
const {
docsearchState,
setDocsearchState,
keyboardShortcuts,
registerView,
initialAskAiMessage,
clearInitialAskAiMessage,
} = useDocSearch();
const handleOpen = React.useCallback((): void => {
setDocsearchState('sidepanel');
}, [setDocsearchState]);
const handleClose = React.useCallback((): void => {
clearInitialAskAiMessage();
setDocsearchState('ready');
}, [setDocsearchState]);
}, [setDocsearchState, clearInitialAskAiMessage]);
const containerElement = React.useMemo(() => portalContainer ?? document.body, [portalContainer]);

View file

@ -1,5 +1,12 @@
import { cleanup } from '@testing-library/react';
import type { Mock } from 'vitest';
import { vi } from 'vitest';
import { afterEach, vi } from 'vitest';
// Vitest does not expose a global `afterEach`, so @testing-library/react never registers its
// automatic cleanup (see RTL dist/index.js). Without this, DOM from earlier tests leaks.
afterEach(() => {
cleanup();
});
type MatchMediaProps = Partial<{
matches: boolean;

View file

@ -2493,6 +2493,7 @@ __metadata:
"@rollup/plugin-terser": "npm:0.4.4"
"@stylistic/eslint-plugin": "npm:2.13.0"
"@testing-library/dom": "npm:10.4.0"
"@testing-library/react": "npm:^16.3.2"
"@types/react": "npm:^19.0.0"
"@types/react-dom": "npm:^19.0.0"
"@typescript-eslint/eslint-plugin": "npm:8.20.0"
@ -7175,6 +7176,26 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/react@npm:^16.3.2":
version: 16.3.2
resolution: "@testing-library/react@npm:16.3.2"
dependencies:
"@babel/runtime": "npm:^7.12.5"
peerDependencies:
"@testing-library/dom": ^10.0.0
"@types/react": ^18.0.0 || ^19.0.0
"@types/react-dom": ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10c0/f9c7f0915e1b5f7b750e6c7d8b51f091b8ae7ea99bacb761d7b8505ba25de9cfcb749a0f779f1650fb268b499dd79165dc7e1ee0b8b4cb63430d3ddc81ffe044
languageName: node
linkType: hard
"@tootallnate/once@npm:2":
version: 2.0.0
resolution: "@tootallnate/once@npm:2.0.0"