Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import './globals.css';
import {Theme} from '@radix-ui/themes';
import type {Metadata} from 'next';
import {Rubik} from 'next/font/google';
import Script from 'next/script';
import PlausibleProvider from 'next-plausible';
import {HashNavigation} from 'sentry-docs/components/hashNavigation';
import {SkipToContent} from 'sentry-docs/components/skipToContent';
Expand Down Expand Up @@ -50,22 +49,6 @@ export default function RootLayout({children}: {children: React.ReactNode}) {
{children}
</Theme>
</ThemeProvider>
<Script
strategy="lazyOnload"
src="https://widget.kapa.ai/kapa-widget.bundle.js"
data-website-id="cac7cc70-969e-4bc1-a968-55534a839be4"
data-button-hide // do not render kapa ai button
data-modal-override-open-class="kapa-ai-class" // all elements with this class will open the kapa ai modal
data-project-name="Sentry"
data-project-color="#6A5FC1"
data-project-logo="https://avatars.githubusercontent.com/u/1396951?s=280&v=4"
data-font-family="var(--font-rubik)"
data-modal-disclaimer="Please note: This is a tool that searches publicly available sources. Do not include any sensitive or personal information in your queries. For more on how Sentry handles your data, see our [Privacy Policy](https://sentry.io/privacy/)."
data-modal-example-questions="How to set up Sentry for Next.js?,What are tracePropagationTargets?"
data-user-analytics-cookie-enabled="false"
data-color-scheme="auto"
data-color-scheme-selector=".dark"
/>
</body>
</html>
);
Expand Down
28 changes: 14 additions & 14 deletions src/components/askAiSearchParams.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,27 @@
import {useSearchParams} from 'next/navigation';
import {Fragment, useEffect} from 'react';
import {usePlausibleEvent} from 'sentry-docs/hooks/usePlausibleEvent';
import {openKapa} from 'sentry-docs/lib/kapa';

