Skip to content

Commit 359d6bc

Browse files
committed
Fix styling and handling of errors.
1 parent a0223b0 commit 359d6bc

5 files changed

Lines changed: 153 additions & 14 deletions

File tree

projects/packages/search/src/instant-search/components/answers-panel.jsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { __ } from '@wordpress/i18n';
1+
import { __, sprintf } from '@wordpress/i18n';
22
import * as React from 'react';
33
import { useLayoutEffect, useRef, useState } from 'react';
44
import { markdownToHtml } from '../lib/markdown';
@@ -31,9 +31,10 @@ const ExternalLinkIcon = () => (
3131
* @param {string} props.status - 'idle' | 'loading' | 'streaming' | 'done' | 'error'
3232
* @param {string} props.text - Accumulated answer text (markdown).
3333
* @param {Array} props.citations - Array of { title, url, excerpt } citation objects.
34+
* @param {object} props.error - Error info: { message, code, source } or null.
3435
* @return {React.ReactElement|null} The rendered panel or null.
3536
*/
36-
export default function AnswersPanel( { status, text, citations = [] } ) {
37+
export default function AnswersPanel( { status, text, citations = [], error = null } ) {
3738
const [ expanded, setExpanded ] = useState( false );
3839
const [ overflows, setOverflows ] = useState( false );
3940
const contentRef = useRef( null );
@@ -46,10 +47,44 @@ export default function AnswersPanel( { status, text, citations = [] } ) {
4647
}
4748
}, [ status, text ] );
4849

49-
if ( status === 'idle' || status === 'error' ) {
50+
if ( status === 'idle' ) {
5051
return null;
5152
}
5253

54+
if ( status === 'error' ) {
55+
return (
56+
<div className="jp-search-answers-panel jp-search-answers-panel--error" aria-live="polite">
57+
<h2 className="jp-search-answers-panel__heading">
58+
{ __( 'AI answer', 'jetpack-search-pkg' ) }
59+
</h2>
60+
<div className="jp-search-answers-panel__error">
61+
<p className="jp-search-answers-panel__error-message">
62+
{ __( 'Sorry, an error occurred while generating an answer.', 'jetpack-search-pkg' ) }
63+
</p>
64+
{ error && (
65+
<p
66+
className="jp-search-answers-panel__error-detail"
67+
data-testid="answers-panel-error-detail"
68+
>
69+
{ error.message }
70+
{ error.code !== null && (
71+
<>
72+
<br />
73+
{
74+
/* translators: %s: numeric error code */ sprintf(
75+
__( 'Error code: %s', 'jetpack-search-pkg' ),
76+
error.code
77+
)
78+
}
79+
</>
80+
) }
81+
</p>
82+
) }
83+
</div>
84+
</div>
85+
);
86+
}
87+
5388
const isCollapsible = status === 'done';
5489
const isCollapsed = isCollapsible && ! expanded;
5590
// Keep fixed height through done+collapsed so the panel stays stable when

projects/packages/search/src/instant-search/components/answers-panel.scss

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,26 @@
4747
text-transform: uppercase;
4848
}
4949

50+
.jp-search-answers-panel--error {
51+
border-color: #f0c0c0;
52+
}
53+
54+
.jp-search-answers-panel__error {
55+
padding: 0 18px 14px;
56+
}
57+
58+
.jp-search-answers-panel__error-message {
59+
color: #cc1818;
60+
font-size: 13px;
61+
margin: 0 0 4px;
62+
}
63+
64+
.jp-search-answers-panel__error-detail {
65+
color: helper.$color-text-lighter;
66+
font-size: 12px;
67+
margin: 0;
68+
}
69+
5070
.jp-search-answers-panel__loading {
5171
align-items: center;
5272
color: #787c82;

projects/packages/search/src/instant-search/components/search-app.jsx

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class SearchApp extends Component {
6464
aiStatus: 'idle', // 'idle' | 'loading' | 'streaming' | 'done' | 'error'
6565
aiText: '',
6666
aiCitations: [],
67+
aiError: null,
6768
};
6869

6970
this.getResults = debounce( this.getResults, 200 );
@@ -256,7 +257,7 @@ class SearchApp extends Component {
256257
const siteId = options.aiAnswersSiteId || options.siteId;
257258

258259
if ( ! query || query.length < 3 ) {
259-
this.setState( { aiStatus: 'idle', aiText: '', aiCitations: [] } );
260+
this.setState( { aiStatus: 'idle', aiText: '', aiCitations: [], aiError: null } );
260261
return;
261262
}
262263

@@ -266,11 +267,26 @@ class SearchApp extends Component {
266267
this.aiController = new AbortController();
267268
const controller = this.aiController; // local capture to avoid race in .catch()
268269

269-
this.setState( { aiStatus: 'loading', aiText: '', aiCitations: [] } );
270+
this.setState( { aiStatus: 'loading', aiText: '', aiCitations: [], aiError: null } );
270271

271272
const url =
272273
'https://public-api.wordpress.com/wpcom/v2/ai/agent/jetpack-workflow-search_summarizer';
273274

275+
const HTTP_STATUS_NAMES = {
276+
400: 'Bad Request',
277+
401: 'Unauthorized',
278+
403: 'Forbidden',
279+
404: 'Not Found',
280+
429: 'Too Many Requests',
281+
500: 'Internal Server Error',
282+
502: 'Bad Gateway',
283+
503: 'Service Unavailable',
284+
504: 'Gateway Timeout',
285+
};
286+
287+
// Captured by onopen before onerror fires, so .catch sees the real HTTP error.
288+
let httpError = null;
289+
274290
fetchEventSource( url, {
275291
method: 'POST',
276292
headers: {
@@ -308,6 +324,11 @@ class SearchApp extends Component {
308324
signal: controller.signal,
309325
onopen: async response => {
310326
if ( ! response.ok ) {
327+
httpError = {
328+
message: HTTP_STATUS_NAMES[ response.status ] || `HTTP ${ response.status }`,
329+
code: response.status,
330+
source: 'http',
331+
};
311332
throw new Error( `HTTP ${ response.status }` );
312333
}
313334
},
@@ -328,19 +349,29 @@ class SearchApp extends Component {
328349
const citations = dataPart?.data?.sources || dataPart?.data?.strict_sources || [];
329350
this.setState( { aiStatus: 'done', aiCitations: citations } );
330351
} else if ( data.result?.status?.state === 'failed' || data.error ) {
331-
this.setState( { aiStatus: 'error' } );
352+
const textPart = data.result?.status?.message?.parts?.find( p => p.type === 'text' );
353+
const message = data.error?.message || textPart?.text || 'Request failed';
354+
const code = data.error?.code ?? null;
355+
this.setState( {
356+
aiStatus: 'error',
357+
aiError: { message, code, source: 'api' },
358+
} );
332359
}
333360
} catch {
334361
// Ignore unparseable events.
335362
}
336363
},
337364
onerror: () => {
338-
this.setState( { aiStatus: 'error' } );
339-
throw new Error( 'SSE error' );
365+
// Rethrow without setting state — .catch handles all error state
366+
// so httpError captured in onopen isn't overwritten.
367+
throw new Error( 'onerror' );
340368
},
341369
} ).catch( () => {
342370
if ( ! controller.signal.aborted ) {
343-
this.setState( { aiStatus: 'error' } );
371+
this.setState( {
372+
aiStatus: 'error',
373+
aiError: httpError ?? { message: 'Network request error', code: null, source: 'network' },
374+
} );
344375
}
345376
} );
346377
};
@@ -395,6 +426,7 @@ class SearchApp extends Component {
395426
aiStatus={ this.state.aiStatus }
396427
aiText={ this.state.aiText }
397428
aiCitations={ this.state.aiCitations }
429+
aiError={ this.state.aiError }
398430
closeOverlay={ this.hideResults }
399431
enableLoadOnScroll={ this.state.overlayOptions.enableInfScroll }
400432
enableFilteringOpensOverlay={ this.state.overlayOptions.enableFilteringOpensOverlay }

projects/packages/search/src/instant-search/components/search-results.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ class SearchResults extends Component {
157157
status={ this.props.aiStatus }
158158
text={ this.props.aiText }
159159
citations={ this.props.aiCitations }
160+
error={ this.props.aiError }
160161
/>
161162
<TabbedSearchFilters />
162163

projects/packages/search/src/instant-search/components/test/answers-panel.test.jsx

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ describe( 'AnswersPanel', () => {
88
expect( container ).toBeEmptyDOMElement();
99
} );
1010

11-
it( 'renders nothing on error', () => {
12-
const { container } = render( <AnswersPanel status="error" text="" citations={ [] } /> );
13-
expect( container ).toBeEmptyDOMElement();
14-
} );
15-
1611
it( 'shows loading message', () => {
1712
render( <AnswersPanel status="loading" text="" citations={ [] } /> );
1813
expect( screen.getByText( 'Finding an answer…' ) ).toBeInTheDocument();
@@ -29,4 +24,60 @@ describe( 'AnswersPanel', () => {
2924
expect( screen.getByText( 'Reset here.' ) ).toBeInTheDocument();
3025
expect( screen.getByText( 'Reset Password' ) ).toBeInTheDocument();
3126
} );
27+
28+
describe( 'error state', () => {
29+
it( 'shows error message when error prop is null', () => {
30+
render( <AnswersPanel status="error" text="" citations={ [] } error={ null } /> );
31+
expect(
32+
screen.getByText( 'Sorry, an error occurred while generating an answer.' )
33+
).toBeInTheDocument();
34+
} );
35+
36+
it( 'shows no detail line when error prop is null', () => {
37+
render( <AnswersPanel status="error" text="" citations={ [] } error={ null } /> );
38+
expect( screen.queryByTestId( 'answers-panel-error-detail' ) ).not.toBeInTheDocument();
39+
} );
40+
41+
it( 'shows network error message', () => {
42+
const error = { message: 'Network request error', code: null, source: 'network' };
43+
render( <AnswersPanel status="error" text="" citations={ [] } error={ error } /> );
44+
expect( screen.getByText( 'Network request error' ) ).toBeInTheDocument();
45+
} );
46+
47+
it( 'shows API error message', () => {
48+
const error = {
49+
message: 'An error occurred while processing the request. Please try again later.',
50+
code: -32000,
51+
source: 'api',
52+
};
53+
render( <AnswersPanel status="error" text="" citations={ [] } error={ error } /> );
54+
expect(
55+
screen.getByText( /An error occurred while processing the request/ )
56+
).toBeInTheDocument();
57+
} );
58+
59+
it( 'shows "Error code: X" when code is present', () => {
60+
const error = { message: 'Request failed', code: -32000, source: 'api' };
61+
render( <AnswersPanel status="error" text="" citations={ [] } error={ error } /> );
62+
expect( screen.getByText( /Error code: -32000/ ) ).toBeInTheDocument();
63+
} );
64+
65+
it( 'omits error code line when code is null', () => {
66+
const error = { message: 'Network request error', code: null, source: 'network' };
67+
render( <AnswersPanel status="error" text="" citations={ [] } error={ error } /> );
68+
expect( screen.queryByText( /Error code:/ ) ).not.toBeInTheDocument();
69+
} );
70+
71+
it( 'shows HTTP friendly name as message', () => {
72+
const error = { message: 'Service Unavailable', code: 503, source: 'http' };
73+
render( <AnswersPanel status="error" text="" citations={ [] } error={ error } /> );
74+
expect( screen.getByText( /Service Unavailable/ ) ).toBeInTheDocument();
75+
expect( screen.getByText( /Error code: 503/ ) ).toBeInTheDocument();
76+
} );
77+
78+
it( 'still shows the AI answer heading in error state', () => {
79+
render( <AnswersPanel status="error" text="" citations={ [] } /> );
80+
expect( screen.getByText( 'AI answer' ) ).toBeInTheDocument();
81+
} );
82+
} );
3283
} );

0 commit comments

Comments
 (0)