fix(browser): show profiles changed outside the window in the profile menu without a reload (#32723)

This commit is contained in:
Kelvin Amoaba
2026-10-24 05:06:34 +00:00
committed by GitHub
parent 2e278adeac
commit 785daec1e1
32 changed files with 279 additions and 13 deletions
@@ -376,6 +376,34 @@ describe('BrowserSessionRegistry', () => {
expect(recreated?.partition).not.toBe(first.partition)
})
it('announces each profile change after the list reflects it', async () => {
const seen: string[][] = []
browserSessionRegistry.onProfilesChanged = () =>
seen.push(browserSessionRegistry.listProfiles().map((profile) => profile.label))
try {
const made = await browserSessionRegistry.createProfile('isolated', 'Made by CLI')
const managed = await ensureManagedBrowserProfile({ kind: 'repo', id: 'announce' }, 'Repo')
if (!made || !managed) {
throw new Error('Expected created profiles')
}
browserSessionRegistry.updateProfileSource(made.id, {
browserFamily: 'manual',
importedAt: 1
})
await browserSessionRegistry.deleteProfile(made.id)
await browserSessionRegistry.deleteProfile('default')
await browserSessionRegistry.createProfile('default', 'Refused')
expect(seen).toHaveLength(4)
expect(seen[0]).toContain('Made by CLI')
expect(seen[1]).toContain('Repo')
expect(seen[2]).toContain('Made by CLI')
expect(seen[3]).not.toContain('Made by CLI')
} finally {
browserSessionRegistry.onProfilesChanged = null
}
})
it('refuses to delete the default profile', async () => {
const deleted = await browserSessionRegistry.deleteProfile('default')
expect(deleted).toBe(false)
@@ -56,6 +56,8 @@ class BrowserSessionRegistry {
private activeOrcaProfileId = DEFAULT_LOCAL_ORCA_PROFILE_ID
private metadataPathOverride: string | null = null
private defaultPartition = ORCA_BROWSER_PARTITION
// Why: profiles change from IPC, the CLI and managed-profile owners; open windows and paired clients only learn of it here.
onProfilesChanged: (() => void) | null = null
constructor() {
this.resetDefaultProfile()
@@ -234,6 +236,7 @@ class BrowserSessionRegistry {
}
this.profiles.set(id, profile)
this.persistProfiles()
this.onProfilesChanged?.()
return profile
}
@@ -252,6 +255,7 @@ class BrowserSessionRegistry {
} else {
this.persistProfiles()
}
this.onProfilesChanged?.()
return updated
}
@@ -262,6 +266,7 @@ class BrowserSessionRegistry {
}
this.profiles.delete(profileId)
this.persistProfiles()
this.onProfilesChanged?.()
forgetBrowserSitePermissions(profileId)
forgetBrowserSiteDisplay(profileId)
const meta = this.loadPersistedMeta()
@@ -309,6 +314,7 @@ class BrowserSessionRegistry {
pendingCookieDbPath: null,
pendingCookieImports
})
this.onProfilesChanged?.()
const sess = session.fromPartition(this.defaultPartition)
await sess.clearStorageData({ storages: ['cookies'] })
@@ -45,6 +45,7 @@ export type RuntimeNotifier = {
worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void
worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void
reposChanged(): void
browserProfilesChanged?(): void
activateWorktree(
repoId: string,
worktreeId: string,
@@ -189,6 +189,12 @@ export abstract class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWi
this.emitClientEvent({ type: 'reposChanged' })
}
// Why: browser profiles change in main's session registry, outside runtime methods.
notifyBrowserProfilesChanged(): void {
this.notifier?.browserProfilesChanged?.()
this.emitClientEvent({ type: 'browserProfilesChanged' })
}
/** `setup` and `defaultTabs` go to the host window only: main tried and failed to start them. */
protected notifyActivateWorktree(
repoId: string,
@@ -36,6 +36,7 @@ const dependencies: Record<string, Record<string, unknown>> = {
'../ipc/desktop-browser-bridge-wiring': { disposeDesktopBrowserBridge: idle() },
'../orca-profiles/profile-cloud-session-owner': { flushUnsavedCloudSessions: idle() },
'../browser/browser-manager': { browserManager: { setBrowserGuestStateChangedListener: idle() } },
'../browser/browser-session-registry': { browserSessionRegistry: {} },
'../browser/local-ssh-browser-route': { closeAllLocalSshBrowserRoutes: settled() },
'../codex/codex-state-db-backfill-recovery': { stopCodexStateDbBackfillRecoveries: settled() },
'../codex/codex-account-session-bridge': { stopCodexAccountSessionBridges: idle() },
@@ -25,6 +25,7 @@ const dependencyExports: [string, string[]][] = [
['../ipc/desktop-browser-bridge-wiring', ['disposeDesktopBrowserBridge']],
['../orca-profiles/profile-cloud-session-owner', ['flushUnsavedCloudSessions']],
['../browser/browser-manager', ['browserManager']],
['../browser/browser-session-registry', ['browserSessionRegistry']],
['../codex/codex-state-db-backfill-recovery', ['stopCodexStateDbBackfillRecoveries']],
['../codex/codex-account-session-bridge', ['stopCodexAccountSessionBridges']],
['../git/local-repo-ref-maintenance', ['awaitPackedRefsLockRelease']],
@@ -12,6 +12,7 @@ import { clearProviderPtyState } from '../ipc/pty/provider/state-cleanup'
import { agentHookServer } from '../agent-hooks/server'
import { toAgentStatusIpcPayload } from '../agent-hooks/server/server-status-identity'
import { browserManager } from '../browser/browser-manager'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import { codexAiVaultSessionResumePreparation } from '../codex/codex-ai-vault-session-resume'
@@ -173,6 +174,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService {
browserManager.setBrowserGuestStateChangedListener((worktreeId) => {
runtime.notifyMobileSessionTabsChanged(worktreeId)
})
browserSessionRegistry.onProfilesChanged = () => runtime.notifyBrowserProfilesChanged()
return runtime
}
@@ -44,6 +44,7 @@ export function registerRuntimeWindowLifecycle(
worktreeBaseStatus: (event) => send('worktree:baseStatus', event),
worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event),
reposChanged: () => send('repos:changed'),
browserProfilesChanged: () => send('browser:session:profilesChanged'),
automationsChanged: (payload) => send('automations:changed', payload),
activateWorktree: (
repoId,
+1
View File
@@ -186,6 +186,7 @@ export type BrowserApi = {
callback: (args: { browserPageId: string; key: 'c' | 's' }) => void
) => () => void
sessionListProfiles: () => Promise<BrowserSessionProfile[]>
onSessionProfilesChanged: (callback: () => void) => () => void
/** Resolves once the SSH workspace's partition is bound and proxy-verified; the webview must wait for it. */
prepareSshWorkspacePartition: (args: {
targetId: string
@@ -123,6 +123,11 @@ export const browserPageInteractionAndSessionsApi = {
return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener)
},
sessionListProfiles: () => ipcRenderer.invoke('browser:session:listProfiles'),
onSessionProfilesChanged: (callback: () => void): (() => void) => {
const listener = (): void => callback()
ipcRenderer.on('browser:session:profilesChanged', listener)
return () => ipcRenderer.removeListener('browser:session:profilesChanged', listener)
},
prepareSshWorkspacePartition: (args: {
targetId: string
expectedSshTargetGeneration?: number
@@ -0,0 +1,35 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { useAppStore } from '@/store'
import type { BrowserSessionProfile } from '../../../../../shared/browser-workspace-types'
import { useBrowserPageWebviewPartition } from './use-browser-page-webview-partition'
const initialState = useAppStore.getState()
afterEach(() => {
cleanup()
useAppStore.setState(initialState, true)
})
const profile = (id: string, scope: BrowserSessionProfile['scope']): BrowserSessionProfile => ({
id,
scope,
partition: `persist:${id}`,
label: id,
source: null
})
it('keeps a tab out of the Default jar once its profile is deleted elsewhere', () => {
const defaultProfile = profile('default', 'default')
useAppStore.setState({
browserSessionProfilesByHostId: { local: [defaultProfile, profile('work', 'isolated')] }
})
const { result } = renderHook(() =>
useBrowserPageWebviewPartition({ sessionProfileId: 'work', sessionPartition: null })
)
expect(result.current).toBe('persist:work')
act(() => useAppStore.setState({ browserSessionProfilesByHostId: { local: [defaultProfile] } }))
expect(result.current).not.toBe(defaultProfile.partition)
})
@@ -2,6 +2,7 @@ import { useAppStore } from '@/store'
import { ORCA_BROWSER_PARTITION } from '../../../../../shared/constants'
import { getOrcaProfileBrowserDefaultPartition } from '../../../../../shared/orca-profiles'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../../shared/execution-host'
import { unadmittedBrowserPartition } from '../../../../../shared/browser-profile-scope'
export function useBrowserPageWebviewPartition({
sessionProfileId,
@@ -11,9 +12,11 @@ export function useBrowserPageWebviewPartition({
sessionPartition: string | null
}): string {
// Why the local list first: this webview renders here, while Settings may be showing a paired host's profiles.
const browserSessionProfiles = useAppStore(
(s) => s.browserSessionProfilesByHostId[LOCAL_EXECUTION_HOST_ID] ?? s.browserSessionProfiles
const localProfiles = useAppStore(
(s) => s.browserSessionProfilesByHostId[LOCAL_EXECUTION_HOST_ID]
)
const mirroredProfiles = useAppStore((s) => s.browserSessionProfiles)
const browserSessionProfiles = localProfiles ?? mirroredProfiles
const activeOrcaProfileId = useAppStore((s) => s.activeOrcaProfileId)
const fallbackBrowserPartition = activeOrcaProfileId
? getOrcaProfileBrowserDefaultPartition(activeOrcaProfileId)
@@ -22,8 +25,14 @@ export function useBrowserPageWebviewPartition({
const sessionProfile = sessionProfileId
? (browserSessionProfiles.find((p) => p.id === sessionProfileId) ?? null)
: defaultSessionProfile
if (sessionPartition) {
return sessionPartition
}
// Why: a profile missing from main's loaded list was deleted; its tab must load nowhere, not in Default's jar.
if (sessionProfileId && !sessionProfile && localProfiles) {
return unadmittedBrowserPartition(sessionProfileId)
}
return (
sessionPartition ??
sessionProfile?.partition ??
defaultSessionProfile?.partition ??
fallbackBrowserPartition ??
@@ -102,6 +102,7 @@ export function buildWindowApi(args: {
onGuestLoadFailed: () => () => {},
onPaneFocus: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onSessionProfilesChanged: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: () => () => {}
},
@@ -261,9 +261,7 @@ export async function useIpcEventsForCloseRouting({
getZoomLevel: () => 0,
set: vi.fn()
},
settings: {
onChanged: () => () => {}
},
settings: { onChanged: () => () => {} },
updater: {
getStatus: () => Promise.resolve({ state: 'idle' }),
onStatus: () => () => {},
@@ -272,6 +270,7 @@ export async function useIpcEventsForCloseRouting({
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onSessionProfilesChanged: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: () => () => {},
onPaneFocus: () => () => {}
@@ -163,6 +163,7 @@ export function buildTerminalCreateWindow(args: {
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onSessionProfilesChanged: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: () => () => {},
onPaneFocus: () => () => {}
@@ -30,6 +30,7 @@ function captureHoldHandler(unsubs: (() => void)[] = []): (event: CapturePaintHo
onActivateView: subscribe,
onPaneFocus: subscribe,
onOpenLinkInOrcaTab: subscribe,
onSessionProfilesChanged: subscribe,
onCapturePaintHold: (callback: (event: CapturePaintHoldEvent) => void) => {
handler = callback
return () => {}
@@ -2,6 +2,7 @@ import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/
import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-owner'
import { openUrlInTabBesideBrowserPage } from '@/lib/browser-page-tab-opening'
import { redactKagiSessionToken } from '../../../../shared/browser-url'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { useAppStore } from '../../store'
import {
acquireBrowserAutomationVisibility,
@@ -153,4 +154,9 @@ export function registerBrowserStateIpcBridge(
}
})
)
unsubs.push(
window.api.browser.onSessionProfilesChanged(() => {
void useAppStore.getState().fetchBrowserSessionProfiles(LOCAL_EXECUTION_HOST_ID)
})
)
}
@@ -54,6 +54,7 @@ function createHarness() {
subscribeRuntimeEnvironmentFromPreload(ipc, args, callbacks, () => `sub-${nextId++}`),
call: vi.fn(async () => ({ id: 'r', ok: true, result: [] }))
},
browser: { sessionClientRouteImportSources: async () => ({}) },
linear: { getIssue }
}
})
@@ -439,3 +440,38 @@ it('accepts a fresh bridge early frame while rejecting the previous bridge frame
}
expect(h.ipc.removeListener).toHaveBeenCalledOnce()
})
it("lists a profile created on the host once the host announces it, without touching other hosts' lists", async () => {
const h = createHarness()
const madeByCli = {
id: 'made-by-cli',
scope: 'isolated' as const,
partition: 'persist:orca-browser-session-made-by-cli',
label: 'Made by CLI',
source: null
}
const status = createCompatibleRuntimeStatusResponse()
vi.mocked(window.api.runtimeEnvironments.call).mockImplementation(async ({ method }) =>
method === 'status.get'
? status
: {
id: 'r',
ok: true,
result: { profiles: [madeByCli] },
_meta: { runtimeId: 'remote-runtime' }
}
)
useAppStore.setState({ browserSessionProfilesByHostId: { local: [] } })
try {
h.start()
h.emit(0, false, { type: 'browserProfilesChanged' })
await vi.waitFor(() =>
expect(useAppStore.getState().browserSessionProfilesByHostId['runtime:host-a']).toEqual([
madeByCli
])
)
expect(useAppStore.getState().browserSessionProfilesByHostId.local).toEqual([])
} finally {
await h.finish()
}
})
@@ -13,7 +13,7 @@ import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events'
import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh'
import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import { getRepoExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host'
import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events'
import { navigationTargetsClients } from '../../../../shared/runtime-navigation'
import { useAppStore } from '../../store'
@@ -126,6 +126,12 @@ export function registerRuntimeClientIpcBridge(
})
return
}
if (event.type === 'browserProfilesChanged') {
void useAppStore
.getState()
.fetchBrowserSessionProfiles(toRuntimeExecutionHostId(environmentId))
return
}
if (event.type === 'sshStateChanged') {
applyRuntimeEnvironmentSshStateChanged(environmentId, event.targetId, event.state, generation)
return
@@ -219,6 +219,7 @@ describe('useIpcEvents browser tab create routing', () => {
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onSessionProfilesChanged: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: (listener: NonNullable<typeof activateViewListenerRef.current>) => {
activateViewListenerRef.current = listener
@@ -194,6 +194,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onSessionProfilesChanged: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: () => () => {},
onPaneFocus: () => () => {}
@@ -464,6 +465,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onSessionProfilesChanged: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: () => () => {},
onPaneFocus: () => () => {}
@@ -200,6 +200,7 @@ describe('useIpcEvents browser tab close routing', () => {
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onSessionProfilesChanged: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: () => () => {},
onPaneFocus: () => () => {}
@@ -430,6 +431,7 @@ describe('useIpcEvents browser tab close routing', () => {
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onSessionProfilesChanged: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: () => () => {},
onPaneFocus: () => () => {}
@@ -655,6 +657,7 @@ describe('useIpcEvents browser tab close routing', () => {
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onSessionProfilesChanged: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: () => () => {},
onPaneFocus: () => () => {}
@@ -199,6 +199,7 @@ describe('useIpcEvents updater integration', () => {
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onSessionProfilesChanged: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: () => () => {},
onPaneFocus: () => () => {}
@@ -169,6 +169,7 @@ describe('useIpcEvents updater integration', () => {
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {},
onSessionProfilesChanged: () => () => {},
onNavigationUpdate: () => () => {},
onActivateView: () => () => {},
onPaneFocus: () => () => {}
@@ -91,6 +91,7 @@ function isRuntimeClientEvent(
message.type === 'terminalSideEffects' ||
message.type === 'sshStateChanged' ||
message.type === 'automationsChanged' ||
message.type === 'browserProfilesChanged' ||
message.type === 'linearLinkedIssueUpdated' ||
message.type === 'activateWorktree' ||
message.type === 'worktreeTerminalSleepState'
@@ -86,6 +86,21 @@ describe('createBrowserSlice runtime guard', () => {
])
})
it('keeps the default profile pick when a server profile fetch fails', async () => {
const store = createTestStore()
runtimeEnvironmentCall.mockRejectedValueOnce(new Error('timed out'))
store.setState({
settings: settingsWithRuntime('env-1'),
defaultBrowserSessionProfileId: 'work',
defaultBrowserSessionProfileIdByHostId: { 'runtime:env-1': 'work' }
})
await store.getState().fetchBrowserSessionProfiles()
expect(store.getState().defaultBrowserSessionProfileId).toBe('work')
expect(store.getState().defaultBrowserSessionProfileIdByHostId['runtime:env-1']).toBe('work')
})
it('creates a profile in the active runtime environment', async () => {
const store = createTestStore()
const profile = {
@@ -349,6 +364,53 @@ describe('createBrowserSlice runtime guard', () => {
])
})
it('drops the default profile pick once a refreshed list no longer has it', async () => {
const store = createTestStore()
const listed = (id: string) => ({
id,
scope: id === 'default' ? ('default' as const) : ('isolated' as const),
partition: `persist:${id}`,
label: id,
source: null
})
store.setState({
defaultBrowserSessionProfileId: 'work',
defaultBrowserSessionProfileIdByHostId: { local: 'work' }
})
mockApi.browser.sessionListProfiles
.mockResolvedValueOnce([listed('default'), listed('work')])
.mockResolvedValueOnce([listed('default')])
await store.getState().fetchBrowserSessionProfiles('local')
expect(store.getState().defaultBrowserSessionProfileIdByHostId.local).toBe('work')
await store.getState().fetchBrowserSessionProfiles('local')
expect(store.getState().defaultBrowserSessionProfileId).toBeNull()
expect(store.getState().defaultBrowserSessionProfileIdByHostId.local).toBeNull()
})
it('keeps the newest listing when an older one answers last', async () => {
const store = createTestStore()
const listed = (id: string) => ({
id,
scope: id === 'default' ? ('default' as const) : ('isolated' as const),
partition: `persist:${id}`,
label: id,
source: null
})
const older = Promise.withResolvers<ReturnType<typeof listed>[]>()
mockApi.browser.sessionListProfiles
.mockReturnValueOnce(older.promise)
.mockResolvedValueOnce([listed('default')])
const olderFetch = store.getState().fetchBrowserSessionProfiles('local')
await store.getState().fetchBrowserSessionProfiles('local')
older.resolve([listed('default'), listed('deleted')])
await olderFetch
expect(store.getState().browserSessionProfilesByHostId.local).toEqual([listed('default')])
})
it('creates a profile through local browser IPC', async () => {
const store = createTestStore()
const profile = {
@@ -138,6 +138,32 @@ export function profileListByHostUpdate(
}
}
/** A host's full listing; it also drops a default pick the host no longer lists, so new tabs don't open on a deleted profile. */
export function listedProfilesByHostUpdate(
state: AppState,
profiles: BrowserSessionProfile[],
hostId: ExecutionHostId
): Partial<BrowserSlice> {
const isUnlisted = (profileId: string | null | undefined): boolean =>
!!profileId && !profiles.some((profile) => profile.id === profileId)
const update = profileListByHostUpdate(state, profiles, hostId)
return {
...update,
// Why: the unkeyed default belongs to the Settings host, the one whose list the mirror follows.
...(update.browserSessionProfiles && isUnlisted(state.defaultBrowserSessionProfileId)
? { defaultBrowserSessionProfileId: null }
: {}),
...(isUnlisted(state.defaultBrowserSessionProfileIdByHostId[hostId])
? {
defaultBrowserSessionProfileIdByHostId: {
...state.defaultBrowserSessionProfileIdByHostId,
[hostId]: null
}
}
: {})
}
}
export function getBrowserProfilesForHost(
state: AppState,
hostId: ExecutionHostId
@@ -5,6 +5,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../../../shared/execution-host'
import {
isManagedBrowserProfileOf,
managedBrowserProfileOwnerKey,
unadmittedBrowserPartition,
type ManagedBrowserProfileOwner
} from '../../../../../shared/browser-profile-scope'
import { withoutKey } from '../../../../../shared/workspace-layout/stored-record-fields'
@@ -16,7 +17,7 @@ const placeholderProfile = (
sessionProfileId: string
): { sessionProfileId: string; sessionPartition: string } => ({
sessionProfileId,
sessionPartition: `persist:orca-browser-unminted-${sessionProfileId}`
sessionPartition: unadmittedBrowserPartition(sessionProfileId)
})
const localProfiles = (state: AppState): BrowserSessionProfile[] =>
@@ -5,14 +5,15 @@ import type {
BrowserProfileDeleteResult,
BrowserProfileListResult
} from '../../../../../shared/runtime-types'
import { parseExecutionHostId } from '../../../../../shared/execution-host'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../../shared/execution-host'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import {
getBrowserProfilesForHost,
getBrowserSettingsHostId,
getBrowserSettingsRuntimeEnvironmentId,
profileListByHostUpdate,
getDefaultBrowserProfileForHost
getDefaultBrowserProfileForHost,
listedProfilesByHostUpdate
} from './browser-host-state'
export function createBrowserProfileListActions(
@@ -22,9 +23,15 @@ export function createBrowserProfileListActions(
BrowserSlice,
'fetchBrowserSessionProfiles' | 'createBrowserSessionProfile' | 'deleteBrowserSessionProfile'
> {
// Why: change events can overlap fetches; only the latest listing per host may land, or an older one restores a deleted profile.
const latestListingByHost = new Map<ExecutionHostId, number>()
let listingCount = 0
return {
fetchBrowserSessionProfiles: async (requestedHostId) => {
const hostId = requestedHostId ?? getBrowserSettingsHostId(get())
const listing = ++listingCount
latestListingByHost.set(hostId, listing)
const isLatest = (): boolean => latestListingByHost.get(hostId) === listing
const parsedHostId = parseExecutionHostId(hostId)
const runtimeEnvironmentId =
parsedHostId?.kind === 'runtime' ? parsedHostId.environmentId : null
@@ -47,15 +54,21 @@ export function createBrowserProfileListActions(
? { ...profile, source: clientImportSources[profile.id] }
: profile
)
set((s) => profileListByHostUpdate(s, profiles, hostId))
if (isLatest()) {
set((s) => listedProfilesByHostUpdate(s, profiles, hostId))
}
} catch {
set((s) => profileListByHostUpdate(s, [], hostId))
if (isLatest()) {
set((s) => profileListByHostUpdate(s, [], hostId))
}
}
return
}
try {
const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[]
set((s) => profileListByHostUpdate(s, profiles, hostId))
if (isLatest()) {
set((s) => listedProfilesByHostUpdate(s, profiles, hostId))
}
} catch {
/* best-effort — stale profile list is preferable to a crash */
}
@@ -3,6 +3,7 @@ import { translate } from '@/i18n/i18n'
import { noopUnsubscribe } from './web-storage'
export function createBrowserApi(): NonNullable<Partial<PreloadApi>['browser']> {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial stub; withFallback in web-preload-api answers every browser member it omits.
return {
registerGuest: () => Promise.resolve(false),
isGuestRegistered: () => Promise.resolve(false),
@@ -65,6 +66,7 @@ export function createBrowserApi(): NonNullable<Partial<PreloadApi>['browser']>
onGrabModeToggle: () => noopUnsubscribe,
onGrabActionShortcut: () => noopUnsubscribe,
sessionListProfiles: () => Promise.resolve([]),
onSessionProfilesChanged: () => noopUnsubscribe,
// Web clients render remote workspaces through the server; no local SSH routing exists.
prepareSshWorkspacePartition: () =>
Promise.reject(new Error('browser_local_route_unavailable')),
+5
View File
@@ -31,3 +31,8 @@ export function isManagedBrowserProfileOf(
): boolean {
return profile.owner?.kind === owner.kind && profile.owner.id === owner.id
}
/** A partition main never admits, so a tab whose profile main has not made, or has deleted, loads nowhere. */
export function unadmittedBrowserPartition(profileId: string): string {
return `persist:orca-browser-unminted-${profileId}`
}
+2
View File
@@ -45,6 +45,8 @@ export type RuntimeClientEvent =
selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' }
reason?: 'definition' | 'run' | 'usage'
}
// Why: browser profiles live on the host; old clients drop the unknown type and refetch on their old triggers.
| { type: 'browserProfilesChanged' }
| {
type: 'linearLinkedIssueUpdated'
worktreeId: string