export default function AskAiSearchParams() {
const searchParams = useSearchParams();
const {emit} = usePlausibleEvent();

useEffect(() => {
const askAi = searchParams?.get('askAI');
// Give Kapa.ai some time to be fully loaded
const timer = setTimeout(() => {
if (window.Kapa?.open && askAi === 'true') {
// open kapa modal
window.Kapa.open({});
if (searchParams?.get('referrer')) {
emit('Ask AI Referrer', {
props: {referrer: searchParams.get('referrer') ?? ''},
});
}
if (searchParams?.get('askAI') !== 'true') {
return undefined;
}
// openKapa resolves once the widget is actually ready, so the deep link no
// longer races a fixed timeout. Loading can take seconds, so abort on
// unmount to avoid popping the modal over whatever page the user moved on to.
const controller = new AbortController();
const referrer = searchParams.get('referrer');
openKapa({}, controller.signal).then(opened => {
if (opened && referrer) {
emit('Ask AI Referrer', {props: {referrer}});
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}, 300);

return () => clearTimeout(timer);
});
return () => controller.abort();
}, [searchParams, emit]);

return <Fragment />;
Expand Down
6 changes: 5 additions & 1 deletion src/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Image from 'next/image';
import Link from 'next/link';
import {useCallback, useEffect, useState} from 'react';
import {useBodyScrollLock} from 'sentry-docs/hooks/useBodyScrollLock';
import {openKapa, prefetchKapa} from 'sentry-docs/lib/kapa';
import SentryLogoSVG from 'sentry-docs/logos/sentry-logo-dark.svg';

import {MagicIcon} from './cutomIcons/magic';
Expand Down Expand Up @@ -274,8 +275,11 @@ export function Header({
</button>
)}
<button
className="kapa-ai-class flex items-center gap-1.5 text-sm font-medium text-[var(--foreground)] px-2 py-1.5 rounded-md hover:bg-[var(--gray-a3)] transition-colors"
className="flex items-center gap-1.5 text-sm font-medium text-[var(--foreground)] px-2 py-1.5 rounded-md hover:bg-[var(--gray-a3)] transition-colors"
aria-label="Ask AI"
onPointerEnter={prefetchKapa}
onFocus={prefetchKapa}
onClick={() => openKapa({})}
>
<MagicIcon className="size-4" />
<span className="uppercase text-xs">Ask AI</span>
Expand Down
29 changes: 18 additions & 11 deletions src/components/search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {Fragment, useCallback, useEffect, useRef, useState} from 'react';
import {createInsightsClient} from 'search-insights';
import {useOnClickOutside} from 'sentry-docs/clientUtils';
import {isDeveloperDocs} from 'sentry-docs/isDeveloperDocs';
import {openKapa, prefetchKapa} from 'sentry-docs/lib/kapa';
import {DocMetrics} from 'sentry-docs/metrics';

import {MagicIcon} from '../cutomIcons/magic';
Expand Down Expand Up @@ -431,9 +432,15 @@ export function Search({
color="gray"
size="3"
radius="medium"
className="font-medium text-[var(--foreground)] py-2 px-3 uppercase cursor-pointer kapa-ai-class hidden md:flex mr-4"
className="font-medium text-[var(--foreground)] py-2 px-3 uppercase cursor-pointer hidden md:flex mr-4"
>
<button type="button" aria-label="Ask AI">
<button
type="button"
aria-label="Ask AI"
onPointerEnter={prefetchKapa}
onFocus={prefetchKapa}
onClick={() => openKapa({})}
>
<MagicIcon />
<span>Ask AI</span>
</button>
Expand All @@ -450,16 +457,16 @@ export function Search({
<button
id="ai-list-entry"
className={styles['sgs-ai-button']}
onPointerEnter={prefetchKapa}
onFocus={prefetchKapa}
onClick={() => {
if (window.Kapa?.open) {
setInputFocus(false);
onAskAi?.();
// Open next frame, after the overlay's scroll lock is released
// on commit, so Kapa's lock and ours never overlap.
requestAnimationFrame(() => {
window.Kapa?.open({query, submit: true});
});
}
setInputFocus(false);
onAskAi?.();
// Open next frame, after the overlay's scroll lock is released
// on commit, so Kapa's lock and ours never overlap.
requestAnimationFrame(() => {
openKapa({query, submit: true});
});
}}
>
<MagicIcon className="size-6 text-[var(--sgs-color-hit-highlight)] flex-shrink-0" />
Expand Down
119 changes: 119 additions & 0 deletions src/lib/kapa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* On-demand loader for the Kapa.ai widget.
*
* The bundle is ~600 KiB and pulls in reCAPTCHA Enterprise behind it, which
* together accounted for roughly a second of main-thread time on every page
* load. Almost nobody opens Ask AI, so instead of shipping it to everyone up
* front we fetch it the first time someone reaches for it, and pre-warm on
* hover/focus so the click still feels instant.
*/

import {captureException} from '@sentry/nextjs';

const KAPA_SRC = 'https://widget.kapa.ai/kapa-widget.bundle.js';

// Mirrors the data-* attributes the <Script> tag used to carry in app/layout.tsx.
const KAPA_ATTRIBUTES: Record<string, string> = {
'data-website-id': 'cac7cc70-969e-4bc1-a968-55534a839be4',
// do not render kapa's own floating button
'data-button-hide': 'true',
'data-project-name': 'Sentry',
'data-project-color': '#6A5FC1',
'data-project-logo': 'https://avatars.githubusercontent.com/u/1396951?s=280&v=4',
'data-font-family': 'var(--font-rubik)',
'data-modal-disclaimer':
'Please note: This is a tool that searches publicly available sources. Do not include any sensitive or personal information in your queries. For more on how Sentry handles your data, see our [Privacy Policy](https://sentry.io/privacy/).',
'data-modal-example-questions':
'How to set up Sentry for Next.js?,What are tracePropagationTargets?',
'data-user-analytics-cookie-enabled': 'false',
'data-color-scheme': 'auto',
'data-color-scheme-selector': '.dark',
};

/** How long to wait for the bundle to define window.Kapa after it loads. */
const INIT_TIMEOUT_MS = 10_000;
const INIT_POLL_MS = 50;

let loadPromise: Promise<void> | null = null;

/**
* The bundle's load event fires before it has finished wiring up window.Kapa,
* so wait for the API itself rather than for the network.
*/
function whenKapaReady(): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + INIT_TIMEOUT_MS;
const poll = () => {
if (window.Kapa?.open) {
resolve();
} else if (Date.now() > deadline) {
reject(new Error('Kapa widget loaded but never initialized'));
} else {
setTimeout(poll, INIT_POLL_MS);
}
};
poll();
});
}

/**
* Inject the widget. Safe to call repeatedly: the work happens once, and a
* failed attempt is cleared so the next interaction can retry.
*/
export function loadKapa(): Promise<void> {
if (typeof window === 'undefined') {
return Promise.resolve();
}
if (loadPromise) {
return loadPromise;
}

const promise = new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = KAPA_SRC;
script.async = true;
Object.entries(KAPA_ATTRIBUTES).forEach(([name, value]) =>
script.setAttribute(name, value)
);
script.addEventListener('load', () => resolve());
script.addEventListener('error', () =>
reject(new Error('Failed to load the Kapa widget'))
);
document.body.appendChild(script);
}).then(whenKapaReady);

promise.catch(() => {
// let the next click try again rather than wedging Ask AI for the session
loadPromise = null;
});

loadPromise = promise;
return promise;
}

/** Start fetching without opening, e.g. on hover or focus of an Ask AI control. */
export function prefetchKapa(): void {
loadKapa().catch(() => {});
}

/**
* Load the widget if needed, then open the modal. Aborting the signal skips the
* open (e.g. the user navigated away mid-load) but lets the load finish, so the
* next Ask AI click is still fast. Resolves to whether the modal was opened.
*/
export async function openKapa(
options: {mode?: string; query?: string; submit?: boolean} = {},
signal?: AbortSignal
): Promise<boolean> {
try {
await loadKapa();
if (signal?.aborted || !window.Kapa?.open) {
return false;
}
window.Kapa.open(options);
return true;
} catch (error) {
captureException(error);
return false;
}
}
Loading