feat: browser URL history and address bar autocomplete (#738)

This commit is contained in:
Jinwoo Hong 2026-04-16 23:07:46 -04:00 committed by GitHub
parent eb8901ce36
commit 63b2353cfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 371 additions and 46 deletions

View file

@ -0,0 +1,248 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Globe } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { useAppStore } from '@/store'
const MAX_SUGGESTIONS = 8
type BrowserAddressBarProps = {
value: string
onChange: (value: string) => void
onSubmit: () => void
onNavigate: (url: string) => void
inputRef: React.RefObject<HTMLInputElement | null>
}
function scoreSuggestion(
entry: { url: string; title: string; lastVisitedAt: number; visitCount: number },
query: string
): number {
const lowerQuery = query.toLowerCase()
const lowerUrl = entry.url.toLowerCase()
const lowerTitle = entry.title.toLowerCase()
if (!lowerUrl.includes(lowerQuery) && !lowerTitle.includes(lowerQuery)) {
return -1
}
let score = 0
if (lowerUrl.startsWith(lowerQuery) || lowerUrl.startsWith(`https://${lowerQuery}`)) {
score += 100
}
score += Math.min(entry.visitCount, 50)
const ageHours = (Date.now() - entry.lastVisitedAt) / (1000 * 60 * 60)
score += Math.max(0, 24 - ageHours)
return score
}
export default function BrowserAddressBar({
value,
onChange,
onSubmit,
onNavigate,
inputRef
}: BrowserAddressBarProps): React.ReactElement {
const [open, setOpen] = useState(false)
const [selectedValue, setSelectedValue] = useState('')
const browserUrlHistory = useAppStore((s) => s.browserUrlHistory)
const closingRef = useRef(false)
const openedAtRef = useRef(0)
const suggestions = useMemo(() => {
if (browserUrlHistory.length === 0) {
return []
}
const trimmed = value.trim()
if (trimmed === '' || trimmed === 'about:blank' || trimmed.startsWith('data:')) {
return [...browserUrlHistory]
.sort((a, b) => b.lastVisitedAt - a.lastVisitedAt)
.slice(0, MAX_SUGGESTIONS)
}
const scored = browserUrlHistory
.map((entry) => ({ entry, score: scoreSuggestion(entry, trimmed) }))
.filter((item) => item.score >= 0)
.sort((a, b) => b.score - a.score)
.slice(0, MAX_SUGGESTIONS)
return scored.map((item) => item.entry)
}, [browserUrlHistory, value])
const handleFocus = useCallback(() => {
if (closingRef.current) {
return
}
inputRef.current?.select()
if (browserUrlHistory.length > 0) {
openedAtRef.current = Date.now()
setOpen(true)
}
}, [browserUrlHistory.length, inputRef])
const handleBlur = useCallback(() => {
// Why: delay close so that clicking a suggestion item registers before
// the popover unmounts. Without this, onSelect never fires because the
// mousedown on PopoverContent triggers input blur first.
//
// Why (grace window): BrowserPane's focusAddressBarNow() retries focus
// across multiple animation frames to fight webview focus stealing. Each
// cycle can cause a transient blur on the input. Without this guard the
// popover opens on focus, immediately gets a blur, and closes ~150ms later
// — producing the "flash then disappear" on first click.
const elapsed = Date.now() - openedAtRef.current
const grace = elapsed < 400
setTimeout(() => {
if (grace && inputRef.current && document.activeElement === inputRef.current) {
return
}
setOpen(false)
}, 200)
}, [inputRef])
const handleSelect = useCallback(
(url: string) => {
closingRef.current = true
setOpen(false)
onNavigate(url)
setTimeout(() => {
closingRef.current = false
}, 100)
},
[onNavigate]
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
setOpen(false)
setSelectedValue('')
return
}
if (!open || suggestions.length === 0) {
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedValue((prev) => {
const idx = suggestions.findIndex((s) => s.url === prev)
const next = idx < suggestions.length - 1 ? idx + 1 : 0
return suggestions[next].url
})
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedValue((prev) => {
const idx = suggestions.findIndex((s) => s.url === prev)
const next = idx > 0 ? idx - 1 : suggestions.length - 1
return suggestions[next].url
})
return
}
if (event.key === 'Enter' && selectedValue) {
const match = suggestions.find((s) => s.url === selectedValue)
if (match) {
event.preventDefault()
handleSelect(match.url)
}
}
},
[open, suggestions, selectedValue, handleSelect]
)
useEffect(() => {
if (open && suggestions.length === 0) {
setOpen(false)
}
}, [open, suggestions.length])
useEffect(() => {
setSelectedValue('')
}, [suggestions])
return (
<Popover
open={open}
onOpenChange={(next) => {
// Why: Radix fires onOpenChange(false) when it detects an outside
// interaction, but during the focus-retry loop the input may still
// hold focus. Only allow programmatic closes (setOpen(false) from
// our handlers) or genuine outside dismissals.
if (!next && inputRef.current && document.activeElement === inputRef.current) {
return
}
setOpen(next)
}}
>
<PopoverTrigger asChild>
<form
className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border border-border bg-background px-3 py-1 shadow-sm"
onSubmit={(event) => {
event.preventDefault()
setOpen(false)
onSubmit()
}}
>
<Globe className="size-4 shrink-0 text-muted-foreground" />
<Input
ref={inputRef}
value={value}
onChange={(event) => onChange(event.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
data-orca-browser-address-bar="true"
className="h-auto border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
role="combobox"
aria-expanded={open}
aria-controls="browser-history-listbox"
aria-autocomplete="list"
/>
</form>
</PopoverTrigger>
{suggestions.length > 0 && (
<PopoverContent
align="start"
sideOffset={4}
className="w-[var(--radix-popover-trigger-width)] p-0"
onOpenAutoFocus={(e) => {
// Why: prevent the popover from stealing focus away from the
// address bar input. The user is still typing; the popover is
// an overlay of suggestions, not a focus target.
e.preventDefault()
}}
>
<Command shouldFilter={false} value={selectedValue} onValueChange={setSelectedValue}>
<CommandList id="browser-history-listbox" role="listbox">
<CommandGroup>
{suggestions.map((entry) => (
<CommandItem
key={entry.url}
value={entry.url}
onSelect={() => handleSelect(entry.url)}
className="flex items-center gap-2 px-3 py-2"
>
<Globe className="size-3.5 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{entry.title}</span>
<span className="truncate text-xs text-muted-foreground">{entry.url}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
)
}

View file

@ -65,6 +65,7 @@ import type {
import { useGrabMode } from './useGrabMode'
import { formatGrabPayloadAsText } from './GrabConfirmationSheet'
import { isEditableKeyboardTarget } from './browser-keyboard'
import BrowserAddressBar from './BrowserAddressBar'
import BrowserFind from './BrowserFind'
import {
consumeBrowserFocusRequest,
@ -377,6 +378,8 @@ function BrowserPagePane({
const lastKnownWebviewUrlRef = useRef<string | null>(null)
const onUpdatePageStateRef = useRef(onUpdatePageState)
const onSetUrlRef = useRef(onSetUrl)
const addBrowserHistoryEntry = useAppStore((s) => s.addBrowserHistoryEntry)
const addBrowserHistoryEntryRef = useRef(addBrowserHistoryEntry)
const [addressBarValue, setAddressBarValue] = useState(browserTab.url)
const addressBarValueRef = useRef(browserTab.url)
const [resourceNotice, setResourceNotice] = useState<string | null>(null)
@ -957,7 +960,8 @@ function BrowserPagePane({
useEffect(() => {
onUpdatePageStateRef.current = onUpdatePageState
onSetUrlRef.current = onSetUrl
}, [onSetUrl, onUpdatePageState])
addBrowserHistoryEntryRef.current = addBrowserHistoryEntry
}, [onSetUrl, onUpdatePageState, addBrowserHistoryEntry])
const syncNavigationState = useCallback(
(webview: Electron.WebviewTag): void => {
@ -1133,9 +1137,10 @@ function BrowserPagePane({
const handleTitleUpdate = (event: { title?: string }): void => {
try {
onUpdatePageStateRef.current(browserTab.id, {
title: getBrowserDisplayTitle(event.title, webview.getURL() || browserTab.url)
})
const currentUrl = webview.getURL() || browserTab.url
const title = getBrowserDisplayTitle(event.title, currentUrl)
onUpdatePageStateRef.current(browserTab.id, { title })
addBrowserHistoryEntryRef.current(currentUrl, title)
} catch {
// Why: title-updated can fire before dom-ready, making getURL() throw.
}
@ -1541,6 +1546,31 @@ function BrowserPagePane({
grab.rearm()
}, [grab, showGrabToast])
const navigateToUrl = useCallback(
(url: string): void => {
setAddressBarValue(toDisplayUrl(url))
onSetUrlRef.current(browserTab.id, url)
onUpdatePageStateRef.current(browserTab.id, {
loading: true,
loadError: null,
title: getBrowserDisplayTitle(url, url)
})
setResourceNotice(null)
const webview = webviewRef.current
if (!webview) {
return
}
trackNextLoadingEventRef.current = url !== ORCA_BROWSER_BLANK_URL
lastKnownWebviewUrlRef.current = url
webview.src = url
if (url !== ORCA_BROWSER_BLANK_URL) {
focusWebviewNow()
}
},
[browserTab.id, focusWebviewNow]
)
const submitAddressBar = (): void => {
keepAddressBarFocusRef.current = false
const nextUrl = normalizeBrowserNavigationUrl(addressBarValue)
@ -1554,26 +1584,7 @@ function BrowserPagePane({
})
return
}
setAddressBarValue(toDisplayUrl(nextUrl))
onSetUrlRef.current(browserTab.id, nextUrl)
onUpdatePageStateRef.current(browserTab.id, {
loading: true,
loadError: null,
title: getBrowserDisplayTitle(nextUrl, nextUrl)
})
setResourceNotice(null)
const webview = webviewRef.current
if (!webview) {
return
}
trackNextLoadingEventRef.current = nextUrl !== ORCA_BROWSER_BLANK_URL
lastKnownWebviewUrlRef.current = nextUrl
webview.src = nextUrl
if (nextUrl !== ORCA_BROWSER_BLANK_URL) {
focusWebviewNow()
}
navigateToUrl(nextUrl)
}
// Why: the store initially holds 'about:blank', but once the webview loads
@ -1920,25 +1931,13 @@ function BrowserPagePane({
)}
</Button>
<form
className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border border-border bg-background px-3 py-1 shadow-sm"
onSubmit={(event) => {
event.preventDefault()
submitAddressBar()
}}
>
<Globe className="size-4 shrink-0 text-muted-foreground" />
<Input
ref={addressBarInputRef}
value={addressBarValue}
onChange={(event) => setAddressBarValue(event.target.value)}
data-orca-browser-address-bar="true"
className="h-auto border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
/>
</form>
<BrowserAddressBar
value={addressBarValue}
onChange={setAddressBarValue}
onSubmit={submitAddressBar}
onNavigate={navigateToUrl}
inputRef={addressBarInputRef}
/>
<Button
size="icon"

View file

@ -22,6 +22,7 @@ type WorkspaceSessionSnapshot = Pick<
| 'browserTabsByWorktree'
| 'browserPagesByWorkspace'
| 'activeBrowserTabIdByWorktree'
| 'browserUrlHistory'
| 'unifiedTabsByWorktree'
| 'groupsByWorktree'
| 'layoutByWorktree'
@ -151,6 +152,7 @@ export function buildWorkspaceSessionPayload(
snapshot.browserPagesByWorkspace,
snapshot.activeBrowserTabIdByWorktree
),
browserUrlHistory: snapshot.browserUrlHistory,
unifiedTabs: snapshot.unifiedTabsByWorktree,
tabGroups: snapshot.groupsByWorktree,
tabGroupLayouts: snapshot.layoutByWorktree,

View file

@ -4,6 +4,7 @@ import type { AppState } from '../types'
import type {
BrowserCookieImportResult,
BrowserCookieImportSummary,
BrowserHistoryEntry,
BrowserLoadError,
BrowserPage,
BrowserSessionProfile,
@ -91,6 +92,40 @@ export type BrowserSlice = {
browserFamily: string
) => Promise<BrowserCookieImportResult>
clearDefaultSessionCookies: () => Promise<boolean>
browserUrlHistory: BrowserHistoryEntry[]
addBrowserHistoryEntry: (url: string, title: string) => void
clearBrowserHistory: () => void
}
const MAX_BROWSER_HISTORY_ENTRIES = 200
function normalizeHistoryUrl(url: string): string {
try {
const parsed = new URL(url)
parsed.hostname = parsed.hostname.toLowerCase()
parsed.protocol = parsed.protocol.toLowerCase()
let normalized = parsed.toString()
if (normalized.endsWith('/')) {
normalized = normalized.slice(0, -1)
}
return normalized
} catch {
return url.toLowerCase()
}
}
function deduplicateHistory(entries: BrowserHistoryEntry[]): BrowserHistoryEntry[] {
const seen = new Set<string>()
const deduped: BrowserHistoryEntry[] = []
for (const entry of entries) {
const key = entry.normalizedUrl || normalizeHistoryUrl(entry.url)
if (seen.has(key)) {
continue
}
seen.add(key)
deduped.push(entry.normalizedUrl ? entry : { ...entry, normalizedUrl: key })
}
return deduped.slice(0, MAX_BROWSER_HISTORY_ENTRIES)
}
function normalizeUrl(url: string): string {
@ -251,6 +286,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
pendingAddressBarFocusByPageId: {},
browserSessionProfiles: [],
browserSessionImportState: null,
browserUrlHistory: [],
createBrowserTab: (worktreeId, url, options) => {
const workspaceId = globalThis.crypto.randomUUID()
@ -1001,7 +1037,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
activeBrowserTabIdByWorktree,
activeBrowserTabId,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
activeTabType
activeTabType,
browserUrlHistory: deduplicateHistory(session.browserUrlHistory ?? [])
}
})
@ -1190,5 +1227,33 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
} catch {
return false
}
}
},
addBrowserHistoryEntry: (url, title) => {
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank' || !url) {
return
}
const normalized = normalizeHistoryUrl(url)
set((s) => {
const existing = s.browserUrlHistory.find((entry) => entry.normalizedUrl === normalized)
let next: BrowserHistoryEntry[] = existing
? s.browserUrlHistory.map((entry) =>
entry === existing
? { ...entry, title, lastVisitedAt: Date.now(), visitCount: entry.visitCount + 1 }
: entry
)
: [
{ url, normalizedUrl: normalized, title, lastVisitedAt: Date.now(), visitCount: 1 },
...s.browserUrlHistory
]
if (next.length > MAX_BROWSER_HISTORY_ENTRIES) {
next = next
.sort((a, b) => b.lastVisitedAt - a.lastVisitedAt)
.slice(0, MAX_BROWSER_HISTORY_ENTRIES)
}
return { browserUrlHistory: next }
})
},
clearBrowserHistory: () => set({ browserUrlHistory: [] })
})

View file

@ -180,6 +180,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState {
browserPagesByWorkspace: {},
activeBrowserTabIdByWorktree: {},
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {}
activeTabTypeByWorktree: {},
browserUrlHistory: []
}
}

View file

@ -119,6 +119,14 @@ export type TerminalTab = {
generation?: number
}
export type BrowserHistoryEntry = {
url: string
normalizedUrl: string
title: string
lastVisitedAt: number
visitCount: number
}
export type BrowserLoadError = {
code: number
description: string
@ -262,6 +270,8 @@ export type WorkspaceSessionState = {
activeBrowserTabIdByWorktree?: Record<string, string | null>
/** Per-worktree active tab type (terminal vs editor vs browser) at shutdown. */
activeTabTypeByWorktree?: Record<string, WorkspaceVisibleTabType>
/** Global browser URL history for address bar autocomplete. */
browserUrlHistory?: BrowserHistoryEntry[]
/** Per-worktree last-active terminal tab ID at shutdown. */
activeTabIdByWorktree?: Record<string, string | null>
/** Unified tab model present when saved by a build that includes TabsSlice.