feat: add search provider settings page (#429)

* feat: add search provider settings page with 5 engine options

Allow users to select their preferred search engine (Google, DuckDuckGo,
Bing, Brave Search, Yahoo) from a new settings page. The selected provider
drives search suggestions, search URL navigation, placeholder text, and
analytics tracking. Replaces all hardcoded Google references with the
stored preference. Adds Brave Search support, replacing Yandex.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add error handling for search provider storage writes

Write to storage before updating React state so UI never diverges from
persisted value on failure. Add try/catch in the settings page to show
an error toast if the write fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nikhil
2026-03-06 12:08:39 -08:00
committed by GitHub
parent bc53ff52e5
commit fb2ad66c91
14 changed files with 265 additions and 14 deletions

View File

@@ -6,6 +6,7 @@ import {
MessageSquare,
Palette,
RotateCcw,
Search,
Server,
Sparkles,
} from 'lucide-react'
@@ -33,6 +34,7 @@ const settingsNavItems: NavItem[] = [
icon: Palette,
feature: Feature.CUSTOMIZATION_SUPPORT,
},
{ name: 'Search Provider', to: '/settings/search', icon: Search },
{
name: 'Agent Soul',
to: '/settings/soul',

View File

@@ -23,6 +23,7 @@ import { MagicLinkCallback } from './login/MagicLinkCallback'
import { MCPSettingsPage } from './mcp-settings/MCPSettingsPage'
import { ProfilePage } from './profile/ProfilePage'
import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
import { SearchProviderPage } from './search-provider/SearchProviderPage'
import { SoulPage } from './soul/SoulPage'
import { WorkflowsPageWrapper } from './workflows/WorkflowsPageWrapper'
@@ -44,6 +45,7 @@ const OptionsRedirect: FC = () => {
'connect-mcp': '/connect-apps',
mcp: '/settings/mcp',
customization: '/settings/customization',
search: '/settings/search',
soul: '/settings/soul',
'jtbd-agent': '/settings/survey',
workflows: '/workflows',
@@ -91,6 +93,7 @@ export const App: FC = () => {
<Route path="chat" element={<LlmHubPage />} />
<Route path="mcp" element={<MCPSettingsPage />} />
<Route path="customization" element={<CustomizationPage />} />
<Route path="search" element={<SearchProviderPage />} />
<Route path="soul" element={<SoulPage />} />
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
</Route>

View File

@@ -0,0 +1,54 @@
import { Check } from 'lucide-react'
import type { FC } from 'react'
import type { SearchProviderConfig } from '@/lib/search-provider/providers'
import { cn } from '@/lib/utils'
interface SearchProviderCardProps {
provider: SearchProviderConfig
isSelected: boolean
onSelect: (provider: SearchProviderConfig) => void
}
export const SearchProviderCard: FC<SearchProviderCardProps> = ({
provider,
isSelected,
onSelect,
}) => {
return (
<button
type="button"
onClick={() => onSelect(provider)}
className={cn(
'group relative flex w-full flex-col items-start gap-3 rounded-xl border p-5 text-left transition-all',
'hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isSelected
? 'border-[var(--accent-orange)] bg-[var(--accent-orange)]/5 shadow-sm'
: 'border-border bg-card shadow-sm hover:border-[var(--accent-orange)]/40',
)}
>
<div className="flex w-full items-center justify-between">
<span
className={cn(
'font-semibold text-base transition-colors',
isSelected && 'text-[var(--accent-orange)]',
)}
>
{provider.name}
</span>
<div
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 transition-all',
isSelected
? 'border-[var(--accent-orange)] bg-[var(--accent-orange)]'
: 'border-muted-foreground/30 group-hover:border-muted-foreground/50',
)}
>
{isSelected && <Check className="h-3.5 w-3.5 text-white" />}
</div>
</div>
<p className="text-muted-foreground text-sm leading-relaxed">
{provider.description}
</p>
</button>
)
}

View File

@@ -0,0 +1,31 @@
import type { FC } from 'react'
import type { SearchProviders } from '@/entrypoints/newtab/index/lib/searchSuggestions/SearchProviders'
import type { SearchProviderConfig } from '@/lib/search-provider/providers'
import { SEARCH_PROVIDERS } from '@/lib/search-provider/providers'
import { SearchProviderCard } from './SearchProviderCard'
interface SearchProviderGridProps {
selectedProvider: SearchProviders
onSelectProvider: (provider: SearchProviderConfig) => void
}
export const SearchProviderGrid: FC<SearchProviderGridProps> = ({
selectedProvider,
onSelectProvider,
}) => {
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
<h3 className="mb-4 font-semibold text-lg">Available Search Engines</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{SEARCH_PROVIDERS.map((provider) => (
<SearchProviderCard
key={provider.id}
provider={provider}
isSelected={provider.id === selectedProvider}
onSelect={onSelectProvider}
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { Search } from 'lucide-react'
import type { FC } from 'react'
import type { SearchProviderConfig } from '@/lib/search-provider/providers'
interface SearchProviderHeaderProps {
activeProvider: SearchProviderConfig
}
export const SearchProviderHeader: FC<SearchProviderHeaderProps> = ({
activeProvider,
}) => {
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
<Search className="h-6 w-6 text-[var(--accent-orange)]" />
</div>
<div className="flex-1">
<h2 className="mb-1 font-semibold text-xl">Search Provider</h2>
<p className="text-muted-foreground text-sm">
Choose the default search engine for your browser's address bar and
new tab page
</p>
<div className="mt-3 inline-flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-1.5">
<span className="text-muted-foreground text-xs">
Currently using:
</span>
<span className="font-medium text-sm">{activeProvider.name}</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import type { FC } from 'react'
import { toast } from 'sonner'
import { SEARCH_PROVIDER_CHANGED_EVENT } from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import type { SearchProviderConfig } from '@/lib/search-provider/providers'
import { getProviderConfig } from '@/lib/search-provider/providers'
import { useSearchProvider } from '@/lib/search-provider/search-provider-storage'
import { SearchProviderGrid } from './SearchProviderGrid'
import { SearchProviderHeader } from './SearchProviderHeader'
export const SearchProviderPage: FC = () => {
const { provider, setProvider } = useSearchProvider()
const activeConfig = getProviderConfig(provider)
const handleSelectProvider = async (selected: SearchProviderConfig) => {
if (selected.id === provider) return
try {
await setProvider(selected.id)
track(SEARCH_PROVIDER_CHANGED_EVENT, {
provider: selected.id,
previous_provider: provider,
})
toast.success(`Search provider changed to ${selected.name}`)
} catch {
toast.error('Failed to save search provider')
}
}
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<SearchProviderHeader activeProvider={activeConfig} />
<SearchProviderGrid
selectedProvider={provider}
onSelectProvider={handleSelectProvider}
/>
</div>
)
}

View File

@@ -122,7 +122,7 @@ export const NewTab = () => {
setSelectedTabs((prev) => prev.filter((t) => t.id !== tabId))
}
const { sections, flatItems } = useSuggestions({
const { sections, flatItems, providerConfig } = useSuggestions({
query: inputValue,
selectedTabs,
})
@@ -290,9 +290,11 @@ export const NewTab = () => {
switch (item.type) {
case 'search':
track(NEWTAB_SEARCH_EXECUTED_EVENT, { search_engine: 'google' })
track(NEWTAB_SEARCH_EXECUTED_EVENT, {
search_engine: providerConfig.id,
})
window.open(
`https://www.google.com/search?q=${encodeURIComponent(item.query)}`,
`${providerConfig.searchUrl}${encodeURIComponent(item.query)}`,
'_self',
)
break
@@ -422,7 +424,7 @@ export const NewTab = () => {
/>
<input
type="text"
placeholder="Ask AI or search Google..."
placeholder={`Ask AI or search ${providerConfig.name}...`}
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
{...getInputProps({
ref: inputRef,

View File

@@ -6,4 +6,4 @@ export type SearchProviders =
| 'bing'
| 'yahoo'
| 'duckduckgo'
| 'yandex'
| 'brave'

View File

@@ -36,9 +36,9 @@ const getDuckDuckGoSuggestions = async (query: string): Promise<string[]> => {
return data[1] || []
}
const getYandexSuggestions = async (query: string): Promise<string[]> => {
const getBraveSuggestions = async (query: string): Promise<string[]> => {
const response = await fetch(
`https://suggest.yandex.com/suggest-ff.cgi?part=${encodeURIComponent(query)}&uil=en&v=3`,
`https://search.brave.com/api/suggest?q=${encodeURIComponent(query)}`,
)
const data = await response.json()
return data[1] || []
@@ -60,8 +60,8 @@ export const getSearchSuggestions = async ([searchEngine, query]: [
return getYahooIndiaSuggestions(query)
case 'duckduckgo':
return getDuckDuckGoSuggestions(query)
case 'yandex':
return getYandexSuggestions(query)
case 'brave':
return getBraveSuggestions(query)
default:
return []
}

View File

@@ -1,4 +1,6 @@
import { useMemo } from 'react'
import { getProviderConfig } from '@/lib/search-provider/providers'
import { useSearchProvider } from '@/lib/search-provider/search-provider-storage'
import { useAITabSuggestions } from '../aiTabSuggestions/useAITabSuggestions'
import { useBrowserOSSuggestions } from '../browserOSSuggestions/useBrowserOSSuggestions'
import { useSearchSuggestions } from '../searchSuggestions/useSearchSuggestions'
@@ -19,9 +21,12 @@ interface UseSuggestionsArgs {
* @public
*/
export const useSuggestions = ({ query, selectedTabs }: UseSuggestionsArgs) => {
const { provider } = useSearchProvider()
const providerConfig = getProviderConfig(provider)
const { data: searchResultsFromAPI } = useSearchSuggestions({
query,
searchEngine: 'google',
searchEngine: provider,
})
const searchResults: string[] = useMemo(() => {
@@ -81,8 +86,8 @@ export const useSuggestions = ({ query, selectedTabs }: UseSuggestionsArgs) => {
}),
)
result.push({
id: 'google-search',
title: 'Google Search',
id: 'search',
title: `${providerConfig.name} Search`,
items: searchItems,
})
}
@@ -94,6 +99,7 @@ export const useSuggestions = ({ query, selectedTabs }: UseSuggestionsArgs) => {
selectedTabs.length,
aiTabResults,
searchResults,
providerConfig.name,
])
const flatItems = useMemo(
@@ -101,7 +107,7 @@ export const useSuggestions = ({ query, selectedTabs }: UseSuggestionsArgs) => {
[sections],
)
return { sections, flatItems }
return { sections, flatItems, providerConfig }
}
/**

View File

@@ -177,6 +177,9 @@ export const SCHEDULED_TASK_RETRIED_EVENT = 'settings.scheduled_task.retried'
/** @public */
export const JTBD_POPUP_DISMISSED_EVENT = 'ui.jtbd_popup.dismissed'
/** @public */
export const SEARCH_PROVIDER_CHANGED_EVENT = 'settings.search_provider.changed'
/** @public */
export const ONBOARDING_STARTED_EVENT = 'onboarding.started'

View File

@@ -0,0 +1,45 @@
import type { SearchProviders } from '@/entrypoints/newtab/index/lib/searchSuggestions/SearchProviders'
export interface SearchProviderConfig {
id: SearchProviders
name: string
searchUrl: string
description: string
}
export const SEARCH_PROVIDERS: SearchProviderConfig[] = [
{
id: 'google',
name: 'Google',
searchUrl: 'https://www.google.com/search?q=',
description: 'The most popular search engine worldwide',
},
{
id: 'duckduckgo',
name: 'DuckDuckGo',
searchUrl: 'https://duckduckgo.com/?q=',
description: 'Privacy-focused search with no tracking',
},
{
id: 'bing',
name: 'Bing',
searchUrl: 'https://www.bing.com/search?q=',
description: 'Microsoft search with AI-powered answers',
},
{
id: 'brave',
name: 'Brave Search',
searchUrl: 'https://search.brave.com/search?q=',
description: 'Independent search with its own index',
},
{
id: 'yahoo',
name: 'Yahoo',
searchUrl: 'https://search.yahoo.com/search?p=',
description: 'Classic search with news and web results',
},
]
export function getProviderConfig(id: SearchProviders): SearchProviderConfig {
return SEARCH_PROVIDERS.find((p) => p.id === id) ?? SEARCH_PROVIDERS[0]
}

View File

@@ -0,0 +1,32 @@
import { storage } from '@wxt-dev/storage'
import { useCallback, useEffect, useState } from 'react'
import type { SearchProviders } from '@/entrypoints/newtab/index/lib/searchSuggestions/SearchProviders'
const DEFAULT_PROVIDER: SearchProviders = 'google'
export const searchProviderStorage = storage.defineItem<SearchProviders>(
'local:search-provider',
{ fallback: DEFAULT_PROVIDER },
)
export function useSearchProvider() {
const [provider, setProviderState] =
useState<SearchProviders>(DEFAULT_PROVIDER)
useEffect(() => {
searchProviderStorage.getValue().then((value) => {
setProviderState(value ?? DEFAULT_PROVIDER)
})
const unwatch = searchProviderStorage.watch((newValue) => {
setProviderState(newValue ?? DEFAULT_PROVIDER)
})
return unwatch
}, [])
const setProvider = useCallback(async (value: SearchProviders) => {
await searchProviderStorage.setValue(value)
setProviderState(value)
}, [])
return { provider, setProvider }
}

View File

@@ -66,7 +66,7 @@ export default defineConfig({
'https://api.bing.com/*',
'https://in.search.yahoo.com/*',
'https://duckduckgo.com/*',
'https://suggest.yandex.com/*',
'https://search.brave.com/*',
],
},
vite: () => ({