feat(browser): history controls: Chrome-style Escape, removable suggestions, history view (#31846)

This commit is contained in:
Kelvin Amoaba
2026-10-24 04:46:47 +00:00
committed by GitHub
parent 1753f03e33
commit 5dc197e5ec
38 changed files with 1231 additions and 130 deletions
@@ -133,6 +133,8 @@ export function forwardGuestShortcutInput(
keybindingMatchesAction('browser.focusAddressBar', input, process.platform, keybindings)
) {
renderer.send('ui:focusBrowserAddressBar', { browserPageId: browserTabId })
} else if (keybindingMatchesAction('browser.showHistory', input, process.platform, keybindings)) {
renderer.send('ui:showBrowserHistory', { browserPageId: browserTabId })
} else if (keybindingMatchesAction('browser.hardReload', input, process.platform, keybindings)) {
// The renderer owns the parked webview used for reload.
renderer.send('ui:hardReloadBrowserPage', { browserPageId: browserTabId })
@@ -284,6 +284,15 @@ describe('browserManager', () => {
control: !isDarwin,
alt: false,
shift: true
},
{
type: 'keyDown',
code: isDarwin ? 'KeyY' : 'KeyH',
key: isDarwin ? 'y' : 'h',
meta: isDarwin,
control: !isDarwin,
alt: false,
shift: false
}
]
@@ -311,6 +320,9 @@ describe('browserManager', () => {
expect(rendererSendMock).toHaveBeenNthCalledWith(10, 'ui:hardReloadBrowserPage', {
browserPageId: 'browser-1'
})
expect(rendererSendMock).toHaveBeenNthCalledWith(11, 'ui:showBrowserHistory', {
browserPageId: 'browser-1'
})
})
it('uses customized keybindings when forwarding browser guest shortcuts', () => {
@@ -67,6 +67,12 @@ export const uiTabAndBrowserCommandsApi = {
ipcRenderer.on('ui:focusBrowserAddressBar', listener)
return () => ipcRenderer.removeListener('ui:focusBrowserAddressBar', listener)
},
onShowBrowserHistory: (callback: (target: BrowserPageCommandTarget) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, target: BrowserPageCommandTarget) =>
callback(target)
ipcRenderer.on('ui:showBrowserHistory', listener)
return () => ipcRenderer.removeListener('ui:showBrowserHistory', listener)
},
onFindInBrowserPage: browserFindSubscriptions.subscribe,
onReloadBrowserPage: (callback: (target: BrowserPageCommandTarget) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, target: BrowserPageCommandTarget) =>
+1
View File
@@ -143,6 +143,7 @@ export type UiCommandEventApi = {
}) => void
onNewTerminalTab: (callback: () => void) => () => void
onFocusBrowserAddressBar: (callback: (target: BrowserPageCommandTarget) => void) => () => void
onShowBrowserHistory: (callback: (target: BrowserPageCommandTarget) => void) => () => void
onFindInBrowserPage: (source: BrowserFindSource, callback: () => void) => () => void
onReloadBrowserPage: (callback: (target: BrowserPageCommandTarget) => void) => () => void
onBrowserHistoryNavigate: (
@@ -155,6 +155,7 @@ function dropdownMenuStubs(): Record<string, unknown> {
DropdownMenuRadioGroup: passthrough,
DropdownMenuRadioItem: block,
DropdownMenuSeparator: () => <hr />,
DropdownMenuShortcut: block,
DropdownMenuSub: passthrough,
DropdownMenuSubContent: block,
DropdownMenuSubTrigger: block,
@@ -4,6 +4,7 @@ import { act, type ReactNode, useRef, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types'
import { TooltipProvider } from '@/components/ui/tooltip'
import BrowserAddressBar from './BrowserAddressBar'
const mocks = vi.hoisted(() => {
@@ -14,6 +15,9 @@ const mocks = vi.hoisted(() => {
browserPagesByWorkspace: Record<string, { id: string; worktreeId: string; url: string }[]>
openPageUrl: string | null
activateBrowserPage: ReturnType<typeof vi.fn>
removeBrowserHistoryEntry: ReturnType<typeof vi.fn>
removeWorkspaceDocHistoryEntry: ReturnType<typeof vi.fn>
requestBrowserFocus: ReturnType<typeof vi.fn>
} = {
browserUrlHistory: [],
browserDefaultSearchEngine: null,
@@ -22,7 +26,10 @@ const mocks = vi.hoisted(() => {
'workspace-1': [{ id: 'page-1', worktreeId: 'wt-1', url: 'about:blank' }]
},
openPageUrl: null,
activateBrowserPage: vi.fn()
activateBrowserPage: vi.fn(),
removeBrowserHistoryEntry: vi.fn(),
removeWorkspaceDocHistoryEntry: vi.fn(),
requestBrowserFocus: vi.fn()
}
return state
})
@@ -50,7 +57,7 @@ vi.mock('@/lib/browser-page-palette-activation', () => ({
activateBrowserPagePaletteResult: mocks.activateBrowserPage
}))
vi.mock('../host-guest/browser-focus', () => ({ requestBrowserFocus: vi.fn() }))
vi.mock('../host-guest/browser-focus', () => ({ requestBrowserFocus: mocks.requestBrowserFocus }))
vi.mock('@/store', () => ({
useAppStore: Object.assign((selector: (state: typeof mocks) => unknown) => selector(mocks), {
@@ -103,10 +110,12 @@ function typeInto(input: HTMLInputElement, value: string, inputType: string): vo
function AddressBarHarness({
initialValue,
committedValue = initialValue,
onNavigate,
onSubmit
}: {
initialValue: string
committedValue?: string
onNavigate: (url: string) => void
onSubmit: () => void
}): React.ReactElement {
@@ -114,17 +123,18 @@ function AddressBarHarness({
const inputRef = useRef<HTMLInputElement | null>(null)
return (
<>
<TooltipProvider>
<BrowserAddressBar
pageId="page-1"
value={value}
committedValue={committedValue}
onChange={setValue}
onSubmit={onSubmit}
onNavigate={onNavigate}
inputRef={inputRef}
/>
<span data-current-address-value="true">{value}</span>
</>
</TooltipProvider>
)
}
@@ -388,39 +398,125 @@ describe('BrowserAddressBar autocomplete preview', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
it('dismisses suggestions when Escape is pressed outside React input handling', async () => {
const onNavigate = vi.fn()
const onSubmit = vi.fn()
it('backs Escape out one step at a time, in Chrome order', async () => {
await act(async () => {
root.render(
<AddressBarHarness initialValue="local" onNavigate={onNavigate} onSubmit={onSubmit} />
<AddressBarHarness
initialValue="http://localhost:3000/"
onNavigate={vi.fn()}
onSubmit={vi.fn()}
/>
)
})
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')
expect(input).not.toBeNull()
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')!
const shown = (): string | null | undefined =>
container.querySelector('[data-current-address-value="true"]')?.textContent
const press = async (key: string): Promise<void> => {
await act(async () => {
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
})
}
await act(async () => {
input?.focus()
input.focus()
typeInto(input, 'review', 'insertText')
})
await press('ArrowDown')
expect(shown()).toBe('http://localhost:3000/review-one')
await press('Escape')
expect(shown()).toBe('review')
expect(input.getAttribute('aria-expanded')).toBe('true')
await press('Escape')
expect(shown()).toBe('review')
expect(input.getAttribute('aria-expanded')).toBe('false')
await press('Escape')
expect(shown()).toBe('http://localhost:3000/')
expect([input.selectionStart, input.selectionEnd]).toEqual([0, shown()!.length])
expect(mocks.requestBrowserFocus).not.toHaveBeenCalled()
await press('Escape')
expect(mocks.requestBrowserFocus).toHaveBeenCalledWith({ pageId: 'page-1', target: 'webview' })
})
it('keeps focus in the bar when the typed text is the page address retyped', async () => {
await act(async () => {
root.render(
<AddressBarHarness
initialValue="local"
committedValue="localhost"
onNavigate={vi.fn()}
onSubmit={vi.fn()}
/>
)
})
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')!
await act(async () => {
input.focus()
typeInto(input, 'localhost', 'insertText')
})
for (const _ of [1, 2]) {
await act(async () => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
}
expect(mocks.requestBrowserFocus).not.toHaveBeenCalled()
})
it('removes the highlighted history row on Shift+Delete and puts back what was typed', async () => {
await act(async () => {
root.render(
<AddressBarHarness initialValue="local" onNavigate={vi.fn()} onSubmit={vi.fn()} />
)
})
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')!
await act(async () => {
input.focus()
})
await act(async () => {
input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
})
const removal = new KeyboardEvent('keydown', {
key: 'Delete',
shiftKey: true,
bubbles: true,
cancelable: true
})
expect(container.querySelector('[data-current-address-value="true"]')?.textContent).toBe(
'http://localhost:3000/review-one'
)
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
input.dispatchEvent(removal)
})
expect(mocks.removeBrowserHistoryEntry).toHaveBeenCalledWith('http://localhost:3000/review-one')
expect(removal.defaultPrevented).toBe(true)
expect(container.querySelector('[data-current-address-value="true"]')?.textContent).toBe(
'local'
)
expect(onNavigate).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
})
it('leaves Shift+Delete to the text field when the highlighted row is not history', async () => {
mocks.browserUrlHistory = []
await act(async () => {
root.render(
<AddressBarHarness initialValue="local" onNavigate={vi.fn()} onSubmit={vi.fn()} />
)
})
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')!
await act(async () => {
input.focus()
})
const removal = new KeyboardEvent('keydown', {
key: 'Delete',
shiftKey: true,
bubbles: true,
cancelable: true
})
await act(async () => {
input.dispatchEvent(removal)
})
expect(removal.defaultPrevented).toBe(false)
expect(mocks.removeBrowserHistoryEntry).not.toHaveBeenCalled()
})
it('dismisses suggestions when focus moves into an Electron webview guest', async () => {
@@ -5,6 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { findPage } from '@/store/slices/browser-page-records'
import { DEFAULT_SEARCH_ENGINE, type SearchEngine } from '../../../../../shared/browser-url'
import type { BrowserPageDocLocation } from '../../../../../shared/browser-workspace-types'
import { buildBrowserAddressBarSuggestions } from './browser-address-bar-suggestions'
@@ -14,9 +15,15 @@ import { useBrowserAddressBarEditHandoff } from './use-browser-address-bar-edit-
import { useBrowserAddressBarOverlay } from './browser-address-bar-expansion'
import { useBrowserAddressBarClickSelect } from './use-browser-address-bar-click-select'
import { useBrowserAddressBarDismissal } from './use-browser-address-bar-dismissal'
import { useBrowserAddressBarEscape } from './use-browser-address-bar-escape'
import { useBrowserAddressBarSuggestionPreview } from './use-browser-address-bar-suggestion-preview'
import { useRemoveSuggestionRow } from './use-browser-address-bar-row-removal'
import { isRemovableHistoryRow, isRemoveRowShortcut } from '../history/browser-history-row-removal'
import type { BrowserAddressBarEditSessionBinding } from './use-browser-address-bar-edit-session'
import BrowserAddressBarSuggestionList from './BrowserAddressBarSuggestionList'
import { BrowserLoadProgress } from './browser-load-progress'
import { toDisplayUrl } from '../describe-page/browser-page-url-display'
import BrowserHistoryDialog from '../history/BrowserHistoryDialog'
function clearTimerRef(timerRef: { current: number | null }): void {
if (timerRef.current !== null) {
@@ -29,6 +36,10 @@ type BrowserAddressBarProps = {
/** The page this bar edits; its worktree's other open pages are offered as "Switch to tab". */
pageId: string
value: string
/** What Escape reverts an edit to; the page's own address when omitted. */
committedValue?: string
/** Where the last Escape goes once nothing is left to undo; the page itself by default. */
onLeave?: () => void
onChange: (value: string) => void
onSubmit: () => void
onNavigate: (url: string) => void
@@ -50,6 +61,8 @@ type BrowserAddressBarProps = {
export default function BrowserAddressBar({
pageId,
value,
committedValue,
onLeave,
onChange,
onSubmit,
onNavigate,
@@ -71,6 +84,9 @@ export default function BrowserAddressBar({
const workspaceDocHistory = useAppStore((s) => s.workspaceDocHistory)
const browserDefaultSearchEngine = useAppStore((s) => s.browserDefaultSearchEngine)
const browserKagiSessionLink = useAppStore((s) => s.browserKagiSessionLink)
const pageAddress = useAppStore((s) =>
toDisplayUrl(findPage(s.browserPagesByWorkspace, pageId)?.url ?? '')
)
const closingRef = useRef(false)
const addressBarClickSelect = useBrowserAddressBarClickSelect()
const openedAtRef = useRef(0)
@@ -124,61 +140,16 @@ export default function BrowserAddressBar({
[buildSuggestions, autocompleteQuery, inlineText]
)
const clearSuggestionPreview = useCallback((): void => {
prePreviewValueRef.current = null
setSelectedValueOverride(null)
clearInline()
}, [clearInline])
const previewSuggestion = useCallback(
(url: string): void => {
if (prePreviewValueRef.current === null) {
prePreviewValueRef.current = autocompleteQuery
}
setSelectedValueOverride(url)
onChange(url)
},
[autocompleteQuery, onChange]
)
const selectSuggestionAtIndex = useCallback(
(index: number): void => {
const suggestion = suggestions[index]
if (!suggestion) {
return
}
if (suggestion.isSearch) {
// Why: the search row is what Enter does with the typed query, so the bar keeps the
// typed text instead of the search URL.
prePreviewValueRef.current = null
clearInline()
setSelectedValueOverride(suggestion.url)
onChange(autocompleteQuery)
return
}
if (suggestion.inlineText !== undefined) {
// Why: like Chrome, the completion comes back as typed text plus a selected tail, so
// Escape still restores what was typed.
setSelectedValueOverride(suggestion.url)
showInline(autocompleteQuery, suggestion.inlineText)
onChange(suggestion.inlineText)
return
}
previewSuggestion(suggestion.url)
},
[autocompleteQuery, clearInline, onChange, previewSuggestion, showInline, suggestions]
)
const restoreTypedQuery = useCallback((): void => {
const typed = prePreviewValueRef.current
if (typed === null) {
return
}
prePreviewValueRef.current = null
setSelectedValueOverride(null)
clearInline()
onChange(typed)
}, [clearInline, onChange])
const { clearSuggestionPreview, selectSuggestionAtIndex, restoreTypedQuery } =
useBrowserAddressBarSuggestionPreview({
prePreviewValueRef,
autocompleteQuery,
suggestions,
setSelectedValueOverride,
onChange,
clearInline,
showInline
})
const dismissSuggestions = useCallback((): void => {
clearTimerRef(blurCloseTimerRef)
@@ -186,6 +157,8 @@ export default function BrowserAddressBar({
setOpen(false)
}, [restoreTypedQuery])
const removeSuggestion = useRemoveSuggestionRow(restoreTypedQuery)
const switchToTab = useBrowserAddressBarSwitchToTab({
pageId,
open,
@@ -194,6 +167,23 @@ export default function BrowserAddressBar({
onNavigate
})
const { handleEscapeKeyDown, noteTyped } = useBrowserAddressBarEscape({
pageId,
inputRef,
value,
committedValue: committedValue ?? pageAddress,
open,
previewing:
prePreviewValueRef.current !== null && !(inlineText !== null && value === inlineText),
restoreTypedQuery: () => {
// Why: like Chrome's revert of an arrowed row, the row's Switch to tab button lets go too.
switchToTab.clearFocus()
restoreTypedQuery()
},
closeSuggestions: dismissSuggestions,
onChange,
onLeave
})
// Why nothing for an empty bar: row 0 always shows what Enter does, and there Enter does nothing.
const selectedValue =
selectedValueOverride &&
@@ -246,6 +236,7 @@ export default function BrowserAddressBar({
closingRef.current = true
setOpen(false)
clearSuggestionPreview()
noteTyped(null)
// Why looked up by row: a workspace-doc suggestion opens on a fresh grant instead of
// navigating; its url is the document's path, so even the fallback routes via detection.
const docLocation = suggestions.find(
@@ -262,7 +253,7 @@ export default function BrowserAddressBar({
closingRef.current = false
}, 100)
},
[clearSuggestionPreview, onNavigate, onOpenWorkspaceDoc, suggestions]
[clearSuggestionPreview, noteTyped, onNavigate, onOpenWorkspaceDoc, suggestions]
)
const submitTypedValue = useCallback((): void => {
@@ -272,13 +263,18 @@ export default function BrowserAddressBar({
}
setOpen(false)
clearSuggestionPreview()
noteTyped(null)
onSubmit()
}, [clearSuggestionPreview, onSubmit, value])
}, [clearSuggestionPreview, noteTyped, onSubmit, value])
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
dismissSuggestions()
if (handleEscapeKeyDown(event)) {
return
}
if (open && selectedRow && isRemovableHistoryRow(selectedRow) && isRemoveRowShortcut(event)) {
event.preventDefault()
removeSuggestion(selectedRow)
return
}
@@ -340,7 +336,8 @@ export default function BrowserAddressBar({
switchFocused,
selectSuggestionAtIndex,
restoreTypedQuery,
dismissSuggestions,
handleEscapeKeyDown,
removeSuggestion,
submitTypedValue
]
)
@@ -436,6 +433,7 @@ export default function BrowserAddressBar({
if (completed !== null) {
prePreviewValueRef.current = nextValue
}
noteTyped(nextValue)
onChange(completed ?? nextValue)
}}
role="combobox"
@@ -466,10 +464,16 @@ export default function BrowserAddressBar({
hasSwitchTarget={switchToTab.hasSwitchTarget}
switchFocusedUrl={switchFocused ? selectedValue : null}
onSwitchToTab={switchToTab.switchToTab}
onRemove={removeSuggestion}
/>
</PopoverContent>
)}
</Popover>
<BrowserHistoryDialog
pageId={pageId}
onNavigate={onNavigate}
onOpenWorkspaceDoc={onOpenWorkspaceDoc}
/>
</div>
)
}
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button'
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { translate } from '@/i18n/i18n'
import type { BrowserAddressBarSuggestion } from './browser-address-bar-suggestions'
import BrowserHistoryRowRemoveButton from '../history/BrowserHistoryRowRemoveButton'
import { isRemovableHistoryRow } from '../history/browser-history-row-removal'
const NO_SELECTED_ROW = 'orca:no-selected-row'
@@ -16,6 +18,7 @@ type BrowserAddressBarSuggestionListProps = {
/** The row whose button holds keyboard focus (focus itself stays in the input). */
switchFocusedUrl: string | null
onSwitchToTab: (url: string) => void
onRemove: (suggestion: BrowserAddressBarSuggestion) => void
}
export default function BrowserAddressBarSuggestionList({
@@ -25,7 +28,8 @@ export default function BrowserAddressBarSuggestionList({
onSelect,
hasSwitchTarget,
switchFocusedUrl,
onSwitchToTab
onSwitchToTab,
onRemove
}: BrowserAddressBarSuggestionListProps): React.ReactElement {
return (
// Why the sentinel: cmdk selects its first row whenever the value is empty, and an empty bar
@@ -82,6 +86,9 @@ export default function BrowserAddressBarSuggestionList({
)}
</Button>
) : null}
{isRemovableHistoryRow(entry) ? (
<BrowserHistoryRowRemoveButton onRemove={() => onRemove(entry)} />
) : null}
</CommandItem>
))}
</CommandGroup>
@@ -35,6 +35,8 @@ export type BrowserAddressBarSuggestion = {
visitCount: number
isSearch: boolean
faviconUrl?: string | null
/** Set on a visited-page row: the history entry it came from, so the row can be removed. */
normalizedUrl?: string
/** Set on a previewed-document row; selecting it opens the document on a fresh grant. */
docLocation?: BrowserPageDocLocation
/** On row 0 when the bar completes the typed text inline to it: the full bar text. */
@@ -53,6 +55,21 @@ function toWorkspaceDocSuggestion(entry: WorkspaceDocHistoryEntry): BrowserAddre
}
}
function toBrowserHistorySuggestion(entry: BrowserHistoryEntry): BrowserAddressBarSuggestion {
return { ...entry, subtitle: entry.url, isSearch: false }
}
/** Every visited page and previewed document as rows, newest first. */
export function listBrowserHistoryRecents(
browserUrlHistory: readonly BrowserHistoryEntry[],
workspaceDocHistory: readonly WorkspaceDocHistoryEntry[]
): BrowserAddressBarSuggestion[] {
return [
...browserUrlHistory.map(toBrowserHistorySuggestion),
...workspaceDocHistory.map(toWorkspaceDocSuggestion)
].sort((a, b) => b.lastVisitedAt - a.lastVisitedAt)
}
export function isBrowserAddressBarQueryTooLarge(
query: string,
maxBytes = BROWSER_ADDRESS_BAR_QUERY_MAX_BYTES
@@ -112,13 +129,10 @@ function buildSuggestionRows({
}
const trimmed = value.trim()
if (trimmed === '' || trimmed === 'about:blank' || trimmed.startsWith('data:')) {
const recents: BrowserAddressBarSuggestion[] = [
...browserUrlHistory.map((entry) => ({ ...entry, subtitle: entry.url, isSearch: false })),
...workspaceDocHistory.map(toWorkspaceDocSuggestion)
]
return recents
.sort((a, b) => b.lastVisitedAt - a.lastVisitedAt)
.slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS)
return listBrowserHistoryRecents(browserUrlHistory, workspaceDocHistory).slice(
0,
MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS
)
}
const documentRows = workspaceDocHistory.map(toWorkspaceDocSuggestion)
const documentEntries: BrowserHistoryEntry[] = documentRows.map((row) => ({
@@ -130,7 +144,7 @@ function buildSuggestionRows({
}))
const rowByEntry = new Map<BrowserHistoryEntry, BrowserAddressBarSuggestion>()
for (const entry of browserUrlHistory) {
rowByEntry.set(entry, { ...entry, subtitle: entry.url, isSearch: false })
rowByEntry.set(entry, toBrowserHistorySuggestion(entry))
}
documentEntries.forEach((entry, index) => rowByEntry.set(entry, documentRows[index]))
// Why prepare the caller's array as-is: it is the stable `browserUrlHistory`
@@ -45,6 +45,7 @@ function renderRow(overrides: Partial<BrowserNavigationControls> = {}): BrowserN
<BrowserAddressBar
pageId="page-1"
value="https://example.com/"
committedValue="https://example.com/"
onChange={vi.fn()}
onSubmit={vi.fn()}
onNavigate={controls.navigate}
@@ -1,4 +1,4 @@
import { Check, Ellipsis, FileCode2, Import, Monitor, Plus, Settings } from 'lucide-react'
import { Check, Ellipsis, FileCode2, History, Import, Monitor, Plus, Settings } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -8,6 +8,7 @@ import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
@@ -16,6 +17,7 @@ import {
import { BrowserCookieImportDisclosure } from '@/components/BrowserCookieImportDisclosure'
import { BrowserCookieImportMachineNotice } from '@/components/BrowserCookieImportMachineNotice'
import { useAppStore } from '@/store'
import { formatOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { BROWSER_FAMILY_LABELS } from '../../../../../shared/constants'
import type {
BrowserSessionProfile,
@@ -87,6 +89,8 @@ export function BrowserToolbarMenuDropdown({
onOpenSourceFile = null,
overflow
}: BrowserToolbarMenuDropdownProps): React.JSX.Element {
const keybindings = useAppStore((s) => s.keybindings)
const historyShortcut = formatOptionalShortcutLabel('browser.showHistory', keybindings)
return (
<DropdownMenu modal={false} open={menuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
@@ -276,6 +280,18 @@ export function BrowserToolbarMenuDropdown({
<DropdownMenuSeparator />
<DropdownMenuItem
// Why deferred: focus returning to the ⋯ trigger would otherwise close the dialog at once.
onSelect={() =>
overflow.deferUntilClose(() =>
useAppStore.getState().setBrowserHistoryPageId(browserPageId)
)
}
>
<History className="mr-2 size-3.5" />
{translate('browser.history.menuItem', 'History')}
{historyShortcut ? <DropdownMenuShortcut>{historyShortcut}</DropdownMenuShortcut> : null}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
useAppStore.getState().openSettingsTarget({ pane: 'browser', repoId: null })
@@ -361,7 +361,9 @@ describe.each([
})
mocks.ensureRemotePage.mockClear()
mocks.callRuntimeRpc.mockClear()
installClientHostedPaneApi({ ui: { onFocusBrowserAddressBar: () => () => {} } })
installClientHostedPaneApi({
ui: { onFocusBrowserAddressBar: () => () => {}, onShowBrowserHistory: () => () => {} }
})
useAppStore.setState({
browserPagesByWorkspace: { [WORKSPACE_ID]: [browserPage()] },
browserUrlHistory: [
@@ -1,11 +1,10 @@
import { useEffect } from 'react'
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
/**
* Why: Electron <webview> guests run in a separate process, so clicking the page never dispatches
* pointerdown on the renderer document and Radix cannot detect an outside dismiss. Window blur and
* focus moves into the guest (the host <webview> tag) close the dropdown the same way
* BrowserImportHintButton does for its popover; Escape closes it at window capture.
* BrowserImportHintButton does for its popover. Escape belongs to the bar's own keydown.
*/
export function useBrowserAddressBarDismissal(open: boolean, dismissSuggestions: () => void): void {
useEffect(() => {
@@ -25,22 +24,11 @@ export function useBrowserAddressBarDismissal(open: boolean, dismissSuggestions:
dismissSuggestions()
}
const handleEscape = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') {
return
}
dismissSuggestions()
event.preventDefault()
event.stopImmediatePropagation()
}
window.addEventListener('blur', handleWindowBlur)
document.addEventListener('focusin', handleFocusIn, true)
addAppKeyListener('keydown', handleEscape)
return () => {
window.removeEventListener('blur', handleWindowBlur)
document.removeEventListener('focusin', handleFocusIn, true)
removeAppKeyListener('keydown', handleEscape)
}
}, [dismissSuggestions, open])
}
@@ -0,0 +1,96 @@
import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react'
import { isImeOwnedKeyboardEvent } from '@/lib/ime-composition-keyboard-event'
import { requestBrowserFocus } from '../host-guest/browser-focus'
/** Escape in the bar, in the order of Chromium's OmniboxEditModel::OnEscapeKeyPressed. */
export function useBrowserAddressBarEscape({
pageId,
inputRef,
value,
committedValue,
open,
previewing,
restoreTypedQuery,
closeSuggestions,
onChange,
onLeave
}: {
pageId: string
inputRef: RefObject<HTMLInputElement | null>
value: string
/** What the bar shows when nobody is editing it: the page's address. */
committedValue: string
open: boolean
/** An arrowed-to row fills the bar (Chrome's temporary text); an inline completion is not one. */
previewing: boolean
restoreTypedQuery: () => void
closeSuggestions: () => void
onChange: (value: string) => void
onLeave?: () => void
}): {
handleEscapeKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => boolean
/** What the user's own keystroke put in the bar; null once it is submitted or picked. */
noteTyped: (value: string | null) => void
} {
const typedValueRef = useRef<string | null>(null)
const selectAllOnCommitRef = useRef(false)
// Why after commit: React moves the caret to the end when it writes a new value into a focused
// input, so selecting in the handler would be undone by the reverted text landing.
useLayoutEffect(() => {
if (!selectAllOnCommitRef.current) {
return
}
selectAllOnCommitRef.current = false
inputRef.current?.select()
}, [inputRef, value])
const noteTyped = useCallback((typed: string | null) => {
typedValueRef.current = typed
}, [])
const handleEscapeKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>): boolean => {
if (event.key !== 'Escape' || isImeOwnedKeyboardEvent(event)) {
return false
}
event.preventDefault()
event.stopPropagation()
// Why both: the typed text tells a retyped page address from an untouched one, and the
// comparison covers drafts handed over from a pane that remounted mid-edit.
const inputInProgress = value === typedValueRef.current || value !== committedValue
if (previewing) {
restoreTypedQuery()
} else if (open) {
closeSuggestions()
} else if (inputInProgress) {
typedValueRef.current = null
if (value === committedValue) {
inputRef.current?.select()
} else {
selectAllOnCommitRef.current = true
onChange(committedValue)
}
} else if (onLeave) {
onLeave()
} else {
requestBrowserFocus({ pageId, target: 'webview' })
}
return true
},
[
closeSuggestions,
committedValue,
inputRef,
onChange,
onLeave,
open,
pageId,
previewing,
restoreTypedQuery,
value
]
)
return { handleEscapeKeyDown, noteTyped }
}
@@ -0,0 +1,18 @@
import { useCallback } from 'react'
import { useRemoveHistoryRow } from '../history/browser-history-row-removal'
import type { BrowserAddressBarSuggestion } from './browser-address-bar-suggestions'
/** Removes a dropdown row, then puts back what was typed if that row filled the bar. */
export function useRemoveSuggestionRow(
restoreTypedQuery: () => void
): (row: BrowserAddressBarSuggestion) => void {
const removeHistoryRow = useRemoveHistoryRow()
return useCallback(
(row: BrowserAddressBarSuggestion) => {
removeHistoryRow(row)
// Why: a previewed or inline-completed row would otherwise stay in the bar for Enter to submit.
restoreTypedQuery()
},
[removeHistoryRow, restoreTypedQuery]
)
}
@@ -0,0 +1,92 @@
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import type { BrowserAddressBarSuggestion } from './browser-address-bar-suggestions'
/** Arrowing through the dropdown fills the bar with the row while remembering what was typed. */
export function useBrowserAddressBarSuggestionPreview({
prePreviewValueRef,
autocompleteQuery,
suggestions,
setSelectedValueOverride,
onChange,
clearInline,
showInline
}: {
prePreviewValueRef: MutableRefObject<string | null>
autocompleteQuery: string
suggestions: BrowserAddressBarSuggestion[]
setSelectedValueOverride: Dispatch<SetStateAction<string | null>>
onChange: (value: string) => void
clearInline: () => void
showInline: (typed: string, text: string) => void
}): {
clearSuggestionPreview: () => void
selectSuggestionAtIndex: (index: number) => void
restoreTypedQuery: () => void
} {
const clearSuggestionPreview = useCallback((): void => {
prePreviewValueRef.current = null
setSelectedValueOverride(null)
clearInline()
}, [clearInline, prePreviewValueRef, setSelectedValueOverride])
const previewSuggestion = useCallback(
(url: string): void => {
if (prePreviewValueRef.current === null) {
prePreviewValueRef.current = autocompleteQuery
}
setSelectedValueOverride(url)
onChange(url)
},
[autocompleteQuery, onChange, prePreviewValueRef, setSelectedValueOverride]
)
const selectSuggestionAtIndex = useCallback(
(index: number): void => {
const suggestion = suggestions[index]
if (!suggestion) {
return
}
if (suggestion.isSearch) {
// Why: the search row is what Enter does with the typed query, so the bar keeps the
// typed text instead of the search URL.
prePreviewValueRef.current = null
clearInline()
setSelectedValueOverride(suggestion.url)
onChange(autocompleteQuery)
return
}
if (suggestion.inlineText !== undefined) {
// Why: like Chrome, the completion comes back as typed text plus a selected tail, so
// Escape still restores what was typed.
setSelectedValueOverride(suggestion.url)
showInline(autocompleteQuery, suggestion.inlineText)
onChange(suggestion.inlineText)
return
}
previewSuggestion(suggestion.url)
},
[
autocompleteQuery,
clearInline,
onChange,
prePreviewValueRef,
previewSuggestion,
setSelectedValueOverride,
showInline,
suggestions
]
)
const restoreTypedQuery = useCallback((): void => {
const typed = prePreviewValueRef.current
if (typed === null) {
return
}
prePreviewValueRef.current = null
setSelectedValueOverride(null)
clearInline()
onChange(typed)
}, [clearInline, onChange, prePreviewValueRef, setSelectedValueOverride])
return { clearSuggestionPreview, selectSuggestionAtIndex, restoreTypedQuery }
}
@@ -129,7 +129,8 @@ describe('useBrowserPageChromeFocus', () => {
value: {
ui: {
onFocusBrowserAddressBar: (callback: (target: BrowserPageCommandTarget) => void) =>
focusAddressBarFromIpc.subscribe(callback)
focusAddressBarFromIpc.subscribe(callback),
onShowBrowserHistory: () => () => {}
}
}
})
@@ -20,6 +20,7 @@ import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-t
import type { BrowserAddressBarSelection } from './browser-address-bar-edit-session'
import type { BrowserPageGuestFocus } from './browser-page-guest-focus'
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
import { useBrowserHistoryShortcut } from '../history/use-browser-history-shortcut'
/** Frames an address-bar grab keeps retrying before it lets the guest have focus back. */
const ADDRESS_BAR_FOCUS_FRAMES = 6
@@ -30,6 +31,7 @@ const GUEST_ATTACH_WAIT_FRAMES = 120
* Who owns focus in a browser pane's chrome. Every pane — local, client-hosted and streamed —
* uses this so a new blank tab lands in the address bar with its text selected, the command
* palette can aim focus at either surface, and Cmd/Ctrl+L reaches the bar from anywhere.
* It also carries the history shortcut, so every pane kind opens the history view the same way.
*/
export function useBrowserPageChromeFocus({
browserTabId,
@@ -56,6 +58,7 @@ export function useBrowserPageChromeFocus({
/** Set while a focus grab is in flight, so panes don't hand focus back to the guest mid-retry. */
keepAddressBarFocusRef: MutableRefObject<boolean>
} {
useBrowserHistoryShortcut({ browserTabId, workspaceId, isActive, chromeShortcutScope })
const consumeAddressBarFocusRequest = useAppStore((s) => s.consumeAddressBarFocusRequest)
const keybindings = useAppStore((s) => s.keybindings)
const keepAddressBarFocusRef = useRef(false)
@@ -53,6 +53,7 @@ export function installClientHostedPaneApi(overrides?: {
},
ui: {
onFocusBrowserAddressBar: inert,
onShowBrowserHistory: inert,
onFindInBrowserPage: inert,
onBrowserHistoryNavigate: inert,
onReloadBrowserPage: inert,
@@ -0,0 +1,280 @@
import { useDeferredValue, useMemo, useRef, useState } from 'react'
import { FileText, Trash2 } from 'lucide-react'
import { BrowserFavicon } from '@/components/browser-favicon'
import { Button } from '@/components/ui/button'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { FooterKey } from '@/components/worktree-jump-palette-primitives'
import { getIntlLocale, translate } from '@/i18n/i18n'
import { getShortcutPlatform } from '@/hooks/useShortcutLabel'
import { useAppStore } from '@/store'
import { requestBrowserFocus } from '../host-guest/browser-focus'
import type { BrowserPageDocLocation } from '../../../../../shared/browser-workspace-types'
import { startOfLocalDay } from '../../../../../shared/automation-cron-occurrence'
import { formatKeybinding } from '../../../../../shared/keybindings/formatting'
import type { RemovedBrowserHistory } from '@/store/slices/browser/browser-slice-contract'
import type { BrowserAddressBarSuggestion } from '../assemble-chrome/browser-address-bar-suggestions'
import BrowserHistoryRowRemoveButton from './BrowserHistoryRowRemoveButton'
import { isRemoveRowShortcut, useRemoveHistoryRow } from './browser-history-row-removal'
import {
browserHistoryClearSince,
browserHistoryRowKey,
groupBrowserHistoryRowsByDay,
listBrowserHistoryRows,
type BrowserHistoryClearRange
} from './browser-history-view-rows'
type BrowserHistoryDialogProps = {
pageId: string
onNavigate: (url: string) => void
onOpenWorkspaceDoc?: (docLocation: BrowserPageDocLocation) => void
}
/** Rendered by every address bar; only the page the history view was opened for shows it. */
export default function BrowserHistoryDialog(
props: BrowserHistoryDialogProps
): React.JSX.Element | null {
const open = useAppStore((s) => s.browserHistoryPageId === props.pageId)
return open ? <BrowserHistoryDialogContent {...props} /> : null
}
// Why cached per locale: the UI language can change while the app runs.
let historyFormats: {
locale: string
dateFormat: Intl.DateTimeFormat
timeFormat: Intl.DateTimeFormat
} | null = null
function getHistoryFormats(): NonNullable<typeof historyFormats> {
const locale = getIntlLocale()
if (historyFormats?.locale !== locale) {
historyFormats = {
locale,
dateFormat: new Intl.DateTimeFormat(locale, { dateStyle: 'full' }),
timeFormat: new Intl.DateTimeFormat(locale, { timeStyle: 'short' })
}
}
return historyFormats
}
function formatDayHeading(startsAt: number, now: number, dateFormat: Intl.DateTimeFormat): string {
const today = startOfLocalDay(now)
if (startsAt === today) {
return translate('browser.history.today', 'Today')
}
if (startsAt === startOfLocalDay(today - 1)) {
return translate('browser.history.yesterday', 'Yesterday')
}
return dateFormat.format(startsAt)
}
const CLEAR_RANGES: readonly BrowserHistoryClearRange[] = ['hour', 'today', 'all']
function clearRangeLabel(range: BrowserHistoryClearRange): string {
if (range === 'hour') {
return translate('browser.history.clearLastHour', 'Last hour')
}
return range === 'today'
? translate('browser.history.clearToday', 'Today')
: translate('browser.history.clearAllTime', 'All time')
}
function BrowserHistoryDialogContent({
pageId,
onNavigate,
onOpenWorkspaceDoc
}: BrowserHistoryDialogProps): React.JSX.Element {
const browserUrlHistory = useAppStore((s) => s.browserUrlHistory)
const workspaceDocHistory = useAppStore((s) => s.workspaceDocHistory)
const setBrowserHistoryPageId = useAppStore((s) => s.setBrowserHistoryPageId)
const clearBrowserHistory = useAppStore((s) => s.clearBrowserHistory)
const restoreBrowserHistory = useAppStore((s) => s.restoreBrowserHistory)
const removeHistoryRow = useRemoveHistoryRow()
const [query, setQuery] = useState('')
// Why in the footer and not a toast: the modal overlay covers the app's toaster.
const [cleared, setCleared] = useState<RemovedBrowserHistory | null>(null)
const deferredQuery = useDeferredValue(query)
const [now] = useState(() => Date.now())
const rows = useMemo(
() => listBrowserHistoryRows(browserUrlHistory, workspaceDocHistory, deferredQuery),
[browserUrlHistory, workspaceDocHistory, deferredQuery]
)
const days = useMemo(() => groupBrowserHistoryRowsByDay(rows), [rows])
const { dateFormat, timeFormat } = getHistoryFormats()
const openedRowRef = useRef(false)
const close = (): void => setBrowserHistoryPageId(null)
const openRow = (row: BrowserAddressBarSuggestion): void => {
openedRowRef.current = true
close()
if (row.docLocation && onOpenWorkspaceDoc) {
onOpenWorkspaceDoc(row.docLocation)
} else {
onNavigate(row.url)
}
}
const clear = (range: BrowserHistoryClearRange): void => {
const removed = clearBrowserHistory(browserHistoryClearSince(range))
if (removed.browserUrlHistory.length === 0 && removed.workspaceDocHistory.length === 0) {
return
}
setCleared(removed)
}
const hasHistory = browserUrlHistory.length > 0 || workspaceDocHistory.length > 0
return (
<CommandDialog
open
onOpenChange={(next) => !next && close()}
shouldFilter={false}
onCloseAutoFocus={(event) => {
if (!openedRowRef.current) {
return
}
// Why after teardown: like picking a row in the address bar, the opened page gets the
// keyboard, and the dialog's focus trap would pull a request made any earlier back in.
event.preventDefault()
requestBrowserFocus({ pageId, target: 'webview' })
}}
title={translate('browser.history.title', 'History')}
description={translate('browser.history.description', 'Search and manage browsing history')}
contentClassName="top-[min(10%,4rem)] w-[720px] max-w-[96vw] overflow-hidden"
commandProps={{
loop: true,
className: 'jump-palette-command bg-transparent',
onKeyDownCapture: (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!isRemoveRowShortcut(event)) {
return
}
// Why read from cmdk: tracking its selection in state would re-render every row per hover.
const selectedKey = event.currentTarget
.querySelector('[cmdk-item][data-selected="true"]')
?.getAttribute('data-value')
const selected = rows.find((row) => browserHistoryRowKey(row) === selectedKey)
if (selected) {
event.preventDefault()
event.stopPropagation()
removeHistoryRow(selected)
}
}
}}
>
<CommandInput
placeholder={translate('browser.history.search', 'Search history')}
value={query}
onValueChange={setQuery}
className="h-11 min-w-0 flex-1"
/>
<CommandList className="max-h-[min(560px,calc(100vh-14rem))]">
<CommandEmpty>
{hasHistory
? translate('browser.history.noMatches', 'No pages match your search')
: translate('browser.history.empty', 'Pages you visit will show up here')}
</CommandEmpty>
{days.map((day) => (
<CommandGroup
key={day.startsAt}
heading={formatDayHeading(day.startsAt, now, dateFormat)}
>
{day.rows.map((row) => {
const key = browserHistoryRowKey(row)
return (
<CommandItem
key={key}
value={key}
onSelect={() => openRow(row)}
className="cursor-pointer"
>
{row.docLocation ? (
<FileText className="size-3.5 text-muted-foreground" />
) : (
<BrowserFavicon
faviconUrl={row.faviconUrl}
className="size-3.5"
fallbackClassName="text-muted-foreground"
/>
)}
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="truncate text-[13px] text-foreground">
{row.title || row.url}
</span>
<span className="min-w-0 shrink-[2] truncate text-xs text-muted-foreground">
{row.subtitle}
</span>
</div>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{timeFormat.format(row.lastVisitedAt)}
</span>
<BrowserHistoryRowRemoveButton onRemove={() => removeHistoryRow(row)} />
</CommandItem>
)
})}
</CommandGroup>
))}
</CommandList>
<div
className="flex items-center justify-between border-t border-border/60 px-3.5 py-2 text-2xs text-muted-foreground"
// Why: cmdk's root takes Enter and arrows for the highlighted row; footer buttons keep their
// own. Tab still bubbles so the dialog's focus loop wraps.
onKeyDown={(event) => event.key !== 'Tab' && event.stopPropagation()}
>
{cleared ? (
<div className="flex items-center gap-2">
<span>{translate('browser.history.cleared', 'Cleared browsing history')}</span>
<Button
variant="ghost"
size="xs"
onClick={() => {
restoreBrowserHistory(cleared)
setCleared(null)
}}
>
{translate('browser.newTab.undo', 'Undo')}
</Button>
</div>
) : (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" disabled={!hasHistory}>
<Trash2 />
{translate('browser.history.clear', 'Clear browsing history')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top">
<DropdownMenuLabel>
{translate('browser.history.clearRange', 'Clear history from')}
</DropdownMenuLabel>
{CLEAR_RANGES.map((range) => (
<DropdownMenuItem key={range} onSelect={() => clear(range)}>
{clearRangeLabel(range)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="flex items-center gap-2">
<FooterKey>{translate('browser.history.enterKey', 'Enter')}</FooterKey>
<span>{translate('browser.history.open', 'Open')}</span>
<FooterKey>{formatKeybinding('Shift+Delete', getShortcutPlatform()).join(' ')}</FooterKey>
<span>{translate('browser.history.removeShort', 'Remove')}</span>
<FooterKey>{translate('browser.history.escKey', 'Esc')}</FooterKey>
<span>{translate('browser.history.close', 'Close')}</span>
</div>
</div>
</CommandDialog>
)
}
@@ -0,0 +1,38 @@
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
/** The X on a history row: shown when cmdk marks the row selected (hover or arrows), like Chrome's. */
export default function BrowserHistoryRowRemoveButton({
onRemove
}: {
onRemove: () => void
}): React.JSX.Element {
const label = translate('browser.history.remove', 'Remove from history')
return (
<span className="shrink-0 text-muted-foreground opacity-0 [[data-selected=true]_&]:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
tabIndex={-1}
aria-label={label}
// Why: keep focus in the text field, so its list keeps the keyboard after the row goes.
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
onRemove()
}}
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
</span>
)
}
@@ -0,0 +1,38 @@
import { useCallback } from 'react'
import { isImeOwnedKeyboardEvent } from '@/lib/ime-composition-keyboard-event'
import { useAppStore } from '@/store'
import type { BrowserAddressBarSuggestion } from '../assemble-chrome/browser-address-bar-suggestions'
type RemovableRow = Pick<BrowserAddressBarSuggestion, 'normalizedUrl' | 'docLocation'>
export function isRemovableHistoryRow(row: RemovableRow): boolean {
return row.docLocation !== undefined || row.normalizedUrl !== undefined
}
/** Removes a visited page or document from history, so it stops being suggested anywhere. */
export function useRemoveHistoryRow(): (row: RemovableRow) => void {
const removeBrowserHistoryEntry = useAppStore((s) => s.removeBrowserHistoryEntry)
const removeWorkspaceDocHistoryEntry = useAppStore((s) => s.removeWorkspaceDocHistoryEntry)
return useCallback(
(row: RemovableRow) => {
if (row.docLocation) {
removeWorkspaceDocHistoryEntry(row.docLocation)
} else if (row.normalizedUrl !== undefined) {
removeBrowserHistoryEntry(row.normalizedUrl)
}
},
[removeBrowserHistoryEntry, removeWorkspaceDocHistoryEntry]
)
}
/** Chrome's Shift+Delete (Shift+Fn+Delete on Mac laptops) on the highlighted row. */
export function isRemoveRowShortcut(event: React.KeyboardEvent): boolean {
return (
event.key === 'Delete' &&
event.shiftKey &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!isImeOwnedKeyboardEvent(event)
)
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types'
import {
browserHistoryClearSince,
groupBrowserHistoryRowsByDay,
listBrowserHistoryRows
} from './browser-history-view-rows'
function entry(url: string, title: string, lastVisitedAt: number): BrowserHistoryEntry {
return { url, normalizedUrl: url, title, lastVisitedAt, visitCount: 1 }
}
const at = (day: number, hour: number): number => new Date(2026, 9, day, hour).getTime()
describe('browser history view rows', () => {
it('matches every word in the title or address, in any order', () => {
const history = [
entry('https://github.com/stablyai/orca', 'Orca repo', at(20, 9)),
entry('https://en.wikipedia.org/wiki/Orca', 'Orca - Wikipedia', at(21, 9))
]
expect(listBrowserHistoryRows(history, [], 'orca github').map((r) => r.title)).toEqual([
'Orca repo'
])
expect(listBrowserHistoryRows(history, [], '').map((r) => r.title)).toEqual([
'Orca - Wikipedia',
'Orca repo'
])
})
it('groups by the local day of the last visit, newest day first', () => {
const rows = listBrowserHistoryRows(
[
entry('https://a.test', 'A', at(21, 23)),
entry('https://b.test', 'B', at(22, 0)),
entry('https://c.test', 'C', at(22, 8))
],
[],
''
)
expect(groupBrowserHistoryRowsByDay(rows).map((day) => day.rows.map((r) => r.title))).toEqual([
['C', 'B'],
['A']
])
})
it('clears from an hour ago, from local midnight, or everything', () => {
const now = at(22, 8)
expect(browserHistoryClearSince('hour', now)).toBe(at(22, 7))
expect(browserHistoryClearSince('today', now)).toBe(at(22, 0))
expect(browserHistoryClearSince('all', now)).toBeUndefined()
})
})
@@ -0,0 +1,66 @@
import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types'
import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history'
import { startOfLocalDay } from '../../../../../shared/automation-cron-occurrence'
import {
listBrowserHistoryRecents,
type BrowserAddressBarSuggestion
} from '../assemble-chrome/browser-address-bar-suggestions'
export type BrowserHistoryClearRange = 'hour' | 'today' | 'all'
export type BrowserHistoryDay = {
/** Local midnight of the day, which also orders the groups. */
startsAt: number
rows: BrowserAddressBarSuggestion[]
}
/** The cmdk value for a row: unique across pages and documents. */
export function browserHistoryRowKey(row: BrowserAddressBarSuggestion): string {
return row.docLocation
? `doc:${row.docLocation.worktreeId}:${row.docLocation.filePath}`
: `url:${row.normalizedUrl ?? row.url}`
}
/**
* Pages and documents as one list, newest first. Like Chrome's history search, every word must
* appear in the title or the address, in any order.
*/
export function listBrowserHistoryRows(
browserUrlHistory: readonly BrowserHistoryEntry[],
workspaceDocHistory: readonly WorkspaceDocHistoryEntry[],
query: string
): BrowserAddressBarSuggestion[] {
const words = query.toLowerCase().split(/\s+/).filter(Boolean)
return listBrowserHistoryRecents(browserUrlHistory, workspaceDocHistory).filter((row) => {
const text = `${row.title}\n${row.subtitle}`.toLowerCase()
return words.every((word) => text.includes(word))
})
}
/** Groups rows already sorted newest first by the local day of their last visit. */
export function groupBrowserHistoryRowsByDay(
rows: readonly BrowserAddressBarSuggestion[]
): BrowserHistoryDay[] {
const days: BrowserHistoryDay[] = []
for (const row of rows) {
const startsAt = startOfLocalDay(row.lastVisitedAt)
const day = days.at(-1)
if (day?.startsAt === startsAt) {
day.rows.push(row)
} else {
days.push({ startsAt, rows: [row] })
}
}
return days
}
/** The earliest visit a clear removes; undefined clears everything. */
export function browserHistoryClearSince(
range: BrowserHistoryClearRange,
now = Date.now()
): number | undefined {
if (range === 'hour') {
return now - 60 * 60 * 1000
}
return range === 'today' ? startOfLocalDay(now) : undefined
}
@@ -0,0 +1,72 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useAppStore } from '@/store'
import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types'
import { useBrowserHistoryShortcut } from './use-browser-history-shortcut'
function Pane({
pageId,
workspaceId,
chromeShortcutScope
}: {
pageId: string
workspaceId: string
chromeShortcutScope: BrowserChromeShortcutScope
}): null {
useBrowserHistoryShortcut({
browserTabId: pageId,
workspaceId,
isActive: true,
chromeShortcutScope
})
return null
}
function pressShowHistory(target: Element): void {
target.dispatchEvent(
new KeyboardEvent('keydown', { key: 'y', code: 'KeyY', metaKey: true, bubbles: true })
)
}
describe('useBrowserHistoryShortcut', () => {
beforeEach(() => {
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
})
Object.defineProperty(window, 'api', {
configurable: true,
value: { ui: { onShowBrowserHistory: () => () => {} } }
})
useAppStore.setState({ browserHistoryPageId: null })
})
afterEach(() => {
cleanup()
document.body.replaceChildren()
})
it('keeps a floating browser history open on its own page when the shortcut repeats', () => {
render(
<>
<Pane pageId="main-page" workspaceId="main-ws" chromeShortcutScope="focused" />
<Pane pageId="floating-page" workspaceId="floating-ws" chromeShortcutScope="owned-target" />
</>
)
const floatingPanel = document.createElement('div')
floatingPanel.setAttribute('data-floating-terminal-panel', '')
const floatingChrome = document.createElement('div')
floatingChrome.setAttribute('data-browser-overlay-tab-id', 'floating-ws')
floatingPanel.append(floatingChrome)
// The dialog portals its search field to the body, outside both panes.
const dialogSearch = document.createElement('input')
document.body.append(floatingPanel, dialogSearch)
act(() => pressShowHistory(floatingChrome))
expect(useAppStore.getState().browserHistoryPageId).toBe('floating-page')
act(() => pressShowHistory(dialogSearch))
expect(useAppStore.getState().browserHistoryPageId).toBe('floating-page')
})
})
@@ -0,0 +1,71 @@
import { useEffect } from 'react'
import { useAppStore } from '@/store'
import { getShortcutPlatform } from '@/hooks/useShortcutLabel'
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
import { keybindingMatchesAction } from '../../../../../shared/keybindings'
import { browserChromeShortcutOwnsEvent } from '../describe-page/browser-overlay-shortcut-target'
import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types'
/** Cmd+Y (Ctrl+H on Linux/Windows) opens the history view, from the page or from Orca's chrome. */
export function useBrowserHistoryShortcut({
browserTabId,
workspaceId,
isActive,
chromeShortcutScope
}: {
browserTabId: string
workspaceId: string
isActive: boolean
chromeShortcutScope: BrowserChromeShortcutScope
}): void {
const setBrowserHistoryPageId = useAppStore((s) => s.setBrowserHistoryPageId)
const keybindings = useAppStore((s) => s.keybindings)
useEffect(() => {
if (!isActive) {
return
}
// Why: a focused page swallows the chord in main, which forwards it here.
const unsubscribe = window.api.ui.onShowBrowserHistory(({ browserPageId }) => {
if (browserPageId === browserTabId) {
setBrowserHistoryPageId(browserTabId)
}
})
return () => {
unsubscribe()
// Why: a history view left over a page the user switched away from would open its pick there.
if (useAppStore.getState().browserHistoryPageId === browserTabId) {
setBrowserHistoryPageId(null)
}
}
}, [browserTabId, isActive, setBrowserHistoryPageId])
useEffect(() => {
if (chromeShortcutScope === 'inactive') {
return
}
const shortcutPlatform = getShortcutPlatform()
const handleKeyDown = (event: KeyboardEvent): void => {
if (!keybindingMatchesAction('browser.showHistory', event, shortcutPlatform, keybindings)) {
return
}
// Why: an open view keeps its page; its portaled search field sits outside every pane, so
// ownership would hand the chord, and the next pick, to whichever pane claims stray keys.
const historyOpen = useAppStore.getState().browserHistoryPageId !== null
if (
!historyOpen &&
!browserChromeShortcutOwnsEvent(chromeShortcutScope, event, workspaceId)
) {
return
}
event.preventDefault()
// Why: every mounted pane registers this same capture listener on window.
event.stopImmediatePropagation()
if (!historyOpen) {
setBrowserHistoryPageId(browserTabId)
}
}
addAppKeyListener('keydown', handleKeyDown)
return () => removeAppKeyListener('keydown', handleKeyDown)
}, [browserTabId, chromeShortcutScope, keybindings, setBrowserHistoryPageId, workspaceId])
}
@@ -90,7 +90,7 @@ function BrowserNewTabPageLayout({
}): React.JSX.Element {
const browserUrlHistory = useAppStore((s) => s.browserUrlHistory)
const removeBrowserHistoryEntry = useAppStore((s) => s.removeBrowserHistoryEntry)
const restoreBrowserHistoryEntry = useAppStore((s) => s.restoreBrowserHistoryEntry)
const restoreBrowserHistory = useAppStore((s) => s.restoreBrowserHistory)
const suggested = useMemo(
() =>
rankBrowserTopSites({
@@ -116,7 +116,8 @@ function BrowserNewTabPageLayout({
{
action: {
label: translate('browser.newTab.undo', 'Undo'),
onClick: () => restoreBrowserHistoryEntry(removed)
onClick: () =>
restoreBrowserHistory({ browserUrlHistory: [removed], workspaceDocHistory: [] })
}
}
)
@@ -199,7 +199,9 @@ describe('RemoteBrowserPagePane address bar parity', () => {
vi.stubGlobal('cancelAnimationFrame', () => {})
Object.defineProperty(window, 'api', {
configurable: true,
value: { ui: { onFocusBrowserAddressBar: () => () => {} } }
value: {
ui: { onFocusBrowserAddressBar: () => () => {}, onShowBrowserHistory: () => () => {} }
}
})
useAppStore.setState({
browserUrlHistory: [],
@@ -147,7 +147,9 @@ beforeEach(() => {
mocks.viewportRenders.current = 0
Object.defineProperty(window, 'api', {
configurable: true,
value: { ui: { onFocusBrowserAddressBar: () => () => {} } }
value: {
ui: { onFocusBrowserAddressBar: () => () => {}, onShowBrowserHistory: () => () => {} }
}
})
})
@@ -111,11 +111,6 @@ export function DocPreviewAddressEdit({
return (
<div
className="flex min-w-0 flex-1"
onKeyDown={(event) => {
if (event.key === 'Escape') {
exitEdit()
}
}}
onBlur={(event) => {
// Why a grace timer and not an immediate exit: a suggestion click blurs the input before
// its own click lands, and unmounting the list mid-gesture would swallow the commit.
@@ -134,6 +129,8 @@ export function DocPreviewAddressEdit({
<BrowserAddressBar
pageId={previewId}
value={value}
committedValue={`${identity.directoryPrefix}${identity.fileName}`}
onLeave={exitEdit}
onChange={setValue}
onSubmit={submit}
onNavigate={navigateToUrl}
+22
View File
@@ -193,6 +193,28 @@
},
"managedProfile": {
"createFailed": "Couldn't create this workspace's browser profile. Close this tab and open a new one to try again."
},
"history": {
"clear": "Clear browsing history",
"clearAllTime": "All time",
"clearLastHour": "Last hour",
"clearRange": "Clear history from",
"clearToday": "Today",
"cleared": "Cleared browsing history",
"close": "Close",
"description": "Search and manage browsing history",
"empty": "Pages you visit will show up here",
"enterKey": "Enter",
"escKey": "Esc",
"menuItem": "History",
"noMatches": "No pages match your search",
"open": "Open",
"remove": "Remove from history",
"removeShort": "Remove",
"search": "Search history",
"title": "History",
"today": "Today",
"yesterday": "Yesterday"
}
},
"editor": {
+37 -2
View File
@@ -436,16 +436,51 @@ describe('createBrowserSlice annotations', () => {
const removed = store.getState().removeBrowserHistoryEntry(visited!.normalizedUrl)
expect(removed).toBe(visited)
expect(store.getState().browserUrlHistory).toEqual([])
store.getState().restoreBrowserHistoryEntry(removed!)
store
.getState()
.restoreBrowserHistory({ browserUrlHistory: [removed!], workspaceDocHistory: [] })
expect(store.getState().browserUrlHistory).toEqual([visited])
store.getState().removeBrowserHistoryEntry(visited!.normalizedUrl)
store.getState().addBrowserHistoryEntry('https://example.com', 'Example')
const revisited = store.getState().browserUrlHistory
store.getState().restoreBrowserHistoryEntry(removed!)
store
.getState()
.restoreBrowserHistory({ browserUrlHistory: [removed!], workspaceDocHistory: [] })
expect(store.getState().browserUrlHistory).toBe(revisited)
})
it('clears pages and documents visited since a time, and Undo puts back exactly those', () => {
vi.useFakeTimers()
try {
const store = createTestStore()
const doc = { kind: 'workspace-doc' as const, worktreeId: 'wt-1', filePath: '/repo/a.html' }
vi.setSystemTime(1_000)
store.getState().addBrowserHistoryEntry('https://old.example', 'Old')
vi.setSystemTime(5_000)
store.getState().addBrowserHistoryEntry('https://new.example', 'New')
store.getState().recordWorkspaceDocVisit(doc, 'Doc')
const removed = store.getState().clearBrowserHistory(5_000)
expect(store.getState().browserUrlHistory.map((e) => e.title)).toEqual(['Old'])
expect(store.getState().workspaceDocHistory).toEqual([])
store.getState().restoreBrowserHistory(removed)
expect(
store
.getState()
.browserUrlHistory.map((e) => e.title)
.sort()
).toEqual(['New', 'Old'])
expect(store.getState().workspaceDocHistory.map((e) => e.title)).toEqual(['Doc'])
store.getState().clearBrowserHistory()
expect(store.getState().browserUrlHistory).toEqual([])
} finally {
vi.useRealTimers()
}
})
it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', async () => {
const { store, main } = await createTestStoreDrawnFromMain()
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
@@ -1,5 +1,10 @@
import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types'
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import type {
BrowserSlice,
BrowserSliceGet,
BrowserSliceSet,
RemovedBrowserHistory
} from './browser-slice-contract'
import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history'
import { redactKagiSessionToken } from '../../../../../shared/browser-url'
import {
@@ -23,8 +28,10 @@ export function createBrowserHistoryActions(
| 'recordWorkspaceDocVisit'
| 'addBrowserHistoryEntry'
| 'removeBrowserHistoryEntry'
| 'restoreBrowserHistoryEntry'
| 'restoreBrowserHistory'
| 'removeWorkspaceDocHistoryEntry'
| 'clearBrowserHistory'
| 'setBrowserHistoryPageId'
> {
return {
recordWorkspaceDocVisit: (docLocation, title, options) => {
@@ -125,16 +132,50 @@ export function createBrowserHistoryActions(
return removed
},
restoreBrowserHistoryEntry: (entry) => {
set((s) =>
restoreBrowserHistory: (removed) => {
set((s) => {
// A revisit between remove and Undo already re-created the row; keep the live one.
s.browserUrlHistory.some((existing) => existing.normalizedUrl === entry.normalizedUrl)
? s
: { browserUrlHistory: [entry, ...s.browserUrlHistory] }
)
const urls = removed.browserUrlHistory.filter(
(entry) => !s.browserUrlHistory.some((live) => live.normalizedUrl === entry.normalizedUrl)
)
const docs = removed.workspaceDocHistory.filter(
(entry) =>
!s.workspaceDocHistory.some((live) =>
browserPageDocLocationsEqual(live.docLocation, entry.docLocation)
)
)
if (urls.length === 0 && docs.length === 0) {
return s
}
return {
browserUrlHistory: [...urls, ...s.browserUrlHistory],
workspaceDocHistory: [...docs, ...s.workspaceDocHistory]
}
})
},
removeWorkspaceDocHistoryEntry: (docLocation) => {
set((s) => ({
workspaceDocHistory: s.workspaceDocHistory.filter(
(entry) => !browserPageDocLocationsEqual(entry.docLocation, docLocation)
)
}))
},
// One clear for both sources: the dropdown presents them as one history.
clearBrowserHistory: () => set({ browserUrlHistory: [], workspaceDocHistory: [] })
clearBrowserHistory: (since = -Infinity) => {
const { browserUrlHistory, workspaceDocHistory } = get()
const removed: RemovedBrowserHistory = {
browserUrlHistory: browserUrlHistory.filter((entry) => entry.lastVisitedAt >= since),
workspaceDocHistory: workspaceDocHistory.filter((entry) => entry.lastVisitedAt >= since)
}
set({
browserUrlHistory: browserUrlHistory.filter((entry) => entry.lastVisitedAt < since),
workspaceDocHistory: workspaceDocHistory.filter((entry) => entry.lastVisitedAt < since)
})
return removed
},
setBrowserHistoryPageId: (pageId) => set({ browserHistoryPageId: pageId })
}
}
@@ -106,6 +106,11 @@ export type RemoteBrowserPageHandle = {
restoredClientHosted?: true
}
export type RemovedBrowserHistory = {
browserUrlHistory: BrowserHistoryEntry[]
workspaceDocHistory: WorkspaceDocHistoryEntry[]
}
export type BrowserCookieImportExecutionResult = BrowserCookieImportResult & {
executionHostId: ExecutionHostId
executionHostLabel: string
@@ -275,15 +280,21 @@ export type BrowserSlice = {
addBrowserHistoryEntry: (url: string, title: string, faviconUrl?: string | null) => void
/** Returns the removed entry so an Undo can put it back with its visit count. */
removeBrowserHistoryEntry: (normalizedUrl: string) => BrowserHistoryEntry | null
restoreBrowserHistoryEntry: (entry: BrowserHistoryEntry) => void
/** Puts removed or cleared entries back, except ones a revisit has already re-created. */
restoreBrowserHistory: (removed: RemovedBrowserHistory) => void
workspaceDocHistory: WorkspaceDocHistoryEntry[]
removeWorkspaceDocHistoryEntry: (docLocation: BrowserPageDocLocation) => void
/** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */
recordWorkspaceDocVisit: (
docLocation: BrowserPageDocLocation,
title: string | null,
options?: { bump?: boolean }
) => void
clearBrowserHistory: () => void
/** Clears pages and documents last visited at or after `since` (everything when omitted). */
clearBrowserHistory: (since?: number) => RemovedBrowserHistory
/** The page whose history view is open; it opens what you pick in that page. */
browserHistoryPageId: string | null
setBrowserHistoryPageId: (pageId: string | null) => void
defaultBrowserSessionProfileId: string | null
defaultBrowserSessionProfileIdByHostId: Partial<Record<ExecutionHostId, string | null>>
setDefaultBrowserSessionProfileId: (profileId: string | null) => void
@@ -38,6 +38,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
browserSessionImportState: null,
browserUrlHistory: [],
workspaceDocHistory: [],
browserHistoryPageId: null,
defaultBrowserSessionProfileId: null,
defaultBrowserSessionProfileIdByHostId: {},
detectedBrowsers: [],
@@ -245,6 +245,7 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
replyTabClose: () => {},
onNewTerminalTab: () => noopUnsubscribe,
onFocusBrowserAddressBar: () => noopUnsubscribe,
onShowBrowserHistory: () => noopUnsubscribe,
onFindInBrowserPage: () => noopUnsubscribe,
onReloadBrowserPage: () => noopUnsubscribe,
onBrowserHistoryNavigate: () => noopUnsubscribe,
@@ -276,5 +276,14 @@ export const KEYBINDING_DEFINITION_CORE_2: readonly KeybindingDefinition[] = [
linux: ['Alt+ArrowRight'],
win32: ['Alt+ArrowRight']
}
},
{
id: 'browser.showHistory',
title: 'Show Browser History',
group: 'Browser',
scope: 'browser',
searchKeywords: ['shortcut', 'browser', 'history', 'visited', 'clear'],
// Why Chrome's split: Ctrl+Y is redo (and readline yank) in pages on Linux/Windows.
defaultBindings: { darwin: ['Mod+Y'], linux: ['Mod+H'], win32: ['Mod+H'] }
}
]
+1
View File
@@ -85,6 +85,7 @@ export type KeybindingActionId =
| 'browser.reload'
| 'browser.hardReload'
| 'browser.focusAddressBar'
| 'browser.showHistory'
| 'browser.grabElement'
| 'browser.annotateElement'
| 'editor.find'