diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 2dcdac48319..fb715f151c7 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -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) diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 9d43c59abf3..1e2f20a727f 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -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'] }) diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 6aba19f72ea..958d4ae40de 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -45,6 +45,7 @@ export type RuntimeNotifier = { worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void reposChanged(): void + browserProfilesChanged?(): void activateWorktree( repoId: string, worktreeId: string, diff --git a/src/main/runtime/workspaces/workspace-host-notifications.ts b/src/main/runtime/workspaces/workspace-host-notifications.ts index 563179c0394..d5e14172372 100644 --- a/src/main/runtime/workspaces/workspace-host-notifications.ts +++ b/src/main/runtime/workspaces/workspace-host-notifications.ts @@ -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, diff --git a/src/main/startup/main-process-quit-pending-writer.test.ts b/src/main/startup/main-process-quit-pending-writer.test.ts index 35fa8f9396e..eff3d7b3f83 100644 --- a/src/main/startup/main-process-quit-pending-writer.test.ts +++ b/src/main/startup/main-process-quit-pending-writer.test.ts @@ -36,6 +36,7 @@ const dependencies: Record> = { '../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() }, diff --git a/src/main/startup/main-process-quit-update-veto.test.ts b/src/main/startup/main-process-quit-update-veto.test.ts index 5bdba8f1e82..897f92c4245 100644 --- a/src/main/startup/main-process-quit-update-veto.test.ts +++ b/src/main/startup/main-process-quit-update-veto.test.ts @@ -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']], diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 3680effce80..41382433fb1 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -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 } diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 459c133fd07..ebec79fed02 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -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, diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index d6ff0afcb31..a7067f5c001 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -186,6 +186,7 @@ export type BrowserApi = { callback: (args: { browserPageId: string; key: 'c' | 's' }) => void ) => () => void sessionListProfiles: () => Promise + 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 diff --git a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts index d666aa7c079..efba98ae261 100644 --- a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -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 diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-partition.test.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-partition.test.ts new file mode 100644 index 00000000000..3856d02f394 --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-partition.test.ts @@ -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) +}) diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-partition.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-partition.ts index e20c34d2658..722a89b1ffa 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-partition.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-partition.ts @@ -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 ?? diff --git a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts index 73b439c4d86..ecedfe0759f 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts @@ -102,6 +102,7 @@ export function buildWindowApi(args: { onGuestLoadFailed: () => () => {}, onPaneFocus: () => () => {}, onOpenLinkInOrcaTab: () => () => {}, + onSessionProfilesChanged: () => () => {}, onNavigationUpdate: () => () => {}, onActivateView: () => () => {} }, diff --git a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts index 0d4864a5bae..13fb929835d 100644 --- a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts @@ -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: () => () => {} diff --git a/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts index b6ca3aa46e0..2a86115162f 100644 --- a/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts @@ -163,6 +163,7 @@ export function buildTerminalCreateWindow(args: { browser: { onGuestLoadFailed: () => () => {}, onOpenLinkInOrcaTab: () => () => {}, + onSessionProfilesChanged: () => () => {}, onNavigationUpdate: () => () => {}, onActivateView: () => () => {}, onPaneFocus: () => () => {} diff --git a/src/renderer/src/hooks/ipc-events/browser-state-capture-paint-hold.test.ts b/src/renderer/src/hooks/ipc-events/browser-state-capture-paint-hold.test.ts index 93edfbf7a3d..9dd99d33959 100644 --- a/src/renderer/src/hooks/ipc-events/browser-state-capture-paint-hold.test.ts +++ b/src/renderer/src/hooks/ipc-events/browser-state-capture-paint-hold.test.ts @@ -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 () => {} diff --git a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts index 955c01db304..dc792c258d8 100644 --- a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts @@ -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) + }) + ) } diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts index e48d86d244e..ada9964824c 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts @@ -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() + } +}) diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index 1908c357381..e59741d32ba 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -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 diff --git a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts index 65ed69ee9d1..21cfb9b1374 100644 --- a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts @@ -219,6 +219,7 @@ describe('useIpcEvents browser tab create routing', () => { browser: { onGuestLoadFailed: () => () => {}, onOpenLinkInOrcaTab: () => () => {}, + onSessionProfilesChanged: () => () => {}, onNavigationUpdate: () => () => {}, onActivateView: (listener: NonNullable) => { activateViewListenerRef.current = listener diff --git a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts index 6994f9d2176..f5c117b6c4a 100644 --- a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts @@ -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: () => () => {} diff --git a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts index 86de4ce12dc..b20de4a6d69 100644 --- a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts @@ -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: () => () => {} diff --git a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts index 6832ab98de2..1c351de3cb1 100644 --- a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts @@ -199,6 +199,7 @@ describe('useIpcEvents updater integration', () => { browser: { onGuestLoadFailed: () => () => {}, onOpenLinkInOrcaTab: () => () => {}, + onSessionProfilesChanged: () => () => {}, onNavigationUpdate: () => () => {}, onActivateView: () => () => {}, onPaneFocus: () => () => {} diff --git a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts index dde430feb93..1ae3df89d45 100644 --- a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts @@ -169,6 +169,7 @@ describe('useIpcEvents updater integration', () => { browser: { onGuestLoadFailed: () => () => {}, onOpenLinkInOrcaTab: () => () => {}, + onSessionProfilesChanged: () => () => {}, onNavigationUpdate: () => () => {}, onActivateView: () => () => {}, onPaneFocus: () => () => {} diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index e113312ba60..26311b9fcb5 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -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' diff --git a/src/renderer/src/store/slices/browser-session-profiles.test.ts b/src/renderer/src/store/slices/browser-session-profiles.test.ts index cb126274c1c..0ab5af78d10 100644 --- a/src/renderer/src/store/slices/browser-session-profiles.test.ts +++ b/src/renderer/src/store/slices/browser-session-profiles.test.ts @@ -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[]>() + 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 = { diff --git a/src/renderer/src/store/slices/browser/browser-host-state.ts b/src/renderer/src/store/slices/browser/browser-host-state.ts index 5c1865aa1f5..a0adcb29316 100644 --- a/src/renderer/src/store/slices/browser/browser-host-state.ts +++ b/src/renderer/src/store/slices/browser/browser-host-state.ts @@ -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 { + 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 diff --git a/src/renderer/src/store/slices/browser/browser-managed-profile-actions.ts b/src/renderer/src/store/slices/browser/browser-managed-profile-actions.ts index ee1eb310b78..5aeb696c0c5 100644 --- a/src/renderer/src/store/slices/browser/browser-managed-profile-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-managed-profile-actions.ts @@ -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[] => diff --git a/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts b/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts index b1b5f4de9b0..07e77c7c049 100644 --- a/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts @@ -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() + 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 */ } diff --git a/src/renderer/src/web/preload-api/web-browser-api.ts b/src/renderer/src/web/preload-api/web-browser-api.ts index 96fa2e39bfb..3fde1a06553 100644 --- a/src/renderer/src/web/preload-api/web-browser-api.ts +++ b/src/renderer/src/web/preload-api/web-browser-api.ts @@ -3,6 +3,7 @@ import { translate } from '@/i18n/i18n' import { noopUnsubscribe } from './web-storage' export function createBrowserApi(): NonNullable['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['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')), diff --git a/src/shared/browser-profile-scope.ts b/src/shared/browser-profile-scope.ts index 6f86a9d422e..e621cac74e8 100644 --- a/src/shared/browser-profile-scope.ts +++ b/src/shared/browser-profile-scope.ts @@ -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}` +} diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index 4c2a58fe1fb..2086b58850d 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -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