Skip to content

Commit 0bbb013

Browse files
authored
add AI Search CTA (#55470)
1 parent 7ef2c83 commit 0bbb013

File tree

11 files changed

+229
-7
lines changed

11 files changed

+229
-7
lines changed
316 KB
Loading

data/ui.yml

+3
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ search:
6464
general_title: There was an error loading search results.
6565
ai_title: There was an error loading Copilot.
6666
description: You can still use this field to search our docs.
67+
cta:
68+
heading: New! Copilot for Docs
69+
description: Ask your question in the search bar and get help in seconds.
6770
old_search:
6871
description: Enter a search term to find it in the GitHub Docs.
6972
placeholder: Search GitHub Docs

src/fixtures/fixtures/data/ui.yml

+3
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ search:
6464
general_title: There was an error loading search results.
6565
ai_title: There was an error loading Copilot.
6666
description: You can still use this field to search our docs.
67+
cta:
68+
heading: New! Copilot for Docs
69+
description: Ask your question in the search bar and get help in seconds.
6770
old_search:
6871
description: Enter a search term to find it in the GitHub Docs.
6972
placeholder: Search GitHub Docs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Context to keep track of a call to action (e.g. popover introducing a new feature)
2+
// The state of the CTA will be stored in local storage, so it will persist across page reloads
3+
// If `dismiss` is called, the CTA will not be shown again
4+
import {
5+
createContext,
6+
useCallback,
7+
useContext,
8+
useEffect,
9+
useState,
10+
PropsWithChildren,
11+
} from 'react'
12+
13+
type CTAPopoverState = {
14+
isOpen: boolean
15+
initializeCTA: () => void // Call to "open" the CTA if it's not already been dismissed by the user
16+
dismiss: () => void // Call to "close" the CTA and store the dismissal in local storage
17+
}
18+
19+
type StoredValue = { dismissed: true }
20+
21+
const CTAPopoverContext = createContext<CTAPopoverState | undefined>(undefined)
22+
23+
const STORAGE_KEY = 'ctaPopoverDismissed'
24+
25+
const isDismissed = (): boolean => {
26+
if (typeof window === 'undefined') return false // SSR guard
27+
try {
28+
const raw = localStorage.getItem(STORAGE_KEY)
29+
if (!raw) return false
30+
const parsed = JSON.parse(raw) as StoredValue
31+
return parsed?.dismissed
32+
} catch {
33+
return false // corruption / quota / disabled storage
34+
}
35+
}
36+
37+
export function CTAPopoverProvider({ children }: PropsWithChildren) {
38+
// We start closed because we might only want to "turn on" the CTA if an experiment is active
39+
const [isOpen, setIsOpen] = useState(false)
40+
41+
const persistDismissal = useCallback(() => {
42+
setIsOpen(false)
43+
try {
44+
const obj: StoredValue = { dismissed: true }
45+
localStorage.setItem(STORAGE_KEY, JSON.stringify(obj))
46+
} catch {
47+
/* ignore */
48+
}
49+
}, [])
50+
51+
const dismiss = useCallback(() => persistDismissal(), [persistDismissal])
52+
const initializeCTA = useCallback(() => {
53+
const dismissed = isDismissed()
54+
if (dismissed) {
55+
setIsOpen(false)
56+
} else {
57+
setIsOpen(true)
58+
}
59+
}, [isDismissed])
60+
61+
// Wrap in a useEffect to avoid a hydration mismatch (SSR guard)
62+
useEffect(() => {
63+
const stored = isDismissed()
64+
setIsOpen(!stored)
65+
}, [])
66+
67+
return (
68+
<CTAPopoverContext.Provider value={{ isOpen, initializeCTA, dismiss }}>
69+
{children}
70+
</CTAPopoverContext.Provider>
71+
)
72+
}
73+
74+
export const useCTAPopoverContext = () => {
75+
const ctx = useContext(CTAPopoverContext)
76+
if (!ctx) throw new Error('useCTAPopoverContext must be used inside <CTAPopoverProvider>')
77+
return ctx
78+
}

src/frame/components/page-header/Header.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useShouldShowExperiment } from '@/events/components/experiments/useShou
2323
import { useQueryParam } from '@/frame/components/hooks/useQueryParam'
2424
import { useMultiQueryParams } from '@/search/components/hooks/useMultiQueryParams'
2525
import { SearchOverlayContainer } from '@/search/components/input/SearchOverlayContainer'
26+
import { useCTAPopoverContext } from '@/frame/components/context/CTAContext'
2627

2728
import styles from './Header.module.scss'
2829

