diff --git a/src/renderer/src/lib/browser-palette-search.test.ts b/src/renderer/src/lib/browser-palette-search.test.ts
index 41cfd58f54d..759e1cc5421 100644
--- a/src/renderer/src/lib/browser-palette-search.test.ts
+++ b/src/renderer/src/lib/browser-palette-search.test.ts
@@ -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(
[
diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts
index 1529c46860e..0cd9f7e0618 100644
--- a/src/renderer/src/lib/browser-palette-search.ts
+++ b/src/renderer/src/lib/browser-palette-search.ts
@@ -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,
diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts
index ba014db3aa3..7e296104aa9 100644
--- a/src/renderer/src/store/slices/browser.test.ts
+++ b/src/renderer/src/store/slices/browser.test.ts
@@ -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', {
diff --git a/src/renderer/src/store/slices/browser/browser-history-actions.ts b/src/renderer/src/store/slices/browser/browser-history-actions.ts
index 97ebf8b873b..d1e5df78cf2 100644
--- a/src/renderer/src/store/slices/browser/browser-history-actions.ts
+++ b/src/renderer/src/store/slices/browser/browser-history-actions.ts
@@ -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
},
diff --git a/src/renderer/src/store/slices/browser/browser-page-state-actions.ts b/src/renderer/src/store/slices/browser/browser-page-state-actions.ts
index 15e4892b351..a79d6f29e6a 100644
--- a/src/renderer/src/store/slices/browser/browser-page-state-actions.ts
+++ b/src/renderer/src/store/slices/browser/browser-page-state-actions.ts
@@ -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,
diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts
index c5577aaf0c4..8906dece2d0 100644
--- a/src/renderer/src/store/slices/browser/browser-slice-contract.ts
+++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts
@@ -239,7 +239,7 @@ export type BrowserSlice = {
) => Promise
clearDefaultSessionCookies: () => Promise
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: (
diff --git a/src/shared/browser-favicon-url.test.ts b/src/shared/browser-favicon-url.test.ts
new file mode 100644
index 00000000000..57fb5d03596
--- /dev/null
+++ b/src/shared/browser-favicon-url.test.ts
@@ -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)
+ })
+})
diff --git a/src/shared/browser-favicon-url.ts b/src/shared/browser-favicon-url.ts
new file mode 100644
index 00000000000..a1a24a3efef
--- /dev/null
+++ b/src/shared/browser-favicon-url.ts
@@ -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
+}
diff --git a/src/shared/browser-workspace-types.ts b/src/shared/browser-workspace-types.ts
index 5b2a1e7f33d..3d5ec0fdc70 100644
--- a/src/shared/browser-workspace-types.ts
+++ b/src/shared/browser-workspace-types.ts
@@ -2,6 +2,7 @@ export type BrowserHistoryEntry = {
url: string
normalizedUrl: string
title: string
+ faviconUrl?: string
lastVisitedAt: number
visitCount: number
}
diff --git a/src/shared/workspace-session-browser-schema.ts b/src/shared/workspace-session-browser-schema.ts
index 71f88fb3b0c..c4c80b417b4 100644
--- a/src/shared/workspace-session-browser-schema.ts
+++ b/src/shared/workspace-session-browser-schema.ts
@@ -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)
diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts
index 66e2ddcc514..57262dcfc93 100644
--- a/src/shared/workspace-session-schema.test.ts
+++ b/src/shared/workspace-session-schema.test.ts
@@ -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')
}
})