Skip to content

Commit ca599aa

Browse files
committed
feat(agentStudio): surface thread depth error
1 parent b686914 commit ca599aa

12 files changed

Lines changed: 283 additions & 32 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"@rollup/plugin-terser": "0.4.4",
4848
"@stylistic/eslint-plugin": "2.13.0",
4949
"@testing-library/dom": "10.4.0",
50+
"@testing-library/react": "^16.3.2",
5051
"@types/react": "^19.0.0",
5152
"@types/react-dom": "^19.0.0",
5253
"@typescript-eslint/eslint-plugin": "8.20.0",

packages/docsearch-css/src/sidepanel.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,11 @@ html[data-theme="dark"] .DocSearch-Sidepanel-Prompt--stop:hover {
623623
flex-direction: column;
624624
}
625625

626+
.DocSearch-Sidepanel-ConversationScreen-threadDepth {
627+
margin: 0 1rem 0.75rem;
628+
flex-shrink: 0;
629+
}
630+
626631
@media screen and (min-width: 769px) {
627632
.DocSearch-Sidepanel-ConversationScreen {
628633
padding: 1rem 0;

packages/docsearch-react/src/AskAiScreen.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import type { StoredSearchPlugin } from './stored-searches';
99
import { ToolCall } from './ToolCall';
1010
import type { InternalDocSearchHit, StoredAskAiState } from './types';
1111
import type { AIMessage } from './types/AskiAi';
12-
import { extractLinksFromMessage, getMessageContent, isThreadDepthError } from './utils/ai';
12+
import {
13+
extractLinksFromMessage,
14+
filterExchangesForThreadDepthError,
15+
getMessageContent,
16+
isThreadDepthError,
17+
} from './utils/ai';
1318
import { groupConsecutiveToolResults } from './utils/groupConsecutiveToolResults';
1419

1520
export type AskAiScreenTranslations = Partial<{
@@ -392,17 +397,7 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
392397
}
393398
}
394399

395-
// If there's a thread depth error, remove the last exchange (the one that triggered the error)
396-
// We only want to show successful exchanges
397-
if (hasThreadDepthError && grouped.length > 0) {
398-
// Check if the last exchange has no assistant message (failed to complete)
399-
const lastExchange = grouped[grouped.length - 1];
400-
if (!lastExchange.assistantMessage) {
401-
grouped.pop();
402-
}
403-
}
404-
405-
return grouped;
400+
return filterExchangesForThreadDepthError(grouped, hasThreadDepthError);
406401
}, [messages, hasThreadDepthError]);
407402

408403
const handleSearchQueryClick = (query: string): void => {

packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { StoredSearchPlugin } from '../stored-searches';
99
import { ToolCall, type ToolCallTranslations } from '../ToolCall';
1010
import type { StoredAskAiState } from '../types';
1111
import { isAIToolPart, type AIMessage } from '../types/AskiAi';
12-
import { extractLinksFromMessage, getMessageContent } from '../utils/ai';
12+
import { extractLinksFromMessage, getMessageContent, isThreadDepthError } from '../utils/ai';
1313
import { groupConsecutiveToolResults } from '../utils/groupConsecutiveToolResults';
1414

1515
import { AggregatedSearchBlock } from './AggregatedSearchBlock';
@@ -60,7 +60,15 @@ export type ConversationScreenTranslations = Partial<
6060
/**
6161
* Error title shown if there is an error while chatting.
6262
*/
63-
errorTitleText;
63+
errorTitleText: string;
64+
/**
65+
* Message shown when thread depth limit is exceeded (AI-217).
66+
*/
67+
threadDepthExceededMessage: string;
68+
/**
69+
* Button label to start a new conversation after a thread depth error.
70+
*/
71+
startNewConversationButtonText: string;
6472
}
6573
>;
6674

@@ -72,6 +80,8 @@ export type ConversationScreenProps = {
7280
handleFeedback?: (messageId: string, thumbs: 0 | 1) => Promise<void>;
7381
streamError?: Error;
7482
agentStudio?: boolean;
83+
showThreadDepthError?: boolean;
84+
onThreadDepthNewConversation?: () => void;
7585
};
7686

7787
type ConversationnExchangeProps = {
@@ -105,6 +115,8 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
105115
errorTitleText = 'Chat error',
106116
} = translations;
107117

118+
const isThreadDepth = isThreadDepthError(streamError);
119+
108120
const assistantContent = useMemo(() => getMessageContent(assistantMessage), [assistantMessage]);
109121
const userContent = useMemo(() => getMessageContent(userMessage), [userMessage]);
110122

@@ -130,7 +142,7 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
130142
</div>
131143
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
132144
<div className="DocSearch-AskAiScreen-MessageContent">
133-
{status === 'error' && streamError && isLastExchange && (
145+
{status === 'error' && streamError && isLastExchange && !isThreadDepth && (
134146
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error">
135147
<AlertIcon />
136148
<div className="DocSearch-AskAiScreen-Error-Content">
@@ -233,9 +245,19 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
233245
);
234246

235247
export const ConversationScreen = memo(
236-
({ exchanges, translations = {}, handleFeedback, ...props }: ConversationScreenProps): JSX.Element => {
237-
const { conversationDisclaimer = 'Answers are generated with AI which can make mistakes. Verify responses.' } =
238-
translations;
248+
({
249+
exchanges,
250+
translations = {},
251+
handleFeedback,
252+
showThreadDepthError = false,
253+
onThreadDepthNewConversation,
254+
...props
255+
}: ConversationScreenProps): JSX.Element => {
256+
const {
257+
conversationDisclaimer = 'Answers are generated with AI which can make mistakes. Verify responses.',
258+
threadDepthExceededMessage = 'This conversation is now closed to keep responses accurate.',
259+
startNewConversationButtonText = 'Start a new conversation',
260+
} = translations;
239261

240262
const mostRecentExchangeRef = React.useRef<HTMLDivElement>(null);
241263
const totalExchanges = exchanges.length;
@@ -252,6 +274,23 @@ export const ConversationScreen = memo(
252274

253275
return (
254276
<div className="DocSearch-Sidepanel-ConversationScreen">
277+
{showThreadDepthError && onThreadDepthNewConversation ? (
278+
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error DocSearch-AskAiScreen-Error--ThreadDepth DocSearch-Sidepanel-ConversationScreen-threadDepth">
279+
<div className="DocSearch-AskAiScreen-Error-Content">
280+
<p>
281+
{threadDepthExceededMessage}{' '}
282+
<button
283+
type="button"
284+
className="DocSearch-ThreadDepthError-Link"
285+
onClick={onThreadDepthNewConversation}
286+
>
287+
{startNewConversationButtonText}
288+
</button>{' '}
289+
to continue.
290+
</p>
291+
</div>
292+
</div>
293+
) : null}
255294
<p className="DocSearch-Sidepanel-ConversationScreen-disclaimer">{conversationDisclaimer}</p>
256295

257296
{exchanges.slice().map((exchange, idx) => {

packages/docsearch-react/src/Sidepanel/PromptForm.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,18 @@ export type PromptFormTranslations = Partial<{
2222
* Disclaimer text displayed beneath the prompt form.
2323
**/
2424
promptDisclaimerText: string;
25+
/**
26+
* Visually hidden label text (`aria-labelledby`); usually keyboard hints for the textarea.
27+
**/
2528
promptLabelText: string;
29+
/**
30+
* Accessible name for the textarea (`aria-label`).
31+
**/
2632
promptAriaLabelText: string;
33+
/**
34+
* Placeholder when the conversation hit the thread depth limit (AI-217).
35+
**/
36+
threadDepthErrorPlaceholder: string;
2737
}>;
2838

2939
type Props = {
@@ -32,12 +42,17 @@ type Props = {
3242
translations?: PromptFormTranslations;
3343
onSend: (prompt: string) => void;
3444
onStopStreaming: () => void;
45+
/** When true, the prompt is disabled (same as modal SearchBox in Ask AI mode). */
46+
isThreadDepthError?: boolean;
3547
};
3648

3749
const MAX_PROMPT_ROWS = 8;
3850

3951
export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
40-
({ exchanges, isStreaming, translations = {}, onSend, onStopStreaming }, ref): JSX.Element => {
52+
(
53+
{ exchanges, isStreaming, translations = {}, onSend, onStopStreaming, isThreadDepthError = false },
54+
ref,
55+
): JSX.Element => {
4156
const isMobile = useIsMobile();
4257
const [userPrompt, setUserPrompt] = React.useState('');
4358
const promptRef = React.useRef<HTMLTextAreaElement>(null);
@@ -51,6 +66,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
5166
promptDisclaimerText = 'Answers are generated with AI which can make mistakes.',
5267
promptLabelText = 'Press Enter to send, or Shift and Enter for new line.',
5368
promptAriaLabelText = 'Prompt input',
69+
threadDepthErrorPlaceholder = 'Conversation limit reached',
5470
} = translations;
5571

5672
const managePromptHeight = (): void => {
@@ -74,7 +90,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
7490
};
7591

7692
const handleSend = (): void => {
77-
if (isStreaming) return;
93+
if (isStreaming || isThreadDepthError) return;
7894

7995
const prompt = userPrompt.trim();
8096

@@ -93,7 +109,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
93109

94110
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
95111
// Allow Enter to work normally (new line) when streaming
96-
if (isStreaming) return;
112+
if (isStreaming || isThreadDepthError) return;
97113

98114
if (e.key === 'Enter' && !e.shiftKey) {
99115
e.preventDefault();
@@ -108,7 +124,9 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
108124

109125
let promptPlaceholder = promptPlaceholderText;
110126

111-
if (isStreaming) {
127+
if (isThreadDepthError) {
128+
promptPlaceholder = threadDepthErrorPlaceholder;
129+
} else if (isStreaming) {
112130
promptPlaceholder = promptAnsweringText;
113131
} else if (exchanges.length > 0) {
114132
promptPlaceholder = promptAskAnotherQuestionText;
@@ -121,7 +139,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
121139
onSubmit={(e) => {
122140
e.preventDefault();
123141

124-
if (isStreaming) return;
142+
if (isStreaming || isThreadDepthError) return;
125143

126144
handleSend();
127145
}}
@@ -136,6 +154,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
136154
autoComplete="off"
137155
translate="no"
138156
rows={isMobile ? 1 : 2}
157+
disabled={isThreadDepthError}
139158
onKeyDown={handleKeyDown}
140159
onInput={managePromptHeight}
141160
onChange={(e) => setUserPrompt(e.target.value)}
@@ -154,7 +173,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
154173
<StopIcon />
155174
</button>
156175
)}
157-
{!isStreaming && (
176+
{!isStreaming && !isThreadDepthError && (
158177
<button
159178
type="submit"
160179
aria-label="Send question"

packages/docsearch-react/src/Sidepanel/Sidepanel.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { useAskAi } from '../useAskAi';
99
import { useIsMobile } from '../useIsMobile';
1010
import { useSearchClient } from '../useSearchClient';
1111
import { useSuggestedQuestions } from '../useSuggestedQuestions';
12-
import { buildDummyAskAiHit } from '../utils/ai';
12+
import { buildDummyAskAiHit, filterExchangesForThreadDepthError, isThreadDepthError } from '../utils/ai';
1313

1414
import { ConversationHistoryScreen } from './ConversationHistoryScreen';
1515
import type { ConversationScreenTranslations } from './ConversationScreen';
@@ -203,6 +203,19 @@ function SidepanelInner(
203203
searchClient,
204204
});
205205

206+
const hasThreadDepthError = React.useMemo(
207+
() => status === 'error' && isThreadDepthError(askAiError),
208+
[status, askAiError],
209+
);
210+
211+
const displayExchanges = React.useMemo(
212+
() => filterExchangesForThreadDepthError(exchanges, hasThreadDepthError),
213+
[exchanges, hasThreadDepthError],
214+
);
215+
216+
const showThreadDepthBanner =
217+
sidepanelState === 'conversation' && hasThreadDepthError && messages.some((m) => m.role === 'assistant');
218+
206219
const prevStatus = React.useRef(status);
207220

208221
const handleSend = (prompt: string): void => {
@@ -373,7 +386,7 @@ function SidepanelInner(
373386
<aside id="docsearch-sidepanel" className={`DocSearch-Sidepanel ${sidepanelState}`}>
374387
<SidepanelHeader
375388
sidepanelState={sidepanelState}
376-
exchanges={exchanges}
389+
exchanges={displayExchanges}
377390
setSidepanelState={setSidepanelState}
378391
hasConversations={conversations.getAll().length > 0}
379392
isStreaming={isStreaming}
@@ -391,13 +404,15 @@ function SidepanelInner(
391404
)}
392405
{sidepanelState === 'conversation' && (
393406
<ConversationScreen
394-
exchanges={exchanges}
407+
exchanges={displayExchanges}
395408
status={status}
396409
conversations={conversations}
397410
handleFeedback={sendFeedback}
398411
translations={translations.conversationScreen}
399412
streamError={askAiError}
400413
agentStudio={agentStudio}
414+
showThreadDepthError={showThreadDepthBanner}
415+
onThreadDepthNewConversation={handleStartNewConversation}
401416
/>
402417
)}
403418
{sidepanelState === 'conversation-history' && (
@@ -406,9 +421,10 @@ function SidepanelInner(
406421
</div>
407422
<PromptForm
408423
ref={promptInputRef}
409-
exchanges={exchanges}
424+
exchanges={displayExchanges}
410425
isStreaming={isStreaming}
411426
translations={translations.promptForm}
427+
isThreadDepthError={showThreadDepthBanner}
412428
onSend={handleSend}
413429
onStopStreaming={handleStopStreaming}
414430
/>

packages/docsearch-react/src/__tests__/askai.test.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render } from '@testing-library/react';
1+
import { render, within } from '@testing-library/react';
22
import type { UIMessage } from 'ai';
33
import React from 'react';
44
import { describe, it, expect } from 'vitest';
@@ -42,4 +42,44 @@ describe('AskAiScreen', () => {
4242

4343
expect(getByText('oh no')).toBeInTheDocument();
4444
});
45+
46+
it('surfaces thread depth (AI-217) with a banner and hides the generic chat error', () => {
47+
const messages: UIMessage[] = [
48+
{
49+
id: '1',
50+
role: 'user',
51+
parts: [{ type: 'text', text: 'first' }],
52+
},
53+
{
54+
id: '2',
55+
role: 'assistant',
56+
parts: [{ type: 'text', text: 'answer' }],
57+
},
58+
{
59+
id: '3',
60+
role: 'user',
61+
parts: [{ type: 'text', text: 'follow-up' }],
62+
},
63+
];
64+
65+
const onNewConversation = (): void => {};
66+
67+
const { container, getByText } = render(
68+
<AskAiScreen
69+
{...baseProps}
70+
messages={messages}
71+
status="error"
72+
askAiError={new Error('AI-217 - Thread depth exceeded')}
73+
onNewConversation={onNewConversation}
74+
/>,
75+
);
76+
77+
expect(
78+
getByText('This conversation is now closed to keep responses accurate.', { exact: false }),
79+
).toBeInTheDocument();
80+
expect(getByText('Start a new conversation')).toBeInTheDocument();
81+
// Bound queries from `render()` use `baseElement` (often `document.body`), so a prior test can still
82+
// match. Restrict to this instance's root so we only assert on this tree.
83+
expect(within(container).queryByText('Chat error')).not.toBeInTheDocument();
84+
});
4585
});

packages/docsearch-react/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export { filterExchangesForThreadDepthError, isThreadDepthError } from './utils/ai';
2+
13
export * from './DocSearch';
24
export * from './DocSearchButton';
35
export * from './DocSearchModal';

0 commit comments

Comments
 (0)