feat(browser): add favicon display for website tabs and history

- Capture favicons from navigation events and store in browser history
- Display favicons in tab bar, tab search, and jump palette results
- Clear favicons on cross-origin navigation; preserve on same-origin
- Extract favicon rendering into reusable BrowserFavicon component
- Validate and normalize favicon URLs (http/https/data URIs only)
This commit is contained in:
Jinjing
2026-09-06 00:02:06 -07:00
parent 4f4872c424
commit 3419c4f93a
27 changed files with 542 additions and 116 deletions
@@ -0,0 +1,71 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { BrowserFavicon } from './browser-favicon'
let container: HTMLDivElement
let root: Root
function render(faviconUrl: string | null, loading = false): void {
act(() =>
root.render(<BrowserFavicon faviconUrl={faviconUrl} loading={loading} className="size-4" />)
)
}
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
describe('BrowserFavicon', () => {
it('renders a normalized web favicon', () => {
render(' https://example.com/favicon.ico ')
expect(container.querySelector('img')?.getAttribute('src')).toBe(
'https://example.com/favicon.ico'
)
expect(container.querySelector('img')?.className).toContain('size-4')
})
it('falls back for invalid and failed favicons', () => {
render('javascript:alert(1)')
expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('svg')).not.toBeNull()
render('https://example.com/favicon.ico')
act(() => container.querySelector('img')?.dispatchEvent(new Event('error')))
expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('svg')).not.toBeNull()
})
it('retries a failed URL after its identity changes', () => {
const faviconUrl = 'https://example.com/favicon.ico'
render(faviconUrl)
act(() => container.querySelector('img')?.dispatchEvent(new Event('error')))
render('https://example.org/favicon.ico')
render(faviconUrl)
expect(container.querySelector('img')?.getAttribute('src')).toBe(faviconUrl)
})
it('retries a failed URL when a reload settles', () => {
const faviconUrl = 'https://example.com/favicon.ico'
render(faviconUrl)
act(() => container.querySelector('img')?.dispatchEvent(new Event('error')))
render(faviconUrl, true)
expect(container.firstElementChild?.className).toContain('motion-safe:animate-spin')
render(faviconUrl)
expect(container.querySelector('img')?.getAttribute('src')).toBe(faviconUrl)
})
})
@@ -0,0 +1,75 @@
import { useState } from 'react'
import { Globe, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { normalizeBrowserFaviconUrl } from '../../../shared/browser-favicon-url'
type BrowserFaviconClasses = {
className?: string
fallbackClassName?: string
}
function FaviconFallback({
className,
fallbackClassName
}: BrowserFaviconClasses): React.JSX.Element {
return <Globe className={cn('shrink-0', className, fallbackClassName)} aria-hidden="true" />
}
function FaviconImage({
src,
className,
fallbackClassName
}: BrowserFaviconClasses & { src: string }): React.JSX.Element {
const [failed, setFailed] = useState(false)
if (failed) {
return <FaviconFallback className={className} fallbackClassName={fallbackClassName} />
}
return (
<img
src={src}
alt=""
aria-hidden
draggable={false}
decoding="async"
loading="lazy"
fetchPriority="low"
className={cn(
'shrink-0 rounded-sm object-contain drop-shadow-[0_0_1px_var(--foreground)]',
className
)}
onError={() => setFailed(true)}
/>
)
}
export function BrowserFavicon({
faviconUrl,
loading = false,
className,
fallbackClassName
}: {
faviconUrl: string | null | undefined
loading?: boolean
className?: string
fallbackClassName?: string
}): React.JSX.Element {
if (loading) {
return (
<Loader2
className={cn('shrink-0 motion-safe:animate-spin', className, fallbackClassName)}
aria-hidden="true"
/>
)
}
const displayUrl = normalizeBrowserFaviconUrl(faviconUrl)
return displayUrl ? (
<FaviconImage
key={displayUrl}
src={displayUrl}
className={className}
fallbackClassName={fallbackClassName}
/>
) : (
<FaviconFallback className={className} fallbackClassName={fallbackClassName} />
)
}
@@ -57,7 +57,9 @@ export type AttachBrowserPageWebviewArgs = {
setPendingAnnotationPayload: Dispatch<SetStateAction<BrowserGrabPayload | null>>
setBrowserOverlayViewport: Dispatch<SetStateAction<BrowserOverlayViewport>>
setAddressBarValue: Dispatch<SetStateAction<string>>
addBrowserHistoryEntryRef: MutableRefObject<(url: string, title: string) => void>
addBrowserHistoryEntryRef: MutableRefObject<
(url: string, title: string, faviconUrl?: string) => void
>
annotationViewportBridgeTokenRef: MutableRefObject<string>
initialBrowserUrlRef: MutableRefObject<string>
validateVisibleGuestRegistrationRef: MutableRefObject<() => void>
@@ -116,6 +116,7 @@ export function bindBrowserPageWebviewListeners({
const {
handleDidStartNavigation,
handleDidRedirectNavigation,
handleFullDidNavigate,
handleDidNavigateInPage,
handleTitleUpdate,
@@ -149,6 +150,7 @@ export function bindBrowserPageWebviewListeners({
webview.addEventListener('focus', dismissAddressBarSuggestions)
webview.addEventListener('did-start-loading', handleDidStartLoading)
webview.addEventListener('did-start-navigation', handleDidStartNavigation)
webview.addEventListener('did-redirect-navigation', handleDidRedirectNavigation)
webview.addEventListener('did-stop-loading', handleDidStopLoading)
// Why: close find only on full 'did-navigate', not the shared handler, which also fires on SPA in-page hash/pushState changes.
const handleFindCloseOnNavigate = (): void => {
@@ -186,6 +188,7 @@ export function bindBrowserPageWebviewListeners({
webview.removeEventListener('focus', dismissAddressBarSuggestions)
webview.removeEventListener('did-start-loading', handleDidStartLoading)
webview.removeEventListener('did-start-navigation', handleDidStartNavigation)
webview.removeEventListener('did-redirect-navigation', handleDidRedirectNavigation)
webview.removeEventListener('did-stop-loading', handleDidStopLoading)
webview.removeEventListener('did-navigate', handleFullDidNavigate)
webview.removeEventListener('did-navigate', handleFindCloseOnNavigate)
@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from 'vitest'
import { createBrowserPageWebviewNavigationHandlers } from './browser-page-webview-navigation-handlers'
import type { BrowserTabPageState } from '../describe-page/browser-page-types'
const ICON_URL = 'https://example.com/favicon.ico'
function createHarness(startUrl = 'https://example.com/one') {
let currentUrl = startUrl
const faviconUrlRef = { current: ICON_URL as string | null }
const updatePage = vi.fn<(tabId: string, updates: BrowserTabPageState) => void>()
const addHistory = vi.fn<(url: string, title: string, faviconUrl?: string) => void>()
const ref = <T>(current: T) => ({ current })
const handlers = createBrowserPageWebviewNavigationHandlers({
webview: { getURL: () => currentUrl } as Electron.WebviewTag,
browserTabId: 'page-1',
browserTabUrl: startUrl,
recoveryNavigationValidationRef: ref(null),
activeLoadFailureRef: ref(null),
lastKnownWebviewUrlRef: ref<string | null>(startUrl),
addressBarInputRef: ref(null),
onSetUrlRef: ref(vi.fn()),
onUpdatePageStateRef: ref(updatePage),
addBrowserHistoryEntryRef: ref(addHistory),
faviconUrlRef,
setAddressBarValue: vi.fn(),
annotationViewportBridgeTokenRef: ref('token'),
setBrowserOverlayViewport: vi.fn()
})
return {
addHistory,
faviconUrlRef,
handlers,
setCurrentUrl: (url: string) => {
currentUrl = url
},
updatePage
}
}
describe('browser page favicon navigation', () => {
it('retains an icon for same-origin navigation', () => {
const harness = createHarness()
harness.handlers.handleDidStartNavigation({
isMainFrame: true,
isInPlace: false,
url: 'https://example.com/two'
} as Electron.DidStartNavigationEvent)
expect(harness.faviconUrlRef.current).toBe(ICON_URL)
expect(harness.updatePage).not.toHaveBeenCalled()
})
it('clears an icon before filing history for a different origin', () => {
const harness = createHarness()
const destination = 'https://example.org/two'
harness.handlers.handleDidStartNavigation({
isMainFrame: true,
isInPlace: false,
url: destination
} as Electron.DidStartNavigationEvent)
harness.setCurrentUrl(destination)
harness.handlers.handleTitleUpdate({ title: 'Example org' })
expect(harness.faviconUrlRef.current).toBeNull()
expect(harness.updatePage).toHaveBeenCalledWith('page-1', { faviconUrl: null })
expect(harness.addHistory).toHaveBeenCalledWith(destination, 'Example org', undefined)
})
it('clears an icon when a same-origin navigation redirects elsewhere', () => {
const harness = createHarness()
harness.handlers.handleDidRedirectNavigation({
isMainFrame: true,
isInPlace: false,
url: 'https://example.org/redirected'
} as Electron.DidRedirectNavigationEvent)
expect(harness.faviconUrlRef.current).toBeNull()
})
it('uses the first displayable icon Chromium reports', () => {
const harness = createHarness()
harness.handlers.handleFaviconUpdate({
favicons: ['data:,', 'https://example.com/second.ico']
})
expect(harness.faviconUrlRef.current).toBe('https://example.com/second.ico')
})
})
@@ -78,11 +78,7 @@ export function createBrowserPageWebviewLoadingHandlers({
if (!trackNextLoadingEventRef.current) {
return
}
faviconUrlRef.current = null
onUpdatePageStateRef.current(browserTabId, {
loading: true,
faviconUrl: null
})
onUpdatePageStateRef.current(browserTabId, { loading: true })
}
const handleDidStopLoading = (): void => {
@@ -5,6 +5,10 @@ import {
redactKagiSessionToken
} from '../../../../../shared/browser-url'
import type { BrowserLoadError } from '../../../../../shared/browser-workspace-types'
import {
browserNavigationChangesFaviconOrigin,
firstBrowserFaviconUrl
} from '../../../../../shared/browser-favicon-url'
import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery'
import { rememberLiveBrowserUrl } from '../describe-page/live-browser-url-registry'
import type { BrowserOverlayViewport } from '../describe-page/browser-annotation-geometry'
@@ -30,7 +34,9 @@ export type BrowserPageWebviewNavigationHandlersArgs = {
addressBarInputRef: RefObject<HTMLInputElement | null>
onSetUrlRef: MutableRefObject<BrowserPageUrlSetter>
onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void>
addBrowserHistoryEntryRef: MutableRefObject<(url: string, title: string) => void>
addBrowserHistoryEntryRef: MutableRefObject<
(url: string, title: string, faviconUrl?: string) => void
>
faviconUrlRef: MutableRefObject<string | null>
setAddressBarValue: Dispatch<SetStateAction<string>>
annotationViewportBridgeTokenRef: MutableRefObject<string>
@@ -39,6 +45,7 @@ export type BrowserPageWebviewNavigationHandlersArgs = {
export type BrowserPageWebviewNavigationHandlers = {
handleDidStartNavigation: (event: Electron.DidStartNavigationEvent) => void
handleDidRedirectNavigation: (event: Electron.DidRedirectNavigationEvent) => void
handleFullDidNavigate: (event: BrowserPageNavigateEvent) => void
handleDidNavigateInPage: (event: BrowserPageNavigateEvent) => void
handleTitleUpdate: (event: { title?: string }) => void
@@ -62,6 +69,24 @@ export function createBrowserPageWebviewNavigationHandlers({
annotationViewportBridgeTokenRef,
setBrowserOverlayViewport
}: BrowserPageWebviewNavigationHandlersArgs): BrowserPageWebviewNavigationHandlers {
const clearFaviconAcrossOrigins = (
event: Electron.DidStartNavigationEvent | Electron.DidRedirectNavigationEvent
): void => {
if (!event.isMainFrame || event.isInPlace || !event.url || !faviconUrlRef.current) {
return
}
let currentUrl: string | null = null
try {
currentUrl = webview.getURL() || null
} catch {
// An unattached guest has no trustworthy current origin.
}
if (browserNavigationChangesFaviconOrigin(currentUrl, event.url)) {
faviconUrlRef.current = null
onUpdatePageStateRef.current(browserTabId, { faviconUrl: null })
}
}
const handleDidStartNavigation = (event: Electron.DidStartNavigationEvent): void => {
if (!event.isMainFrame || event.isInPlace || !event.url) {
return
@@ -72,8 +97,13 @@ export function createBrowserPageWebviewNavigationHandlers({
if (pendingRecoveryNavigation?.targetUrl === startedUrl) {
pendingRecoveryNavigation.started = true
}
// Chromium may not re-announce an unchanged favicon after a same-origin navigation.
clearFaviconAcrossOrigins(event)
}
const handleDidRedirectNavigation = (event: Electron.DidRedirectNavigationEvent): void =>
clearFaviconAcrossOrigins(event)
const handleDidNavigate = (
event: { url?: string; isMainFrame?: boolean },
persistUrl = true,
@@ -127,21 +157,14 @@ export function createBrowserPageWebviewNavigationHandlers({
const browserModelUrl = redactKagiSessionToken(currentUrl)
const title = getBrowserDisplayTitle(event.title, browserModelUrl)
onUpdatePageStateRef.current(browserTabId, { title })
addBrowserHistoryEntryRef.current(browserModelUrl, title)
addBrowserHistoryEntryRef.current(browserModelUrl, title, faviconUrlRef.current ?? undefined)
} catch {
// Why: title-updated can fire before dom-ready, making getURL() throw.
}
}
const handleFaviconUpdate = (event: { favicons?: string[] }): void => {
const faviconUrl = event.favicons?.[0] ?? null
faviconUrlRef.current =
faviconUrl &&
(faviconUrl.startsWith('https://') ||
faviconUrl.startsWith('http://') ||
faviconUrl.startsWith('data:image/'))
? faviconUrl
: null
faviconUrlRef.current = firstBrowserFaviconUrl(event.favicons)
onUpdatePageStateRef.current(browserTabId, { faviconUrl: faviconUrlRef.current })
}
@@ -173,6 +196,7 @@ export function createBrowserPageWebviewNavigationHandlers({
return {
handleDidStartNavigation,
handleDidRedirectNavigation,
handleFullDidNavigate,
handleDidNavigateInPage,
handleTitleUpdate,
@@ -69,6 +69,9 @@ vi.mock('lucide-react', () => ({
Globe: function Globe(props: Record<string, unknown>) {
return { type: 'Globe', props }
},
Loader2: function Loader2(props: Record<string, unknown>) {
return { type: 'Loader2', props }
},
Pin: function Pin(props: Record<string, unknown>) {
return { type: 'Pin', props }
},
@@ -256,7 +259,9 @@ describe('BrowserTab favicon', { timeout: 30_000 }, () => {
expect(images[0].props.alt).toBe('')
expect(images[0].props['aria-hidden']).toBe(true)
expect(images[0].props.draggable).toBe(false)
expect(images[0].props.className).toContain('size-3 mr-1 shrink-0')
expect(images[0].props.className).toContain('size-3')
expect(images[0].props.className).toContain('mr-1')
expect(images[0].props.className).toContain('shrink-0')
expect(images[0].props.className).toContain('object-contain')
expect(images[0].props.className).toContain('drop-shadow-[0_0_1px_var(--foreground)]')
expect(findElementsByType(element, 'Globe')).toHaveLength(0)
@@ -274,7 +279,9 @@ describe('BrowserTab favicon', { timeout: 30_000 }, () => {
expect(findElementsByType(element, 'img')).toHaveLength(0)
const globes = findElementsByType(element, 'Globe')
expect(globes).toHaveLength(1)
expect(globes[0].props.className).toContain('size-3 mr-1 shrink-0')
expect(globes[0].props.className).toContain('size-3')
expect(globes[0].props.className).toContain('mr-1')
expect(globes[0].props.className).toContain('shrink-0')
expect(globes[0].props.className).toContain('text-blue-500')
})
@@ -289,24 +296,4 @@ describe('BrowserTab favicon', { timeout: 30_000 }, () => {
expect(findElementsByType(secondRender, 'img')).toHaveLength(0)
expect(findElementsByType(secondRender, 'Globe')).toHaveLength(1)
})
it('resets the image-error fallback when faviconUrl changes', async () => {
const tab = baseBrowserTab({ faviconUrl: 'https://example.com/favicon.ico' })
const firstRender = await renderExpandedBrowserTab(tab)
const image = findElementsByType(firstRender, 'img')[0]
;(image.props.onError as () => void)()
const failedRender = await renderExpandedBrowserTab(tab)
expect(findElementsByType(failedRender, 'Globe')).toHaveLength(1)
const nextIconUrl = 'data:image/png;base64,abc123'
const resetRender = await renderExpandedBrowserTab(
baseBrowserTab({ id: tab.id, faviconUrl: nextIconUrl })
)
const images = findElementsByType(resetRender, 'img')
expect(images).toHaveLength(1)
expect(images[0].props.src).toBe(nextIconUrl)
expect(findElementsByType(resetRender, 'Globe')).toHaveLength(0)
})
})
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import {
Globe,
X,
ExternalLink,
Copy,
@@ -39,6 +38,7 @@ import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing'
import { cn } from '@/lib/utils'
import { BrowserFavicon } from '@/components/browser-favicon'
export function formatBrowserTabUrlLabel(url: string): string {
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') {
@@ -68,51 +68,6 @@ function isBlankBrowserTab(tab: BrowserTabState): boolean {
return tab.url === ORCA_BROWSER_BLANK_URL || tab.url === 'about:blank'
}
type FailedFavicon = {
tabId: string
faviconUrl: string
}
function BrowserTabFavicon({
tabId,
faviconUrl
}: {
tabId: string
faviconUrl: string | null
}): React.JSX.Element {
const displayFaviconUrl = faviconUrl?.trim() ? faviconUrl : null
const [failedFavicon, setFailedFavicon] = useState<FailedFavicon | null>(null)
// Why: reset during render so a new favicon identity retries before the tab
// commits one frame with the stale fallback icon.
if (
failedFavicon &&
(failedFavicon.tabId !== tabId || failedFavicon.faviconUrl !== displayFaviconUrl)
) {
setFailedFavicon(null)
}
const currentFaviconFailed =
failedFavicon?.tabId === tabId && failedFavicon.faviconUrl === displayFaviconUrl
if (displayFaviconUrl && !currentFaviconFailed) {
return (
<img
src={displayFaviconUrl}
alt=""
aria-hidden
draggable={false}
// Why: transparent dark/light-mode favicons can disappear against tab
// chrome; a token-colored 1px shadow keeps the 12px mark legible.
className="size-3 mr-1 shrink-0 rounded-sm object-contain drop-shadow-[0_0_1px_var(--foreground)]"
onError={() => setFailedFavicon({ tabId, faviconUrl: displayFaviconUrl })}
/>
)
}
return <Globe className="size-3 mr-1 shrink-0 text-blue-500" />
}
export default function BrowserTab({
tab,
isActive,
@@ -227,19 +182,15 @@ export default function BrowserTab({
}}
>
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
{/* Why: the browser tab icon is the only non-terminal, non-editor
surface in the tab strip. Coloring the Globe blue (matching the
in-app browser's identity and the default tab insertion bar)
gives it a distinct, recognizable anchor so users can spot
browser tabs at a glance even when the strip is saturated. We
keep full color on both active and inactive tabs — dimming to
muted-foreground made the icon read as "disabled" in practice. */}
<BrowserTabFavicon tabId={tab.id} faviconUrl={tab.faviconUrl} />
{/* Keep favicons full-color; muted icons read as disabled in the tab strip. */}
<BrowserFavicon
faviconUrl={tab.faviconUrl}
loading={tab.loading && !tab.loadError && !isBlankBrowserTab(tab)}
className="size-3 mr-1"
fallbackClassName="text-blue-500"
/>
{isPinned && <Pin className="mr-1 size-3 shrink-0 text-muted-foreground" aria-hidden />}
<span className={`${TAB_LABEL_WIDTH_CLASSES} mr-1`}>{tabLabel}</span>
{tab.loading && !tab.loadError && !isBlankBrowserTab(tab) && (
<span className="mr-1 size-1.5 rounded-full bg-sky-500/80 shrink-0" />
)}
{!isPinned && (
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
@@ -169,6 +169,16 @@ describe('TabBarCreateEntry browser history rows', () => {
expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false)
})
it('uses the favicon captured with a history entry', () => {
const faviconUrl = 'https://linear.app/favicon.ico'
historyStoreMock.entries = [historyEntry({ ...linear, faviconUrl })]
mount()
setQuery('linear')
expect(container.querySelector<HTMLImageElement>('[role="option"] img')?.src).toBe(faviconUrl)
})
it('skips history for a path-shaped query and for a forced search', () => {
pathLikeMock.value = true
mount()
@@ -192,7 +202,8 @@ describe('TabBarCreateEntry browser history rows', () => {
contentType: 'browser',
pageId: 'page-1',
workspaceId: 'ws-1',
url: 'https://linear.app/acme/team/ORC/active'
url: 'https://linear.app/acme/team/ORC/active',
faviconUrl: null
}
]
mount()
@@ -16,6 +16,7 @@ import { translate } from '@/i18n/i18n'
import { SEARCH_ENGINE_LABELS } from '../../../../shared/browser-url'
import { formatBrowserHistoryUrl } from '@/lib/browser-history-match'
import type { ActiveOption } from './tab-create-entry-active-option'
import { BrowserFavicon } from '@/components/browser-favicon'
export const RESULT_LISTBOX_ID = 'tab-create-entry-results'
@@ -138,7 +139,7 @@ function getOpenTabIcon(option: Extract<ActiveOption, { kind: 'tab' }>['option']
return <TerminalSquare className="size-3.5 shrink-0" aria-hidden="true" />
}
if (contentType === 'browser') {
return <Globe className="size-3.5 shrink-0" aria-hidden="true" />
return <BrowserFavicon faviconUrl={option.faviconUrl} className="size-3.5" />
}
if (contentType === 'simulator') {
return <Smartphone className="size-3.5 shrink-0" aria-hidden="true" />
@@ -191,7 +192,7 @@ function getActionPresentation(option: ActiveOption): {
// Why the title is detail, not label: the label span is shrink-0 whenever a
// detail shows, so a variable-length title there would refuse to truncate.
detail: entry.title ? `${entry.title} · ${url}` : url,
icon: <Globe className="size-3.5 shrink-0" aria-hidden="true" />,
icon: <BrowserFavicon faviconUrl={entry.faviconUrl} className="size-3.5" />,
label: translate('auto.components.tab.bar.TabBarCreateEntry.openPage', 'Open page'),
showDetail: true
}
@@ -90,7 +90,8 @@ describe('dropFileEntriesCoveredByTabResults', () => {
contentType: 'browser',
pageId: 'page-1',
url: 'https://example.com/zebra',
workspaceId: 'ws-1'
workspaceId: 'ws-1',
faviconUrl: null
},
{
executionHostId: 'local',
@@ -124,12 +124,14 @@ function makeBrowserPage({
id,
title,
url = 'https://example.com/one',
faviconUrl = null,
workspaceLabel = null,
isCurrentPage = false
}: {
id: string
title: string
url?: string
faviconUrl?: string | null
workspaceLabel?: string | null
isCurrentPage?: boolean
}): SearchableBrowserPage {
@@ -140,7 +142,7 @@ function makeBrowserPage({
url,
title,
loading: false,
faviconUrl: null,
faviconUrl,
canGoBack: false,
canGoForward: false,
loadError: null,
@@ -445,10 +447,11 @@ describe('searchOpenTabs result fields', () => {
})
it('carries the activation identifiers each source needs', () => {
const faviconUrl = 'https://example.com/favicon.ico'
const results = search({
query: 'zebra',
workspaceTabs: [makeWorkspaceTab({ id: 'tab-1', title: 'Zebra tab' })],
browserPages: [makeBrowserPage({ id: 'page-1', title: 'Zebra page' })],
browserPages: [makeBrowserPage({ id: 'page-1', title: 'Zebra page', faviconUrl })],
simulatorTabs: [makeSimulatorTab({ id: 'sim-1', label: 'Zebra emulator' })]
})
@@ -467,7 +470,8 @@ describe('searchOpenTabs result fields', () => {
contentType: 'browser',
pageId: 'page-1',
workspaceId: 'page-1-ws',
worktreeId: 'wt-1'
worktreeId: 'wt-1',
faviconUrl
},
{
source: 'simulator',
@@ -58,6 +58,7 @@ export type OpenTabSearchResult =
pageId: string
workspaceId: string
url: string
faviconUrl: string | null
})
| (OpenTabSearchResultBase & {
source: 'simulator'
@@ -206,7 +207,8 @@ export function searchOpenTabs({
contentType: 'browser',
pageId: result.pageId,
workspaceId: result.workspaceId,
url: result.url
url: result.url,
faviconUrl: result.faviconUrl
})),
...rank('simulator', searchSimulatorTabs([...simulatorTabs], trimmed), (result) => ({
...baseResult('simulator', result.tabId, result, executionHostId),
@@ -63,7 +63,8 @@ const browserResult: OpenTabSearchResult = {
contentType: 'browser',
pageId: 'page-1',
workspaceId: 'ws-1',
url: 'https://example.com/docs'
url: 'https://example.com/docs',
faviconUrl: null
}
const simulatorResult: OpenTabSearchResult = {
@@ -1,5 +1,5 @@
import type React from 'react'
import { Globe, Smartphone } from 'lucide-react'
import { Smartphone } from 'lucide-react'
import { CommandItem } from '@/components/ui/command'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { getPaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
@@ -15,6 +15,7 @@ import {
} from './worktree-jump-palette-primitives'
import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age'
import { resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution'
import { BrowserFavicon } from '@/components/browser-favicon'
export function WorktreeJumpPaletteSimulatorRow({
entry,
@@ -147,7 +148,7 @@ export function WorktreeJumpPaletteBrowserRow({
)}
>
<div className="flex h-5 w-4 shrink-0 items-center justify-center self-start text-muted-foreground/85">
<Globe className="size-3.5" aria-hidden="true" />
<BrowserFavicon faviconUrl={result.faviconUrl} className="size-3.5" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-center justify-between gap-2.5">
@@ -102,6 +102,26 @@ describe('browser-palette-search', () => {
expect(result.executionHostId).toBe('ssh:box')
})
it('carries the page favicon into palette results', () => {
const faviconUrl = 'https://example.com/favicon.ico'
const [result] = searchBrowserPages(
[
makeEntry({
page: makePage({ faviconUrl }),
workspace: makeWorkspace(),
worktree: makeWorktree(),
repoName: 'repo/one',
worktreeSortIndex: 0,
isCurrentPage: false,
isCurrentWorktree: false
})
],
''
)
expect(result.faviconUrl).toBe(faviconUrl)
})
it('keeps empty-query ordering deterministic and context-first', () => {
const results = searchBrowserPages(
[
@@ -42,6 +42,7 @@ export type BrowserPaletteSearchResult = {
workspaceId: string
worktreeId: string
title: string
faviconUrl: string | null
/** Raw page URL, so callers can dedupe a row against another list of destinations. */
url: string
secondaryText: string
@@ -153,6 +154,7 @@ function baseResult(entry: SearchableBrowserPage): BrowserPaletteSearchResult {
workspaceId: entry.workspace.id,
worktreeId: entry.worktree.id,
title: entry.page.title || formattedUrl,
faviconUrl: entry.page.faviconUrl,
url: entry.page.url,
secondaryText: formattedUrl,
workspaceLabel: entry.workspace.label ?? null,
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH } from '../../../../shared/browser-favicon-url'
import {
createBrowserMockApi,
createTestStore,
@@ -302,6 +303,34 @@ describe('createBrowserSlice annotations', () => {
expect(store.getState().browserTabsByWorktree).toBe(browserTabsByWorktree)
})
it('persists a captured favicon with history and refreshes it with the page state', () => {
const store = createTestStore()
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
title: 'Example'
})
const pageId = tab.activePageId
if (!pageId) {
throw new Error('Expected a new browser page')
}
const initialFavicon = 'https://example.com/favicon.ico'
const refreshedFavicon = 'https://cdn.example.com/favicon.png'
store.getState().addBrowserHistoryEntry('https://example.com', 'Example', initialFavicon)
expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBe(initialFavicon)
store.getState().updateBrowserPageState(pageId, { faviconUrl: refreshedFavicon })
expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBe(refreshedFavicon)
store
.getState()
.addBrowserHistoryEntry(
'https://large-icon.example',
'Large icon',
`data:image/png,${'a'.repeat(MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH)}`
)
expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBeUndefined()
})
it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', () => {
const store = createTestStore()
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
@@ -13,6 +13,7 @@ import {
} from '../../../../../shared/workspace-doc-history'
import { browserPageDocLocationsEqual } from '../../../../../shared/browser-page-doc-location'
import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants'
import { normalizePersistedBrowserFaviconUrl } from '../../../../../shared/browser-favicon-url'
export function createBrowserHistoryActions(
set: BrowserSliceSet,
@@ -60,18 +61,25 @@ export function createBrowserHistoryActions(
})
},
addBrowserHistoryEntry: (url, title) => {
addBrowserHistoryEntry: (url, title, faviconUrl) => {
const safeUrl = redactKagiSessionToken(url)
if (safeUrl === ORCA_BROWSER_BLANK_URL || safeUrl === 'about:blank' || !safeUrl) {
return
}
const normalized = normalizeBrowserHistoryUrl(safeUrl)
const normalizedFaviconUrl = normalizePersistedBrowserFaviconUrl(faviconUrl)
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,
title,
...(normalizedFaviconUrl ? { faviconUrl: normalizedFaviconUrl } : {}),
lastVisitedAt: Date.now(),
visitCount: entry.visitCount + 1
}
: entry
)
: [
@@ -79,6 +87,7 @@ export function createBrowserHistoryActions(
url: safeUrl,
normalizedUrl: normalized,
title,
...(normalizedFaviconUrl ? { faviconUrl: normalizedFaviconUrl } : {}),
lastVisitedAt: Date.now(),
visitCount: 1
},
@@ -9,6 +9,8 @@ import {
normalizeBrowserTitle,
normalizeUrl
} from '../browser-page-records'
import { normalizeBrowserHistoryUrl } from '../../../../../shared/workspace-session-browser-history'
import { normalizePersistedBrowserFaviconUrl } from '../../../../../shared/browser-favicon-url'
export function createBrowserPageStateActions(
set: BrowserSliceSet,
@@ -100,6 +102,20 @@ export function createBrowserPageStateActions(
[workspace.id]: nextPages
}
}
const historyFaviconUrl = normalizePersistedBrowserFaviconUrl(updates.faviconUrl)
if (historyFaviconUrl) {
// Title and favicon events can arrive in either order; refresh history when the icon wins.
const normalizedPageUrl = normalizeBrowserHistoryUrl(page.url)
const historyIndex = s.browserUrlHistory.findIndex(
(entry) => entry.normalizedUrl === normalizedPageUrl
)
const historyEntry = s.browserUrlHistory[historyIndex]
if (historyEntry && historyEntry.faviconUrl !== historyFaviconUrl) {
nextState.browserUrlHistory = s.browserUrlHistory.map((entry, index) =>
index === historyIndex ? { ...entry, faviconUrl: historyFaviconUrl } : entry
)
}
}
if (!browserWorkspaceMirrorFieldsEqual(workspace, nextWorkspace)) {
nextState.browserTabsByWorktree = {
...s.browserTabsByWorktree,
@@ -239,7 +239,7 @@ export type BrowserSlice = {
) => Promise<BrowserCookieImportExecutionResult>
clearDefaultSessionCookies: () => Promise<boolean>
browserUrlHistory: BrowserHistoryEntry[]
addBrowserHistoryEntry: (url: string, title: string) => void
addBrowserHistoryEntry: (url: string, title: string, faviconUrl?: string) => void
workspaceDocHistory: WorkspaceDocHistoryEntry[]
/** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */
recordWorkspaceDocVisit: (
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH,
browserNavigationChangesFaviconOrigin,
firstBrowserFaviconUrl,
normalizeBrowserFaviconUrl,
normalizePersistedBrowserFaviconUrl
} from './browser-favicon-url'
describe('normalizeBrowserFaviconUrl', () => {
it('accepts web and bounded image data URLs', () => {
expect(normalizeBrowserFaviconUrl(' https://example.com/icon.png ')).toBe(
'https://example.com/icon.png'
)
expect(normalizeBrowserFaviconUrl('data:image/png;base64,AAAA')).toBe(
'data:image/png;base64,AAAA'
)
})
it('rejects unsupported URLs', () => {
expect(normalizeBrowserFaviconUrl('file:///tmp/icon.png')).toBeNull()
})
it('bounds persisted favicons without restricting live display', () => {
const oversized = `data:image/png,${'a'.repeat(MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH)}`
expect(normalizeBrowserFaviconUrl(oversized)).toBe(oversized)
expect(normalizePersistedBrowserFaviconUrl(oversized)).toBeNull()
})
it('takes the first usable favicon', () => {
expect(firstBrowserFaviconUrl(['data:,', 'https://example.com/icon.png'])).toBe(
'https://example.com/icon.png'
)
})
})
describe('browserNavigationChangesFaviconOrigin', () => {
it('retains same-origin favicons and clears cross-origin ones', () => {
expect(
browserNavigationChangesFaviconOrigin('https://example.com/one', 'https://example.com/two')
).toBe(false)
expect(
browserNavigationChangesFaviconOrigin('https://example.com', 'https://example.org')
).toBe(true)
})
it('clears favicons for non-web destinations but preserves them when origin is unknown', () => {
expect(browserNavigationChangesFaviconOrigin('https://example.com', 'about:blank')).toBe(true)
expect(browserNavigationChangesFaviconOrigin(null, 'https://example.com')).toBe(false)
})
})
+57
View File
@@ -0,0 +1,57 @@
// Favicons render at icon scale; bound page-controlled data before it reaches session history.
export const MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH = 64 * 1024
export function normalizeBrowserFaviconUrl(faviconUrl: string | null | undefined): string | null {
const trimmed = faviconUrl?.trim()
if (!trimmed) {
return null
}
if (trimmed.startsWith('data:image/')) {
return trimmed
}
try {
const url = new URL(trimmed)
return url.protocol === 'http:' || url.protocol === 'https:' ? trimmed : null
} catch {
return null
}
}
export function normalizePersistedBrowserFaviconUrl(
faviconUrl: string | null | undefined
): string | null {
const normalized = normalizeBrowserFaviconUrl(faviconUrl)
return normalized && normalized.length <= MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH
? normalized
: null
}
export function firstBrowserFaviconUrl(favicons: readonly string[] | undefined): string | null {
for (const favicon of favicons ?? []) {
const normalized = normalizeBrowserFaviconUrl(favicon)
if (normalized) {
return normalized
}
}
return null
}
function browserOrigin(url: string): string | null {
try {
const parsed = new URL(url)
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.origin : null
} catch {
return null
}
}
export function browserNavigationChangesFaviconOrigin(
currentUrl: string | null,
destinationUrl: string
): boolean {
const destinationOrigin = browserOrigin(destinationUrl)
if (!destinationOrigin) {
return true
}
return currentUrl ? browserOrigin(currentUrl) !== destinationOrigin : false
}
+1
View File
@@ -2,6 +2,7 @@ export type BrowserHistoryEntry = {
url: string
normalizedUrl: string
title: string
faviconUrl?: string
lastVisitedAt: number
visitCount: number
}
+14 -7
View File
@@ -8,6 +8,7 @@ import { normalizeBrowserHistoryEntries } from './workspace-session-browser-hist
import { normalizeWorkspaceDocHistoryEntries } from './workspace-doc-history'
import { isDocPreviewUrl } from './doc-preview-scheme'
import { salvagingArray } from './zod-salvage'
import { normalizePersistedBrowserFaviconUrl } from './browser-favicon-url'
const browserLoadErrorSchema = z.object({
code: z.number(),
@@ -113,13 +114,19 @@ export const browserPageSchema = z.object({
convertedTo: browserPageConversionOriginSchema
})
const browserHistoryEntrySchema = z.object({
url: z.string(),
normalizedUrl: z.string(),
title: z.string(),
lastVisitedAt: z.number(),
visitCount: z.number()
})
const browserHistoryEntrySchema = z
.object({
url: z.string(),
normalizedUrl: z.string(),
title: z.string(),
faviconUrl: z.string().nullable().optional(),
lastVisitedAt: z.number(),
visitCount: z.number()
})
.transform(({ faviconUrl, ...entry }) => {
const normalizedFaviconUrl = normalizePersistedBrowserFaviconUrl(faviconUrl)
return normalizedFaviconUrl ? { ...entry, faviconUrl: normalizedFaviconUrl } : entry
})
export const browserHistoryEntriesSchema = salvagingArray(browserHistoryEntrySchema).transform(
(entries) => normalizeBrowserHistoryEntries(entries)
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import { parseWorkspaceSession } from './workspace-session-schema'
import { MAX_BROWSER_HISTORY_ENTRIES } from './workspace-session-browser-history'
import { MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH } from './browser-favicon-url'
describe('parseWorkspaceSession', () => {
it('accepts a minimal valid session', () => {
@@ -504,6 +505,12 @@ describe('parseWorkspaceSession', () => {
url: `https://example.com/${index}`,
normalizedUrl: `https://example.com/${index}`,
title: `Example ${index}`,
faviconUrl:
index === 0
? 'https://example.com/favicon.ico'
: index === 1
? `data:image/png,${'a'.repeat(MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH)}`
: null,
lastVisitedAt: 1_700_000_000_000 - index,
visitCount: 1
}))
@@ -512,6 +519,10 @@ describe('parseWorkspaceSession', () => {
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
expect(result.value.browserUrlHistory?.[0]?.faviconUrl).toBe(
'https://example.com/favicon.ico'
)
expect(result.value.browserUrlHistory?.[1]?.faviconUrl).toBeUndefined()
expect(result.value.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
}
})