@@ -50,6 +51,7 @@ export const Header = () => {
5051
const { width } = useInnerWindowWidth()
5152
const returnFocusRef = useRef(null)
5253
const searchButtonRef = useRef<HTMLButtonElement>(null)
54+
const { initializeCTA } = useCTAPopoverContext()
5355

5456
const showNewSearch = useShouldShowExperiment(EXPERIMENTS.ai_search_experiment)
5557
let SearchButton: JSX.Element | null = (
@@ -62,6 +64,8 @@ export const Header = () => {
6264
)
6365
if (!showNewSearch) {
6466
SearchButton = null
67+
} else {
68+
initializeCTA()
6569
}
6670

6771
useEffect(() => {

src/frame/pages/app.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from 'src/languages/components/LanguagesContext'
1818
import { useTheme } from 'src/color-schemes/components/useTheme'
1919
import { SharedUIContextProvider } from 'src/frame/components/context/SharedUIContext'
20+
import { CTAPopoverProvider } from 'src/frame/components/context/CTAContext'
2021

2122
type MyAppProps = AppProps & {
2223
isDotComAuthenticated: boolean
@@ -140,7 +141,9 @@ const MyApp = ({ Component, pageProps, languagesContext, stagingName }: MyAppPro
140141
>
141142
<LanguagesContext.Provider value={languagesContext}>
142143
<SharedUIContextProvider>
143-
<Component {...pageProps} />
144+
<CTAPopoverProvider>
145+
<Component {...pageProps} />
146+
</CTAPopoverProvider>
144147
</SharedUIContextProvider>
145148
</LanguagesContext.Provider>
146149
</ThemeProvider>

src/search/components/hooks/useBreakpoint.ts

+15-3
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,20 @@ import { useTheme } from '@primer/react'
22

33
import { useMediaQuery } from './useMediaQuery'
44

5-
type Size = 'small' | 'medium' | 'large' | 'xlarge'
6-
export function useBreakpoint(size: Size) {
5+
type Size = 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge'
6+
export function useMinWidthBreakpoint(size: Size) {
77
const { theme } = useTheme()
8-
return useMediaQuery(`(min-width: ${theme?.sizes[size]})`)
8+
// For some reason, xsmall isn't in theme for Primer: https://github.com/primer/react/blob/308fe82909f3d922be0a6582f83e96798678ec78/packages/react/src/utils/layout.ts#L6
9+
let sizePx = theme?.sizes[size]
10+
if (size === 'xsmall') {
11+
sizePx = '320px'
12+
}
13+
return useMediaQuery(`(min-width: ${sizePx})`)
14+
}
15+
16+
export function useMaxWidthBreakpoint(sizePx: string) {
17+
if (!sizePx.endsWith('px')) {
18+
sizePx = `${sizePx}px`
19+
}
20+
return useMediaQuery(`(max-width: ${sizePx})`)
921
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { useEffect, useRef } from 'react'
2+
import { Text, Button, Heading, Popover, useOnEscapePress } from '@primer/react'
3+
import { focusTrap } from '@primer/behaviors'
4+
5+
import { useTranslation } from '@/languages/components/useTranslation'
6+
import { useMaxWidthBreakpoint, useMinWidthBreakpoint } from '../hooks/useBreakpoint'
7+
8+
let previouslyFocused: HTMLElement | null = null
9+
10+
export function AISearchCTAPopup({ isOpen, dismiss }: { isOpen: boolean; dismiss: () => void }) {
11+
const { t } = useTranslation('search')
12+
const isLargeOrUp = useMinWidthBreakpoint('large')
13+
const isTooSmallForCTA = useMaxWidthBreakpoint('293px')
14+
let overlayRef = useRef<HTMLDivElement>(null)
15+
let dismissButtonRef = useRef<HTMLButtonElement>(null)
16+
17+
// For a11y, focus trap the CTA and allow it to be closed with Escape
18+
useEffect(() => {
19+
if (isTooSmallForCTA) {
20+
return
21+
}
22+
if (isOpen && overlayRef.current && dismissButtonRef.current) {
23+
focusTrap(overlayRef.current, dismissButtonRef.current)
24+
previouslyFocused = document.activeElement as HTMLElement | null
25+
}
26+
}, [isOpen, isTooSmallForCTA])
27+
28+
const onDismiss = () => {
29+
if (isTooSmallForCTA) {
30+
return
31+
}
32+
if (previouslyFocused) {
33+
previouslyFocused.focus()
34+
}
35+
dismiss()
36+
}
37+
38+
useOnEscapePress(onDismiss)
39+
40+
if (isTooSmallForCTA) {
41+
return null
42+
}
43+
44+
return (
45+
<Popover
46+
ref={overlayRef}
47+
role="alertdialog"
48+
aria-modal="true"
49+
aria-labelledby="ai-search-cta-heading"
50+
aria-describedby="ai-search-cta-description"
51+
open={isOpen}
52+
caret={isLargeOrUp ? 'top' : 'top-right'}
53+
sx={{
54+
top: '55px',
55+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
56+
width: '270px',
57+
// When in mobile (< large) the search bar collapses to a button on the RHS of the screen
58+
// To align the popover with the button we need to move it to the left
59+
marginLeft: isLargeOrUp ? 0 : -235,
60+
}}
61+
>
62+
<Popover.Content
63+
sx={{
64+
width: '270px',
65+
}}
66+
>
67+
<img
68+
src="/assets/images/search/copilot-action.png"
69+
width={220}
70+
alt="The Copilot Icon in front of an explosion of color."
71+
/>
72+
<Heading
73+
as="h2"
74+
id="ai-search-cta-heading"
75+
sx={{
76+
fontSize: '16px',
77+
fontWeight: 'bold',
78+
marginTop: '12px',
79+
}}
80+
>
81+
{t('search.cta.heading')}
82+
</Heading>
83+
<Text
84+
id="ai-search-cta-description"
85+
sx={{
86+
display: 'block',
87+
fontSize: '15px',
88+
marginTop: '12px',
89+
}}
90+
>
91+
{t('search.cta.description')}
92+
</Text>
93+
<Button
94+
ref={dismissButtonRef}
95+
aria-label="Dismiss"
96+
sx={{
97+
marginTop: '16px',
98+
fontWeight: 'bold',
99+
}}
100+
onClick={onDismiss}
101+
>
102+
Dismiss
103+
</Button>
104+
</Popover.Content>
105+
</Popover>
106+
)
107+
}

src/search/components/input/AskAIResults.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { sendEvent, uuidv4 } from '@/events/components/events'
1515
import { EventType } from '@/events/types'
1616
import { generateAISearchLinksJson } from '../helpers/ai-search-links-json'
1717
import { ASK_AI_EVENT_GROUP } from '@/events/components/event-groups'
18+
import { useCTAPopoverContext } from '@/frame/components/context/CTAContext'
19+
1820
import type { AIReference } from '../types'
1921

2022
type AIQueryResultsProps = {
@@ -74,6 +76,7 @@ export function AskAIResults({
7476
aiCouldNotAnswer: boolean
7577
connectedEventId?: string
7678
}>('ai-query-cache', 1000, 7)
79+
const { isOpen: isCTAOpen, dismiss: dismissCTA } = useCTAPopoverContext()
7780

7881
const [isCopied, setCopied] = useClipboard(message, { successDuration: 1400 })
7982
const [feedbackSelected, setFeedbackSelected] = useState<null | 'up' | 'down'>(null)
@@ -128,6 +131,11 @@ export function AskAIResults({
128131
setResponseLoading(true)
129132
disclaimerRef.current?.focus()
130133

134+
// Upon performing an AI Search, dismiss the CTA if it is open
135+
if (isCTAOpen) {
136+
dismissCTA()
137+
}
138+
131139
const cachedData = getItem(query, version, router.locale || 'en')
132140
if (cachedData) {
133141
setMessage(cachedData.message)

src/search/components/input/OldSearchInput.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { SearchIcon } from '@primer/octicons-react'
66
import { useTranslation } from 'src/languages/components/useTranslation'
77
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
88
import { useQuery } from 'src/search/components/hooks/useQuery'
9-
import { useBreakpoint } from 'src/search/components/hooks/useBreakpoint'
9+
import { useMinWidthBreakpoint } from 'src/search/components/hooks/useBreakpoint'
1010
import { sendEvent } from 'src/events/components/events'
1111
import { EventType } from 'src/events/types'
1212
import { GENERAL_SEARCH_CONTEXT } from '../helpers/execute-search-actions'
@@ -19,7 +19,7 @@ export function OldSearchInput({ isSearchOpen }: Props) {
1919
const [localQuery, setLocalQuery] = useState(query)
2020
const { t } = useTranslation('old_search')
2121
const { currentVersion } = useVersion()
22-
const atMediumViewport = useBreakpoint('medium')
22+
const atMediumViewport = useMinWidthBreakpoint('medium')
2323

2424
function redirectSearch() {
2525
let asPath = `/${router.locale}`

src/search/components/input/SearchBarButton.tsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { IconButton } from '@primer/react'
33
import { CopilotIcon, SearchIcon } from '@primer/octicons-react'
44

55
import { useTranslation } from 'src/languages/components/useTranslation'
6+
import { QueryParams } from '@/search/components/hooks/useMultiQueryParams'
7+
import { useCTAPopoverContext } from '@/frame/components/context/CTAContext'
68

79
import styles from './SearchBarButton.module.scss'
8-
import { QueryParams } from '../hooks/useMultiQueryParams'
10+
import { AISearchCTAPopup } from './AISearchCTAPopup'
911

1012
type Props = {
1113
isSearchOpen: boolean
@@ -16,6 +18,7 @@ type Props = {
1618

1719
export function SearchBarButton({ isSearchOpen, setIsSearchOpen, params, searchButtonRef }: Props) {
1820
const { t } = useTranslation('search')
21+
const { isOpen, dismiss } = useCTAPopoverContext()
1922

2023
const urlSearchInputQuery = params['search-overlay-input']
2124

@@ -53,6 +56,7 @@ export function SearchBarButton({ isSearchOpen, setIsSearchOpen, params, searchB
5356
{/* We don't want to show the input when overlay is open */}
5457
{!isSearchOpen ? (
5558
<>
59+
<AISearchCTAPopup isOpen={isOpen} dismiss={dismiss} />
5660
{/* On mobile only the IconButton is shown */}
5761
<IconButton
5862
data-testid="mobile-search-button"

0 commit comments

Comments
 (0)