orca/src/renderer/src/store/slices/browser.ts

1932 lines
67 KiB
TypeScript

/* eslint-disable max-lines */
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type {
BrowserCookieImportResult,
BrowserCookieImportSummary,
BrowserHistoryEntry,
BrowserLoadError,
BrowserPage,
BrowserSessionProfile,
BrowserViewportPresetId,
BrowserWorkspace,
WorkspaceSessionState
} from '../../../../shared/types'
import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types'
import { FLOATING_TERMINAL_WORKTREE_ID, ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
import { redactKagiSessionToken } from '../../../../shared/browser-url'
import {
MAX_BROWSER_HISTORY_ENTRIES,
normalizeBrowserHistoryEntries,
normalizeBrowserHistoryUrl
} from '../../../../shared/workspace-session-browser-history'
import { pickNeighbor } from './tab-group-state'
import { destroyWorkspaceWebviews } from './browser-webview-cleanup'
import {
callRuntimeRpc,
getActiveRuntimeTarget,
type RuntimeClientTarget
} from '@/runtime/runtime-rpc-client'
import type {
BrowserDetectProfilesResult,
BrowserProfileClearDefaultCookiesResult,
BrowserProfileCreateResult,
BrowserProfileDeleteResult,
BrowserProfileImportFromBrowserResult,
BrowserProfileListResult
} from '../../../../shared/runtime-types'
import { createBrowserUuid } from '@/lib/browser-uuid'
type CreateBrowserTabOptions = {
activate?: boolean
title?: string
sessionProfileId?: string | null
// Why: callers like "Open Preview to the Side" need to place the new browser
// tab in a specific (sibling or newly-split) group rather than the ambient
// active group. Defaults to the worktree's current active group.
targetGroupId?: string
// Why: the explicit "New Tab" action (keyboard shortcut, + button) should
// land the user in the address bar even when their configured home page is a
// real URL, so they can type a destination immediately. Link-opened tabs
// (context menu, window.open, http link routing) leave this unset so focus
// stays on the webview. When omitted, we fall back to the blank-URL check.
focusAddressBar?: boolean
}
type CreateBrowserPageOptions = {
activate?: boolean
title?: string
}
type BrowserTabPageState = {
title?: string
loading?: boolean
faviconUrl?: string | null
canGoBack?: boolean
canGoForward?: boolean
loadError?: BrowserLoadError | null
}
type ClosedBrowserWorkspaceSnapshot = {
workspace: BrowserWorkspace
pages: BrowserPage[]
}
function sanitizeBrowserPageAnnotation(annotation: BrowserPageAnnotation): BrowserPageAnnotation {
return {
...annotation,
comment:
annotation.comment.length > GRAB_BUDGET.annotationCommentMaxLength
? annotation.comment.slice(0, GRAB_BUDGET.annotationCommentMaxLength)
: annotation.comment,
payload: {
...annotation.payload,
// Why: annotations live in persisted renderer state; screenshots are
// transient copy payloads and can retain megabytes per note.
screenshot: null
}
}
}
export type RemoteBrowserPageHandle = {
environmentId: string
remotePageId: string
}
export type BrowserSlice = {
browserTabsByWorktree: Record<string, BrowserWorkspace[]>
browserPagesByWorkspace: Record<string, BrowserPage[]>
browserAnnotationsByPageId: Record<string, BrowserPageAnnotation[]>
remoteBrowserPageHandlesByPageId: Record<string, RemoteBrowserPageHandle>
activeBrowserTabId: string | null
activeBrowserTabIdByWorktree: Record<string, string | null>
recentlyClosedBrowserTabsByWorktree: Record<string, ClosedBrowserWorkspaceSnapshot[]>
recentlyClosedBrowserPagesByWorkspace: Record<string, BrowserPage[]>
pendingAddressBarFocusByTabId: Record<string, true>
pendingAddressBarFocusByPageId: Record<string, true>
createBrowserTab: (
worktreeId: string,
url: string,
options?: CreateBrowserTabOptions
) => BrowserWorkspace
closeBrowserTab: (tabId: string) => void
shutdownWorktreeBrowsers: (worktreeId: string) => Promise<void>
reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null
setActiveBrowserTab: (tabId: string) => void
createBrowserPage: (
workspaceId: string,
url: string,
options?: CreateBrowserPageOptions
) => BrowserPage | null
closeBrowserPage: (pageId: string) => void
reopenClosedBrowserPage: (workspaceId: string) => BrowserPage | null
setActiveBrowserPage: (workspaceId: string, pageId: string) => void
// Why: scoped sibling of setActiveBrowserTab+setActiveBrowserPage that
// never yanks the user across worktrees. Multiple agents can drive
// browsers in parallel worktrees; a global focus call from agent X would
// steal the screen from the user reading agent Y. Updates per-worktree
// active tab/page unconditionally; updates the GLOBAL active tab and (if
// surfacePane) global activeTabType only when worktreeId === active
// worktree. Cross-worktree calls pre-stage the targeted worktree's view
// for whenever the user next switches to it.
focusBrowserTabInWorktree: (
worktreeId: string,
browserPageId: string,
options?: { surfacePane?: boolean }
) => void
consumeAddressBarFocusRequest: (pageId: string) => boolean
updateBrowserTabPageState: (pageId: string, updates: BrowserTabPageState) => void
updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void
setBrowserTabUrl: (pageId: string, url: string) => void
setBrowserPageUrl: (pageId: string, url: string) => void
setRemoteBrowserPageHandle: (pageId: string, handle: RemoteBrowserPageHandle) => void
removeRemoteBrowserPageHandle: (
pageId: string,
remotePageId?: string
) => RemoteBrowserPageHandle | null
setBrowserPageViewportPreset: (
pageId: string,
viewportPresetId: BrowserViewportPresetId | null
) => void
addBrowserPageAnnotation: (annotation: BrowserPageAnnotation) => void
deleteBrowserPageAnnotation: (pageId: string, annotationId: string) => void
clearBrowserPageAnnotations: (pageId: string) => void
hydrateBrowserSession: (session: WorkspaceSessionState) => void
switchBrowserTabProfile: (workspaceId: string, profileId: string | null) => void
browserSessionProfiles: BrowserSessionProfile[]
browserSessionImportState: {
profileId: string
status: 'idle' | 'importing' | 'success' | 'error'
summary: BrowserCookieImportSummary | null
error: string | null
} | null
fetchBrowserSessionProfiles: () => Promise<void>
createBrowserSessionProfile: (
scope: 'isolated' | 'imported',
label: string
) => Promise<BrowserSessionProfile | null>
deleteBrowserSessionProfile: (profileId: string) => Promise<boolean>
importCookiesToProfile: (profileId: string) => Promise<BrowserCookieImportResult>
clearBrowserSessionImportState: () => void
detectedBrowsers: {
family: string
label: string
profiles: { name: string; directory: string }[]
selectedProfile: string
}[]
detectedBrowsersLoaded: boolean
fetchDetectedBrowsers: () => Promise<void>
importCookiesFromBrowser: (
profileId: string,
browserFamily: string,
browserProfile?: string
) => Promise<BrowserCookieImportResult>
clearDefaultSessionCookies: () => Promise<boolean>
browserUrlHistory: BrowserHistoryEntry[]
addBrowserHistoryEntry: (url: string, title: string) => void
clearBrowserHistory: () => void
defaultBrowserSessionProfileId: string | null
setDefaultBrowserSessionProfileId: (profileId: string | null) => void
}
function normalizeUrl(url: string): string {
const trimmed = url.trim()
if (trimmed.length === 0) {
return 'about:blank'
}
// Why: setBrowserPageUrl is the single sink for URL updates from did-navigate,
// CDP navigation-update IPC, and direct address-bar submits. Redact at this
// boundary so the Kagi bearer token cannot reach BrowserPage.url, which is
// persisted to disk via the workspace session writer.
return redactKagiSessionToken(trimmed)
}
function normalizeBrowserTitle(title: string | null | undefined, url: string): string {
if (
url === 'about:blank' ||
url === ORCA_BROWSER_BLANK_URL ||
title === 'about:blank' ||
title === ORCA_BROWSER_BLANK_URL ||
!title
) {
// Why: blank pages render through Orca's inert data: URL guest. Persisting
// that internal bootstrap URL as the page/workspace title leaks an
// implementation detail into the tab strip and makes every blank page look
// broken. Keep the user-facing label stable as "New Tab" instead.
return 'New Tab'
}
return title
}
function isRuntimeEnvironmentActive(state: AppState): boolean {
return Boolean(state.settings?.activeRuntimeEnvironmentId?.trim())
}
function closeRemoteBrowserPageInOwningEnvironment(
worktreeId: string,
handle: RemoteBrowserPageHandle
): void {
const target: RuntimeClientTarget = { kind: 'environment', environmentId: handle.environmentId }
void callRuntimeRpc(
target,
'browser.tabClose',
{ worktree: `id:${worktreeId}`, page: handle.remotePageId },
{ timeoutMs: 15_000 }
).catch(() => {})
}
function buildBrowserPage(
workspaceId: string,
worktreeId: string,
url: string,
title?: string
): BrowserPage {
const normalizedUrl = normalizeUrl(url)
return {
id: createBrowserUuid(),
workspaceId,
worktreeId,
url: normalizedUrl,
title: normalizeBrowserTitle(title, normalizedUrl),
// Why: blank pages mount an inert guest first. Treating them as loading
// would make an empty workspace flash the global loading affordance even
// though no real navigation happened yet.
loading: normalizedUrl !== 'about:blank' && normalizedUrl !== ORCA_BROWSER_BLANK_URL,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: Date.now()
}
}
function buildWorkspaceFromPage(
id: string,
worktreeId: string,
page: BrowserPage,
pageIds: string[],
sessionProfileId?: string | null
): BrowserWorkspace {
return {
id,
worktreeId,
sessionProfileId: sessionProfileId ?? null,
activePageId: page.id,
pageIds,
url: page.url,
title: page.title,
loading: page.loading,
faviconUrl: page.faviconUrl,
canGoBack: page.canGoBack,
canGoForward: page.canGoForward,
loadError: page.loadError,
createdAt: page.createdAt
}
}
function mirrorWorkspaceFromActivePage(
workspace: BrowserWorkspace,
pages: BrowserPage[]
): BrowserWorkspace {
const activePage = pages.find((page) => page.id === workspace.activePageId) ?? null
if (!activePage) {
return {
...workspace,
activePageId: null,
pageIds: pages.map((page) => page.id),
url: 'about:blank',
title: 'Browser',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null
}
}
return {
...workspace,
activePageId: activePage.id,
pageIds: pages.map((page) => page.id),
url: activePage.url,
title: activePage.title,
loading: activePage.loading,
faviconUrl: activePage.faviconUrl,
canGoBack: activePage.canGoBack,
canGoForward: activePage.canGoForward,
loadError: activePage.loadError
}
}
function browserWorkspaceMirrorFieldsEqual(
workspace: BrowserWorkspace,
mirrored: BrowserWorkspace
): boolean {
const workspacePageIds = workspace.pageIds ?? []
const mirroredPageIds = mirrored.pageIds ?? []
return (
workspace.activePageId === mirrored.activePageId &&
workspacePageIds.length === mirroredPageIds.length &&
workspacePageIds.every((pageId, index) => pageId === mirroredPageIds[index]) &&
workspace.url === mirrored.url &&
workspace.title === mirrored.title &&
workspace.loading === mirrored.loading &&
workspace.faviconUrl === mirrored.faviconUrl &&
workspace.canGoBack === mirrored.canGoBack &&
workspace.canGoForward === mirrored.canGoForward &&
workspace.loadError === mirrored.loadError
)
}
function getFallbackTabTypeForWorktree(
worktreeId: string,
openFiles: AppState['openFiles'],
terminalTabsByWorktree: AppState['tabsByWorktree'],
browserTabsByWorktree?: AppState['browserTabsByWorktree']
): AppState['activeTabType'] {
if (openFiles.some((file) => file.worktreeId === worktreeId)) {
return 'editor'
}
if ((browserTabsByWorktree?.[worktreeId] ?? []).length > 0) {
return 'browser'
}
if ((terminalTabsByWorktree[worktreeId] ?? []).length > 0) {
return 'terminal'
}
return 'terminal'
}
const browserWorkspaceByIdCache = new WeakMap<
Record<string, BrowserWorkspace[]>,
Map<string, BrowserWorkspace>
>()
const browserPageByIdCache = new WeakMap<Record<string, BrowserPage[]>, Map<string, BrowserPage>>()
function findWorkspace(
browserTabsByWorktree: Record<string, BrowserWorkspace[]>,
workspaceId: string
): BrowserWorkspace | null {
const cached = browserWorkspaceByIdCache.get(browserTabsByWorktree)
if (cached) {
return cached.get(workspaceId) ?? null
}
const workspaceById = new Map<string, BrowserWorkspace>()
for (const workspaces of Object.values(browserTabsByWorktree)) {
for (const workspace of workspaces) {
workspaceById.set(workspace.id, workspace)
}
}
browserWorkspaceByIdCache.set(browserTabsByWorktree, workspaceById)
return workspaceById.get(workspaceId) ?? null
}
function findPage(
browserPagesByWorkspace: Record<string, BrowserPage[]>,
pageId: string
): BrowserPage | null {
const cached = browserPageByIdCache.get(browserPagesByWorkspace)
if (cached) {
return cached.get(pageId) ?? null
}
const pageById = new Map<string, BrowserPage>()
for (const pages of Object.values(browserPagesByWorkspace)) {
for (const page of pages) {
pageById.set(page.id, page)
}
}
browserPageByIdCache.set(browserPagesByWorkspace, pageById)
return pageById.get(pageId) ?? null
}
export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = (set, get) => ({
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
browserAnnotationsByPageId: {},
remoteBrowserPageHandlesByPageId: {},
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
recentlyClosedBrowserTabsByWorktree: {},
recentlyClosedBrowserPagesByWorkspace: {},
pendingAddressBarFocusByTabId: {},
pendingAddressBarFocusByPageId: {},
browserSessionProfiles: [],
browserSessionImportState: null,
browserUrlHistory: [],
defaultBrowserSessionProfileId: null,
setDefaultBrowserSessionProfileId: (profileId) => {
set({ defaultBrowserSessionProfileId: profileId })
},
createBrowserTab: (worktreeId, url, options) => {
const workspaceId = createBrowserUuid()
const page = buildBrowserPage(workspaceId, worktreeId, url, options?.title)
// Why: when no explicit profile is passed, inherit the user's chosen default
// profile. This lets users set a preferred profile in Settings that all new
// browser tabs use automatically.
const sessionProfileId =
options?.sessionProfileId !== undefined
? options.sessionProfileId
: get().defaultBrowserSessionProfileId
const browserTab = buildWorkspaceFromPage(
workspaceId,
worktreeId,
page,
[page.id],
sessionProfileId
)
set((s) => {
const existingTabs = s.browserTabsByWorktree[worktreeId] ?? []
const nextTabBarOrder = (() => {
const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? []
const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
const editorIds = s.openFiles
.filter((file) => file.worktreeId === worktreeId)
.map((file) => file.id)
const browserIds = existingTabs.map((tab) => tab.id)
const allExistingIds = new Set([...terminalIds, ...editorIds, ...browserIds])
const base = currentOrder.filter((entryId) => allExistingIds.has(entryId))
const inBase = new Set(base)
for (const entryId of [...terminalIds, ...editorIds, ...browserIds]) {
if (!inBase.has(entryId)) {
base.push(entryId)
inBase.add(entryId)
}
}
base.push(workspaceId)
return base
})()
const shouldActivate = options?.activate ?? true
const shouldUpdateGlobalActiveSurface = shouldActivate && s.activeWorktreeId === worktreeId
const shouldFocusFloatingTab = shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID
const shouldFocusAddressBar =
(shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) &&
(options?.focusAddressBar ??
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL))
return {
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[worktreeId]: [...existingTabs, browserTab]
},
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspaceId]: [page]
},
tabBarOrderByWorktree: {
...s.tabBarOrderByWorktree,
[worktreeId]: nextTabBarOrder
},
activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId,
activeBrowserTabIdByWorktree: {
...s.activeBrowserTabIdByWorktree,
[worktreeId]: shouldActivate
? workspaceId
: (s.activeBrowserTabIdByWorktree[worktreeId] ?? null)
},
activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType,
activeTabTypeByWorktree: shouldActivate
? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' }
: s.activeTabTypeByWorktree,
pendingAddressBarFocusByPageId: shouldFocusAddressBar
? {
...s.pendingAddressBarFocusByPageId,
[page.id]: true
}
: s.pendingAddressBarFocusByPageId,
pendingAddressBarFocusByTabId: shouldFocusAddressBar
? {
...s.pendingAddressBarFocusByTabId,
[workspaceId]: true,
[page.id]: true
}
: s.pendingAddressBarFocusByTabId
}
})
const state = get()
const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some(
(t) => t.contentType === 'browser' && t.entityId === workspaceId
)
if (!alreadyHasUnifiedTab) {
state.createUnifiedTab(worktreeId, 'browser', {
entityId: workspaceId,
label: browserTab.title,
targetGroupId: options?.targetGroupId
})
}
return browserTab
},
closeBrowserTab: (tabId) => {
let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = []
set((s) => {
let owningWorktreeId: string | null = null
let closedWorkspace: BrowserWorkspace | null = null
const nextBrowserTabsByWorktree: Record<string, BrowserWorkspace[]> = {}
for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) {
const removedTab = tabs.find((tab) => tab.id === tabId) ?? null
const filtered = tabs.filter((tab) => tab.id !== tabId)
if (filtered.length !== tabs.length) {
owningWorktreeId = worktreeId
closedWorkspace = removedTab
}
if (filtered.length > 0) {
nextBrowserTabsByWorktree[worktreeId] = filtered
}
}
if (!owningWorktreeId || !closedWorkspace) {
return s
}
const closedPages = s.browserPagesByWorkspace[tabId] ?? []
const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace }
delete nextBrowserPagesByWorkspace[tabId]
const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId }
for (const page of closedPages) {
delete nextBrowserAnnotationsByPageId[page.id]
}
remotePagesToClose = closedPages.flatMap((page) => {
const handle = s.remoteBrowserPageHandlesByPageId[page.id]
return handle ? [{ worktreeId: page.worktreeId, handle }] : []
})
const nextRemoteBrowserPageHandlesByPageId = {
...s.remoteBrowserPageHandlesByPageId
}
for (const page of closedPages) {
delete nextRemoteBrowserPageHandlesByPageId[page.id]
}
const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree }
const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? []
const tabBarOrder = s.tabBarOrderByWorktree[owningWorktreeId] ?? []
const neighborTabId = pickNeighbor(tabBarOrder, tabId)
if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) {
nextActiveBrowserTabIdByWorktree[owningWorktreeId] =
neighborTabId ?? remainingBrowserTabs[0]?.id ?? null
}
const nextTabBarOrder = {
...s.tabBarOrderByWorktree,
[owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter(
(entryId) => entryId !== tabId
)
}
const isActiveTabInOwningWorktree =
s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
let nextActiveTabType = s.activeTabType
if (remainingBrowserTabs.length === 0) {
const fallbackTabType = getFallbackTabTypeForWorktree(
owningWorktreeId,
s.openFiles,
s.tabsByWorktree
)
nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType
if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') {
nextActiveTabType = fallbackTabType
}
}
const nextRecentlyClosedBrowserTabsByWorktree = { ...s.recentlyClosedBrowserTabsByWorktree }
const existingSnapshots = nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? []
nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [
{ workspace: closedWorkspace, pages: closedPages },
...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id)
].slice(0, 10)
const nextRecentlyClosedBrowserPagesByWorkspace = {
...s.recentlyClosedBrowserPagesByWorkspace
}
delete nextRecentlyClosedBrowserPagesByWorkspace[tabId]
const nextPendingAddressBarFocusByPageId = Object.fromEntries(
Object.entries(s.pendingAddressBarFocusByPageId).filter(
([pageId]) => !closedPages.some((page) => page.id === pageId)
)
)
const nextPendingAddressBarFocusByTabId = Object.fromEntries(
Object.entries(s.pendingAddressBarFocusByTabId).filter(
([focusId]) => focusId !== tabId && !closedPages.some((page) => page.id === focusId)
)
)
return {
browserTabsByWorktree: nextBrowserTabsByWorktree,
browserPagesByWorkspace: nextBrowserPagesByWorkspace,
activeBrowserTabId:
s.activeBrowserTabId === tabId
? (neighborTabId ?? remainingBrowserTabs[0]?.id ?? null)
: s.activeBrowserTabId,
activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree,
tabBarOrderByWorktree: nextTabBarOrder,
activeTabType: nextActiveTabType,
pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId,
pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree,
recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace,
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId
}
})
for (const remotePage of remotePagesToClose) {
closeRemoteBrowserPageInOwningEnvironment(remotePage.worktreeId, remotePage.handle)
}
for (const tabs of Object.values(get().unifiedTabsByWorktree)) {
const workspaceItem = tabs.find(
(entry) => entry.contentType === 'browser' && entry.entityId === tabId
)
if (workspaceItem) {
get().closeUnifiedTab(workspaceItem.id)
}
}
},
shutdownWorktreeBrowsers: async (worktreeId) => {
const workspaces = get().browserTabsByWorktree[worktreeId] ?? []
// Why: snapshot pre-loop so the post-loop set() can reproduce the original
// `hadBrowserTabs` semantics. Reading `s.browserTabsByWorktree[worktreeId]`
// inside set() would always be empty here because each closeBrowserTab call
// above has already removed the workspace from that array.
const hadBrowserTabs = workspaces.length > 0
for (const workspace of workspaces) {
destroyWorkspaceWebviews(get().browserPagesByWorkspace, workspace.id)
get().closeBrowserTab(workspace.id)
}
set((s) => {
const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree }
delete nextBrowserTabsByWorktree[worktreeId]
const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree }
delete nextActiveBrowserTabIdByWorktree[worktreeId]
// Why: mirror shutdownWorktreeTerminals' `hadBrowserTabs && isActive`
// guard. Only reset the globally-visible active browser surface when the
// worktree being shut down is the one the user is looking at AND it
// actually had browser tabs to tear down.
const shouldResetGlobalBrowser = s.activeWorktreeId === worktreeId && hadBrowserTabs
return {
browserTabsByWorktree: nextBrowserTabsByWorktree,
activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree,
...(shouldResetGlobalBrowser
? { activeBrowserTabId: null, activeTabType: 'terminal' as const }
: {})
}
})
},
reopenClosedBrowserTab: (worktreeId) => {
// Why: read and pop atomically inside set() to prevent a TOCTOU race
// where two rapid Cmd+Shift+T presses both restore the same entry.
let entryToRestore: ClosedBrowserWorkspaceSnapshot | undefined
set((s) => {
const recentlyClosed = s.recentlyClosedBrowserTabsByWorktree[worktreeId] ?? []
entryToRestore = recentlyClosed[0]
if (!entryToRestore) {
return s
}
return {
recentlyClosedBrowserTabsByWorktree: {
...s.recentlyClosedBrowserTabsByWorktree,
[worktreeId]: recentlyClosed.slice(1)
}
}
})
if (!entryToRestore) {
return null
}
const snap = entryToRestore.workspace
const pages = entryToRestore.pages
const sessionProfileId = snap.sessionProfileId ?? null
if (pages.length === 0) {
const restored = get().createBrowserTab(worktreeId, snap.url, {
title: snap.title,
activate: true,
sessionProfileId
})
return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null
}
// Why: create the tab with the first page, then append the rest in
// original order so multi-page workspaces preserve their page sequence.
const [firstPage, ...restPages] = pages
const restored = get().createBrowserTab(worktreeId, firstPage.url, {
title: firstPage.title,
activate: true,
sessionProfileId
})
for (const p of restPages) {
get().createBrowserPage(restored.id, p.url, {
activate: false,
title: p.title
})
}
// Activate the originally-active page if it wasn't the first one
const activePageId = snap.activePageId
if (activePageId) {
const restoredPages = get().browserPagesByWorkspace[restored.id] ?? []
const targetPage = restoredPages.find(
(p) => p.url === pages.find((orig) => orig.id === activePageId)?.url
)
if (targetPage && targetPage.id !== restoredPages[0]?.id) {
get().setActiveBrowserPage(restored.id, targetPage.id)
}
}
return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null
},
setActiveBrowserTab: (tabId) => {
set((s) => {
const browserTab = findWorkspace(s.browserTabsByWorktree, tabId)
if (!browserTab) {
return s
}
return {
activeBrowserTabId: tabId,
activeBrowserTabIdByWorktree: {
...s.activeBrowserTabIdByWorktree,
[browserTab.worktreeId]: tabId
},
activeTabType: 'browser',
activeTabTypeByWorktree: {
...s.activeTabTypeByWorktree,
[browserTab.worktreeId]: 'browser'
}
}
})
// Why: notify the CDP bridge which guest webContents is now active so
// subsequent agent commands (snapshot, click, etc.) target the correct tab.
// registerGuest uses page IDs (not workspace IDs), so we resolve the active
// page within the workspace to find the correct browserPageId.
const workspace = findWorkspace(get().browserTabsByWorktree, tabId)
if (
workspace?.activePageId &&
!isRuntimeEnvironmentActive(get()) &&
typeof window !== 'undefined' &&
window.api?.browser
) {
window.api.browser
.notifyActiveTabChanged({ browserPageId: workspace.activePageId })
.catch(() => {})
}
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'browser' && entry.entityId === tabId)
if (item) {
get().activateTab(item.id)
}
},
createBrowserPage: (workspaceId, url, options) => {
const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId)
if (!workspace) {
return null
}
const page = buildBrowserPage(workspaceId, workspace.worktreeId, url, options?.title)
set((s) => {
const pages = s.browserPagesByWorkspace[workspaceId] ?? []
const shouldActivate = options?.activate ?? true
const nextPages = [...pages, page]
const nextWorkspace = mirrorWorkspaceFromActivePage(
{
...workspace,
activePageId: shouldActivate ? page.id : (workspace.activePageId ?? page.id),
pageIds: nextPages.map((entry) => entry.id)
},
nextPages
)
const shouldUpdateGlobalActiveSurface =
shouldActivate &&
s.activeWorktreeId === workspace.worktreeId &&
s.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspaceId
const shouldFocusAddressBar =
shouldUpdateGlobalActiveSurface &&
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)
return {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspaceId]: nextPages
},
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map((tab) =>
tab.id === workspaceId ? nextWorkspace : tab
)
},
pendingAddressBarFocusByPageId: shouldFocusAddressBar
? {
...s.pendingAddressBarFocusByPageId,
[page.id]: true
}
: s.pendingAddressBarFocusByPageId,
pendingAddressBarFocusByTabId: shouldFocusAddressBar
? {
...s.pendingAddressBarFocusByTabId,
[page.id]: true
}
: s.pendingAddressBarFocusByTabId
}
})
const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId)
if (nextWorkspace?.activePageId === page.id) {
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId)
if (item) {
get().setTabLabel(item.id, page.title)
}
}
return page
},
closeBrowserPage: (pageId) => {
let closedWorkspaceIdForLabel: string | null = null
const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = []
set((s) => {
const page = findPage(s.browserPagesByWorkspace, pageId)
if (!page) {
return s
}
const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId)
if (!workspace) {
return s
}
closedWorkspaceIdForLabel = page.workspaceId
const currentPages = s.browserPagesByWorkspace[workspace.id] ?? []
const nextPages = currentPages.filter((entry) => entry.id !== pageId)
const closedIdx = currentPages.findIndex((entry) => entry.id === pageId)
const nextActivePageId =
workspace.activePageId === pageId
? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null)
: workspace.activePageId
const nextWorkspace = mirrorWorkspaceFromActivePage(
{
...workspace,
activePageId: nextActivePageId,
pageIds: nextPages.map((entry) => entry.id)
},
nextPages
)
const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId]
if (remoteHandle) {
remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle })
}
const nextRemoteBrowserPageHandlesByPageId = {
...s.remoteBrowserPageHandlesByPageId
}
delete nextRemoteBrowserPageHandlesByPageId[pageId]
const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId }
delete nextBrowserAnnotationsByPageId[pageId]
return {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspace.id]: nextPages
},
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map((tab) =>
tab.id === workspace.id ? nextWorkspace : tab
)
},
recentlyClosedBrowserPagesByWorkspace: {
...s.recentlyClosedBrowserPagesByWorkspace,
[workspace.id]: [
page,
...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter(
(entry) => entry.id !== page.id
)
].slice(0, 10)
},
pendingAddressBarFocusByPageId: Object.fromEntries(
Object.entries(s.pendingAddressBarFocusByPageId).filter(
([pendingPageId]) => pendingPageId !== pageId
)
),
pendingAddressBarFocusByTabId: Object.fromEntries(
Object.entries(s.pendingAddressBarFocusByTabId).filter(
([pendingPageId]) => pendingPageId !== pageId
)
),
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId
}
})
for (const remotePage of remotePagesToClose) {
closeRemoteBrowserPageInOwningEnvironment(remotePage.worktreeId, remotePage.handle)
}
const closedWorkspaceId = closedWorkspaceIdForLabel
if (!closedWorkspaceId) {
return
}
const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId)
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId)
if (item && workspace) {
get().setTabLabel(item.id, workspace.title)
}
},
reopenClosedBrowserPage: (workspaceId) => {
// Why: read and pop atomically inside set() to prevent a TOCTOU race
// where two rapid Cmd+Shift+T presses both restore the same page.
let pageToRestore: BrowserPage | undefined
set((s) => {
const recentlyClosed = s.recentlyClosedBrowserPagesByWorkspace[workspaceId] ?? []
pageToRestore = recentlyClosed[0]
if (!pageToRestore) {
return s
}
return {
recentlyClosedBrowserPagesByWorkspace: {
...s.recentlyClosedBrowserPagesByWorkspace,
[workspaceId]: recentlyClosed.slice(1)
}
}
})
if (!pageToRestore) {
return null
}
return get().createBrowserPage(workspaceId, pageToRestore.url, {
title: pageToRestore.title,
activate: true
})
},
setActiveBrowserPage: (workspaceId, pageId) => {
set((s) => {
const workspace = findWorkspace(s.browserTabsByWorktree, workspaceId)
if (!workspace) {
return s
}
const pages = s.browserPagesByWorkspace[workspaceId] ?? []
if (!pages.some((page) => page.id === pageId)) {
return s
}
const nextWorkspace = mirrorWorkspaceFromActivePage(
{
...workspace,
activePageId: pageId
},
pages
)
return {
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map((tab) =>
tab.id === workspaceId ? nextWorkspace : tab
)
}
}
})
// Why: switching the active page within a workspace changes which guest
// webContents the CDP bridge should target for agent commands.
if (
!isRuntimeEnvironmentActive(get()) &&
typeof window !== 'undefined' &&
window.api?.browser
) {
window.api.browser.notifyActiveTabChanged({ browserPageId: pageId }).catch(() => {})
}
const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId)
if (!workspace) {
return
}
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId)
if (item) {
get().setTabLabel(item.id, workspace.title)
}
},
focusBrowserTabInWorktree: (worktreeId, browserPageId, options) => {
// Why: bridge identifies the target by browserPageId (CDP page id stored
// on BrowserPage.id), but the renderer's tab strip activates a workspace
// (BrowserWorkspace.id, a local UUID). They diverge whenever a workspace
// owns more than one page. Walk pageIds in the targeted worktree's tab
// list to find the owning workspace.
const tabsForWorktree = get().browserTabsByWorktree[worktreeId] ?? []
const workspace = tabsForWorktree.find((tab) => (tab.pageIds ?? []).includes(browserPageId))
if (!workspace) {
// Best-effort: state for this worktree may not be hydrated yet, or the
// page closed between the bridge switching and this IPC arriving.
return
}
// Default to true: the only caller (`tab switch --focus` IPC listener)
// wants the pane surfaced when targeting the active worktree. `false` is
// an opt-out for hypothetical pure-pre-staging callers.
const surfacePane = options?.surfacePane ?? true
const pages = get().browserPagesByWorkspace[workspace.id] ?? []
const nextWorkspace = mirrorWorkspaceFromActivePage(
{ ...workspace, activePageId: browserPageId },
pages
)
// TODO: per-worktree writes below duplicate setActiveBrowserTab /
// setActiveBrowserPage. We can't reuse those because they touch globals
// unconditionally (the very behavior --focus is avoiding). If they ever
// grow side-effects (analytics, persistence) those will silently diverge
// here. Consider extracting a private per-worktree-only helper that
// both call paths share.
set((s) => {
const isActiveWorktree = s.activeWorktreeId === worktreeId
// Per-worktree slots: always update (safe pre-staging; only visible
// when user navigates to this worktree).
const nextTabsByWorktree = {
...s.browserTabsByWorktree,
[worktreeId]: tabsForWorktree.map((tab) => (tab.id === workspace.id ? nextWorkspace : tab))
}
const nextActiveTabIdByWorktree = {
...s.activeBrowserTabIdByWorktree,
[worktreeId]: workspace.id
}
const nextActiveTabTypeByWorktree = surfacePane
? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' as const }
: s.activeTabTypeByWorktree
// Globals: only mutate when the targeted worktree is currently active.
// This is the line that keeps cross-worktree --focus calls silent.
return {
browserTabsByWorktree: nextTabsByWorktree,
activeBrowserTabIdByWorktree: nextActiveTabIdByWorktree,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
activeBrowserTabId: isActiveWorktree ? workspace.id : s.activeBrowserTabId,
activeTabType: isActiveWorktree && surfacePane ? 'browser' : s.activeTabType
}
})
// Why: notify the CDP bridge which guest webContents is now active so
// subsequent agent commands target the correct page. Mirrors the
// notifyActiveTabChanged calls in setActiveBrowserTab/setActiveBrowserPage.
if (
!isRuntimeEnvironmentActive(get()) &&
typeof window !== 'undefined' &&
window.api?.browser
) {
window.api.browser.notifyActiveTabChanged({ browserPageId }).catch(() => {})
}
// Why: keep the unified-tab strip's active entry in sync within the
// targeted worktree. activateTab only mutates per-worktree slices, so
// it's safe to call cross-worktree without yanking the user.
const item = (get().unifiedTabsByWorktree[worktreeId] ?? []).find(
(entry) => entry.contentType === 'browser' && entry.entityId === workspace.id
)
if (item) {
get().activateTab(item.id)
}
},
consumeAddressBarFocusRequest: (pageId) => {
const state = get()
if (
!state.pendingAddressBarFocusByPageId[pageId] &&
!state.pendingAddressBarFocusByTabId[pageId]
) {
return false
}
set((s) => {
const nextByPageId = { ...s.pendingAddressBarFocusByPageId }
delete nextByPageId[pageId]
const nextByTabId = { ...s.pendingAddressBarFocusByTabId }
delete nextByTabId[pageId]
return {
pendingAddressBarFocusByPageId: nextByPageId,
pendingAddressBarFocusByTabId: nextByTabId
}
})
return true
},
updateBrowserTabPageState: (pageId, updates) => get().updateBrowserPageState(pageId, updates),
updateBrowserPageState: (pageId, updates) => {
set((s) => {
const page = findPage(s.browserPagesByWorkspace, pageId)
if (!page) {
return s
}
const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId)
if (!workspace) {
return s
}
const nextPage = {
...page,
title:
updates.title === undefined ? page.title : normalizeBrowserTitle(updates.title, page.url),
loading: updates.loading ?? page.loading,
faviconUrl: updates.faviconUrl === undefined ? page.faviconUrl : updates.faviconUrl,
canGoBack: updates.canGoBack ?? page.canGoBack,
canGoForward: updates.canGoForward ?? page.canGoForward,
loadError: updates.loadError === undefined ? page.loadError : updates.loadError
}
const unifiedTabs = s.unifiedTabsByWorktree[workspace.worktreeId] ?? []
const unifiedIndex =
workspace.activePageId === pageId && updates.title !== undefined
? unifiedTabs.findIndex(
(entry) => entry.contentType === 'browser' && entry.entityId === workspace.id
)
: -1
const unifiedLabelNeedsRepair =
unifiedIndex !== -1 && unifiedTabs[unifiedIndex]?.label !== nextPage.title
const pageStateUnchanged =
nextPage.title === page.title &&
nextPage.loading === page.loading &&
nextPage.faviconUrl === page.faviconUrl &&
nextPage.canGoBack === page.canGoBack &&
nextPage.canGoForward === page.canGoForward &&
nextPage.loadError === page.loadError
const currentPages = s.browserPagesByWorkspace[workspace.id] ?? []
const mirroredWorkspace = pageStateUnchanged
? mirrorWorkspaceFromActivePage(workspace, currentPages)
: null
const workspaceNeedsRepair =
mirroredWorkspace !== null &&
!browserWorkspaceMirrorFieldsEqual(workspace, mirroredWorkspace)
if (pageStateUnchanged && !unifiedLabelNeedsRepair && !workspaceNeedsRepair) {
return s
}
if (pageStateUnchanged) {
const nextState: Partial<AppState> = {}
if (workspaceNeedsRepair && mirroredWorkspace) {
nextState.browserTabsByWorktree = {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map(
(tab) => (tab.id === workspace.id ? mirroredWorkspace : tab)
)
}
}
if (unifiedLabelNeedsRepair) {
nextState.unifiedTabsByWorktree = {
...s.unifiedTabsByWorktree,
[workspace.worktreeId]: unifiedTabs.map((entry, index) =>
index === unifiedIndex ? { ...entry, label: nextPage.title } : entry
)
}
}
return nextState
}
const nextPages = currentPages.map((entry) => (entry.id === pageId ? nextPage : entry))
const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages)
const nextState: Partial<AppState> = {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspace.id]: nextPages
}
}
if (!browserWorkspaceMirrorFieldsEqual(workspace, nextWorkspace)) {
nextState.browserTabsByWorktree = {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map((tab) =>
tab.id === workspace.id ? nextWorkspace : tab
)
}
}
if (workspace.activePageId === pageId && updates.title !== undefined && unifiedIndex !== -1) {
if (unifiedLabelNeedsRepair || unifiedTabs[unifiedIndex]?.label !== nextWorkspace.title) {
nextState.unifiedTabsByWorktree = {
...s.unifiedTabsByWorktree,
[workspace.worktreeId]: unifiedTabs.map((entry, index) =>
index === unifiedIndex ? { ...entry, label: nextWorkspace.title } : entry
)
}
}
}
return nextState
})
},
setBrowserTabUrl: (pageId, url) => get().setBrowserPageUrl(pageId, url),
setBrowserPageUrl: (pageId, url) =>
set((s) => {
const page = findPage(s.browserPagesByWorkspace, pageId)
if (!page) {
return s
}
const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId)
if (!workspace) {
return s
}
const nextUrl = normalizeUrl(url)
// Why: annotations point at DOM coordinates from one loaded document.
// A real URL change invalidates those markers and copied context.
const shouldClearAnnotations = normalizeUrl(page.url) !== nextUrl
const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) =>
entry.id === pageId
? {
...entry,
url: nextUrl,
title: normalizeBrowserTitle(entry.title, nextUrl),
loading: true,
loadError: null
}
: entry
)
const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages)
const nextBrowserAnnotationsByPageId = shouldClearAnnotations
? { ...s.browserAnnotationsByPageId }
: s.browserAnnotationsByPageId
if (shouldClearAnnotations) {
delete nextBrowserAnnotationsByPageId[pageId]
}
return {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspace.id]: nextPages
},
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map((tab) =>
tab.id === workspace.id ? nextWorkspace : tab
)
},
...(shouldClearAnnotations
? { browserAnnotationsByPageId: nextBrowserAnnotationsByPageId }
: {})
}
}),
setRemoteBrowserPageHandle: (pageId, handle) => {
set((s) => ({
remoteBrowserPageHandlesByPageId: {
...s.remoteBrowserPageHandlesByPageId,
[pageId]: handle
}
}))
},
removeRemoteBrowserPageHandle: (pageId, remotePageId) => {
let removedHandle: RemoteBrowserPageHandle | null = null
set((s) => {
const current = s.remoteBrowserPageHandlesByPageId[pageId]
if (!current || (remotePageId && current.remotePageId !== remotePageId)) {
return s
}
removedHandle = current
const nextRemoteBrowserPageHandlesByPageId = {
...s.remoteBrowserPageHandlesByPageId
}
delete nextRemoteBrowserPageHandlesByPageId[pageId]
return { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId }
})
return removedHandle
},
// viewportPresetId is a per-page setting on BrowserPage and is intentionally not
// mirrored onto BrowserWorkspace: the outer tab strip doesn't surface the preset,
// so there's no UI consumer at the workspace layer. Keeping it page-local avoids
// cross-layer plumbing; do NOT add mirrorWorkspaceFromActivePage here.
setBrowserPageViewportPreset: (pageId, viewportPresetId) =>
set((s) => {
const page = findPage(s.browserPagesByWorkspace, pageId)
if (!page) {
return s
}
const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId)
if (!workspace) {
return s
}
const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) =>
entry.id === pageId ? { ...entry, viewportPresetId } : entry
)
return {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspace.id]: nextPages
}
}
}),
addBrowserPageAnnotation: (annotation) =>
set((s) => {
const existing = s.browserAnnotationsByPageId[annotation.browserPageId] ?? []
const next = [...existing, sanitizeBrowserPageAnnotation(annotation)].slice(
-GRAB_BUDGET.annotationsMaxPerPage
)
return {
browserAnnotationsByPageId: {
...s.browserAnnotationsByPageId,
[annotation.browserPageId]: next
}
}
}),
deleteBrowserPageAnnotation: (pageId, annotationId) =>
set((s) => {
const existing = s.browserAnnotationsByPageId[pageId] ?? []
const next = existing.filter((annotation) => annotation.id !== annotationId)
if (next.length === existing.length) {
return s
}
const nextByPageId = { ...s.browserAnnotationsByPageId }
if (next.length > 0) {
nextByPageId[pageId] = next
} else {
delete nextByPageId[pageId]
}
return { browserAnnotationsByPageId: nextByPageId }
}),
clearBrowserPageAnnotations: (pageId) =>
set((s) => {
if (!s.browserAnnotationsByPageId[pageId]?.length) {
return s
}
const nextByPageId = { ...s.browserAnnotationsByPageId }
delete nextByPageId[pageId]
return { browserAnnotationsByPageId: nextByPageId }
}),
hydrateBrowserSession: (session) => {
const persistedTabsByWorktree = session.browserTabsByWorktree ?? {}
const currentState = get()
const validWorktreeIdsForCleanup = new Set(
Object.values(currentState.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
)
validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID)
// Why: mirror closeBrowserTab's contract — reducers are pure, imperative
// side effects bracket them. Compute dropped workspaces first, destroy
// their webviews, then run the state reducer unchanged. hydrate is called
// once at boot (App.tsx) when the webview registry is empty, so this loop
// is a no-op today; it's defense-in-depth for any future caller that
// re-hydrates after webviews are live.
const droppedWorkspaceIds: string[] = []
for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) {
if (!validWorktreeIdsForCleanup.has(worktreeId)) {
for (const tab of tabs) {
droppedWorkspaceIds.push(tab.id)
}
}
}
for (const workspaceId of droppedWorkspaceIds) {
destroyWorkspaceWebviews(currentState.browserPagesByWorkspace, workspaceId)
}
set((s) => {
const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {}
const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {}
const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {}
const validWorktreeIds = new Set(
Object.values(s.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
const browserTabsByWorktree: Record<string, BrowserWorkspace[]> = {}
const browserPagesByWorkspace: Record<string, BrowserPage[]> = {}
for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) {
if (!validWorktreeIds.has(worktreeId)) {
continue
}
const hydratedTabs: BrowserWorkspace[] = []
for (const tab of tabs) {
const persistedPages = persistedPagesByWorkspace[tab.id] ?? [
{
id: createBrowserUuid(),
workspaceId: tab.id,
worktreeId,
url: normalizeUrl(tab.url),
title: tab.title,
loading: false,
faviconUrl: tab.faviconUrl ?? null,
canGoBack: tab.canGoBack,
canGoForward: tab.canGoForward,
loadError: tab.loadError ?? null,
createdAt: tab.createdAt
} satisfies BrowserPage
]
const nextPages = persistedPages.map((page) => ({
...page,
workspaceId: tab.id,
worktreeId,
url: normalizeUrl(page.url),
loading: false,
loadError: page.loadError ?? null
}))
browserPagesByWorkspace[tab.id] = nextPages
hydratedTabs.push(
mirrorWorkspaceFromActivePage(
{
...tab,
activePageId: nextPages.some((page) => page.id === tab.activePageId)
? (tab.activePageId ?? nextPages[0]?.id ?? null)
: (nextPages[0]?.id ?? null),
pageIds: nextPages.map((page) => page.id)
},
nextPages
)
)
}
if (hydratedTabs.length > 0) {
browserTabsByWorktree[worktreeId] = hydratedTabs
}
}
const validBrowserTabIds = new Set(
Object.values(browserTabsByWorktree)
.flat()
.map((tab) => tab.id)
)
const activeBrowserTabIdByWorktree: Record<string, string | null> = {}
for (const [worktreeId, tabs] of Object.entries(browserTabsByWorktree)) {
const persistedTabId = persistedActiveBrowserTabIdByWorktree[worktreeId]
activeBrowserTabIdByWorktree[worktreeId] =
persistedTabId && validBrowserTabIds.has(persistedTabId)
? persistedTabId
: (tabs[0]?.id ?? null)
}
const activeWorktreeId = s.activeWorktreeId
const activeBrowserTabId =
activeWorktreeId && activeBrowserTabIdByWorktree[activeWorktreeId]
? activeBrowserTabIdByWorktree[activeWorktreeId]
: null
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
for (const worktreeId of validWorktreeIds) {
const hasBrowserTabs = (browserTabsByWorktree[worktreeId] ?? []).length > 0
if (
persistedActiveTabTypeByWorktree[worktreeId] === 'browser' &&
hasBrowserTabs &&
!nextActiveTabTypeByWorktree[worktreeId]
) {
nextActiveTabTypeByWorktree[worktreeId] = 'browser'
continue
}
if (nextActiveTabTypeByWorktree[worktreeId] === 'browser' && !hasBrowserTabs) {
nextActiveTabTypeByWorktree[worktreeId] = getFallbackTabTypeForWorktree(
worktreeId,
s.openFiles,
s.tabsByWorktree,
browserTabsByWorktree
)
}
}
const activeTabType = (() => {
if (!activeWorktreeId) {
return s.activeTabType
}
const restoredTabType = nextActiveTabTypeByWorktree[activeWorktreeId]
if (restoredTabType === 'browser' && activeBrowserTabId) {
return 'browser'
}
if (
restoredTabType === 'editor' &&
s.openFiles.some((file) => file.worktreeId === activeWorktreeId)
) {
return 'editor'
}
return getFallbackTabTypeForWorktree(
activeWorktreeId,
s.openFiles,
s.tabsByWorktree,
browserTabsByWorktree
)
})()
return {
browserTabsByWorktree,
browserPagesByWorkspace,
activeBrowserTabIdByWorktree,
activeBrowserTabId,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
activeTabType,
remoteBrowserPageHandlesByPageId: {},
browserAnnotationsByPageId: {},
browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? [])
}
})
const state = get()
for (const [worktreeId, browserTabs] of Object.entries(state.browserTabsByWorktree)) {
for (const bt of browserTabs) {
const exists = (state.unifiedTabsByWorktree[worktreeId] ?? []).some(
(t) => t.contentType === 'browser' && t.entityId === bt.id
)
if (!exists) {
state.createUnifiedTab(worktreeId, 'browser', {
entityId: bt.id,
label: bt.title
})
}
}
}
},
switchBrowserTabProfile: (workspaceId, profileId) => {
set((s) => {
for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) {
const tabIndex = tabs.findIndex((t) => t.id === workspaceId)
if (tabIndex !== -1) {
const updatedTabs = [...tabs]
updatedTabs[tabIndex] = { ...updatedTabs[tabIndex], sessionProfileId: profileId }
return {
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[worktreeId]: updatedTabs
}
}
}
}
return {}
})
},
fetchBrowserSessionProfiles: async () => {
if (isRuntimeEnvironmentActive(get())) {
try {
const result = await callRuntimeRpc<BrowserProfileListResult>(
getActiveRuntimeTarget(get().settings),
'browser.profileList',
undefined,
{ timeoutMs: 15_000 }
)
set({ browserSessionProfiles: result.profiles })
} catch {
set({ browserSessionProfiles: [] })
}
return
}
try {
const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[]
set({ browserSessionProfiles: profiles })
} catch {
/* best-effort — stale profile list is preferable to a crash */
}
},
createBrowserSessionProfile: async (scope, label) => {
if (isRuntimeEnvironmentActive(get())) {
try {
const result = await callRuntimeRpc<BrowserProfileCreateResult>(
getActiveRuntimeTarget(get().settings),
'browser.profileCreate',
{ scope, label },
{ timeoutMs: 15_000 }
)
const profile = result.profile
if (profile) {
set((s) => ({
browserSessionProfiles: [...s.browserSessionProfiles, profile]
}))
}
return profile
} catch {
return null
}
}
try {
const profile = (await window.api.browser.sessionCreateProfile({
scope,
label
})) as BrowserSessionProfile | null
if (profile) {
set((s) => ({
browserSessionProfiles: [...s.browserSessionProfiles, profile]
}))
}
return profile
} catch {
return null
}
},
deleteBrowserSessionProfile: async (profileId) => {
if (isRuntimeEnvironmentActive(get())) {
try {
const result = await callRuntimeRpc<BrowserProfileDeleteResult>(
getActiveRuntimeTarget(get().settings),
'browser.profileDelete',
{ profileId },
{ timeoutMs: 15_000 }
)
if (result.deleted) {
set((s) => ({
browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId),
...(s.defaultBrowserSessionProfileId === profileId
? { defaultBrowserSessionProfileId: null }
: {})
}))
}
return result.deleted
} catch {
return false
}
}
try {
const ok = await window.api.browser.sessionDeleteProfile({ profileId })
if (ok) {
set((s) => ({
browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId),
...(s.defaultBrowserSessionProfileId === profileId
? { defaultBrowserSessionProfileId: null }
: {})
}))
}
return ok
} catch {
return false
}
},
importCookiesToProfile: async (profileId) => {
if (isRuntimeEnvironmentActive(get())) {
const reason = 'Manual cookie file import is unavailable while a remote runtime is active.'
set({
browserSessionImportState: {
profileId,
status: 'error',
summary: null,
error: reason
}
})
return { ok: false as const, reason }
}
set({
browserSessionImportState: {
profileId,
status: 'importing',
summary: null,
error: null
}
})
try {
const result = (await window.api.browser.sessionImportCookies({
profileId
})) as BrowserCookieImportResult
if (result.ok) {
set({
browserSessionImportState: {
profileId,
status: 'success',
summary: result.summary,
error: null
}
})
await get()
.fetchBrowserSessionProfiles()
.catch(() => {})
} else {
set({
browserSessionImportState: {
profileId,
status: result.reason === 'canceled' ? 'idle' : 'error',
summary: null,
error: result.reason === 'canceled' ? null : result.reason
}
})
}
return result
} catch (err) {
const reason = String((err as Error)?.message ?? err)
set({
browserSessionImportState: {
profileId,
status: 'error',
summary: null,
error: reason
}
})
return { ok: false as const, reason }
}
},
clearBrowserSessionImportState: () => {
set({ browserSessionImportState: null })
},
detectedBrowsers: [],
detectedBrowsersLoaded: false,
fetchDetectedBrowsers: async () => {
if (isRuntimeEnvironmentActive(get())) {
try {
const result = await callRuntimeRpc<BrowserDetectProfilesResult>(
getActiveRuntimeTarget(get().settings),
'browser.profileDetectBrowsers',
undefined,
{ timeoutMs: 15_000 }
)
set({ detectedBrowsers: result.browsers, detectedBrowsersLoaded: true })
} catch {
set({ detectedBrowsers: [], detectedBrowsersLoaded: true })
}
return
}
if (get().detectedBrowsersLoaded) {
return
}
try {
const browsers = (await window.api.browser.sessionDetectBrowsers()) as {
family: string
label: string
profiles: { name: string; directory: string }[]
selectedProfile: string
}[]
set({ detectedBrowsers: browsers, detectedBrowsersLoaded: true })
} catch {
/* best-effort — empty list is acceptable fallback */
set({ detectedBrowsersLoaded: true })
}
},
importCookiesFromBrowser: async (profileId, browserFamily, browserProfile?) => {
if (isRuntimeEnvironmentActive(get())) {
set({
browserSessionImportState: {
profileId,
status: 'importing',
summary: null,
error: null
}
})
try {
const result = await callRuntimeRpc<BrowserProfileImportFromBrowserResult>(
getActiveRuntimeTarget(get().settings),
'browser.profileImportFromBrowser',
{ profileId, browserFamily, browserProfile },
{ timeoutMs: 30_000 }
)
if (result.ok) {
set({
browserSessionImportState: {
profileId,
status: 'success',
summary: result.summary,
error: null
}
})
await get()
.fetchBrowserSessionProfiles()
.catch(() => {})
} else {
set({
browserSessionImportState: {
profileId,
status: 'error',
summary: null,
error: result.reason
}
})
}
return result
} catch (err) {
const reason = String((err as Error)?.message ?? err)
set({
browserSessionImportState: {
profileId,
status: 'error',
summary: null,
error: reason
}
})
return { ok: false as const, reason }
}
}
set({
browserSessionImportState: {
profileId,
status: 'importing',
summary: null,
error: null
}
})
try {
const result = (await window.api.browser.sessionImportFromBrowser({
profileId,
browserFamily,
browserProfile
})) as BrowserCookieImportResult
if (result.ok) {
set({
browserSessionImportState: {
profileId,
status: 'success',
summary: result.summary,
error: null
}
})
await get()
.fetchBrowserSessionProfiles()
.catch(() => {})
} else {
set({
browserSessionImportState: {
profileId,
status: 'error',
summary: null,
error: result.reason
}
})
}
return result
} catch (err) {
const reason = String((err as Error)?.message ?? err)
set({
browserSessionImportState: {
profileId,
status: 'error',
summary: null,
error: reason
}
})
return { ok: false as const, reason }
}
},
clearDefaultSessionCookies: async () => {
if (isRuntimeEnvironmentActive(get())) {
try {
const result = await callRuntimeRpc<BrowserProfileClearDefaultCookiesResult>(
getActiveRuntimeTarget(get().settings),
'browser.profileClearDefaultCookies',
undefined,
{ timeoutMs: 15_000 }
)
if (result.cleared) {
await get().fetchBrowserSessionProfiles()
}
return result.cleared
} catch {
return false
}
}
try {
const ok = await window.api.browser.sessionClearDefaultCookies()
if (ok) {
await get().fetchBrowserSessionProfiles()
}
return ok
} catch {
return false
}
},
addBrowserHistoryEntry: (url, title) => {
const safeUrl = redactKagiSessionToken(url)
if (safeUrl === ORCA_BROWSER_BLANK_URL || safeUrl === 'about:blank' || !safeUrl) {
return
}
const normalized = normalizeBrowserHistoryUrl(safeUrl)
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: safeUrl,
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: [] })
})