From 05cc3ea99cb22db51c4f71ea764ae32d8ef9357a Mon Sep 17 00:00:00 2001 From: Charly Abraham Date: Thu, 2 Apr 2026 19:39:15 +0530 Subject: [PATCH] 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 --- src/css/custom.css | 87 +++++++++++++++++++ src/theme/SearchBar/index.js | 163 +++++++++++++++++++++++++++++++++-- 2 files changed, 245 insertions(+), 5 deletions(-) diff --git a/src/css/custom.css b/src/css/custom.css index fc7047a5..1cff5585 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -29,4 +29,91 @@ --docusaurus-highlighted-code-line-bg: rgba(231, 111, 0, 0.2); /* Slightly more visible Orange for code highlights */ } +/* AI Follow-up Suggestion Chips + * Evidence-backed design: + * - Pill shape: industry standard (ChatGPT, Google AI) per NNG research + * - 3-4 max: Hick's Law + Miller's Law reduce cognitive load + * - Outlined, low visual weight: answer content stays dominant + * - DocSearch CSS variables: automatic dark mode support + */ +.askai-suggestions { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px 0; + margin-top: 8px; +} + +.askai-suggestion-btn { + appearance: none; + background: var(--docsearch-hit-background, #fff); + border: 1px solid var(--docsearch-muted-color, #969faf); + border-radius: 16px; + color: var(--docsearch-text-color, #1c1e21); + cursor: pointer; + font-family: inherit; + font-size: 13px; + line-height: 1.4; + min-height: 36px; + padding: 6px 14px; + transition: background 150ms ease, border-color 150ms ease; +} + +.askai-suggestion-btn:hover { + background: var(--docsearch-hit-highlight-color, rgba(0, 61, 255, 0.1)); + border-color: var(--docsearch-primary-color, #003dff); +} + +.askai-suggestion-btn:focus-visible { + outline: 2px solid var(--docsearch-primary-color, #003dff); + outline-offset: 2px; +} + +.askai-suggestion-btn:active { + background: var(--docsearch-hit-highlight-color, rgba(0, 61, 255, 0.15)); +} + +/* Staggered entrance animation (NNG: appear after response completes) */ +@media (prefers-reduced-motion: no-preference) { + .askai-suggestion-btn { + animation: askai-chip-enter 300ms ease forwards; + opacity: 0; + transform: translateY(8px); + } + + .askai-suggestion-btn:nth-child(2) { animation-delay: 75ms; } + .askai-suggestion-btn:nth-child(3) { animation-delay: 150ms; } + .askai-suggestion-btn:nth-child(4) { animation-delay: 225ms; } + + @keyframes askai-chip-enter { + to { + opacity: 1; + transform: translateY(0); + } + } +} +/* Mobile: stack vertically with WCAG 2.5.5 touch targets (44px min) */ +@media (max-width: 640px) { + .askai-suggestions { + flex-direction: column; + } + + .askai-suggestion-btn { + width: 100%; + text-align: left; + min-height: 44px; + } +} + +/* Screen reader only text */ +.askai-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} diff --git a/src/theme/SearchBar/index.js b/src/theme/SearchBar/index.js index 94472ccb..cf1561a2 100644 --- a/src/theme/SearchBar/index.js +++ b/src/theme/SearchBar/index.js @@ -1,14 +1,167 @@ +import {useEffect} from 'react'; import SearchBar from '@theme-original/SearchBar'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -// Wrap SearchBar to inject agentStudio: true into askAi config. -// Docusaurus 3.9.2's Joi validation doesn't recognize agentStudio yet, -// but @docsearch/react 4.6+ requires it for Agent Studio assistants. +// Max suggestions to show (Hick's Law: fewer choices = faster decisions) +const MAX_SUGGESTIONS = 4; +const ALLOWED_PARTS = new Set(['step-start', 'text']); + +function injectSuggestionButtons(suggestions) { + document.querySelectorAll('.askai-suggestions').forEach((el) => el.remove()); + + const responseArea = document.querySelector( + '.DocSearch-AskAiScreen-Response-Container' + ); + if (!responseArea || !suggestions.length) return; + + const limited = suggestions.slice(0, MAX_SUGGESTIONS); + + // Accessible wrapper: role="group" + aria-live for screen readers + const wrapper = document.createElement('div'); + wrapper.className = 'askai-suggestions'; + wrapper.setAttribute('role', 'group'); + wrapper.setAttribute('aria-label', 'Follow-up suggestions'); + wrapper.setAttribute('aria-live', 'polite'); + + // Screen reader announcement + const srAnnounce = document.createElement('span'); + srAnnounce.className = 'askai-sr-only'; + srAnnounce.textContent = `${limited.length} follow-up suggestions available`; + wrapper.appendChild(srAnnounce); + + limited.forEach((text) => { + const btn = document.createElement('button'); + btn.className = 'askai-suggestion-btn'; + btn.textContent = text; + btn.type = 'button'; + btn.onclick = () => { + const input = document.querySelector( + '.DocSearch-Input, input[placeholder*="Ask"]' + ); + if (input) { + const nativeSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value' + ).set; + nativeSetter.call(input, text); + input.dispatchEvent(new Event('input', {bubbles: true})); + input.focus(); + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + bubbles: true, + }) + ); + } + }; + wrapper.appendChild(btn); + }); + + const containers = document.querySelectorAll( + '.DocSearch-AskAiScreen-Response-Container' + ); + const last = containers[containers.length - 1] || responseArea; + last.after(wrapper); +} + +function extractSuggestionsFromStream(response) { + if (!response.ok || !response.body) return; + const clone = response.clone(); + const reader = clone.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + function pump() { + reader.read().then(({done, value}) => { + if (done) return; + buffer += decoder.decode(value, {stream: true}); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + if ( + data.type === 'data-suggestions' && + data.data?.suggestions + ) { + setTimeout( + () => injectSuggestionButtons(data.data.suggestions), + 500 + ); + } + } catch { + // ignore + } + } + } + pump(); + }); + } + pump(); +} + +function useAgentStudioFetchPatch(appId) { + useEffect(() => { + if (!appId) return; + const endpoint = `https://${appId}.algolia.net/agent-studio/1`; + const originalFetch = window.fetch; + + window.fetch = function (url, options) { + if ( + typeof url === 'string' && + url.startsWith(endpoint) && + options?.method === 'POST' && + options?.body + ) { + try { + const body = JSON.parse(options.body); + + // Fix assistant message parts for follow-ups + if (Array.isArray(body.messages) && body.messages.length > 1) { + body.messages = body.messages.map((msg) => { + if (msg.role === 'assistant' && Array.isArray(msg.parts)) { + const cleaned = msg.parts.filter((p) => + ALLOWED_PARTS.has(p.type) + ); + if (!cleaned.some((p) => p.type === 'step-start')) { + cleaned.unshift({type: 'step-start'}); + } + return {...msg, parts: cleaned}; + } + return msg; + }); + } + + const result = originalFetch.call(this, url, { + ...options, + body: JSON.stringify(body), + }); + + result.then(extractSuggestionsFromStream); + + return result; + } catch { + // pass through + } + } + return originalFetch.apply(this, arguments); + }; + + return () => { + window.fetch = originalFetch; + }; + }, [appId]); +} + export default function SearchBarWrapper(props) { const {siteConfig} = useDocusaurusContext(); const askAi = siteConfig.themeConfig.algolia?.askAi; - if (askAi && !askAi.agentStudio) { - askAi.agentStudio = true; + if (askAi) { + if (!askAi.agentStudio) askAi.agentStudio = true; } + useAgentStudioFetchPatch(siteConfig.themeConfig.algolia?.appId); return ; }