From 9e6e1ea520628b750020c92fa3956b79495f354f Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Mon, 7 Sep 2026 15:38:51 -0700 Subject: [PATCH 1/2] perf: split AI chat from ordinary search loading --- src/components/AiDock.tsx | 2433 ++++++++++++++++++++ src/components/Navbar.tsx | 2 +- src/components/SearchModal.tsx | 3729 ++----------------------------- src/components/SearchShared.tsx | 962 ++++++++ src/contexts/SearchContext.tsx | 2 - 5 files changed, 3594 insertions(+), 3534 deletions(-) create mode 100644 src/components/AiDock.tsx create mode 100644 src/components/SearchShared.tsx diff --git a/src/components/AiDock.tsx b/src/components/AiDock.tsx new file mode 100644 index 000000000..24e46df7e --- /dev/null +++ b/src/components/AiDock.tsx @@ -0,0 +1,2433 @@ +import * as React from 'react' +import { createPortal } from 'react-dom' +import { twMerge } from 'tailwind-merge' +import { + Dropdown, + DropdownTrigger, + DropdownContent, + DropdownItem, +} from './Dropdown' +import { + InstantSearch, + useInstantSearch, + useInfiniteHits, + useSearchBox, +} from 'react-instantsearch' +import { + XIcon, + MagnifyingGlassIcon, + MagnifyingGlassMinusIcon, + ArrowUpIcon, + CheckIcon, + CopyIcon, + ArrowSquareOutIcon, + ClockCounterClockwiseIcon, + ChatCenteredDotsIcon, + ThumbsDownIcon, + ThumbsUpIcon, + ArrowsOutSimpleIcon, + ArrowsInSimpleIcon, +} from '@phosphor-icons/react' +import { + DefaultKapaApiService, + KapaProvider, + processStream, + useChat, + type StreamSource, +} from '@kapaai/react-sdk' +import { streamingMarkdownExtension } from '@tanstack/markdown/extensions/streaming' +import { Markdown as TanStackMarkdown } from '@tanstack/markdown/react' +import { useSearchContext } from '~/contexts/SearchContext' +import { publicLibraries } from '~/libraries' +import { frameworkOptions } from '~/libraries/frameworks' +import { capitalize } from '~/utils/utils' +import { CodeBlock } from '~/components/markdown/CodeBlock' +import { InlineCode } from '~/ui/InlineCode' +import { env } from '~/utils/env' +import { isSafeHref } from '~/utils/url-boundary' +import { + type AlgoliaHit, + dedupeSearchHitsByPage, + searchClient, + searchIndexName, + useSearchFilters, + SearchFiltersProvider, + DynamicFilters, + getInternalLinkTarget, + SafeLink, + AlgoliaAttribution, + Hit, + LibraryRefinement, + FrameworkRefinement, + NoResults, +} from './SearchShared' + +const AI_DOCK_WIDTH_STORAGE_KEY = 'tanstack-ai-dock-width' +const AI_DOCK_MIN_WIDTH = 320 +const AI_DOCK_DEFAULT_WIDTH = 360 +const AI_DOCK_MAX_WIDTH_RATIO = 0.5 +const AI_DOCK_MAXIMIZED_WIDTH = 1200 +const KAPA_RECAPTCHA_READY_TIMEOUT_MS = 8_000 +const KAPA_INTEGRATION_ID = '86e864f7-401e-48c8-ac6f-5fcbc86c5668' + +type SearchSurface = 'modal' | 'dock' +type AiDockStyle = React.CSSProperties & { + '--ai-dock-width': string + '--ai-dock-max-width': string +} + +function getAiDockMaxWidth(viewportWidth: number) { + return Math.max( + AI_DOCK_MIN_WIDTH, + Math.floor(viewportWidth * AI_DOCK_MAX_WIDTH_RATIO), + ) +} + +function clampAiDockWidth(width: number, viewportWidth: number) { + return Math.min( + Math.max(Math.round(width), AI_DOCK_MIN_WIDTH), + getAiDockMaxWidth(viewportWidth), + ) +} + +function readAiDockWidth() { + if (typeof window === 'undefined') { + return AI_DOCK_DEFAULT_WIDTH + } + + const storedWidth = Number(localStorage.getItem(AI_DOCK_WIDTH_STORAGE_KEY)) + + if (!Number.isFinite(storedWidth)) { + return clampAiDockWidth(AI_DOCK_DEFAULT_WIDTH, window.innerWidth) + } + + return clampAiDockWidth(storedWidth, window.innerWidth) +} + +function writeAiDockWidth(width: number) { + if (typeof window === 'undefined') { + return + } + + localStorage.setItem(AI_DOCK_WIDTH_STORAGE_KEY, String(Math.round(width))) +} + +function waitForKapaRecaptchaReady() { + if (typeof window === 'undefined') { + return Promise.resolve() + } + + return new Promise((resolve) => { + let timeoutId: number | null = null + let intervalId: number | null = null + let isSettled = false + let hasRegisteredReadyCallback = false + + const cleanup = () => { + if (timeoutId !== null) { + window.clearTimeout(timeoutId) + timeoutId = null + } + + if (intervalId !== null) { + window.clearInterval(intervalId) + intervalId = null + } + } + + const finish = () => { + if (isSettled) { + return + } + + isSettled = true + cleanup() + window.setTimeout(resolve, 0) + } + + const checkReady = () => { + if (hasRegisteredReadyCallback) { + return true + } + + const enterprise = window.grecaptcha?.enterprise + + if (!enterprise) { + return false + } + + hasRegisteredReadyCallback = true + + if (intervalId !== null) { + window.clearInterval(intervalId) + intervalId = null + } + + enterprise.ready(finish) + return true + } + + timeoutId = window.setTimeout(finish, KAPA_RECAPTCHA_READY_TIMEOUT_MS) + + if (checkReady()) { + return + } + + intervalId = window.setInterval(checkReady, 50) + }) +} + +function KapaMarkdownLink({ + href, + children, + ...props +}: React.AnchorHTMLAttributes) { + return ( + + {children} + + ) +} + +function KapaMarkdownTable({ + children, + className, + ...props +}: React.TableHTMLAttributes) { + return ( +
+ + {children} +
+
+ ) +} + +function KapaMarkdownTableHead({ + children, + className, + ...props +}: React.HTMLAttributes) { + return ( + + {children} + + ) +} + +function KapaMarkdownTableBody({ + children, + className, + ...props +}: React.HTMLAttributes) { + return ( + + {children} + + ) +} + +function KapaMarkdownTableCell({ + children, + className, + ...props +}: React.TdHTMLAttributes) { + return ( + + {children} + + ) +} + +function KapaMarkdownTableHeaderCell({ + children, + className, + ...props +}: React.ThHTMLAttributes) { + return ( + + {children} + + ) +} + +const kapaMarkdownComponents = { + pre: CodeBlock, + code: InlineCode, + a: KapaMarkdownLink, + table: KapaMarkdownTable, + thead: KapaMarkdownTableHead, + tbody: KapaMarkdownTableBody, + td: KapaMarkdownTableCell, + th: KapaMarkdownTableHeaderCell, + ul: ({ children }: { children?: React.ReactNode }) => ( +
    {children}
+ ), + ol: ({ children }: { children?: React.ReactNode }) => ( +
    + {children} +
+ ), + li: ({ children }: { children?: React.ReactNode }) => ( +
  • {children}
  • + ), +} + +const kapaMarkdownExtensions = [streamingMarkdownExtension()] + +function parseSourceGroupIDs(value: string | undefined) { + if (!value) { + return undefined + } + + const ids = value + .split(',') + .map((id) => id.trim()) + .filter((id) => id.length > 0) + + return ids.length > 0 ? ids : undefined +} + +type KapaReaction = 'upvote' | 'downvote' + +type KapaDisplayQA = { + id: string | null + question: string + answer: string + sources: Array + isGenerationAborted: boolean + isFeedbackSubmissionEnabled?: boolean + reaction: KapaReaction | null +} + +type KapaHistoryItem = { + threadId: string + title: string + createdAt: number + updatedAt: number + conversation: Array +} + +type KapaSubmitQueryArgs = Parameters[0] +type KapaChatStreamCallbacks = Parameters< + DefaultKapaApiService['submitQuery'] +>[1] +type KapaSubmitFeedbackArgs = Parameters< + DefaultKapaApiService['addFeedback'] +>[0] + +const KAPA_HISTORY_STORAGE_KEY = 'tanstack-kapa-chat-history' +const KAPA_HISTORY_LIMIT = 5 + +class KapaThreadOverrideApiService { + private service = new DefaultKapaApiService(processStream) + + constructor( + private threadIdOverrideRef: React.MutableRefObject, + ) {} + + submitQuery( + args: KapaSubmitQueryArgs, + callbacks: KapaChatStreamCallbacks, + ): Promise { + return this.service.submitQuery( + { ...args, threadId: this.threadIdOverrideRef.current }, + callbacks, + ) + } + + addFeedback(args: KapaSubmitFeedbackArgs): Promise { + return this.service.addFeedback(args) + } + + abortCurrent(): void { + this.service.abortCurrent() + } +} + +function isUnknownRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isDefined(value: T | null): value is T { + return value !== null +} + +function readString(value: unknown) { + return typeof value === 'string' ? value : '' +} + +function readNumber(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +function parseKapaReaction(value: unknown): KapaReaction | null { + if (value === 'upvote' || value === 'downvote') { + return value + } + + return null +} + +function parseKapaSource(value: unknown): StreamSource | null { + if (!isUnknownRecord(value)) { + return null + } + + const title = readString(value.title) + const sourceUrl = readString(value.source_url) + + if ( + !title || + title.length > 160 || + !sourceUrl || + sourceUrl.length > 2048 || + !isSafeHref(sourceUrl) + ) { + return null + } + + return { + title, + subtitle: readString(value.subtitle), + source_url: sourceUrl, + source_type: readString(value.source_type), + } +} + +function parseKapaHistoryQA(value: unknown): KapaDisplayQA | null { + if (!isUnknownRecord(value)) { + return null + } + + const question = readString(value.question).trim() + + if (!question) { + return null + } + + const sourceValues = Array.isArray(value.sources) ? value.sources : [] + + return { + id: typeof value.id === 'string' ? value.id : null, + question, + answer: readString(value.answer), + sources: sourceValues.map(parseKapaSource).filter(isDefined), + isGenerationAborted: value.isGenerationAborted === true, + isFeedbackSubmissionEnabled: value.isFeedbackSubmissionEnabled === true, + reaction: parseKapaReaction(value.reaction), + } +} + +function parseKapaHistoryItem(value: unknown): KapaHistoryItem | null { + if (!isUnknownRecord(value)) { + return null + } + + const threadId = readString(value.threadId) + const conversationValues = Array.isArray(value.conversation) + ? value.conversation + : [] + const conversation = conversationValues + .map(parseKapaHistoryQA) + .filter(isDefined) + + if (!threadId || conversation.length === 0) { + return null + } + + const updatedAt = readNumber(value.updatedAt) || Date.now() + + return { + threadId, + title: readString(value.title) || buildKapaHistoryTitle(conversation), + createdAt: readNumber(value.createdAt) || updatedAt, + updatedAt, + conversation, + } +} + +function readKapaHistory() { + if (typeof window === 'undefined') { + return [] + } + + try { + const raw = localStorage.getItem(KAPA_HISTORY_STORAGE_KEY) + if (!raw) { + return [] + } + + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) { + return [] + } + + return parsed + .map(parseKapaHistoryItem) + .filter(isDefined) + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, KAPA_HISTORY_LIMIT) + } catch { + return [] + } +} + +function writeKapaHistory(items: Array) { + if (typeof window === 'undefined') { + return + } + + try { + localStorage.setItem( + KAPA_HISTORY_STORAGE_KEY, + JSON.stringify(items.slice(0, KAPA_HISTORY_LIMIT)), + ) + } catch { + // Ignore storage failures so chat keeps working. + } +} + +function buildKapaHistoryTitle(conversation: Array) { + const firstQuestion = + conversation + .find((qa) => qa.question.trim().length > 0) + ?.question.trim() + .replace(/\s+/g, ' ') || 'Untitled chat' + + return firstQuestion.length > 64 + ? `${firstQuestion.slice(0, 61)}...` + : firstQuestion +} + +function toStoredKapaQA(qa: KapaDisplayQA): KapaDisplayQA { + return { + id: qa.id, + question: qa.question, + answer: qa.answer, + sources: qa.sources.map((source) => ({ + title: source.title, + subtitle: source.subtitle, + source_url: source.source_url, + source_type: source.source_type, + })), + isGenerationAborted: qa.isGenerationAborted, + isFeedbackSubmissionEnabled: qa.isFeedbackSubmissionEnabled, + reaction: qa.reaction, + } +} + +function upsertKapaHistoryItem( + threadId: string, + conversation: Array, +) { + const current = readKapaHistory() + const existing = current.find((item) => item.threadId === threadId) + const now = Date.now() + const nextItem: KapaHistoryItem = { + threadId, + title: buildKapaHistoryTitle(conversation), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + conversation: conversation.map(toStoredKapaQA), + } + const next = [ + nextItem, + ...current.filter((item) => item.threadId !== threadId), + ].slice(0, KAPA_HISTORY_LIMIT) + + writeKapaHistory(next) + return next +} + +function formatKapaHistoryTime(updatedAt: number) { + const elapsed = Date.now() - updatedAt + + if (elapsed < 60_000) { + return 'now' + } + + if (elapsed < 3_600_000) { + return `${Math.floor(elapsed / 60_000)}m ago` + } + + if (elapsed < 86_400_000) { + return `${Math.floor(elapsed / 3_600_000)}h ago` + } + + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + }).format(new Date(updatedAt)) +} + +function useKapaChatHistory() { + const [items, setItems] = + React.useState>(readKapaHistory) + + const save = React.useCallback( + (threadId: string, conversation: Array) => { + if (conversation.length === 0) { + return + } + + setItems(upsertKapaHistoryItem(threadId, conversation)) + }, + [], + ) + + return { items, save } +} + +function getSourceScope(sourceUrl: string) { + let pathname = sourceUrl + + try { + pathname = new URL(sourceUrl).pathname + } catch { + pathname = sourceUrl + } + + const pathParts = pathname.split('/').filter(Boolean) + const libraryId = pathParts[0] + const library = publicLibraries.find((item) => item.id === libraryId) + const frameworkIndex = pathParts.indexOf('framework') + const frameworkValue = + frameworkIndex >= 0 ? pathParts[frameworkIndex + 1] : undefined + const framework = frameworkValue + ? frameworkOptions.find((item) => item.value === frameworkValue) + : undefined + + return { library, framework } +} + +function SourceScopeBadges({ + library, + framework, +}: ReturnType) { + if (!library && !framework) { + return null + } + + return ( + + {library ? ( + + {library.id} + + ) : null} + {framework ? ( + + + {capitalize(framework.label)} + + ) : null} + + ) +} + +function ChatControlTooltip({ children }: { children: React.ReactNode }) { + return ( + + ) +} + +const compactChatControlClass = + 'group relative h-8 w-8 justify-center rounded-lg bg-transparent shadow-none border-transparent hover:border-gray-200 focus-visible:border-gray-200 dark:hover:border-white/10 dark:focus-visible:border-white/10' + +function DockMaximizeButton({ + isMaximized, + onToggle, +}: { + isMaximized: boolean + onToggle: () => void +}) { + const label = isMaximized ? 'Minimize panel' : 'Maximize panel' + + return ( + + ) +} + +function CopyChatButton({ + conversation, + compact = false, +}: { + conversation: Array + compact?: boolean +}) { + const [copied, setCopied] = React.useState(false) + const hasConversation = conversation.length > 0 + const label = copied ? 'Copied' : 'Copy chat' + const resetCopiedTimerRef = React.useRef(null) + + React.useEffect(() => { + return () => { + if (resetCopiedTimerRef.current) { + window.clearTimeout(resetCopiedTimerRef.current) + } + } + }, []) + + const handleCopy = () => { + const text = conversation + .map((qa) => + [`You: ${qa.question}`, qa.answer ? `TanStack: ${qa.answer}` : ''] + .filter(Boolean) + .join('\n\n'), + ) + .join('\n\n') + + if (!text) { + return + } + + navigator.clipboard.writeText(text).then(() => { + if (resetCopiedTimerRef.current) { + window.clearTimeout(resetCopiedTimerRef.current) + } + + setCopied(true) + resetCopiedTimerRef.current = window.setTimeout(() => { + setCopied(false) + resetCopiedTimerRef.current = null + }, 2000) + }) + } + + return ( + + ) +} + +function KapaHistoryButton({ + items, + activeThreadId, + isBusy, + onSelect, + compact = false, +}: { + items: Array + activeThreadId: string | null + isBusy: boolean + onSelect: (item: KapaHistoryItem) => void + compact?: boolean +}) { + const { cancelAiDockHoverClose } = useSearchContext() + const cancelPendingDockClose = React.useCallback(() => { + if (!compact) { + return + } + + cancelAiDockHoverClose() + }, [cancelAiDockHoverClose, compact]) + + return ( + { + if (open) { + cancelPendingDockClose() + } + }} + > + + + + + {items.length === 0 ? ( +
    + No saved chats yet +
    + ) : null} + {items.map((item) => { + const isActive = item.threadId === activeThreadId + + return ( + { + cancelPendingDockClose() + onSelect(item) + }} + className="items-start justify-between gap-3 py-2" + > + + + {item.title} + + + {formatKapaHistoryTime(item.updatedAt)} + + + {isActive ? ( + + ) : null} + + ) + })} +
    +
    + ) +} + +function MessageActionButton({ + icon, + title, + onClick, +}: { + icon: 'copy' + title: string + onClick: () => void +}) { + const [flashed, setFlashed] = React.useState(false) + + const handleClick = () => { + onClick() + if (icon === 'copy') { + setFlashed(true) + setTimeout(() => setFlashed(false), 1500) + } + } + + const iconEl = flashed ? ( + + ) : ( + + ) + + return ( + + ) +} + +function AIMessageHeader({ action }: { action?: React.ReactNode }) { + return ( +
    +
    +
    + TanStack + +
    +
    + + TanStack + + + answers by kapa.ai + +
    +
    + {action} +
    + ) +} + +function getKapaSuggestions(selectedLibrary: string) { + if (selectedLibrary === 'query') { + return [ + 'How do I invalidate a query?', + 'What is staleTime vs gcTime?', + 'How do I handle optimistic updates?', + ] + } + + if (selectedLibrary === 'router') { + return [ + 'How do I do nested layouts?', + 'What is the difference between loader and beforeLoad?', + 'How do I type search params?', + ] + } + + if (selectedLibrary === 'start') { + return [ + 'How do I create a server function?', + 'How do I add authentication?', + 'How does middleware work?', + ] + } + + if (selectedLibrary === 'table') { + return [ + 'How do I add column sorting?', + 'How do I implement row selection?', + 'How do I virtualize rows?', + ] + } + + if (selectedLibrary === 'form') { + return [ + 'How do I validate on submit?', + 'How do I handle async validation?', + 'How do I use array fields?', + ] + } + + return [ + 'How do I get started with TanStack Query?', + 'What is the difference between Start and Router?', + 'Which TanStack library should I use for data fetching?', + ] +} + +function KapaWelcome({ + selectedLibrary, + selectedFramework, + onSuggestion, + disabled, + compact = false, +}: { + selectedLibrary: string + selectedFramework: string + onSuggestion: (suggestion: string) => void + disabled?: boolean + compact?: boolean +}) { + const suggestions = getKapaSuggestions(selectedLibrary) + + return ( +
    + +
    +

    + Ask a TanStack docs question in plain English. + {(selectedLibrary || selectedFramework) && ( + <> + {' '} + Your search is focused on{' '} + {selectedLibrary && ( + + TanStack {selectedLibrary.toUpperCase()} + + )} + {selectedLibrary && selectedFramework && ' + '} + {selectedFramework && ( + + {capitalize(selectedFramework)} + + )} + . + + )} +

    +
    +

    + Try asking +

    +
    + {suggestions.map((suggestion) => ( + + ))} +
    +
    +
    +
    + ) +} + +function KapaAnswer({ + qa, + isStreaming, + error, + onCopyQuestion, + onFeedback, + compact = false, +}: { + qa: KapaDisplayQA + isStreaming: boolean + error?: string | null + onCopyQuestion: () => void + onFeedback: (reaction: 'upvote' | 'downvote') => void + compact?: boolean +}) { + const canSubmitFeedback = + qa.id !== null && qa.isFeedbackSubmissionEnabled && qa.reaction === null + const hasAnswerError = !isStreaming && !qa.answer && !!error + + return ( +
    +
    +
    + {qa.question} +
    +
    + +
    +
    +
    + +
    + {hasAnswerError ? ( +

    + {error} +

    + ) : qa.answer ? ( +
    + + {qa.answer} + +
    + ) : ( +
    + + + +
    + )} +
    + {!isStreaming && qa.sources.length > 0 && ( +
    +

    + Sources +

    +
    + {qa.sources.map((source) => { + const subtitle = + source.subtitle && source.subtitle !== source.title + ? source.subtitle + : '' + const label = subtitle + ? `${source.title} - ${subtitle}` + : source.title + const sourceScope = getSourceScope(source.source_url) + const hasSourceScope = + !!sourceScope.library || !!sourceScope.framework + const internalTarget = getInternalLinkTarget(source.source_url) + + return ( + + + {!hasSourceScope ? ( + + ) : null} + {label} + + ) + })} +
    +
    + )} + {!isStreaming && qa.answer && ( +
    + + +
    + navigator.clipboard.writeText(qa.answer)} + /> +
    + )} +
    +
    + ) +} + +function KapaChatPanel({ + onReset, + isFullHeight, + onToggleFullHeight, + threadIdOverrideRef, + newChatRequestId, + surface, + isDockMaximized = false, + onToggleDockMaximized, +}: { + onReset: () => void + isFullHeight: boolean + onToggleFullHeight: () => void + threadIdOverrideRef: React.MutableRefObject + newChatRequestId: number + surface: SearchSurface + isDockMaximized?: boolean + onToggleDockMaximized?: () => void +}) { + const { + conversation, + threadId, + submitQuery, + isGeneratingAnswer, + isPreparingAnswer, + stopGeneration, + resetConversation, + addFeedback, + error, + } = useChat() + const { + aiDockAskRequest, + clearAiDockAskRequest, + closeSearch, + setAiDockDirty, + } = useSearchContext() + const { selectedLibrary, selectedFramework, showSearchResults, searchQuery } = + useSearchFilters() + const scrollRef = React.useRef(null) + const { items: historyItems, save: saveHistory } = useKapaChatHistory() + const [selectedHistoryItem, setSelectedHistoryItem] = + React.useState(null) + const isBusy = isGeneratingAnswer || isPreparingAnswer + const isDock = surface === 'dock' + const isSubmittingRef = React.useRef(false) + const pendingSubmitIdRef = React.useRef(0) + const lockedToBottom = React.useRef(true) + const handledNewChatRequestId = React.useRef(newChatRequestId) + const handledAiDockAskRequestId = React.useRef(0) + const displayConversation = React.useMemo>(() => { + const liveConversation = Array.from(conversation) + + if (!selectedHistoryItem) { + return liveConversation + } + + return [...selectedHistoryItem.conversation, ...liveConversation] + }, [conversation, selectedHistoryItem]) + const hasConversation = displayConversation.length > 0 + const activeThreadId = threadId ?? selectedHistoryItem?.threadId ?? null + + React.useEffect(() => { + if (!isDock) { + return + } + + setAiDockDirty( + searchQuery.trim().length > 0 || displayConversation.length > 0 || isBusy, + ) + }, [displayConversation.length, isBusy, isDock, searchQuery, setAiDockDirty]) + + React.useEffect(() => { + if (threadId) { + threadIdOverrideRef.current = threadId + } + }, [threadId, threadIdOverrideRef]) + + React.useEffect(() => { + if (!threadId || isBusy || displayConversation.length === 0) { + return + } + + saveHistory(threadId, displayConversation) + }, [displayConversation, isBusy, saveHistory, threadId]) + + React.useEffect(() => { + const element = scrollRef.current + if (!element) return + + const handleScroll = () => { + const distanceFromBottom = + element.scrollHeight - element.scrollTop - element.clientHeight + lockedToBottom.current = distanceFromBottom < 80 + } + + element.addEventListener('scroll', handleScroll, { passive: true }) + return () => { + element.removeEventListener('scroll', handleScroll) + } + }, []) + + React.useEffect(() => { + const frame = requestAnimationFrame(() => { + const element = scrollRef.current + if (element && lockedToBottom.current) { + element.scrollTop = element.scrollHeight + } + }) + + return () => cancelAnimationFrame(frame) + }, [displayConversation, isBusy]) + + React.useEffect(() => { + if (!isBusy) { + isSubmittingRef.current = false + } + }, [isBusy]) + + const ask = React.useCallback( + (question: string) => { + const trimmed = question.trim() + if (trimmed.length < 3 || isBusy || isSubmittingRef.current) { + return + } + + const submitId = pendingSubmitIdRef.current + 1 + pendingSubmitIdRef.current = submitId + isSubmittingRef.current = true + + waitForKapaRecaptchaReady().then(() => { + if (pendingSubmitIdRef.current !== submitId) { + return + } + + submitQuery(trimmed) + }) + }, + [isBusy, submitQuery], + ) + + const selectHistoryItem = React.useCallback( + (item: KapaHistoryItem) => { + if (isBusy) { + return + } + + resetConversation() + threadIdOverrideRef.current = item.threadId + setSelectedHistoryItem(item) + lockedToBottom.current = true + }, + [isBusy, resetConversation, threadIdOverrideRef], + ) + + const clearActiveChat = React.useCallback(() => { + pendingSubmitIdRef.current += 1 + isSubmittingRef.current = false + resetConversation() + threadIdOverrideRef.current = null + setSelectedHistoryItem(null) + lockedToBottom.current = true + onReset() + }, [onReset, resetConversation, threadIdOverrideRef]) + + const startNewChat = React.useCallback(() => { + if (isBusy) { + stopGeneration() + } + + clearActiveChat() + }, [clearActiveChat, isBusy, stopGeneration]) + + React.useEffect(() => { + if (handledNewChatRequestId.current === newChatRequestId) { + return + } + + handledNewChatRequestId.current = newChatRequestId + startNewChat() + }, [newChatRequestId, startNewChat]) + + React.useEffect(() => { + if (!isDock || !aiDockAskRequest) { + return + } + + if (handledAiDockAskRequestId.current === aiDockAskRequest.id) { + return + } + + if (isBusy) { + stopGeneration() + return + } + + handledAiDockAskRequestId.current = aiDockAskRequest.id + clearActiveChat() + ask(aiDockAskRequest.question) + clearAiDockAskRequest(aiDockAskRequest.id) + }, [ + ask, + aiDockAskRequest, + clearActiveChat, + clearAiDockAskRequest, + isBusy, + isDock, + stopGeneration, + ]) + + return ( +
    +
    + {isDock ? null : } +
    + {isDock ? ( +
    +
    + TanStack AI +
    +
    + ) : ( + <> + + + + )} +
    +
    + {isDock && onToggleDockMaximized ? ( + + ) : null} + + {hasConversation ? ( + <> + + + + ) : null} +
    +
    +
    + {displayConversation.length === 0 ? ( + + ) : ( + displayConversation.map((qa, index) => { + const liveIndex = + index - + (selectedHistoryItem + ? selectedHistoryItem.conversation.length + : 0) + const isLiveQA = liveIndex >= 0 + const isLatestLiveQA = liveIndex === conversation.length - 1 + const isStreamingLatest = isLiveQA && isLatestLiveQA && isBusy + const answerError = isLiveQA && isLatestLiveQA ? error : null + + return ( + + navigator.clipboard.writeText(qa.question) + } + onFeedback={(reaction) => { + if (qa.id !== null) { + addFeedback(qa.id, reaction) + } + }} + compact={isDock} + /> + ) + }) + )} + {error && conversation.length === 0 && ( +
    + {error} +
    + )} +
    +
    +
    + +
    + +
    +
    + ) +} + +function StickyTopBlur() { + const mask = + '[mask-image:linear-gradient(to_bottom,black_0%,transparent_100%)] [-webkit-mask-image:linear-gradient(to_bottom,black_0%,transparent_100%)]' + + return ( +