mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(browser): show profiles changed outside the window in the profile menu without a reload (#32723)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+35
@@ -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)
|
||||
})
|
||||
+12
-3
@@ -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')),
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user