mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-31 03:47:12 +08:00
Port desktop-improvements-0 desktop and browser-agent work
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
+4
@@ -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()
|
||||
})
|
||||
|
||||
+69
-26
@@ -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>
|
||||
))}
|
||||
|
||||
+62
@@ -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)
|
||||
|
||||
+55
@@ -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
|
||||
|
||||
+2
-2
@@ -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,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 }
|
||||
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(() => [])
|
||||
: []
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user