From 46aae116b5a5638ea2b30c1bc8db6c56cd71d42a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 14:40:53 -0700 Subject: [PATCH] Port desktop-improvements-0 desktop and browser-agent work --- .../main/browser-search/suggestions.test.ts | 74 +++++++++++++ .../src/main/browser-search/suggestions.ts | 102 ++++++++++++++++++ apps/desktop/src/main/config.ts | 3 + .../desktop/src/main/desktop-settings.test.ts | 11 ++ apps/desktop/src/main/desktop-settings.ts | 7 ++ apps/desktop/src/main/ipc.test.ts | 23 ++++ apps/desktop/src/main/ipc.ts | 20 ++++ apps/desktop/src/preload/index.test.ts | 4 + apps/desktop/src/preload/index.ts | 4 + .../browser-session/browser-session.test.ts | 4 + .../browser-session/browser-session.tsx | 95 +++++++++++----- .../browser-session/url-suggestions.test.ts | 62 +++++++++++ .../browser-session/url-suggestions.ts | 55 ++++++++++ .../resource-tabs/resource-tabs.tsx | 4 +- .../app/workspace/[workspaceId]/home/home.tsx | 22 ++-- .../[workspaceId]/home/hooks/index.ts | 2 + .../[workspaceId]/home/hooks/use-chat.test.ts | 15 +++ .../[workspaceId]/home/hooks/use-chat.ts | 48 ++++++--- .../components/browser/browser.test.tsx | 42 +++++++- .../settings/components/browser/browser.tsx | 28 +++++ apps/sim/lib/browser-agent/open-in-panel.ts | 2 +- apps/sim/lib/browser-agent/transport.test.ts | 29 +++++ apps/sim/lib/browser-agent/transport.ts | 22 ++++ .../lib/copilot/chat/desktop-capabilities.ts | 3 + apps/sim/lib/copilot/chat/post.ts | 10 +- apps/sim/lib/desktop/index.test.ts | 38 +++++++ apps/sim/lib/desktop/index.ts | 27 +++-- packages/desktop-bridge/src/index.ts | 12 +++ 28 files changed, 708 insertions(+), 60 deletions(-) create mode 100644 apps/desktop/src/main/browser-search/suggestions.test.ts create mode 100644 apps/desktop/src/main/browser-search/suggestions.ts create mode 100644 apps/sim/lib/copilot/chat/desktop-capabilities.ts diff --git a/apps/desktop/src/main/browser-search/suggestions.test.ts b/apps/desktop/src/main/browser-search/suggestions.test.ts new file mode 100644 index 0000000000..c9b839d25c --- /dev/null +++ b/apps/desktop/src/main/browser-search/suggestions.test.ts @@ -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.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 => + 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([]) + }) +}) diff --git a/apps/desktop/src/main/browser-search/suggestions.ts b/apps/desktop/src/main/browser-search/suggestions.ts new file mode 100644 index 0000000000..139588aaac --- /dev/null +++ b/apps/desktop/src/main/browser-search/suggestions.ts @@ -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 + +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() + + constructor( + private readonly fetcher: SearchSuggestionFetch, + private readonly now: () => number = Date.now + ) {} + + async suggest(rawQuery: unknown): Promise { + 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 { + return searchSuggestionService.suggest(query) +} diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 3e0d10949a..654fbacfa3 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -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, } diff --git a/apps/desktop/src/main/desktop-settings.test.ts b/apps/desktop/src/main/desktop-settings.test.ts index b7849a5755..d2bcac944a 100644 --- a/apps/desktop/src/main/desktop-settings.test.ts +++ b/apps/desktop/src/main/desktop-settings.test.ts @@ -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({ diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts index f84b364b40..7f25296b99 100644 --- a/apps/desktop/src/main/desktop-settings.ts +++ b/apps/desktop/src/main/desktop-settings.ts @@ -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) diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index a23f83b474..a74e43a9b5 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -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') diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 4b4b48bb7d..23d89dcd23 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -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', diff --git a/apps/desktop/src/preload/index.test.ts b/apps/desktop/src/preload/index.test.ts index dea8ac8957..42cab747db 100644 --- a/apps/desktop/src/preload/index.test.ts +++ b/apps/desktop/src/preload/index.test.ts @@ -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], ]) }) }) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index ffb5314df7..afe84f2a42 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -147,6 +147,8 @@ const api: SimDesktopApi = { getPreferences: (): Promise => ipcRenderer.invoke('desktop:settings:get'), setPreference: (key: DesktopPreferenceKey, value: boolean): Promise => ipcRenderer.invoke('desktop:settings:set', key, value), + setBrowserSearchSuggestionsEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke('desktop:settings:set-browser-search-suggestions', enabled), notify: (payload: DesktopNotificationPayload): Promise => ipcRenderer.invoke('desktop:settings:notify', payload), setBrowserTheme: (theme: DesktopAppearanceTheme): Promise => @@ -281,6 +283,8 @@ const api: SimDesktopApi = { ipcRenderer.invoke('browser-agent:get-tabs-state', scopeId), getKnownSessions: (): Promise => ipcRenderer.invoke('browser-agent:get-known-sessions'), + getSearchSuggestions: (query: string): Promise => + ipcRenderer.invoke('browser-agent:search-suggestions', query), clearBrowsingData: (kinds?: readonly BrowserDataKind[]): Promise => ipcRenderer.invoke('browser-agent:clear-browsing-data', kinds), getDownloadsState: (scopeId: string): Promise => diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts index 0edd0e9f3f..957dab4d10 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts @@ -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() }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx index 3d241ca738..2f4ba98caf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx @@ -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(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) => ( navigateTo(suggestion.url)} >
- - {suggestion.name ? ( + {suggestion.kind === 'search' ? ( + <> + + {suggestion.query} + + ) : ( + + )} + {suggestion.kind === 'site' && suggestion.name ? (
{suggestion.name} — {suggestion.hostname}
- ) : ( + ) : suggestion.kind === 'site' ? ( {suggestion.hostname} - )} + ) : null}
))} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts index f4407ab3f6..978d67badf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts @@ -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) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts index c5ae7399c7..4ab226a38f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index d907369426..b4333837c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -243,9 +243,9 @@ const ResourceTabItem = memo(function ResourceTabItem({ > {config.renderTabIcon(resource, 'mr-1.5 size-[14px]')} {displayName} - {hasActivity && !isActive && ( + {hasActivity && !isActive && !isHovered && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index dd5d02da37..eaefecea24 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -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 { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts index 995df51986..8c1fa13edd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts @@ -1,6 +1,8 @@ +export type { ResourceEventOptions } from './use-chat' export { getMothershipUseChatOptions, getWorkflowCopilotUseChatOptions, + shouldActivateResourceEvent, useChat, } from './use-chat' export { useMothershipResize } from './use-mothership-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts index 281714e81d..fa4d09e96b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts @@ -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, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index acbea0bd61..926e87362e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -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]) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx index 327771b3a5..bb3e1a1109 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx @@ -87,8 +87,22 @@ vi.mock('@sim/emcn', () => ({ ), Label: ({ children }: { children: ReactNode }) => {children}, - Switch: ({ checked }: { checked: boolean }) => ( -