Port desktop-improvements-0 desktop and browser-agent work

This commit is contained in:
Siddharth Ganesan
2026-08-13 14:40:53 -07:00
parent 297525eba6
commit 46aae116b5
28 changed files with 708 additions and 60 deletions
@@ -0,0 +1,74 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import {
parseSearchSuggestionResponse,
SearchSuggestionService,
} from '@/main/browser-search/suggestions'
describe('parseSearchSuggestionResponse', () => {
it('keeps unique non-empty completions and omits the exact query row', () => {
expect(
parseSearchSuggestionResponse(
[
'what is the be',
[
'what is the be',
'what is the best sleeping position',
' WHAT IS THE BEST SLEEPING POSITION ',
'',
42,
'what is the benefit of creatine',
],
],
'what is the be'
)
).toEqual(['what is the best sleeping position', 'what is the benefit of creatine'])
})
it('rejects malformed provider payloads', () => {
expect(parseSearchSuggestionResponse(null, 'sim')).toEqual([])
expect(parseSearchSuggestionResponse(['sim', {}], 'sim')).toEqual([])
})
})
describe('SearchSuggestionService', () => {
it('uses only the fixed provider URL and caches repeated queries', async () => {
const fetcher = vi.fn(
async (_url: string, _init: RequestInit): Promise<Response> =>
Response.json(['sim studio', ['sim studio ai', 'sim studio workflow']])
)
const service = new SearchSuggestionService(fetcher, () => 1_000)
await expect(service.suggest(' sim studio ')).resolves.toEqual([
'sim studio ai',
'sim studio workflow',
])
await expect(service.suggest('SIM STUDIO')).resolves.toEqual([
'sim studio ai',
'sim studio workflow',
])
expect(fetcher).toHaveBeenCalledTimes(1)
const [url, init] = fetcher.mock.calls[0]
expect(new URL(url).origin).toBe('https://suggestqueries.google.com')
expect(new URL(url).searchParams.get('q')).toBe('sim studio')
expect(init).toMatchObject({ method: 'GET', redirect: 'error' })
})
it('fails silently for short, oversized, rejected, and unsuccessful requests', async () => {
const fetcher = vi.fn(
async (_url: string, _init: RequestInit): Promise<Response> =>
new Response(null, { status: 503 })
)
const service = new SearchSuggestionService(fetcher)
await expect(service.suggest('s')).resolves.toEqual([])
await expect(service.suggest('x'.repeat(201))).resolves.toEqual([])
await expect(service.suggest('search me')).resolves.toEqual([])
fetcher.mockRejectedValueOnce(new Error('offline'))
await expect(service.suggest('another search')).resolves.toEqual([])
})
})
@@ -0,0 +1,102 @@
import { net } from 'electron'
const GOOGLE_SUGGESTIONS_ENDPOINT = 'https://suggestqueries.google.com/complete/search'
const SEARCH_SUGGESTION_TIMEOUT_MS = 2_500
const SEARCH_SUGGESTION_CACHE_TTL_MS = 5 * 60 * 1_000
const MAX_SEARCH_SUGGESTION_CACHE_ENTRIES = 100
const MAX_SEARCH_SUGGESTION_QUERY_LENGTH = 200
const MAX_SEARCH_SUGGESTION_LENGTH = 256
const MAX_SEARCH_SUGGESTIONS = 7
type SearchSuggestionFetch = (url: string, init: RequestInit) => Promise<Response>
interface CachedSearchSuggestions {
expiresAt: number
values: string[]
}
/**
* Validates the small portion of Google's Firefox-completion response that the
* omnibox consumes. Everything else in the provider payload is ignored.
*/
export function parseSearchSuggestionResponse(payload: unknown, query: string): string[] {
if (!Array.isArray(payload) || !Array.isArray(payload[1])) return []
const queryKey = query.toLocaleLowerCase()
const seen = new Set([queryKey])
const suggestions: string[] = []
for (const candidate of payload[1]) {
if (typeof candidate !== 'string') continue
const value = candidate.trim()
const key = value.toLocaleLowerCase()
if (!value || value.length > MAX_SEARCH_SUGGESTION_LENGTH || seen.has(key)) continue
seen.add(key)
suggestions.push(value)
if (suggestions.length === MAX_SEARCH_SUGGESTIONS) break
}
return suggestions
}
/**
* In-memory, bounded search completion client. Queries go only to the fixed
* Google suggestions origin and are never logged or written to disk.
*/
export class SearchSuggestionService {
private readonly cache = new Map<string, CachedSearchSuggestions>()
constructor(
private readonly fetcher: SearchSuggestionFetch,
private readonly now: () => number = Date.now
) {}
async suggest(rawQuery: unknown): Promise<string[]> {
if (typeof rawQuery !== 'string') return []
const query = rawQuery.trim()
if (query.length < 2 || query.length > MAX_SEARCH_SUGGESTION_QUERY_LENGTH) return []
const cacheKey = query.toLocaleLowerCase()
const cached = this.cache.get(cacheKey)
if (cached && cached.expiresAt > this.now()) return [...cached.values]
if (cached) this.cache.delete(cacheKey)
const url = new URL(GOOGLE_SUGGESTIONS_ENDPOINT)
url.searchParams.set('client', 'firefox')
url.searchParams.set('q', query)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), SEARCH_SUGGESTION_TIMEOUT_MS)
try {
const response = await this.fetcher(url.toString(), {
method: 'GET',
redirect: 'error',
signal: controller.signal,
})
if (!response.ok) return []
const values = parseSearchSuggestionResponse(await response.json(), query)
this.remember(cacheKey, values)
return [...values]
} catch {
return []
} finally {
clearTimeout(timeout)
}
}
private remember(key: string, values: string[]): void {
if (this.cache.size >= MAX_SEARCH_SUGGESTION_CACHE_ENTRIES) {
const oldest = this.cache.keys().next().value
if (typeof oldest === 'string') this.cache.delete(oldest)
}
this.cache.set(key, {
expiresAt: this.now() + SEARCH_SUGGESTION_CACHE_TTL_MS,
values: [...values],
})
}
}
const searchSuggestionService = new SearchSuggestionService((url, init) => net.fetch(url, init))
/** Fetches live Google completions, failing closed to an empty local-only list. */
export function getSearchSuggestions(query: unknown): Promise<string[]> {
return searchSuggestionService.suggest(query)
}
+3
View File
@@ -101,6 +101,8 @@ export interface DesktopSettings {
launchAtLogin?: boolean
autoDownloadUpdates?: boolean
browserEnabled?: boolean
/** Whether omnibox typing may request live Google search completions. */
browserSearchSuggestionsEnabled?: boolean
terminalEnabled?: boolean
/** Device-wide browser page appearance; `app` follows Sim. */
browserTheme?: 'app' | 'light' | 'dark'
@@ -216,6 +218,7 @@ const DEFAULT_SETTINGS: DesktopSettings = {
launchAtLogin: false,
autoDownloadUpdates: true,
browserEnabled: true,
browserSearchSuggestionsEnabled: true,
terminalEnabled: true,
}
@@ -117,6 +117,17 @@ describe('desktop settings service', () => {
expect(setTerminalEnabled).toHaveBeenCalledWith(false)
})
it('defaults live browser search suggestions on and persists the privacy switch', () => {
const { config, service } = makeService()
expect(service.getPreferences().browserSearchSuggestionsEnabled).toBe(true)
const preferences = service.setBrowserSearchSuggestionsEnabled(false)
expect(config.get('browserSearchSuggestionsEnabled')).toBe(false)
expect(preferences.browserSearchSuggestionsEnabled).toBe(false)
})
it('persists browser and terminal appearance with match-Sim defaults', () => {
const { config, service, setBrowserTheme, onBrowserThemeChanged } = makeService()
expect(service.getPreferences()).toMatchObject({
@@ -36,6 +36,7 @@ export function isDesktopPreferenceKey(value: unknown): value is DesktopPreferen
export interface DesktopSettingsService {
getPreferences(): DesktopPreferences
setPreference(key: DesktopPreferenceKey, value: boolean): DesktopPreferences
setBrowserSearchSuggestionsEnabled(enabled: boolean): DesktopPreferences
setAppearancePreference(
key: DesktopAppearanceSettingKey,
value: DesktopAppearanceTheme
@@ -90,6 +91,7 @@ function readPreferences(
autoDownloadUpdates: config.get('autoDownloadUpdates') ?? true,
trayEnabled: config.get('trayEnabled') ?? true,
browserEnabled: config.get('browserEnabled') ?? true,
browserSearchSuggestionsEnabled: config.get('browserSearchSuggestionsEnabled') ?? true,
terminalEnabled: config.get('terminalEnabled') ?? true,
browserTheme: isDesktopAppearanceTheme(browserTheme) ? browserTheme : 'app',
browserDefaultZoom: isDesktopZoomPercent(browserDefaultZoom) ? browserDefaultZoom : 100,
@@ -153,6 +155,11 @@ export function createDesktopSettingsService(
}
return read()
},
setBrowserSearchSuggestionsEnabled(enabled) {
deps.config.set('browserSearchSuggestionsEnabled', enabled)
deps.config.flush()
return read()
},
setAppearancePreference(key, value) {
const previousBrowserTheme = key === 'browserTheme' ? read().browserTheme : undefined
deps.config.set(key, value)
+23
View File
@@ -18,6 +18,10 @@ vi.mock('@/main/browser-import', () => ({
})),
}))
vi.mock('@/main/browser-search/suggestions', () => ({
getSearchSuggestions: vi.fn(async () => ['sim ai workflow']),
}))
const { terminalThemeProfile } = vi.hoisted(() => ({
terminalThemeProfile: {
id: 'iterm2:ocean',
@@ -124,6 +128,7 @@ import {
importChromePasswords,
listChromeImportProfiles,
} from '@/main/browser-import'
import { getSearchSuggestions } from '@/main/browser-search/suggestions'
import { trackInputActivity } from '@/main/input-activity'
import { type IpcDeps, registerIpcHandlers } from '@/main/ipc'
import { LocalFilesystemService } from '@/main/local-filesystem'
@@ -252,6 +257,7 @@ describe('registerIpcHandlers', () => {
vi.mocked(listChromeImportProfiles).mockClear()
vi.mocked(importChromeCookies).mockClear()
vi.mocked(importChromePasswords).mockClear()
vi.mocked(getSearchSuggestions).mockClear()
vi.mocked(findCachedTerminalThemeProfile).mockClear()
vi.mocked(listTerminalThemeProfiles).mockClear()
vi.mocked(credentialsAvailable).mockClear()
@@ -282,6 +288,7 @@ describe('registerIpcHandlers', () => {
settings: {
getPreferences: vi.fn(() => DEFAULT_DESKTOP_PREFERENCES),
setPreference: vi.fn(),
setBrowserSearchSuggestionsEnabled: vi.fn(),
setAppearancePreference: vi.fn(),
setBrowserDefaultZoom: vi.fn(),
setTerminalDefaultZoom: vi.fn(),
@@ -325,6 +332,22 @@ describe('registerIpcHandlers', () => {
expect(shell.openExternal).toHaveBeenCalledTimes(1)
})
it('keeps live search suggestions behind the app origin and privacy preference', async () => {
const { invoke } = collectHandlers()
const handler = invoke.get('browser-agent:search-suggestions')
expect(await handler?.(evilEvent, 'sim ai')).toEqual([])
expect(await handler?.(appEvent, 'sim ai')).toEqual(['sim ai workflow'])
expect(getSearchSuggestions).toHaveBeenCalledWith('sim ai')
vi.mocked(deps.settings.getPreferences).mockReturnValue({
...DEFAULT_DESKTOP_PREFERENCES,
browserSearchSuggestionsEnabled: false,
})
expect(await handler?.(appEvent, 'sim ai')).toEqual([])
})
it('restricts the OAuth connect handoff to the app origin', async () => {
const { invoke } = collectHandlers()
const handler = invoke.get('desktop:oauth-connect')
+20
View File
@@ -70,6 +70,7 @@ import {
importChromePasswords,
listChromeImportProfiles,
} from '@/main/browser-import'
import { getSearchSuggestions } from '@/main/browser-search/suggestions'
import { listSites } from '@/main/browser-sites'
import { isSafeInternalPath } from '@/main/config'
import type { DesktopSettingsService } from '@/main/desktop-settings'
@@ -655,6 +656,15 @@ export function registerIpcHandlers(deps: IpcDeps): void {
? deps.settings.setPreference(key, value)
: deps.settings.getPreferences(),
},
'desktop:settings:set-browser-search-suggestions': {
kind: 'invoke',
gate: 'app-origin',
denied: null,
handler: (enabled) =>
typeof enabled === 'boolean'
? deps.settings.setBrowserSearchSuggestionsEnabled(enabled)
: deps.settings.getPreferences(),
},
'desktop:settings:set-appearance': {
kind: 'invoke',
gate: 'app-origin',
@@ -922,6 +932,16 @@ export function registerIpcHandlers(deps: IpcDeps): void {
denied: { sessions: [] },
handler: () => getKnownSessions(),
},
'browser-agent:search-suggestions': {
kind: 'invoke',
gate: 'app-origin',
requires: 'browser',
denied: [],
handler: (query) =>
deps.settings.getPreferences().browserSearchSuggestionsEnabled === false
? []
: getSearchSuggestions(query),
},
'browser-agent:clear-browsing-data': {
kind: 'invoke',
gate: 'app-origin',
+4
View File
@@ -32,6 +32,8 @@ describe('desktop preload bridge', () => {
await exposed.browserAgent.setPanelOccluded(true, 'chat-default')
await exposed.browserAgent.setPanelOccluded(false, 'chat-explicit-false', false)
await exposed.browserAgent.setPanelOccluded(true, 'chat-force', true)
await exposed.browserAgent.getSearchSuggestions?.('sim ai')
await exposed.settings.setBrowserSearchSuggestionsEnabled?.(false)
expect(invoke.mock.calls).toEqual([
['browser-agent:cancel-tool', 'tool-1', 'chat-default'],
@@ -39,6 +41,8 @@ describe('desktop preload bridge', () => {
['browser-agent:set-panel-occluded', true, 'chat-default', false],
['browser-agent:set-panel-occluded', false, 'chat-explicit-false', false],
['browser-agent:set-panel-occluded', true, 'chat-force', true],
['browser-agent:search-suggestions', 'sim ai'],
['desktop:settings:set-browser-search-suggestions', false],
])
})
})
+4
View File
@@ -147,6 +147,8 @@ const api: SimDesktopApi = {
getPreferences: (): Promise<DesktopPreferences> => ipcRenderer.invoke('desktop:settings:get'),
setPreference: (key: DesktopPreferenceKey, value: boolean): Promise<DesktopPreferences> =>
ipcRenderer.invoke('desktop:settings:set', key, value),
setBrowserSearchSuggestionsEnabled: (enabled: boolean): Promise<DesktopPreferences> =>
ipcRenderer.invoke('desktop:settings:set-browser-search-suggestions', enabled),
notify: (payload: DesktopNotificationPayload): Promise<boolean> =>
ipcRenderer.invoke('desktop:settings:notify', payload),
setBrowserTheme: (theme: DesktopAppearanceTheme): Promise<DesktopPreferences> =>
@@ -281,6 +283,8 @@ const api: SimDesktopApi = {
ipcRenderer.invoke('browser-agent:get-tabs-state', scopeId),
getKnownSessions: (): Promise<BrowserKnownSessionsState> =>
ipcRenderer.invoke('browser-agent:get-known-sessions'),
getSearchSuggestions: (query: string): Promise<string[]> =>
ipcRenderer.invoke('browser-agent:search-suggestions', query),
clearBrowsingData: (kinds?: readonly BrowserDataKind[]): Promise<BrowserKnownSessionsState> =>
ipcRenderer.invoke('browser-agent:clear-browsing-data', kinds),
getDownloadsState: (scopeId: string): Promise<BrowserDownloadsState> =>
@@ -190,6 +190,10 @@ describe('initialUrlSuggestionIndex', () => {
expect(initialUrlSuggestionIndex('https://sim.ai', 3)).toBeNull()
})
it('selects the exact search row after typing on an existing page', () => {
expect(initialUrlSuggestionIndex('https://sim.ai', 3, 'what is the best')).toBe(0)
})
it('selects nothing when there are no suggestions', () => {
expect(initialUrlSuggestionIndex('', 0)).toBeNull()
})
@@ -39,6 +39,7 @@ import { onFocusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-short
import {
fillBrowserCredential,
loadBrowserFillOptions,
loadBrowserSearchSuggestions,
loadBrowserSuggestionSources,
onBrowserAddToChat,
onBrowserAppearanceThemeChanged,
@@ -84,12 +85,16 @@ import {
import { BrowserTabStrip } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip'
import { BrowserThemeNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice'
import {
buildOmniboxSuggestions,
googleSearchUrl,
isSearchQueryInput,
mergeSuggestionSources,
moveActiveIndex,
rankSuggestions,
type OmniboxSuggestion,
type UrlSuggestion,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions'
import { ResourceZoomMenuItems } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/resource-zoom-menu-items'
import { useDebounce } from '@/hooks/use-debounce'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useBrowserSessionStore } from '@/stores/browser-session/store'
import { MOTHERSHIP_WIDTH } from '@/stores/constants'
@@ -97,6 +102,7 @@ import type { ChatContext } from '@/stores/panel'
/** Ties the omnibox to its listbox for assistive tech. */
const SUGGESTIONS_LIST_ID = 'browser-url-suggestions'
const SEARCH_SUGGESTIONS_DEBOUNCE_MS = 160
const NEW_TAB_CONFIRM_TIMEOUT_MS = 10_000
const EMPTY_BROWSER_TABS: BrowserTabState[] = []
@@ -156,14 +162,10 @@ export function browserSelectionContext({
export function resolveUrlBarInput(raw: string): string {
const input = raw.trim()
if (/^https?:\/\//i.test(input)) return input
const hostLike =
/^([a-z0-9-]+(\.[a-z0-9-]+)+|localhost|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])(:\d+)?([/?#].*)?$/i
if (!input.includes(' ') && hostLike.test(input)) {
const isLocal =
/^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|\[::1?\])(:\d+)?([/?#]|$)/i.test(input)
return `${isLocal ? 'http' : 'https'}://${input}`
}
return `https://www.google.com/search?q=${encodeURIComponent(input)}`
if (isSearchQueryInput(input)) return googleSearchUrl(input)
const isLocal =
/^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|\[::1?\])(:\d+)?([/?#]|$)/i.test(input)
return `${isLocal ? 'http' : 'https'}://${input}`
}
/**
@@ -302,13 +304,14 @@ export function shouldOpenUrlSuggestions(
return activeOverlay === 'suggestions' && suggestionCount > 0
}
/** New tabs submit the best suggestion; existing pages submit their current URL. */
/** Typed searches and new tabs select the first row; an untouched page URL remains literal. */
export function initialUrlSuggestionIndex(
pageUrl: string | undefined,
suggestionCount: number
suggestionCount: number,
query = ''
): number | null {
if (suggestionCount === 0) return null
return !pageUrl || pageUrl === 'about:blank' ? 0 : null
return query.trim() || !pageUrl || pageUrl === 'about:blank' ? 0 : null
}
/** A new-tab request is complete only after the authoritative strip grows and activates a new id. */
@@ -429,6 +432,15 @@ export function BrowserSession({
const [suggestionsVisible, setSuggestionsVisible] = useState(false)
/** Empty on initial focus; follows the typed text once the user edits it. */
const [suggestionQuery, setSuggestionQuery] = useState<string | null>(null)
/** Live completions tagged with the query that produced them, so late replies cannot leak in. */
const [searchCompletions, setSearchCompletions] = useState<{
query: string
values: string[]
}>({ query: '', values: [] })
const debouncedSuggestionQuery = useDebounce(
suggestionQuery ?? '',
SEARCH_SUGGESTIONS_DEBOUNCE_MS
)
/** Whether the find bar is docked above the page. */
const [findOpen, setFindOpen] = useState(false)
const {
@@ -502,6 +514,23 @@ export function BrowserSession({
}
}, [panelVisible])
/** Debounced live completions never block the immediate local/search row. */
useEffect(() => {
const query = debouncedSuggestionQuery.trim()
if (!suggestionsVisible || !isSearchQueryInput(query)) {
setSearchCompletions({ query: '', values: [] })
return
}
let active = true
void loadBrowserSearchSuggestions(query).then((values) => {
if (active) setSearchCompletions({ query, values })
})
return () => {
active = false
}
}, [debouncedSuggestionQuery, suggestionsVisible])
useEffect(() => {
if (appearanceTheme) {
const next = resolveDesktopAppearanceTheme(appearanceTheme, theme)
@@ -833,17 +862,18 @@ export function BrowserSession({
* Programmatic focus on a new tab keeps the omnibox ready for typing without
* opening this list. A pointer interaction or typed edit opts into suggestions.
*/
const suggestions = useMemo(
() =>
suggestionsVisible && suggestionQuery !== null
? rankSuggestions(suggestionCorpus, suggestionQuery)
: [],
[suggestionCorpus, suggestionQuery, suggestionsVisible]
)
const suggestions = useMemo((): OmniboxSuggestion[] => {
if (!suggestionsVisible || suggestionQuery === null) return []
const query = suggestionQuery.trim()
const live = searchCompletions.query === query ? searchCompletions.values : []
return buildOmniboxSuggestions(suggestionCorpus, suggestionQuery, live)
}, [searchCompletions, suggestionCorpus, suggestionQuery, suggestionsVisible])
useEffect(() => {
setActiveSuggestion(initialUrlSuggestionIndex(suggestionOriginUrl, suggestions.length))
}, [suggestionOriginUrl, suggestions])
setActiveSuggestion(
initialUrlSuggestionIndex(suggestionOriginUrl, suggestions.length, suggestionQuery ?? '')
)
}, [suggestionOriginUrl, suggestionQuery, suggestions])
// The suggestion list is renderer UI that extends over the native page.
// Keep the page's exact captured frame underneath it while it is open so
@@ -1075,6 +1105,7 @@ export function BrowserSession({
placeholder='Search Google or enter a URL'
autoComplete='off'
role='combobox'
aria-autocomplete='list'
aria-expanded={suggestionsOpen}
aria-controls={SUGGESTIONS_LIST_ID}
aria-activedescendant={
@@ -1092,6 +1123,7 @@ export function BrowserSession({
onChange={(event) => {
setSuggestionsVisible(true)
setSuggestionQuery(event.target.value)
setSearchCompletions({ query: '', values: [] })
setUrlDraft(event.target.value)
// The old highlight pointed at a row that may no longer be
// in the list, let alone in the same position.
@@ -1150,7 +1182,11 @@ export function BrowserSession({
>
{suggestions.map((suggestion, index) => (
<PopoverItem
key={suggestion.hostname}
key={
suggestion.kind === 'site'
? `site:${suggestion.hostname}`
: `search:${suggestion.query}`
}
id={suggestionRowId(index)}
role='option'
aria-selected={index === activeSuggestion}
@@ -1161,17 +1197,24 @@ export function BrowserSession({
onClick={() => navigateTo(suggestion.url)}
>
<div className='flex w-full min-w-0 items-center gap-2'>
<BrowserSuggestionIcon suggestion={suggestion} />
{suggestion.name ? (
{suggestion.kind === 'search' ? (
<>
<Search className='size-4 shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{suggestion.query}</span>
</>
) : (
<BrowserSuggestionIcon suggestion={suggestion} />
)}
{suggestion.kind === 'site' && suggestion.name ? (
<div className='flex min-w-0 flex-1 items-center gap-1.5'>
<span className='min-w-0 flex-1 truncate'>{suggestion.name}</span>
<span className='max-w-[45%] shrink-0 truncate text-[var(--text-muted)]'>
— {suggestion.hostname}
</span>
</div>
) : (
) : suggestion.kind === 'site' ? (
<span className='truncate'>{suggestion.hostname}</span>
)}
) : null}
</div>
</PopoverItem>
))}
@@ -2,6 +2,9 @@ import type { BrowserKnownSession, BrowserSessionEvidence } from '@sim/browser-p
import type { BrowserCredentialMetadata, BrowserSiteInfo } from '@sim/desktop-bridge'
import { describe, expect, it } from 'vitest'
import {
buildOmniboxSuggestions,
googleSearchUrl,
isSearchQueryInput,
mergeSuggestionSources,
moveActiveIndex,
rankSuggestions,
@@ -421,6 +424,65 @@ describe('rankSuggestions', () => {
})
})
describe('buildOmniboxSuggestions', () => {
it('leads with the exact search, keeps matching sites, then adds live completions', () => {
const results = buildOmniboxSuggestions(
[suggestion('mail.google.com', 100, 'Gmail')],
'gmail',
['gmail login', 'gmail account']
)
expect(results.map((result) => [result.kind, result.url])).toEqual([
['search', googleSearchUrl('gmail')],
['site', 'https://mail.google.com'],
['search', googleSearchUrl('gmail login')],
['search', googleSearchUrl('gmail account')],
])
})
it('does not send URL-looking input through search completions', () => {
const results = buildOmniboxSuggestions([suggestion('github.com', 100)], 'github.com', [
'github.com login',
])
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({ kind: 'site', hostname: 'github.com' })
})
it('deduplicates completions and caps the combined dropdown', () => {
const results = buildOmniboxSuggestions(
[],
'sim ai',
['sim ai', 'SIM AI', 'sim ai workflow', 'sim ai agents'],
2
)
expect(results.map((result) => result.kind === 'search' && result.query)).toEqual([
'sim ai',
'sim ai workflow',
])
})
it('keeps an empty omnibox local-only', () => {
const results = buildOmniboxSuggestions([suggestion('github.com', 100)], '', [
'ignored remote completion',
])
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({ kind: 'site', hostname: 'github.com' })
})
})
describe('isSearchQueryInput', () => {
it('distinguishes searches from navigable addresses', () => {
expect(isSearchQueryInput('what is the best browser')).toBe(true)
expect(isSearchQueryInput('electron')).toBe(true)
expect(isSearchQueryInput('sim.ai/docs')).toBe(false)
expect(isSearchQueryInput('https://sim.ai')).toBe(false)
expect(isSearchQueryInput('localhost:3000')).toBe(false)
})
})
describe('moveActiveIndex', () => {
it('highlights nothing until the user arrows in, so Enter still means "go to what I typed"', () => {
expect(moveActiveIndex(null, 1, 3)).toBe(0)
@@ -47,6 +47,25 @@ export interface UrlSuggestion {
visits?: number
}
export type OmniboxSuggestion =
| ({ kind: 'site' } & UrlSuggestion)
| { kind: 'search'; query: string; url: string }
const HOST_LIKE_INPUT =
/^([a-z0-9-]+(\.[a-z0-9-]+)+|localhost|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])(:\d+)?([/?#].*)?$/i
/** Whether an omnibox value should search rather than navigate directly. */
export function isSearchQueryInput(raw: string): boolean {
const input = raw.trim()
if (!input || /^https?:\/\//i.test(input)) return false
return input.includes(' ') || !HOST_LIKE_INPUT.test(input)
}
/** The canonical Google results URL used by search rows and bare submission. */
export function googleSearchUrl(query: string): string {
return `https://www.google.com/search?q=${encodeURIComponent(query.trim())}`
}
function timestamp(value: string | undefined): number {
if (!value) return 0
const parsed = Date.parse(value)
@@ -189,6 +208,42 @@ export function rankSuggestions(
return scored.slice(0, limit).map((entry) => entry.suggestion)
}
/**
* Combines immediate navigation/search actions with the user's known sites and
* live completions. The exact typed search leads, known sites retain priority,
* and remote completions fill whatever room remains.
*/
export function buildOmniboxSuggestions(
siteCorpus: readonly UrlSuggestion[],
rawQuery: string,
searchCompletions: readonly string[] = [],
limit: number = MAX_URL_SUGGESTIONS
): OmniboxSuggestion[] {
if (limit <= 0) return []
const query = rawQuery.trim()
const sites = rankSuggestions(siteCorpus, query, limit)
if (!query || !isSearchQueryInput(query)) {
return sites.map((site) => ({ ...site, kind: 'site' }))
}
const results: OmniboxSuggestion[] = [{ kind: 'search', query, url: googleSearchUrl(query) }]
for (const site of sites) {
if (results.length === limit) return results
results.push({ ...site, kind: 'site' })
}
const seen = new Set([query.toLocaleLowerCase()])
for (const candidate of searchCompletions) {
const completion = candidate.trim()
const key = completion.toLocaleLowerCase()
if (!completion || seen.has(key)) continue
seen.add(key)
results.push({ kind: 'search', query: completion, url: googleSearchUrl(completion) })
if (results.length === limit) break
}
return results
}
/**
* How well the browser knows a host, then how much it is used, then how
* recently, then alphabetically so the same corpus always comes back in the
@@ -243,9 +243,9 @@ const ResourceTabItem = memo(function ResourceTabItem({
>
{config.renderTabIcon(resource, 'mr-1.5 size-[14px]')}
{displayName}
{hasActivity && !isActive && (
{hasActivity && !isActive && !isHovered && (
<span
className='ml-1 size-1.5 shrink-0 rounded-full bg-[var(--brand-primary)]'
className='-translate-y-1/2 absolute top-1/2 right-[9px] size-1.5 rounded-full bg-[var(--brand-primary)]'
aria-label='Background activity'
/>
)}
@@ -55,7 +55,13 @@ import {
UserInput,
type UserInputHandle,
} from './components'
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
import {
getMothershipUseChatOptions,
type ResourceEventOptions,
shouldActivateResourceEvent,
useChat,
useMothershipResize,
} from './hooks'
import type {
FileAttachmentForApi,
MothershipResource,
@@ -211,14 +217,13 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
const activeResourceParamRef = useRef(activeResourceParam)
activeResourceParamRef.current = activeResourceParam
function handleResourceEvent(resourceId: string) {
// Agent work should always make the resource surface available, but it
// must never replace an existing selection. Activity in another resource
// stays in the background and gets an attention marker instead.
function handleResourceEvent(resourceId: string, options?: ResourceEventOptions) {
// Agent work makes the resource surface available without replacing an
// existing selection. Explicit user navigation can request activation.
if (isResourceCollapsedRef.current) setIsResourceCollapsed(false)
const activeResourceId = activeResourceParamRef.current
if (activeResourceId && activeResourceId !== resourceId) {
if (!shouldActivateResourceEvent(activeResourceId, resourceId, options)) {
setResourceActivityIds((current) => new Set(current).add(resourceId))
return
}
@@ -228,7 +233,10 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
next.delete(resourceId)
return next
})
if (activeResourceId !== resourceId) setActiveResourceUrl(resourceId)
if (activeResourceId !== resourceId) {
activeResourceParamRef.current = resourceId
setActiveResourceUrl(resourceId)
}
}
const {
@@ -1,6 +1,8 @@
export type { ResourceEventOptions } from './use-chat'
export {
getMothershipUseChatOptions,
getWorkflowCopilotUseChatOptions,
shouldActivateResourceEvent,
useChat,
} from './use-chat'
export { useMothershipResize } from './use-mothership-resize'
@@ -13,6 +13,7 @@ import {
panelForExecutingClientTool,
reconcileLiveAssistantTurn,
selectReconnectReplayState,
shouldActivateResourceEvent,
waitForDetachedChatResolution,
} from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
import type {
@@ -30,6 +31,20 @@ vi.mock('next/navigation', () => ({
}),
}))
describe('shouldActivateResourceEvent', () => {
it('keeps background agent activity from replacing another selected resource', () => {
expect(shouldActivateResourceEvent('file-1', 'browser-session')).toBe(false)
})
it('allows an explicit user action to replace another selected resource', () => {
expect(
shouldActivateResourceEvent('file-1', 'browser-session', {
activate: true,
})
).toBe(true)
})
})
function userMessage(id: string): PersistedMessage {
return {
id,
@@ -30,7 +30,7 @@ import { onOpenInBrowserPanel } from '@/lib/browser-agent/open-in-panel'
import {
cancelActiveBrowserTools,
initBrowserAgentTransport,
sendBrowserPanelAction,
openUrlInNewBrowserTab,
} from '@/lib/browser-agent/transport'
import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview'
import { toDisplayMessage } from '@/lib/copilot/chat/display-message'
@@ -1188,8 +1188,22 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId
return true
}
export interface ResourceEventOptions {
activate?: boolean
}
export type ResourceEventHandler = (resourceId: string, options?: ResourceEventOptions) => void
export function shouldActivateResourceEvent(
activeResourceId: string | null,
resourceId: string,
options?: ResourceEventOptions
): boolean {
return options?.activate === true || !activeResourceId || activeResourceId === resourceId
}
export interface UseChatOptions {
onResourceEvent?: (resourceId: string) => void
onResourceEvent?: ResourceEventHandler
apiPath?: string
stopPath?: string
workflowId?: string
@@ -1923,14 +1937,20 @@ export function useChat(
[workspaceId]
)
const openBrowserResource = useCallback(() => {
addResource({
type: 'browser',
id: BROWSER_SESSION_RESOURCE_ID,
title: 'Browser',
})
onResourceEventRef.current?.(BROWSER_SESSION_RESOURCE_ID)
}, [addResource])
const openBrowserResource = useCallback(
(activate = false) => {
addResource({
type: 'browser',
id: BROWSER_SESSION_RESOURCE_ID,
title: 'Browser',
})
onResourceEventRef.current?.(
BROWSER_SESSION_RESOURCE_ID,
activate ? { activate: true } : undefined
)
},
[addResource]
)
const getResourceActivityTracker = useCallback(
(generation: number, targetChatId?: string) => {
@@ -2046,8 +2066,12 @@ export function useChat(
// (message components dispatch the request; this hook owns the resource).
useEffect(() => {
return onOpenInBrowserPanel((url) => {
openBrowserResource()
sendBrowserPanelAction('navigate', { url }, desktopScopeIdRef.current)
openBrowserResource(true)
void openUrlInNewBrowserTab(url, desktopScopeIdRef.current).catch((error) => {
logger.warn('Failed to open chat link in a new browser tab', {
error: getErrorMessage(error),
})
})
})
}, [openBrowserResource])
@@ -87,8 +87,22 @@ vi.mock('@sim/emcn', () => ({
</select>
),
Label: ({ children }: { children: ReactNode }) => <span>{children}</span>,
Switch: ({ checked }: { checked: boolean }) => (
<button type='button' role='switch' aria-checked={checked} />
Switch: ({
checked,
disabled,
onCheckedChange,
}: {
checked: boolean
disabled?: boolean
onCheckedChange?: (checked: boolean) => void
}) => (
<button
type='button'
role='switch'
aria-checked={checked}
disabled={disabled}
onClick={() => onCheckedChange?.(!checked)}
/>
),
toast: mockToast,
}))
@@ -235,6 +249,7 @@ const IMPORTED_BOTH: BrowserChromeImportResult = {
interface BridgeOverrides {
browserEnabled?: boolean
browserSearchSuggestionsEnabled?: boolean
browserTheme?: 'app' | 'light' | 'dark'
browserDefaultZoom?: DesktopZoomPercent
browserDownloadDirectory?: string
@@ -247,6 +262,7 @@ interface BridgeOverrides {
function createBridge({
browserEnabled = true,
browserSearchSuggestionsEnabled = true,
browserTheme = 'app',
browserDefaultZoom = 100,
browserDownloadDirectory = '/Users/sim/Downloads',
@@ -263,6 +279,7 @@ function createBridge({
launchAtLogin: false,
autoDownloadUpdates: true,
browserEnabled,
browserSearchSuggestionsEnabled,
browserTheme,
browserDefaultZoom,
browserDownloadDirectory,
@@ -271,6 +288,10 @@ function createBridge({
settings: {
getPreferences: vi.fn(async () => preferences),
setBrowserEnabled: vi.fn(),
setBrowserSearchSuggestionsEnabled: vi.fn(async (enabled: boolean) => ({
...preferences,
browserSearchSuggestionsEnabled: enabled,
})),
setBrowserTheme: vi.fn(async (theme: 'app' | 'light' | 'dark') => ({
...preferences,
browserTheme: theme,
@@ -343,6 +364,7 @@ describe('Browser settings', () => {
expect(container.textContent).toContain('Let Chat browse the web')
expect(container.textContent).toContain('Theme')
expect(container.textContent).toContain('Search suggestions')
expect(container.textContent).not.toContain(
'Pages open in a browser built into Sim, signed in separately from your own.'
)
@@ -352,6 +374,22 @@ describe('Browser settings', () => {
expect(container.textContent).not.toContain('Saved passwords are never deleted here')
})
it('explains and persists the live-search privacy switch', async () => {
const bridge = createBridge({ browserSearchSuggestionsEnabled: true })
mockBridge.current = bridge
await render()
expect(container.textContent).toContain(
'Send address-bar typing to Google for live completions'
)
const switches = [...container.querySelectorAll<HTMLButtonElement>('[role="switch"]')]
expect(switches[1]).toHaveAttribute('aria-checked', 'true')
await click(switches[1])
expect(bridge.settings.setBrowserSearchSuggestionsEnabled).toHaveBeenCalledWith(false)
})
it('persists an independent website theme', async () => {
const bridge = createBridge({ browserTheme: 'app' })
mockBridge.current = bridge
@@ -114,6 +114,14 @@ export function Browser() {
setPreferences
)
const { pending: searchSuggestionsPending, mutate: setSearchSuggestionsEnabled } =
useDesktopPreferenceMutation(
async (bridge, enabled: boolean) =>
bridge.settings.setBrowserSearchSuggestionsEnabled?.(enabled),
'Could not update browser search suggestions',
setPreferences
)
const clear = useCallback(async (kinds: readonly BrowserDataKind[]) => {
const desktop = getDesktopBridge()
if (!desktop) return
@@ -145,6 +153,9 @@ export function Browser() {
}
const enabled = preferences.browserEnabled
const supportsSearchSuggestions = Boolean(
getDesktopBridge()?.settings.setBrowserSearchSuggestionsEnabled
)
const target = confirming === 'all' ? null : confirming
return (
@@ -172,6 +183,23 @@ export function Browser() {
/>
</div>
{supportsSearchSuggestions && (
<div className='flex items-center justify-between gap-4'>
<div className='flex min-w-0 flex-col gap-1'>
<Label htmlFor='browser-search-suggestions'>Search suggestions</Label>
<p className='text-[var(--text-muted)] text-caption'>
Send address-bar typing to Google for live completions
</p>
</div>
<Switch
id='browser-search-suggestions'
checked={preferences.browserSearchSuggestionsEnabled ?? true}
disabled={searchSuggestionsPending}
onCheckedChange={(checked) => void setSearchSuggestionsEnabled(checked)}
/>
</div>
)}
<div className='flex items-center justify-between gap-4'>
<Label>Theme</Label>
<AppearanceThemeSelect
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* "Open this URL in the Sim browser panel" — the desktop-only affordance that
* routes chat links into the embedded agent browser instead of a new browser
* routes chat links into the embedded agent browser instead of a system-browser
* tab.
*
* Rendering components (markdown links, chips) can't reach the chat's
@@ -153,6 +153,7 @@ import {
fillBrowserCredential,
initBrowserAgentTransport,
loadBrowserFillOptions,
loadBrowserSearchSuggestions,
migrateBrowserScope,
onBrowserAddToChat,
onBrowserFillAvailability,
@@ -162,6 +163,7 @@ import {
onBrowserOmniboxFocus,
onBrowserToolbarCommand,
openBrowserTab,
openUrlInNewBrowserTab,
reorderBrowserTab,
reportBrowserPanelBounds,
reportBrowserPanelFocused,
@@ -243,6 +245,33 @@ describe('browser panel transport', () => {
expect(setTabsState).toHaveBeenCalledWith(state)
})
it('opens chat URLs in a distinct tab before navigating', async () => {
const callOrder: string[] = []
openTab.mockImplementation(async () => {
callOrder.push('open-tab')
return {
scopeId: 'chat-test',
activeTabId: '2',
tabs: [],
}
})
panelAction.mockImplementation(() => {
callOrder.push('navigate')
})
await openUrlInNewBrowserTab('https://example.com/docs', 'chat-test')
expect(callOrder).toEqual(['open-tab', 'navigate'])
expect(panelAction).toHaveBeenCalledWith(
{ action: 'navigate', url: 'https://example.com/docs' },
'chat-test'
)
})
it('keeps search suggestions local-only on older installed shells', async () => {
await expect(loadBrowserSearchSuggestions('sim ai')).resolves.toEqual([])
})
it('forwards panel bounds directly to the native view', () => {
const initialBounds = { x: 10, y: 20, width: 300, height: 200 }
const updatedBounds = { x: 20, y: 30, width: 320, height: 220 }
+22
View File
@@ -307,6 +307,15 @@ export async function openBrowserTab(
return state
}
/** Opens a distinct browser tab and navigates it after the shell accepts it. */
export async function openUrlInNewBrowserTab(
url: string,
scopeId = currentBrowserScopeId()
): Promise<void> {
await openBrowserTab(scopeId)
sendBrowserPanelAction('navigate', { url }, scopeId)
}
/** Pins or unpins a live browser tab. */
export function setBrowserTabPinned(
tabId: string,
@@ -487,6 +496,19 @@ export async function loadBrowserSuggestionSources(): Promise<{
return { sessions: known?.sessions ?? [], credentials, sites }
}
/**
* Requests live Google completions through the desktop shell. Older shells and
* disabled/offline providers resolve to an empty list, leaving local sites and
* ordinary Enter-to-search behavior intact.
*/
export function loadBrowserSearchSuggestions(query: string): Promise<string[]> {
return (
bridge()
?.getSearchSuggestions?.(query)
.catch(() => []) ?? Promise.resolve([])
)
}
/** Subscribes to native browser shortcuts that target the renderer omnibox. */
export function onBrowserOmniboxFocus(
callback: (mode: BrowserOmniboxFocusMode) => void,
@@ -0,0 +1,3 @@
/** Wire limits shared by the desktop capability snapshot and its request schema. */
export const DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH = 64
export const DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH = 1024
+7 -3
View File
@@ -10,6 +10,10 @@ import { z } from 'zod'
import { isZodError, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
import {
DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH,
DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH,
} from '@/lib/copilot/chat/desktop-capabilities'
import { type ChatLoadResult, resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle'
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload'
@@ -289,9 +293,9 @@ const ChatMessageSchema = z.object({
terminals: z
.array(
z.object({
id: z.string().max(64),
cwd: z.string().max(1024).optional(),
running: z.string().max(1024).optional(),
id: z.string().max(DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH),
cwd: z.string().max(DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH).optional(),
running: z.string().max(DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH).optional(),
interactive: z.boolean().optional(),
active: z.boolean().optional(),
})
+38
View File
@@ -3,6 +3,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getDesktopChatCapabilities,
hasBrowserAgent,
hasTerminal,
isBrowserAgentEnabled,
@@ -64,4 +65,41 @@ describe('desktop surface availability', () => {
expect(isBrowserAgentEnabled()).toBe(false)
expect(isTerminalEnabled()).toBe(false)
})
it('bounds terminal hints before adding them to a chat request', async () => {
const oversizedValue = 'x'.repeat(1100)
installBridge({
terminal: {
getTabs: vi.fn(async () => ({
scopeId: 'chat-1',
activeTerminalId: '1',
tabs: [
{
terminalId: '1',
title: 'Terminal',
cwd: oversizedValue,
running: oversizedValue,
interactive: false,
active: true,
},
],
})),
},
})
setDesktopPreferencesSnapshot({
...ENABLED_PREFERENCES,
browserEnabled: false,
})
const capabilities = await getDesktopChatCapabilities('chat-1')
expect(capabilities.desktopCapabilities?.terminals).toEqual([
{
id: '1',
cwd: 'x'.repeat(1024),
running: 'x'.repeat(1024),
active: true,
},
])
})
})
+20 -7
View File
@@ -23,6 +23,11 @@
*/
import type { BrowserKnownSession } from '@sim/browser-protocol'
import type { DesktopPreferences, SimDesktopApi } from '@sim/desktop-bridge'
import { truncate } from '@sim/utils/string'
import {
DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH,
DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH,
} from '@/lib/copilot/chat/desktop-capabilities'
/** The preload bridge, or undefined outside the desktop app (and on the server). */
export function getDesktopBridge(): SimDesktopApi | undefined {
@@ -172,13 +177,21 @@ export async function getDesktopChatCapabilities(
? await bridge.terminal
.getTabs(scopeId)
.then((state) =>
state.tabs.map((tab) => ({
id: tab.terminalId,
...(tab.cwd ? { cwd: tab.cwd } : {}),
...(tab.running ? { running: tab.running } : {}),
...(tab.interactive ? { interactive: true as const } : {}),
...(tab.active ? { active: true as const } : {}),
}))
state.tabs
.filter((tab) => tab.terminalId.length <= DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH)
.map((tab) => ({
id: tab.terminalId,
...(tab.cwd
? { cwd: truncate(tab.cwd, DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH, '') }
: {}),
...(tab.running
? {
running: truncate(tab.running, DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH, ''),
}
: {}),
...(tab.interactive ? { interactive: true as const } : {}),
...(tab.active ? { active: true as const } : {}),
}))
)
.catch(() => [])
: []
+12
View File
@@ -232,6 +232,11 @@ export interface SimDesktopBrowserAgentApi {
getTabsState(scopeId: string): Promise<BrowserTabsState>
/** Read a privacy-preserving hint of websites that may have a usable session. */
getKnownSessions(): Promise<BrowserKnownSessionsState>
/**
* Live search completions for the omnibox. Optional while installed shells
* that predate search suggestions remain supported.
*/
getSearchSuggestions?(query: string): Promise<string[]>
/**
* Erase browsing data from the dedicated profile and resolve the resulting
* session list. Pass the kinds to clear; omit for all of them. Saved
@@ -832,6 +837,8 @@ export interface DesktopPreferences {
trayEnabled: boolean
/** Let Chat drive the built-in agent browser on this device. */
browserEnabled: boolean
/** Whether typing in the omnibox may request live Google search completions. */
browserSearchSuggestionsEnabled?: boolean
/** Let Chat run commands in local shells. */
terminalEnabled: boolean
/**
@@ -916,6 +923,11 @@ export interface SimDesktopSettingsApi {
key: K,
value: DesktopPreferences[K]
): Promise<DesktopPreferences>
/**
* Controls whether partial omnibox queries may be sent to Google. Optional
* for compatibility with installed shells that predate live suggestions.
*/
setBrowserSearchSuggestionsEnabled?(enabled: boolean): Promise<DesktopPreferences>
notify(payload: DesktopNotificationPayload): Promise<boolean>
/** Overrides the appearance requested by browser pages. */
setBrowserTheme(theme: DesktopAppearanceTheme): Promise<DesktopPreferences>