1
0
Fork 0

fix(askai): Ask AI fixes for v5 (#2945)

This commit is contained in:
Paul Jankowski 2026-08-03 12:22:45 -04:00 committed by GitHub
parent b87c28fc87
commit 9fe6738a35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 217 additions and 111 deletions

View file

@ -0,0 +1,20 @@
---
"@docsearch/react": patch
"@docsearch/css": patch
---
fix(askai): Ask AI fixes for v5
- `getMessageContent` now joins every assistant text part instead of stopping
at the first one, so copying an answer that interleaves text with tool calls
returns the complete response (#2782)
- Key Ask AI conversation storage by `appId` instead of `indexName`, fixes
triggering a new conversation in hybrid mode
- Negative feedback panel is labelled with `aria-labelledby` and reason chips
expose selection via `aria-pressed` (styling moved off the
`--selected` modifier)
- `Popover.Trigger` forwards refs, fixes sources panel not showing in some cases
- Add `--docsearch-error-soft-color`, `--docsearch-error-text-color`, and
`--docsearch-code-block-background` variables; restyle the Ask AI error panel
and markdown code blocks to use them
```

View file

@ -317,7 +317,8 @@
"typescript/prefer-namespace-keyword": ["error"],
"typescript/prefer-ts-expect-error": ["error"],
"typescript/unified-signatures": ["error"],
"vitest/require-mock-type-parameters": "off"
"vitest/require-mock-type-parameters": "off",
"no-debugger": ["error"]
},
"overrides": [
{

View file

@ -78,27 +78,6 @@ bun run pw:run:webkit
## Code Style Guidelines
### Imports
Imported modules must be ordered alphabetically with newlines between groups:
1. Built-in modules
2. External dependencies
3. Parent directory imports
4. Sibling imports
5. Index imports
Internal `@/**/*` paths go before parent imports.
```typescript
// Correct order
import type { AutocompleteOptions } from '@algolia/autocomplete-core';
import React, { type JSX } from 'react';
import { DocSearchButton } from './DocSearchButton';
import type { DocSearchHit } from './types';
```
### TypeScript
- Use `type` imports for type-only imports: `import type { Foo } from './types'`

View file

@ -1,6 +1,5 @@
/* eslint-disable react/react-in-jsx-scope */
import { version } from '@docsearch/react';
import { DocSearchSidepanel } from '@docsearch/react/sidepanel';
import { useState, type JSX } from 'react';
import './App.css';
@ -121,17 +120,6 @@ function App(): JSX.Element {
</section>
</main>
</div>
<DocSearchSidepanel
assistantId="ccdec697-e3fe-465b-a1c3-657e7bf18aef"
indexName="docsearch"
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
panel={{
suggestedQuestions: true,
}}
theme={theme}
/>
</div>
);
}

View file

@ -201,19 +201,12 @@
.DocSearch-AskAiScreen-Answer-Footer {
display: flex;
flex-direction: row;
flex-flow: row wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.DocSearch-AskAiScreen-Actions {
display: flex;
flex-direction: column;
width: 100%;
gap: var(--docsearch-spacing);
}
.DocSearch-AskAiScreen-Actions-Controls {
display: flex;
flex-direction: row;
@ -226,13 +219,16 @@
.DocSearch-Feedback-Panel {
flex-basis: 100%;
width: 100%;
padding: var(--docsearch-spacing);
padding-block: var(--docsearch-spacing);
padding-inline: var(--docsearch-spacing);
margin-block-start: var(--docsearch-spacing);
border-top: 1px solid var(--docsearch-subtle-color);
display: flex;
flex-direction: column;
gap: 12px;
gap: 0.75rem;
text-align: start;
animation: fade-in 0.2s ease-in forwards;
font-size: 0.875rem;
}
.DocSearch-Feedback-Panel-Header {
@ -253,7 +249,8 @@
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
padding-inline: 0.25rem;
padding-block: 0.25rem;
border: 0;
background: none;
cursor: pointer;
@ -267,8 +264,8 @@
}
.DocSearch-Feedback-Panel-Close svg {
width: 16px;
height: 16px;
width: 1rem;
height: 1rem;
}
.DocSearch-Feedback-Panel-Reasons {
@ -280,8 +277,9 @@
.DocSearch-Feedback-Panel-Reason {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
gap: 0.375rem;
padding-block: 0.375rem;
padding-inline: 0.75rem;
border-radius: var(--docsearch-border-radius);
border: 1px solid var(--docsearch-subtle-color);
background: var(--docsearch-hit-background);
@ -293,18 +291,13 @@
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
}
.DocSearch-Feedback-Panel-Reason:hover {
border-color: var(--docsearch-highlight-color);
background: var(--docsearch-hit-highlight-color);
}
.DocSearch-Feedback-Panel-Reason--selected {
border-color: var(--docsearch-highlight-color);
background: var(--docsearch-soft-primary-color);
color: var(--docsearch-highlight-color);
font-weight: 500;
&:hover,
&[aria-pressed='true'] {
border-color: var(--docsearch-highlight-color);
background: var(--docsearch-hit-highlight-color);
color: var(--docsearch-highlight-color);
}
}
.DocSearch-Feedback-Panel-Reason svg {
@ -365,8 +358,8 @@
display: flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 6px 15px;
padding-block: 0.5em;
padding-inline: 1em;
border: 0;
border-radius: var(--docsearch-border-radius);
font-family: inherit;
@ -502,9 +495,9 @@
padding-inline: 1em;
padding-block: 1em;
gap: 8px;
background-color: var(--docsearch-background-color);
border-radius: var(--docsearch-modal-radius);
color: var(--docsearch-text-color);
background-color: var(--docsearch-error-soft-color);
border-radius: var(--docsearch-border-radius);
color: var(--docsearch-error-text-color);
font-size: 1em;
font-weight: 400;
flex-direction: row;
@ -525,6 +518,7 @@
.DocSearch-AskAiScreen-Error p {
margin: 0;
color: var(--docsearch-error-text-color);
}
.DocSearch-AskAiScreen-Error-Content {
@ -543,7 +537,7 @@
margin: 12px 0 8px;
color: var(--docsearch-text-color);
font-size: 12px;
border: 1px solid #febdc5;
border: 1px solid var(--docsearch-error-soft-color);
animation: slide-down 0.3s ease-out;
width: 100%;
}
@ -679,7 +673,8 @@
}
.DocSearch-Markdown-Content p {
margin: 1em 0;
margin-block: 1em;
margin-inline: 0;
}
.DocSearch-Markdown-Content p:last-child {
@ -691,11 +686,12 @@
}
.DocSearch-Markdown-Content code {
background-color: var(--docsearch-key-background);
background-color: var(--docsearch-code-block-background);
color: var(--docsearch-text-color);
padding: 0.2em 0;
padding-block: 0.2em;
padding-inline: 0;
margin: 0;
border-radius: 3px;
border-radius: var(--docsearch-border-radius);
font-family:
ui-monospace,
SFMono-Regular,
@ -704,17 +700,19 @@
Consolas,
'Liberation Mono',
monospace;
font-size: 0.9em;
font-size: 0.9rem;
letter-spacing: normal;
}
.DocSearch-Markdown-Content pre {
background-color: var(--docsearch-key-background);
background-color: var(--docsearch-code-block-background);
color: var(--docsearch-text-color);
padding: 1.2em;
border-radius: 6px;
padding-block: 1.2em;
padding-inline: 1.2em;
border-radius: var(--docsearch-border-radius);
overflow-x: auto;
margin: 1.5em 0;
margin-block: 1.5em;
margin-inline: 0;
}
.DocSearch-Markdown-Content pre code {
@ -722,7 +720,6 @@
color: inherit;
padding: 0;
margin: 0;
font-size: 0.8em;
border-radius: 0;
white-space: pre-wrap;
overflow-wrap: break-word;
@ -1059,7 +1056,8 @@
.DocSearch-AskAiScreen-Sources {
padding-inline: 0;
margin-top: 0.5rem;
margin-block: 0;
margin-block-start: 0.5rem;
color: var(--docsearch-text-color);
list-style-type: none;
display: flex;
@ -1100,11 +1098,12 @@
position: absolute;
inset-block-start: 8px;
inset-inline-end: 8px;
background: var(--docsearch-key-background);
background: var(--docsearch-code-block-background);
color: var(--docsearch-text-color);
border: 0;
padding: 0.2em 0.6em;
font-size: 0.75em;
padding-block: 0.2em;
padding-inline: 0.6em;
font-size: 0.95em;
border-radius: 4px;
cursor: pointer;
transition: opacity 0.2s ease;

View file

@ -7,6 +7,8 @@
--docsearch-subtle-color: rgb(214 214 231);
--docsearch-text-color: rgb(35 38 59 / 100%);
--docsearch-error-color: #ef5350;
--docsearch-error-soft-color: rgb(254 189 197);
--docsearch-error-text-color: rgb(122 28 43);
--docsearch-success-color: #e8f5e9;
--docsearch-secondary-text-color: rgb(90 94 154 / 100%);
--docsearch-background-color: rgb(245 245 250);
@ -88,6 +90,9 @@
0 0 0 1px rgb(33 36 61 / 5%),
0 4px 8px -2px rgb(33 36 61 / 25%);
--docsearch-popover-arrow-color: rgb(230 230 240);
/* markdown */
--docsearch-code-block-background: rgb(235 235 240);
}
/* Darkmode */
@ -143,4 +148,7 @@
0 0 0 1px rgb(255 255 255 / 8%),
0 4px 8px -2px rgb(0 0 0 / 55%);
--docsearch-popover-arrow-color: var(--docsearch-hit-focus-background);
/* markdown */
--docsearch-code-block-background: rgb(33 33 57);
}

View file

@ -717,6 +717,14 @@
margin: 0.5em 0;
}
.DocSearch-Sidepanel .DocSearch-Markdown-Content code {
font-size: 0.8rem;
}
.DocSearch-Sidepanel .DocSearch-CodeSnippet pre {
padding-block-start: 2.4em;
}
.DocSearch-Sidepanel .DocSearch-AskAiScreen-MessageContent-Tool-Query:hover {
cursor: unset;
box-shadow: none;

View file

@ -241,9 +241,7 @@ function AskAiExchangeCard({
<div className="DocSearch-AskAiScreen-Response-Container">
<div className="DocSearch-AskAiScreen-Response">
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--user">
<p className="DocSearch-AskAiScreen-Query">
{userContent?.text ?? ''}
</p>
<p className="DocSearch-AskAiScreen-Query">{userContent ?? ''}</p>
</div>
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
<div className="DocSearch-AskAiScreen-MessageContent">
@ -361,7 +359,7 @@ function AskAiExchangeCard({
<FeedbackActions
id={messageId}
showActions={showActions}
latestAssistantMessageContent={assistantContent?.text || null}
latestAssistantMessageContent={assistantContent || null}
translations={translations}
conversations={conversations}
onFeedback={onFeedback}

View file

@ -63,7 +63,7 @@ export type ConversationScreenTranslations = Partial<
feedbackTagSafetyOrLegal: string;
feedbackTagOther: string;
/** Error title shown if there is an error while chatting. */
errorTitleText;
errorTitleText: string;
suggestedPromptsTitleText: string;
}
>;
@ -171,9 +171,7 @@ const ConversationExchange = React.forwardRef<
>
<div className="DocSearch-AskAiScreen-Response">
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--user">
<p className="DocSearch-AskAiScreen-Query">
{userContent?.text ?? ''}
</p>
<p className="DocSearch-AskAiScreen-Query">{userContent ?? ''}</p>
</div>
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
<div className="DocSearch-AskAiScreen-MessageContent">
@ -288,7 +286,7 @@ const ConversationExchange = React.forwardRef<
isSidepanel={true}
id={messageId}
showActions={showActions}
latestAssistantMessageContent={assistantContent?.text || null}
latestAssistantMessageContent={assistantContent || null}
translations={translations}
conversations={conversations}
onFeedback={onFeedback}

View file

@ -1,7 +1,7 @@
import { render } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { UIMessage } from 'ai';
import React from 'react';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { AskAiScreen } from '../AskAiScreen';
@ -17,6 +17,7 @@ const baseProps = {
setQuery: (): void => {},
messages: [],
status: 'ready' as const,
tools: {},
disableUserPersonalization: false,
resultsFooterComponent: null,
} as any;
@ -47,4 +48,46 @@ describe('AskAiScreen', () => {
expect(getByText('oh no')).toBeInTheDocument();
});
it('copies all assistant text parts around tool calls', () => {
const writeText = vi.fn();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const messages: UIMessage[] = [
{
id: 'user-1',
role: 'user',
parts: [{ type: 'text', text: 'What is Docusaurus?' }],
},
{
id: 'assistant-1',
role: 'assistant',
parts: [
{ type: 'text', text: 'Let me look that up.', state: 'done' },
{
type: 'tool-algolia_search_index',
toolCallId: 'tool-1',
state: 'output-available',
input: { query: 'Docusaurus', index: 'docs' },
output: { hits: [] },
},
{
type: 'text',
text: 'Docusaurus is a static site generator.',
state: 'done',
},
],
},
];
render(<AskAiScreen {...baseProps} messages={messages} />);
fireEvent.click(screen.getByTitle('Copy'));
expect(writeText).toHaveBeenCalledWith(
'Let me look that up.\n\nDocusaurus is a static site generator.'
);
});
});

View file

@ -214,7 +214,7 @@ export function FeedbackActions({
);
return (
<div className="DocSearch-AskAiScreen-Actions">
<>
<div className="DocSearch-AskAiScreen-Actions-Controls">
{isSidepanel ? copyButton : null}
@ -259,7 +259,7 @@ export function FeedbackActions({
onClose={handleCloseNote}
/>
) : null}
</div>
</>
);
}
@ -286,6 +286,7 @@ function NegativeFeedbackPanel({
onSubmit,
onClose,
}: NegativeFeedbackPanelProps): JSX.Element {
const feedbackTitleId = React.useId();
const {
feedbackPanelTitle = 'What went wrong? (optional)',
feedbackDetailsPlaceholder = 'Share some details...',
@ -297,10 +298,12 @@ function NegativeFeedbackPanel({
<div
className="DocSearch-Feedback-Panel"
role="group"
aria-label={feedbackPanelTitle}
aria-labelledby={feedbackTitleId}
>
<div className="DocSearch-Feedback-Panel-Header">
<p className="DocSearch-Feedback-Panel-Title">{feedbackPanelTitle}</p>
<p className="DocSearch-Feedback-Panel-Title" id={feedbackTitleId}>
{feedbackPanelTitle}
</p>
</div>
<div className="DocSearch-Feedback-Panel-Reasons">
@ -310,9 +313,7 @@ function NegativeFeedbackPanel({
<button
key={reason.value}
type="button"
className={`DocSearch-Feedback-Panel-Reason${
isSelected ? ' DocSearch-Feedback-Panel-Reason--selected' : ''
}`}
className="DocSearch-Feedback-Panel-Reason"
aria-pressed={isSelected}
onClick={() => onToggleTag(reason.value)}
>

View file

@ -156,9 +156,20 @@ describe('FeedbackActions', () => {
const { onFeedback } = renderComponent();
fireEvent.click(screen.getByTitle('Dislike'));
const tag = screen.getByText('Incorrect or incomplete');
const tag = screen.getByRole('button', {
name: 'Incorrect or incomplete',
});
expect(tag).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(tag);
expect(tag).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(tag);
expect(tag).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(screen.getByText('Submit'));
await waitFor(() =>

View file

@ -1,5 +1,6 @@
import { Popover as PopoverPrimitive } from '@base-ui/react/popover';
import React from 'react';
import type { JSX } from 'react';
import { CloseIcon } from '../../icons';
@ -7,13 +8,13 @@ export function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root {...props} />;
}
function PopoverTrigger({
className,
...props
}: PopoverPrimitive.Trigger.Props) {
const PopoverTrigger = React.forwardRef<
HTMLButtonElement,
PopoverPrimitive.Trigger.Props
>(({ className, ...props }, ref): JSX.Element => {
const cn = `DocSearch-Popover-Trigger${className ? ` ${className}` : ''}`;
return <PopoverPrimitive.Trigger className={cn} {...props} />;
}
return <PopoverPrimitive.Trigger className={cn} ref={ref} {...props} />;
});
Popover.Trigger = PopoverTrigger;

View file

@ -129,7 +129,6 @@ export const useAskAi: UseAskAi = ({
assistantId,
apiKey,
appId,
indexName,
tools = EMPTY_TOOLS,
searchParameters,
memory,
@ -218,7 +217,7 @@ export const useAskAi: UseAskAi = ({
const conversations = useRef(
createStoredConversations<StoredAskAiState>({
key: `__DOCSEARCH_ASKAI_CONVERSATIONS__${indexName}`,
key: `__DOCSEARCH_ASKAI_CONVERSATIONS__${appId}`,
limit: 10,
})
).current;

View file

@ -11,6 +11,7 @@ import {
isAIToolPart,
isAlgoliaMCPSearchOutputPart,
sanitizeMessagesForRequest,
getMessageContent,
} from '../ai';
function message(id: string, parts: AIMessagePart[]): AIMessage {
@ -247,3 +248,50 @@ describe('getSearchToolQueries', () => {
expect(getSearchToolQueries(part)).toEqual(['foo']);
});
});
describe('getMessageContent', () => {
it('joins the text parts around tool calls so copying returns the full answer', () => {
expect(
getMessageContent(
message('a1', [
{ type: 'text', text: 'Let me look that up.', state: 'done' },
{
type: 'tool-algolia_search_index',
toolCallId: 't1',
state: 'output-available',
input: {
query: 'foo',
index: 'bar',
},
output: { hits: [] },
},
{
type: 'text',
text: 'Docusaurus is a static site generator.',
state: 'done',
},
])
)
).toBe('Let me look that up.\n\nDocusaurus is a static site generator.');
});
it('returns an empty string without a message or text parts', () => {
expect(getMessageContent(null)).toBe('');
expect(
getMessageContent(
message('a2', [
{
type: 'tool-algolia_search_index',
toolCallId: 't2',
state: 'output-available',
input: {
query: 'foo',
index: 'bar',
},
output: { hits: [] },
},
])
)
).toBe('');
});
});

View file

@ -113,10 +113,14 @@ export const buildDummyAskAiHit = (
};
};
export const getMessageContent = (
message: AIMessage | null
): TextUIPart | undefined =>
message?.parts.find((part) => part.type === 'text');
// answers can interleave text with tool calls, so join every text part
// instead of stopping at the first one
// see https://github.com/algolia/docsearch/issues/2782
export const getMessageContent = (message: AIMessage | null): string =>
(message?.parts ?? [])
.filter((part): part is TextUIPart => part.type === 'text')
.map((part) => part.text)
.join('\n\n');
/** Helper function to check if error is a thread depth error (AI-217). */
export function isThreadDepthError(error?: Error): boolean {