mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat: browser URL history and address bar autocomplete (#738)
This commit is contained in:
parent
eb8901ce36
commit
63b2353cfd
6 changed files with 371 additions and 46 deletions
248
src/renderer/src/components/browser-pane/BrowserAddressBar.tsx
Normal file
248
src/renderer/src/components/browser-pane/BrowserAddressBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [] })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState {
|
|||
browserPagesByWorkspace: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {}
|
||||
activeTabTypeByWorktree: {},
|
||||
browserUrlHistory: []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue