Skip to content

Commit df3ba63

Browse files
committed
add cmd f
1 parent 4602221 commit df3ba63

11 files changed

Lines changed: 686 additions & 2 deletions

File tree

apps/desktop/src/main/browser-agent/session.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ interface MockView {
2727
isDestroyed: ReturnType<typeof vi.fn>
2828
setBackgroundThrottling: ReturnType<typeof vi.fn>
2929
capturePage: ReturnType<typeof vi.fn>
30+
findInPage: ReturnType<typeof vi.fn>
31+
stopFindInPage: ReturnType<typeof vi.fn>
3032
}
3133
setBackgroundColor: ReturnType<typeof vi.fn>
3234
setBounds: ReturnType<typeof vi.fn>
@@ -116,6 +118,7 @@ describe('browser-agent session', () => {
116118
)
117119
expect(session.browserShortcutForInput({ ...input, key: 't' }, 'darwin')).toBe('new-tab')
118120
expect(session.browserShortcutForInput({ ...input, key: 'w' }, 'darwin')).toBe('close-tab')
121+
expect(session.browserShortcutForInput({ ...input, key: 'f' }, 'darwin')).toBe('find')
119122
expect(
120123
session.browserShortcutForInput({ ...input, key: 't', shift: true }, 'darwin')
121124
).toBeNull()
@@ -169,6 +172,166 @@ describe('browser-agent session', () => {
169172
expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:focus-omnibox', 'clear')
170173
})
171174

175+
it('opens the renderer find bar when the page takes Mod+F', () => {
176+
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
177+
const tab = session.requireTab()
178+
const contents = (tab.view as unknown as MockView).webContents
179+
const beforeInput = contents.on.mock.calls.find(
180+
([eventName]) => eventName === 'before-input-event'
181+
)?.[1] as
182+
| ((event: { preventDefault: () => void }, input: Record<string, unknown>) => void)
183+
| undefined
184+
const event = { preventDefault: vi.fn() }
185+
186+
beforeInput?.(event, {
187+
type: 'keyDown',
188+
key: 'f',
189+
isAutoRepeat: false,
190+
isComposing: false,
191+
shift: false,
192+
control: process.platform !== 'darwin',
193+
alt: false,
194+
meta: process.platform === 'darwin',
195+
})
196+
197+
// The page never sees it — otherwise a site's own Mod+F wins over find.
198+
expect(event.preventDefault).toHaveBeenCalled()
199+
expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:open-find')
200+
})
201+
202+
it('restarts the search while typing and steps without restarting on next/previous', () => {
203+
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
204+
const tab = session.requireTab()
205+
const contents = (tab.view as unknown as MockView).webContents
206+
207+
session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
208+
expect(contents.findInPage).toHaveBeenLastCalledWith('needle', {
209+
forward: true,
210+
findNext: false,
211+
})
212+
213+
session.findInActiveTab({ query: 'needle', findNext: true, forward: false })
214+
expect(contents.findInPage).toHaveBeenLastCalledWith('needle', {
215+
forward: false,
216+
findNext: true,
217+
})
218+
219+
// Clearing the box is a stop, not a search for the empty string — and the
220+
// bar has to survive it, or deleting the last character closes the bar the
221+
// user is still typing in.
222+
vi.mocked(win.webContents.send).mockClear()
223+
session.findInActiveTab({ query: '', findNext: false, forward: true })
224+
expect(contents.stopFindInPage).toHaveBeenCalledWith('clearSelection')
225+
expect(contents.findInPage).toHaveBeenCalledTimes(2)
226+
expect(win.webContents.send).not.toHaveBeenCalledWith('browser-agent:close-find')
227+
})
228+
229+
it('forwards match counts only for the tab the find is running on', () => {
230+
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
231+
const first = session.requireTab()
232+
const second = session.addTab()
233+
const firstContents = (first.view as unknown as MockView).webContents
234+
const secondContents = (second.view as unknown as MockView).webContents
235+
const foundOn = (contents: MockView['webContents']) =>
236+
contents.on.mock.calls.find(([eventName]) => eventName === 'found-in-page')?.[1] as
237+
| ((event: unknown, result: Record<string, unknown>) => void)
238+
| undefined
239+
240+
session.switchTab(first.id)
241+
session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
242+
foundOn(firstContents)?.({}, { activeMatchOrdinal: 2, matches: 7, finalUpdate: true })
243+
expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:find-result', {
244+
activeMatchOrdinal: 2,
245+
matches: 7,
246+
final: true,
247+
})
248+
249+
// A late result from a tab that is not being searched would relabel the bar
250+
// with counts for a page the user is not looking at.
251+
vi.mocked(win.webContents.send).mockClear()
252+
foundOn(secondContents)?.({}, { activeMatchOrdinal: 1, matches: 3, finalUpdate: true })
253+
expect(win.webContents.send).not.toHaveBeenCalledWith(
254+
'browser-agent:find-result',
255+
expect.anything()
256+
)
257+
})
258+
259+
it('drops the find when its page navigates away, but not on a same-document change', () => {
260+
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
261+
const tab = session.requireTab()
262+
const contents = (tab.view as unknown as MockView).webContents
263+
const navigate = contents.on.mock.calls.find(
264+
([eventName]) => eventName === 'did-start-navigation'
265+
)?.[1] as ((details: Record<string, unknown>) => void) | undefined
266+
267+
session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
268+
vi.mocked(win.webContents.send).mockClear()
269+
270+
// A pushState route change keeps the document the matches live in.
271+
navigate?.({ isMainFrame: true, isSameDocument: true })
272+
expect(win.webContents.send).not.toHaveBeenCalledWith('browser-agent:close-find')
273+
// A subframe load likewise leaves the main document alone.
274+
navigate?.({ isMainFrame: false, isSameDocument: false })
275+
expect(win.webContents.send).not.toHaveBeenCalledWith('browser-agent:close-find')
276+
277+
navigate?.({ isMainFrame: true, isSameDocument: false })
278+
expect(contents.stopFindInPage).toHaveBeenCalledWith('clearSelection')
279+
expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find')
280+
})
281+
282+
it('drops the find when the user switches to another tab', () => {
283+
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
284+
const first = session.requireTab()
285+
const second = session.addTab()
286+
const firstContents = (first.view as unknown as MockView).webContents
287+
288+
session.switchTab(first.id)
289+
session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
290+
vi.mocked(win.webContents.send).mockClear()
291+
292+
session.switchTab(second.id)
293+
expect(firstContents.stopFindInPage).toHaveBeenCalledWith('clearSelection')
294+
expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find')
295+
})
296+
297+
it('returns focus to the page only when the user dismissed the bar', () => {
298+
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
299+
const tab = session.requireTab()
300+
const contents = (tab.view as unknown as MockView).webContents
301+
302+
// Panel teardown: the bar unmounts under a user who has already moved on,
303+
// so pulling focus back into the browser would drag them back to it.
304+
session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
305+
contents.focus.mockClear()
306+
session.stopFindInActiveTab(false)
307+
expect(contents.focus).not.toHaveBeenCalled()
308+
309+
session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
310+
contents.focus.mockClear()
311+
session.stopFindInActiveTab(true)
312+
expect(contents.focus).toHaveBeenCalled()
313+
})
314+
315+
it('returns focus to the page even when no search was running', () => {
316+
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
317+
const tab = session.requireTab()
318+
const contents = (tab.view as unknown as MockView).webContents
319+
320+
// Opened and closed without typing. Focus still has to leave the bar: it is
321+
// unmounting, and <body> cannot receive the Mod+F that reopens it.
322+
contents.focus.mockClear()
323+
session.stopFindInActiveTab(true)
324+
expect(contents.focus).toHaveBeenCalled()
325+
326+
// Same once the box is emptied — clearing the query ends the search, so
327+
// dismissing afterwards has no searched tab to key focus off either.
328+
session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
329+
session.findInActiveTab({ query: '', findNext: false, forward: true })
330+
contents.focus.mockClear()
331+
session.stopFindInActiveTab(true)
332+
expect(contents.focus).toHaveBeenCalled()
333+
})
334+
172335
it('closes only the native browser tab targeted by the application menu accelerator', () => {
173336
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
174337
const first = session.requireTab()

apps/desktop/src/main/browser-agent/session.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { join } from 'node:path'
22
import {
33
type BrowserDataKind,
4+
type BrowserFindRequest,
5+
type BrowserFindResult,
46
type BrowserOmniboxFocusMode,
57
type BrowserTabState,
68
type BrowserTabsState,
@@ -74,7 +76,7 @@ export interface AgentSessionEvents {
7476
*/
7577
const MAX_RECENTLY_CLOSED_TABS = 10
7678

77-
export type BrowserShortcut = 'focus-omnibox' | 'new-tab' | 'close-tab'
79+
export type BrowserShortcut = 'focus-omnibox' | 'new-tab' | 'close-tab' | 'find'
7880

7981
type BrowserShortcutInput = Pick<
8082
Input,
@@ -108,6 +110,8 @@ export function browserShortcutForInput(
108110
return 'new-tab'
109111
case 'w':
110112
return 'close-tab'
113+
case 'f':
114+
return 'find'
111115
default:
112116
return null
113117
}
@@ -304,6 +308,106 @@ function focusRendererOmnibox(mode: BrowserOmniboxFocusMode): void {
304308
win.webContents.send('browser-agent:focus-omnibox', mode)
305309
}
306310

311+
/**
312+
* Opens the renderer's find bar and moves keyboard focus to it. The bar is
313+
* renderer chrome rather than an overlay on the page: a renderer element that
314+
* overlapped the native view would trip the occlusion path and hide the very
315+
* page being searched.
316+
*/
317+
function openRendererFind(): void {
318+
const win = panelWindow()
319+
if (!win || win.isDestroyed()) return
320+
win.webContents.focus()
321+
win.webContents.send('browser-agent:open-find')
322+
}
323+
324+
/**
325+
* Tab a find is currently running on. Tracked because the find outlives the
326+
* call that started it — Chromium keeps the highlights until it is told to
327+
* stop, so leaving a tab (or navigating it) has to clear the find explicitly
328+
* or the old matches stay lit under a match count that no longer describes
329+
* anything on screen.
330+
*/
331+
let findingTabId: string | null = null
332+
333+
/**
334+
* Drops a tab's highlights and stops treating it as the tab being searched.
335+
* Leaves the renderer's bar alone — emptying the find box and searching a
336+
* different tab both end a find while the user is still typing in the bar.
337+
*/
338+
function stopFindOnTab(tabId: string | null): void {
339+
if (tabId === null) return
340+
const tab = tabs.find((entry) => entry.id === tabId)
341+
if (tab && !tab.view.webContents.isDestroyed()) {
342+
tab.view.webContents.stopFindInPage('clearSelection')
343+
}
344+
if (findingTabId === tabId) findingTabId = null
345+
}
346+
347+
/**
348+
* Stops the find and dismisses the renderer's bar, for when the page it was
349+
* run against is gone — a navigation or a tab switch. Chrome dismisses find on
350+
* navigation too, and a count for the previous document is worse than no bar.
351+
*/
352+
function dismissFind(tabId: string | null): void {
353+
if (tabId === null) return
354+
const wasFinding = findingTabId === tabId
355+
stopFindOnTab(tabId)
356+
if (!wasFinding) return
357+
const win = panelWindow()
358+
if (win && !win.isDestroyed()) {
359+
win.webContents.send('browser-agent:close-find')
360+
}
361+
}
362+
363+
/**
364+
* Runs Chromium's own find against the active tab. An empty query stops the
365+
* find rather than searching for nothing, matching what emptying Chrome's find
366+
* box does — the bar stays open and ready for the next query.
367+
*/
368+
export function findInActiveTab(request: BrowserFindRequest): void {
369+
const tab = activeTab()
370+
if (!tab) return
371+
if (request.query === '') {
372+
stopFindOnTab(tab.id)
373+
return
374+
}
375+
// A find started on another tab has to go before this one begins, or its
376+
// highlights survive on a page the user can no longer see them on.
377+
if (findingTabId !== null && findingTabId !== tab.id) stopFindOnTab(findingTabId)
378+
findingTabId = tab.id
379+
tab.view.webContents.findInPage(request.query, {
380+
forward: request.forward,
381+
findNext: request.findNext,
382+
})
383+
}
384+
385+
/**
386+
* Stops the running find.
387+
*
388+
* `focusPage` distinguishes the user dismissing the bar — where focus is being
389+
* pulled out from under them and Chrome leaves it on the page — from the bar
390+
* merely unmounting because the browser panel went away. Only the renderer can
391+
* tell those apart: the panel's own teardown reports bounds after its
392+
* children's cleanups run, so by the time this is reached the panel still
393+
* looks visible either way, and focusing the page on teardown would drag the
394+
* user back to a browser they just navigated away from.
395+
*/
396+
export function stopFindInActiveTab(focusPage: boolean): void {
397+
stopFindOnTab(findingTabId)
398+
if (!focusPage) return
399+
// Deliberately the ACTIVE tab, not whichever tab was being searched: there is
400+
// often no search running at all (the bar was opened and closed without a
401+
// query, or the box was emptied first, both of which clear the searched tab).
402+
// Keying focus off the search left those cases with focus on the input that
403+
// just unmounted, which lands on <body> — from there the page cannot receive
404+
// the next Mod+F for the shell to intercept, and the renderer's own handler
405+
// is scoped to the panel, so find became unopenable until something else was
406+
// clicked.
407+
const tab = activeTab()
408+
if (tab) tab.view.webContents.focus()
409+
}
410+
307411
/**
308412
* Opens a link from a page in another tab of this browser. Shared by the
309413
* window.open interception and the page's right-click menu — both have to stay
@@ -405,6 +509,10 @@ function createTabView(): WebContentsView {
405509
focusRendererOmnibox('select')
406510
return
407511
}
512+
if (shortcut === 'find') {
513+
openRendererFind()
514+
return
515+
}
408516
if (shortcut === 'new-tab') {
409517
if (listTabs().length < MAX_BROWSER_TABS) {
410518
addTab()
@@ -416,6 +524,28 @@ function createTabView(): WebContentsView {
416524
const tab = tabs.find((entry) => entry.view === view)
417525
if (tab) closeTabFromUser(tab.id)
418526
})
527+
contents.on('found-in-page', (_event, result) => {
528+
const tab = tabs.find((entry) => entry.view === view)
529+
// Counts from a tab the user has already left would relabel the bar for
530+
// whatever page is on screen now.
531+
if (!tab || tab.id !== findingTabId) return
532+
const win = panelWindow()
533+
if (!win || win.isDestroyed()) return
534+
const payload: BrowserFindResult = {
535+
activeMatchOrdinal: result.activeMatchOrdinal,
536+
matches: result.matches,
537+
final: result.finalUpdate,
538+
}
539+
win.webContents.send('browser-agent:find-result', payload)
540+
})
541+
// A document load replaces what the find was pointing at. Same-document
542+
// route changes do not, and Chromium keeps the highlights across them, so
543+
// only real navigations dismiss the bar.
544+
contents.on('did-start-navigation', (details) => {
545+
if (!details.isMainFrame || details.isSameDocument) return
546+
const tab = tabs.find((entry) => entry.view === view)
547+
if (tab) dismissFind(tab.id)
548+
})
419549
// A pinned tab persists its latest top-level location, including
420550
// user-driven navigations that do not pass through the driver.
421551
contents.on('did-navigate', persistPinnedTabs)
@@ -634,6 +764,8 @@ export function switchTab(tabId: string): AgentTab {
634764
restorePinnedTabs()
635765
const tab = tabs.find((entry) => entry.id === tabId)
636766
if (!tab) throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`)
767+
// The find belongs to the page it was typed against, not to the browser.
768+
if (findingTabId !== null && findingTabId !== tab.id) dismissFind(findingTabId)
637769
const transferBrowserFocus =
638770
focusedBrowserTabId !== null || tabs.some((entry) => entry.view.webContents.isFocused())
639771
activeTabId = tab.id

0 commit comments

Comments
 (0)