Skip to content

Commit a7ed371

Browse files
committed
feat: improve Ask AI suggestion buttons UX
- Move inline styles to CSS classes using DocSearch design tokens - Add staggered fade-in animation with prefers-reduced-motion support - Add accessibility: role="group", aria-label, aria-live="polite", screen reader announcement, focus-visible outline - Limit to 4 suggestions max to reduce cognitive load (Hick's Law) - Add mobile responsive stacking with 44px WCAG touch targets - Fix follow-up messages by stripping tool-call parts from history - Extract and render follow-up suggestions from Agent Studio stream
1 parent 71d32a8 commit a7ed371

File tree

2 files changed

+245
-5
lines changed

2 files changed

+245
-5
lines changed

src/css/custom.css

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,91 @@
2929
--docusaurus-highlighted-code-line-bg: rgba(231, 111, 0, 0.2); /* Slightly more visible Orange for code highlights */
3030
}
3131

32+
/* AI Follow-up Suggestion Chips
33+
* Evidence-backed design:
34+
* - Pill shape: industry standard (ChatGPT, Google AI) per NNG research
35+
* - 3-4 max: Hick's Law + Miller's Law reduce cognitive load
36+
* - Outlined, low visual weight: answer content stays dominant
37+
* - DocSearch CSS variables: automatic dark mode support
38+
*/
39+
.askai-suggestions {
40+
display: flex;
41+
flex-wrap: wrap;
42+
gap: 8px;
43+
padding: 12px 0;
44+
margin-top: 8px;
45+
}
46+
47+
.askai-suggestion-btn {
48+
appearance: none;
49+
background: var(--docsearch-hit-background, #fff);
50+
border: 1px solid var(--docsearch-muted-color, #969faf);
51+
border-radius: 16px;
52+
color: var(--docsearch-text-color, #1c1e21);
53+
cursor: pointer;
54+
font-family: inherit;
55+
font-size: 13px;
56+
line-height: 1.4;
57+
min-height: 36px;
58+
padding: 6px 14px;
59+
transition: background 150ms ease, border-color 150ms ease;
60+
}
61+
62+
.askai-suggestion-btn:hover {
63+
background: var(--docsearch-hit-highlight-color, rgba(0, 61, 255, 0.1));
64+
border-color: var(--docsearch-primary-color, #003dff);
65+
}
66+
67+
.askai-suggestion-btn:focus-visible {
68+
outline: 2px solid var(--docsearch-primary-color, #003dff);
69+
outline-offset: 2px;
70+
}
71+
72+
.askai-suggestion-btn:active {
73+
background: var(--docsearch-hit-highlight-color, rgba(0, 61, 255, 0.15));
74+
}
75+
76+
/* Staggered entrance animation (NNG: appear after response completes) */
77+
@media (prefers-reduced-motion: no-preference) {
78+
.askai-suggestion-btn {
79+
animation: askai-chip-enter 300ms ease forwards;
80+
opacity: 0;
81+
transform: translateY(8px);
82+
}
83+
84+
.askai-suggestion-btn:nth-child(2) { animation-delay: 75ms; }
85+
.askai-suggestion-btn:nth-child(3) { animation-delay: 150ms; }
86+
.askai-suggestion-btn:nth-child(4) { animation-delay: 225ms; }
87+
88+
@keyframes askai-chip-enter {
89+
to {
90+
opacity: 1;
91+
transform: translateY(0);
92+
}
93+
}
94+
}
3295

96+
/* Mobile: stack vertically with WCAG 2.5.5 touch targets (44px min) */
97+
@media (max-width: 640px) {
98+
.askai-suggestions {
99+
flex-direction: column;
100+
}
101+
102+
.askai-suggestion-btn {
103+
width: 100%;
104+
text-align: left;
105+
min-height: 44px;
106+
}
107+
}
108+
109+
/* Screen reader only text */
110+
.askai-sr-only {
111+
position: absolute;
112+
width: 1px;
113+
height: 1px;
114+
padding: 0;
115+
margin: -1px;
116+
overflow: hidden;
117+
clip: rect(0, 0, 0, 0);
118+
border: 0;
119+
}

src/theme/SearchBar/index.js

Lines changed: 158 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,167 @@
1+
import {useEffect} from 'react';
12
import SearchBar from '@theme-original/SearchBar';
23
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
34

4-
// Wrap SearchBar to inject agentStudio: true into askAi config.
5-
// Docusaurus 3.9.2's Joi validation doesn't recognize agentStudio yet,
6-
// but @docsearch/react 4.6+ requires it for Agent Studio assistants.
5+
// Max suggestions to show (Hick's Law: fewer choices = faster decisions)
6+
const MAX_SUGGESTIONS = 4;
7+
const ALLOWED_PARTS = new Set(['step-start', 'text']);
8+
9+
function injectSuggestionButtons(suggestions) {
10+
document.querySelectorAll('.askai-suggestions').forEach((el) => el.remove());
11+
12+
const responseArea = document.querySelector(
13+
'.DocSearch-AskAiScreen-Response-Container'
14+
);
15+
if (!responseArea || !suggestions.length) return;
16+
17+
const limited = suggestions.slice(0, MAX_SUGGESTIONS);
18+
19+
// Accessible wrapper: role="group" + aria-live for screen readers
20+
const wrapper = document.createElement('div');
21+
wrapper.className = 'askai-suggestions';
22+
wrapper.setAttribute('role', 'group');
23+
wrapper.setAttribute('aria-label', 'Follow-up suggestions');
24+
wrapper.setAttribute('aria-live', 'polite');
25+
26+
// Screen reader announcement
27+
const srAnnounce = document.createElement('span');
28+
srAnnounce.className = 'askai-sr-only';
29+
srAnnounce.textContent = `${limited.length} follow-up suggestions available`;
30+
wrapper.appendChild(srAnnounce);
31+
32+
limited.forEach((text) => {
33+
const btn = document.createElement('button');
34+
btn.className = 'askai-suggestion-btn';
35+
btn.textContent = text;
36+
btn.type = 'button';
37+
btn.onclick = () => {
38+
const input = document.querySelector(
39+
'.DocSearch-Input, input[placeholder*="Ask"]'
40+
);
41+
if (input) {
42+
const nativeSetter = Object.getOwnPropertyDescriptor(
43+
HTMLInputElement.prototype,
44+
'value'
45+
).set;
46+
nativeSetter.call(input, text);
47+
input.dispatchEvent(new Event('input', {bubbles: true}));
48+
input.focus();
49+
input.dispatchEvent(
50+
new KeyboardEvent('keydown', {
51+
key: 'Enter',
52+
code: 'Enter',
53+
keyCode: 13,
54+
bubbles: true,
55+
})
56+
);
57+
}
58+
};
59+
wrapper.appendChild(btn);
60+
});
61+
62+
const containers = document.querySelectorAll(
63+
'.DocSearch-AskAiScreen-Response-Container'
64+
);
65+
const last = containers[containers.length - 1] || responseArea;
66+
last.after(wrapper);
67+
}
68+
69+
function extractSuggestionsFromStream(response) {
70+
if (!response.ok || !response.body) return;
71+
const clone = response.clone();
72+
const reader = clone.body.getReader();
73+
const decoder = new TextDecoder();
74+
let buffer = '';
75+
76+
function pump() {
77+
reader.read().then(({done, value}) => {
78+
if (done) return;
79+
buffer += decoder.decode(value, {stream: true});
80+
const lines = buffer.split('\n');
81+
buffer = lines.pop() || '';
82+
for (const line of lines) {
83+
if (line.startsWith('data: ')) {
84+
try {
85+
const data = JSON.parse(line.slice(6));
86+
if (
87+
data.type === 'data-suggestions' &&
88+
data.data?.suggestions
89+
) {
90+
setTimeout(
91+
() => injectSuggestionButtons(data.data.suggestions),
92+
500
93+
);
94+
}
95+
} catch {
96+
// ignore
97+
}
98+
}
99+
}
100+
pump();
101+
});
102+
}
103+
pump();
104+
}
105+
106+
function useAgentStudioFetchPatch(appId) {
107+
useEffect(() => {
108+
if (!appId) return;
109+
const endpoint = `https://${appId}.algolia.net/agent-studio/1`;
110+
const originalFetch = window.fetch;
111+
112+
window.fetch = function (url, options) {
113+
if (
114+
typeof url === 'string' &&
115+
url.startsWith(endpoint) &&
116+
options?.method === 'POST' &&
117+
options?.body
118+
) {
119+
try {
120+
const body = JSON.parse(options.body);
121+
122+
// Fix assistant message parts for follow-ups
123+
if (Array.isArray(body.messages) && body.messages.length > 1) {
124+
body.messages = body.messages.map((msg) => {
125+
if (msg.role === 'assistant' && Array.isArray(msg.parts)) {
126+
const cleaned = msg.parts.filter((p) =>
127+
ALLOWED_PARTS.has(p.type)
128+
);
129+
if (!cleaned.some((p) => p.type === 'step-start')) {
130+
cleaned.unshift({type: 'step-start'});
131+
}
132+
return {...msg, parts: cleaned};
133+
}
134+
return msg;
135+
});
136+
}
137+
138+
const result = originalFetch.call(this, url, {
139+
...options,
140+
body: JSON.stringify(body),
141+
});
142+
143+
result.then(extractSuggestionsFromStream);
144+
145+
return result;
146+
} catch {
147+
// pass through
148+
}
149+
}
150+
return originalFetch.apply(this, arguments);
151+
};
152+
153+
return () => {
154+
window.fetch = originalFetch;
155+
};
156+
}, [appId]);
157+
}
158+
7159
export default function SearchBarWrapper(props) {
8160
const {siteConfig} = useDocusaurusContext();
9161
const askAi = siteConfig.themeConfig.algolia?.askAi;
10-
if (askAi && !askAi.agentStudio) {
11-
askAi.agentStudio = true;
162+
if (askAi) {
163+
if (!askAi.agentStudio) askAi.agentStudio = true;
12164
}
165+
useAgentStudioFetchPatch(siteConfig.themeConfig.algolia?.appId);
13166
return <SearchBar {...props} />;
14167
}

0 commit comments

Comments
 (0)