diff --git a/src/main/index.ts b/src/main/index.ts index e98003b2051..d66fb535865 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -242,7 +242,6 @@ import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservatio import { CliInstaller } from './cli/cli-installer' import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher' import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation' -import { selfHealRuntimeEnvironmentFocus } from './runtime-environment-focus-self-heal' let mainWindow: BrowserWindow | null = null /** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */ @@ -1838,7 +1837,6 @@ app.whenReady().then(async () => { `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` ) } - selfHealRuntimeEnvironmentFocus({ store, userDataPath: app.getPath('userData') }) applyAppIcon(store.getSettings().appIcon) if (shouldSuppressDevEducation({ isDev: is.dev })) { suppressDevEducationForStore(store) diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index c76aacd25b6..f3ce3caec94 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -14,7 +14,6 @@ import { removeEnvironment, updateEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' -import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal' import { cleanupEphemeralVmRuntime, resumeEphemeralVmRuntime, @@ -104,7 +103,6 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { if (result.ok && runtime.runtimeEnvironmentId) { try { removeEnvironment(userDataPath, runtime.runtimeEnvironmentId) - clearActiveRuntimeEnvironmentFocusIfMatches(store, runtime.runtimeEnvironmentId) } catch { // Cleanup of provider resources matters more than hiding a stale local // environment row; users can still remove that manually. diff --git a/src/main/ipc/ephemeral-vm.test.ts b/src/main/ipc/ephemeral-vm.test.ts index 765b1471115..45f980bad9a 100644 --- a/src/main/ipc/ephemeral-vm.test.ts +++ b/src/main/ipc/ephemeral-vm.test.ts @@ -275,10 +275,8 @@ describe('registerEphemeralVmHandlers', () => { } as never) expect(cleaned).toEqual(expect.objectContaining({ status: 'cleaned' })) expect(listEnvironments(userDataPath)).toEqual([]) - expect(store.updateSettings).toHaveBeenLastCalledWith( - { activeRuntimeEnvironmentId: null }, - { notifyListeners: true } - ) + expect(store.getSettings().activeRuntimeEnvironmentId).toBe(result.environment!.id) + expect(store.updateSettings).toHaveBeenCalledTimes(1) }) it('provisions an ssh recipe without creating a runtime environment', async () => { diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 9b94212c76a..5a0cd612864 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -168,8 +168,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) expect(JSON.stringify(added)).not.toContain('device-token') expect(JSON.stringify(added)).not.toContain('publicKeyB64') - activeRuntimeEnvironmentId = added.environment.id - const list = handler('runtimeEnvironments:list') expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }]) expect(JSON.stringify(await list(null, undefined))).not.toContain('device-token') @@ -190,16 +188,31 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(removed).toMatchObject({ removed: { id: added.environment.id, name: 'desk' } }) - expect(store.updateSettings).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: null }, - { notifyListeners: true } - ) expect(activeRuntimeEnvironmentId).toBeNull() expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) expect(JSON.stringify(removed)).not.toContain('device-token') expect(await list(null, undefined)).toEqual([]) }) + it('requires an explicit Advanced selection before removing the Active Server', async () => { + registerRuntimeEnvironmentHandlers(store as never) + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + activeRuntimeEnvironmentId = added.environment.id + const remove = handler<{ selector: string }, { removed: { id: string } }>( + 'runtimeEnvironments:remove' + ) + + expect(() => remove(null, { selector: added.environment.id })).toThrow( + 'Choose another Active Server in Advanced' + ) + expect(activeRuntimeEnvironmentId).toBe(added.environment.id) + expect(store.updateSettings).not.toHaveBeenCalled() + }) + it('disconnects a saved runtime without removing it', async () => { registerRuntimeEnvironmentHandlers(store as never) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index f2c31beaf54..5eadd75fe0d 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -14,7 +14,6 @@ import type { RuntimeStatus } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import type { Store } from '../persistence' -import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal' import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections' import { advanceRuntimeEnvironmentTransportGeneration, @@ -100,13 +99,16 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { ipcMain.handle( 'runtimeEnvironments:remove', (_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => { + const environment = resolveEnvironment(getUserDataPath(), args.selector) + if (store.getSettings().activeRuntimeEnvironmentId === environment.id) { + throw new Error('Choose another Active Server in Advanced before removing this server.') + } const removed = removeEnvironment(getUserDataPath(), args.selector) invalidateRuntimeEnvironmentTransport(removed.id) if (args.selector !== removed.id) { closeRemoteRuntimeRequestConnection(args.selector) clearSharedControlSupport(args.selector) } - clearActiveRuntimeEnvironmentFocusIfMatches(store, removed.id) return { removed: redactRuntimeEnvironment(removed) } } ) diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 08b0abbe98d..ef293d20626 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -108,6 +108,43 @@ describe('registerSettingsHandlers', () => { expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false }) }) + it('rejects durable Active Server writes through generic settings:set', async () => { + store.getSettings.mockReturnValue({ activeRuntimeEnvironmentId: null }) + store.updateSettings.mockReturnValue({ activeRuntimeEnvironmentId: null }) + registerSettingsHandlers(store as never) + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + event: typeof settingsInvokeEvent, + args: { activeRuntimeEnvironmentId: string } + ) => Promise + + await handler(settingsInvokeEvent, { activeRuntimeEnvironmentId: 'windows-2' }) + + expect(store.updateSettings).toHaveBeenCalledWith( + {}, + expect.objectContaining({ originWebContentsId: 1 }) + ) + }) + + it('persists Active Server only through the dedicated preference channel', () => { + store.updateSettings.mockReturnValue({ activeRuntimeEnvironmentId: 'windows-2' }) + registerSettingsHandlers(store as never) + const handler = handleMock.mock.calls.find( + (call) => call[0] === 'settings:set-active-runtime-environment-preference' + )?.[1] as (event: typeof settingsInvokeEvent, args: { environmentId: string | null }) => unknown + + expect(handler(settingsInvokeEvent, { environmentId: ' windows-2 ' })).toEqual({ + activeRuntimeEnvironmentId: 'windows-2' + }) + expect(store.updateSettings).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: 'windows-2' }, + { notifyListeners: true, originWebContentsId: 1 } + ) + + expect(() => handler(settingsInvokeEvent, { environmentId: 42 as never })).toThrow( + 'Invalid Active Server preference' + ) + }) + it('applies bot-author deltas against the authoritative settings snapshot', () => { store.getSettings .mockReturnValueOnce({ prBotAuthorOverrides: ['alice'] }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 1ba9a015b66..e5c1081e1c4 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -92,6 +92,9 @@ export function registerSettingsHandlers( ipcMain.handle('settings:set', async (event, args: Partial) => { const sanitizedArgs = sanitizeRendererSettingsUpdate(args) + // Why: connection/navigation code receives the generic settings writer; the + // durable server preference has a dedicated Advanced-control boundary. + delete sanitizedArgs.activeRuntimeEnvironmentId // Why: Floating Workspace grants are trusted only when written by the // main-process directory picker, never by renderer-provided settings IPC. delete sanitizedArgs.floatingTerminalTrustedCwds @@ -205,6 +208,21 @@ export function registerSettingsHandlers( return result }) + ipcMain.handle( + 'settings:set-active-runtime-environment-preference', + (event, args: { environmentId?: unknown }): GlobalSettings => { + const requestedEnvironmentId = args?.environmentId + if (requestedEnvironmentId !== null && typeof requestedEnvironmentId !== 'string') { + throw new Error('Invalid Active Server preference') + } + const environmentId = requestedEnvironmentId?.trim() || null + return store.updateSettings( + { activeRuntimeEnvironmentId: environmentId }, + { notifyListeners: true, originWebContentsId: event.sender.id } + ) + } + ) + ipcMain.handle('settings:listFonts', () => { return listSystemFontFamilies() }) diff --git a/src/main/runtime-environment-focus-self-heal.test.ts b/src/main/runtime-environment-focus-self-heal.test.ts deleted file mode 100644 index edce1d6902b..00000000000 --- a/src/main/runtime-environment-focus-self-heal.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../shared/types' -import type { KnownRuntimeEnvironment } from '../shared/runtime-environments' -import { - clearActiveRuntimeEnvironmentFocusIfMatches, - selfHealRuntimeEnvironmentFocus -} from './runtime-environment-focus-self-heal' - -function environment( - id: string, - source?: KnownRuntimeEnvironment['source'] -): KnownRuntimeEnvironment { - return { - id, - name: id, - createdAt: 0, - updatedAt: 0, - lastUsedAt: null, - runtimeId: null, - ...(source ? { source } : {}), - endpoints: [ - { - id: `ws-${id}`, - kind: 'websocket', - label: 'WebSocket', - endpoint: 'ws://127.0.0.1:6768', - deviceToken: 'token', - publicKeyB64: 'key' - } - ], - preferredEndpointId: `ws-${id}` - } -} - -function makeStore(activeRuntimeEnvironmentId: string | null | undefined) { - const settings: Pick = {} - if (activeRuntimeEnvironmentId !== undefined) { - settings.activeRuntimeEnvironmentId = activeRuntimeEnvironmentId - } - const updateSettings = vi.fn((updates: Pick) => { - settings.activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId - return settings - }) - return { - store: { - getSettings: () => settings, - updateSettings - }, - updateSettings - } -} - -describe('runtime environment focus self-heal', () => { - it('keeps a focus id that resolves to a user-managed environment', () => { - const { store, updateSettings } = makeStore('env-1') - - selfHealRuntimeEnvironmentFocus({ - store, - userDataPath: '/user-data', - listKnownEnvironments: () => [environment('env-1')] - }) - - expect(updateSettings).not.toHaveBeenCalled() - }) - - it('clears a dangling focus id and logs one diagnostic line', () => { - const { store, updateSettings } = makeStore('missing-env') - const log = vi.fn() - - selfHealRuntimeEnvironmentFocus({ - store, - userDataPath: '/user-data', - listKnownEnvironments: () => [environment('env-1')], - log - }) - - expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null }) - expect(log).toHaveBeenCalledTimes(1) - expect(log.mock.calls[0][0]).toContain('missing-env') - }) - - it('clears an ephemeral-VM focus id after restart', () => { - const { store, updateSettings } = makeStore('vm-env') - const log = vi.fn() - - selfHealRuntimeEnvironmentFocus({ - store, - userDataPath: '/user-data', - listKnownEnvironments: () => [environment('vm-env', 'ephemeral-vm')], - log - }) - - expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null }) - expect(log).toHaveBeenCalledTimes(1) - }) - - it('leaves null and absent focus settings untouched', () => { - const nullCase = makeStore(null) - const absentCase = makeStore(undefined) - const listKnownEnvironments = vi.fn(() => [environment('env-1')]) - - selfHealRuntimeEnvironmentFocus({ - store: nullCase.store, - userDataPath: '/user-data', - listKnownEnvironments - }) - selfHealRuntimeEnvironmentFocus({ - store: absentCase.store, - userDataPath: '/user-data', - listKnownEnvironments - }) - - expect(nullCase.updateSettings).not.toHaveBeenCalled() - expect(absentCase.updateSettings).not.toHaveBeenCalled() - expect(listKnownEnvironments).not.toHaveBeenCalled() - }) - - it('normalizes an empty persisted id to null without reading the registry', () => { - const { store, updateSettings } = makeStore('') - const listKnownEnvironments = vi.fn(() => [environment('env-1')]) - - selfHealRuntimeEnvironmentFocus({ - store, - userDataPath: '/user-data', - listKnownEnvironments - }) - - expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null }) - expect(listKnownEnvironments).not.toHaveBeenCalled() - }) - - it('fails soft when the registry cannot be read', () => { - const { store, updateSettings } = makeStore('env-1') - - selfHealRuntimeEnvironmentFocus({ - store, - userDataPath: '/user-data', - listKnownEnvironments: () => { - throw new Error('invalid registry') - } - }) - - expect(updateSettings).not.toHaveBeenCalled() - }) - - it('clears the active focus on matching in-process removal with listener notification', () => { - const { store, updateSettings } = makeStore('env-1') - - clearActiveRuntimeEnvironmentFocusIfMatches(store, 'env-1') - - expect(updateSettings).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: null }, - { notifyListeners: true } - ) - }) -}) diff --git a/src/main/runtime-environment-focus-self-heal.ts b/src/main/runtime-environment-focus-self-heal.ts deleted file mode 100644 index a748f4794e2..00000000000 --- a/src/main/runtime-environment-focus-self-heal.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { GlobalSettings } from '../shared/types' -import { listEnvironments } from '../shared/runtime-environment-store' -import { - isUserManagedRuntimeEnvironment, - type KnownRuntimeEnvironment -} from '../shared/runtime-environments' - -type RuntimeEnvironmentFocusStore = { - getSettings: () => Pick - updateSettings: ( - updates: Pick, - options?: { notifyListeners?: boolean } - ) => unknown -} - -type SelfHealRuntimeEnvironmentFocusArgs = { - store: RuntimeEnvironmentFocusStore - userDataPath: string - listKnownEnvironments?: (userDataPath: string) => KnownRuntimeEnvironment[] - log?: (message: string) => void -} - -function logClearedFocus(log: ((message: string) => void) | undefined, reason: string): void { - const writeLog = log ?? console.info - writeLog(`[runtime-environment-focus] cleared active runtime environment: ${reason}`) -} - -export function clearActiveRuntimeEnvironmentFocusIfMatches( - store: RuntimeEnvironmentFocusStore, - environmentId: string -): void { - if (store.getSettings().activeRuntimeEnvironmentId !== environmentId) { - return - } - store.updateSettings({ activeRuntimeEnvironmentId: null }, { notifyListeners: true }) -} - -export function selfHealRuntimeEnvironmentFocus({ - store, - userDataPath, - listKnownEnvironments = listEnvironments, - log -}: SelfHealRuntimeEnvironmentFocusArgs): void { - const activeRuntimeEnvironmentId = store.getSettings().activeRuntimeEnvironmentId - if (activeRuntimeEnvironmentId === undefined || activeRuntimeEnvironmentId === null) { - return - } - - if (activeRuntimeEnvironmentId.trim() === '') { - store.updateSettings({ activeRuntimeEnvironmentId: null }) - logClearedFocus(log, 'empty persisted id') - return - } - - let environments: KnownRuntimeEnvironment[] - try { - environments = listKnownEnvironments(userDataPath) - } catch { - // Why: an unreadable registry must not clear a possibly-valid focus; keep - // it and let a later launch heal once the registry reads again. - return - } - - const focusedEnvironment = environments.find((entry) => entry.id === activeRuntimeEnvironmentId) - if (focusedEnvironment && isUserManagedRuntimeEnvironment(focusedEnvironment)) { - return - } - - store.updateSettings({ activeRuntimeEnvironmentId: null }) - logClearedFocus(log, `dangling id ${activeRuntimeEnvironmentId}`) -} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index eb4b6f1e542..b02f8474911 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2144,6 +2144,9 @@ export type PreloadApi = { /** Synchronous persisted-settings read for startup decisions that can't wait for async hydration. Blocking IPC — call sparingly. */ getSync: () => GlobalSettings | null set: (args: Partial) => Promise + setActiveRuntimeEnvironmentPreference: (args: { + environmentId: string | null + }) => Promise updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }) => Promise listFonts: () => Promise previewGhosttyImport: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index dbf8ad7278e..41d850a8827 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1839,6 +1839,11 @@ const api = { set: (args: Record): Promise => ipcRenderer.invoke('settings:set', args), + setActiveRuntimeEnvironmentPreference: (args: { + environmentId: string | null + }): Promise => + ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args), + updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise => ipcRenderer.invoke('settings:update-pr-bot-author-override', args), diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 0a1536e5bb0..c3d1bfc2ee5 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -21,7 +21,6 @@ import { buildSidebarHostOptions } from '../sidebar/sidebar-host-options' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { getSettingsFocusedExecutionHostId, - parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { isMacUserAgent } from '@/components/terminal-pane/pane-helpers' @@ -59,7 +58,8 @@ export function BrowserPane({ const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) - const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) + const browserSessionHostIdOverride = useAppStore((s) => s.browserSessionHostIdOverride) + const setBrowserSessionHostId = useAppStore((s) => s.setBrowserSessionHostId) const detectedBrowsers = useAppStore((s) => s.detectedBrowsers) const browserSessionImportState = useAppStore((s) => s.browserSessionImportState) const defaultBrowserSessionProfileId = useAppStore((s) => s.defaultBrowserSessionProfileId) @@ -137,19 +137,13 @@ export function BrowserPane({ hostLabelOverrides ] ) - const selectedBrowserSessionHostId = getSettingsFocusedExecutionHostId(settings) + const selectedBrowserSessionHostId = + browserSessionHostIdOverride ?? getSettingsFocusedExecutionHostId(settings) const selectBrowserSessionHost = useCallback( (hostId: ExecutionHostId) => { - const parsed = parseExecutionHostId(hostId) - if (parsed?.kind === 'runtime') { - void switchRuntimeEnvironment(parsed.environmentId) - return - } - if (parsed?.kind === 'local') { - void switchRuntimeEnvironment(null) - } + void setBrowserSessionHostId(hostId) }, - [switchRuntimeEnvironment] + [setBrowserSessionHostId] ) const requestSessionCookieScrollFrame = (callback: FrameRequestCallback): void => { diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 54184982ed7..6af2bc2f3f7 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -67,7 +67,7 @@ const NO_RUNTIME_VALUE = '__none__' type RuntimeEnvironmentsPaneProps = { settings: GlobalSettings - switchRuntimeEnvironment: (environmentId: string | null) => Promise + setActiveRuntimeEnvironmentPreference: (environmentId: string | null) => Promise canGeneratePairingUrl?: boolean allowLocalRuntime?: boolean addServerIntentSignal?: number @@ -251,7 +251,7 @@ function getRuntimeServerDotClass(state: RuntimeServerConnectionState): string { export function RuntimeEnvironmentsPane({ settings, - switchRuntimeEnvironment, + setActiveRuntimeEnvironmentPreference, canGeneratePairingUrl = true, allowLocalRuntime = true, addServerIntentSignal @@ -447,12 +447,6 @@ export function RuntimeEnvironmentsPane({ } setIsSaving(true) try { - if (!allowLocalRuntime && settings.activeRuntimeEnvironmentId) { - const disconnected = await switchRuntimeEnvironment(null) - if (!disconnected) { - return - } - } const result = await window.api.runtimeEnvironments.addFromPairingCode({ name: trimmedName, pairingCode: trimmedPairingCode @@ -463,8 +457,8 @@ export function RuntimeEnvironmentsPane({ } await loadEnvironments() if (!allowLocalRuntime) { - const switched = await switchRuntimeEnvironment(result.environment.id) - if (!switched) { + const connected = await connectEnvironment(result.environment) + if (!connected) { await window.api.runtimeEnvironments.remove({ selector: result.environment.id }) await loadEnvironments() return @@ -483,7 +477,7 @@ export function RuntimeEnvironmentsPane({ toast.success( translate( 'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df', - 'Saved {{value0}}. Use Advanced > Default runtime to make it the default.', + 'Saved {{value0}}. Use Advanced > Active Server to make it the default.', { value0: result.environment.name } ) ) @@ -516,31 +510,11 @@ export function RuntimeEnvironmentsPane({ setRemovingId(environment.id) setRemoveError(null) try { - if (settings.activeRuntimeEnvironmentId === environment.id) { - const switched = await switchRuntimeEnvironment(null) - if (!switched) { - if (mountedRef.current) { - setRemoveError( - allowLocalRuntime - ? 'Could not switch to Local desktop. Fix the issue and try again.' - : 'Could not disconnect from this server. Fix the issue and try again.' - ) - } - return false - } - if (!allowLocalRuntime) { - await loadEnvironments() - if (mountedRef.current) { - toast.success( - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.b5b5114cb0', - 'Removed {{value0}}.', - { value0: environment.name } - ) - ) - } - return true + if (allowLocalRuntime && settings.activeRuntimeEnvironmentId === environment.id) { + if (mountedRef.current) { + setRemoveError('Choose another Active Server in Advanced before removing this server.') } + return false } await window.api.runtimeEnvironments.remove({ selector: environment.id }) await loadEnvironments() @@ -575,19 +549,6 @@ export function RuntimeEnvironmentsPane({ setDisconnectingId(environment.id) setSwitchError(null) try { - if (settings.activeRuntimeEnvironmentId === environment.id) { - const switched = await switchRuntimeEnvironment(null) - if (!switched) { - if (mountedRef.current) { - setSwitchError( - allowLocalRuntime - ? 'Could not switch to Local desktop. Fix the issue and try again.' - : 'Could not disconnect from this server. Fix the issue and try again.' - ) - } - return false - } - } await window.api.runtimeEnvironments.disconnect({ selector: environment.id }) // Why: disconnect is non-destructive; keep the saved server but show the // user that this live client is no longer attached to it. @@ -715,7 +676,7 @@ export function RuntimeEnvironmentsPane({ setSwitchingValue(value) setSwitchError(null) try { - const switched = await switchRuntimeEnvironment( + const switched = await setActiveRuntimeEnvironmentPreference( allowLocalRuntime && value === LOCAL_RUNTIME_VALUE ? null : value ) if (switched) { @@ -1139,7 +1100,7 @@ export function RuntimeEnvironmentsPane({

diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 3f80e1b3198..ac0578fa38b 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -280,7 +280,9 @@ function Settings(): React.JSX.Element { const settings = useAppStore((s) => s.settings) const keybindings = useAppStore((s) => s.keybindings) const updateSettings = useAppStore((s) => s.updateSettings) - const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) + const setActiveRuntimeEnvironmentPreference = useAppStore( + (s) => s.setActiveRuntimeEnvironmentPreference + ) const fetchSettings = useAppStore((s) => s.fetchSettings) const fetchKeybindings = useAppStore((s) => s.fetchKeybindings) const closeSettingsPage = useAppStore((s) => s.closeSettingsPage) @@ -1612,7 +1614,7 @@ function Settings(): React.JSX.Element { {isSectionMounted('servers') ? ( ({ hostOptions: [] as SidebarHostOption[], storeState: { settings: { activeRuntimeEnvironmentId: null as string | null }, - switchRuntimeEnvironment: vi.fn(), + setActiveRuntimeEnvironmentPreference: vi.fn(), setSshConnectionState: vi.fn(), sshConnectionStates: new Map(), runtimeEnvironments: [] as { id: string; name: string; source?: 'manual' | 'ephemeral-vm' }[] @@ -98,7 +98,7 @@ describe('useAddRepoHostSelection', () => { } ] mocks.storeState.settings = { activeRuntimeEnvironmentId: null } - mocks.storeState.switchRuntimeEnvironment.mockResolvedValue(true) + mocks.storeState.setActiveRuntimeEnvironmentPreference.mockResolvedValue(true) mocks.storeState.sshConnectionStates = new Map() mocks.storeState.runtimeEnvironments = [] mocks.sshConnect.mockReset() @@ -124,7 +124,7 @@ describe('useAddRepoHostSelection', () => { expect(result.selectedSshTargetId).toBe('ssh-1') }) - it('switches runtime before selecting a runtime host', async () => { + it('selects a runtime host without changing the durable active server', async () => { mocks.stateValues = ['local', false] const setStep = vi.fn() const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') @@ -132,12 +132,12 @@ describe('useAddRepoHostSelection', () => { const result = useAddRepoHostSelection({ isOpen: true, setStep }) await result.handleSelectAddProjectHost('runtime:env-1') - expect(mocks.storeState.switchRuntimeEnvironment).toHaveBeenCalledWith('env-1') + expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled() expect(mocks.stateSetters[0]).toHaveBeenCalledWith('runtime:env-1') expect(setStep).toHaveBeenCalledWith('add') }) - it('clears the active runtime before selecting a local or SSH host', async () => { + it('selects a local or SSH host without changing the durable active server', async () => { mocks.stateValues = ['runtime:env-1', false] mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } const setStep = vi.fn() @@ -146,7 +146,7 @@ describe('useAddRepoHostSelection', () => { const result = useAddRepoHostSelection({ isOpen: true, setStep }) await result.handleSelectAddProjectHost('ssh:ssh-1') - expect(mocks.storeState.switchRuntimeEnvironment).toHaveBeenCalledWith(null) + expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled() expect(mocks.stateSetters[0]).toHaveBeenCalledWith('ssh:ssh-1') expect(setStep).toHaveBeenCalledWith('add') }) @@ -177,7 +177,7 @@ describe('useAddRepoHostSelection', () => { const result = useAddRepoHostSelection({ isOpen: true, setStep }) await result.handleSelectAddProjectHost('ssh:ssh-1') - expect(mocks.storeState.switchRuntimeEnvironment).not.toHaveBeenCalled() + expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled() expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('ssh:ssh-1') expect(setStep).not.toHaveBeenCalled() }) @@ -249,7 +249,9 @@ describe('useAddRepoHostSelection', () => { expect(result.hostOptions.map((host) => host.id)).not.toContain('runtime:env-vm') expect(result.selectedHostId).toBe('local') await result.handleSelectAddProjectHost('runtime:env-vm') - expect(mocks.storeState.switchRuntimeEnvironment).not.toHaveBeenCalledWith('env-vm') + expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalledWith( + 'env-vm' + ) expect(setStep).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts index 71a04591b52..684fa75d78b 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts @@ -31,7 +31,6 @@ export function useAddRepoHostSelection({ handleConnectAddProjectHost: (hostId: ExecutionHostId) => Promise } { const settings = useAppStore((s) => s.settings) - const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) const setSshConnectionState = useAppStore((s) => s.setSshConnectionState) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) @@ -96,22 +95,10 @@ export function useAddRepoHostSelection({ if (!host || !canSelectAddRepoHost(host)) { return } - const parsed = parseExecutionHostId(hostId) - if (parsed?.kind === 'runtime') { - const switched = await switchRuntimeEnvironment(parsed.environmentId) - if (!switched) { - return - } - } else if (settings?.activeRuntimeEnvironmentId?.trim()) { - const switched = await switchRuntimeEnvironment(null) - if (!switched) { - return - } - } setSelectedAddProjectHostId(hostId) setStep('add') }, - [selectableHostOptions, settings?.activeRuntimeEnvironmentId, setStep, switchRuntimeEnvironment] + [selectableHostOptions, setStep] ) const handleConnectAddProjectHost = useCallback( @@ -148,12 +135,6 @@ export function useAddRepoHostSelection({ if (state?.status !== 'connected') { return } - if (settings?.activeRuntimeEnvironmentId?.trim()) { - const switched = await switchRuntimeEnvironment(null) - if (!switched) { - return - } - } setSelectedAddProjectHostId(hostId) setStep('add') setHostSelectorOpen(false) @@ -183,14 +164,7 @@ export function useAddRepoHostSelection({ ) } }, - [ - selectableHostOptions, - settings?.activeRuntimeEnvironmentId, - setSshConnectionState, - setStep, - sshConnectionStates, - switchRuntimeEnvironment - ] + [selectableHostOptions, setSshConnectionState, setStep, sshConnectionStates] ) return { diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.test.ts b/src/renderer/src/components/status-bar/SshStatusSegment.test.ts index 1e5db8431bf..406de54c6f6 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.test.ts +++ b/src/renderer/src/components/status-bar/SshStatusSegment.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from 'vitest' -import { isConnectedRuntimeHostState, runtimeStatusForOverall } from './SshStatusSegment' +import { describe, expect, it, vi } from 'vitest' +import { + connectRuntimeHostForNavigation, + isConnectedRuntimeHostState, + runtimeStatusForOverall +} from './SshStatusSegment' describe('SshStatusSegment host status helpers', () => { it('counts connected remote servers as connected hosts', () => { @@ -16,3 +20,40 @@ describe('SshStatusSegment host status helpers', () => { expect(isConnectedRuntimeHostState('disconnected')).toBe(false) }) }) + +describe('connectRuntimeHostForNavigation', () => { + it('loads the transient host catalog without writing Active Server', async () => { + const refreshStatus = vi.fn().mockResolvedValue(true) + const fetchRepos = vi.fn().mockResolvedValue([{ id: 'repo-a' }, { id: 'repo-b' }]) + const fetchWorktrees = vi.fn().mockResolvedValue(undefined) + const fetchLineage = vi.fn().mockResolvedValue(undefined) + + await expect( + connectRuntimeHostForNavigation({ + environmentId: 'windows-2', + refreshStatus, + fetchRepos, + fetchWorktrees, + fetchLineage + }) + ).resolves.toBe(true) + + expect(fetchRepos).toHaveBeenCalledWith('windows-2') + expect(fetchWorktrees).toHaveBeenCalledTimes(2) + expect(fetchLineage).toHaveBeenCalledOnce() + }) + + it('does not load a catalog when the server is unreachable', async () => { + const fetchRepos = vi.fn() + await expect( + connectRuntimeHostForNavigation({ + environmentId: 'windows-2', + refreshStatus: vi.fn().mockResolvedValue(false), + fetchRepos, + fetchWorktrees: vi.fn(), + fetchLineage: vi.fn() + }) + ).resolves.toBe(false) + expect(fetchRepos).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index d8d15f821a8..e6785870c87 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -147,6 +147,22 @@ export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): return state === 'connected' } +export async function connectRuntimeHostForNavigation(args: { + environmentId: string + refreshStatus: (environmentId: string, timeoutMs: number) => Promise + fetchRepos: (environmentId: string) => Promise<{ id: string }[]> + fetchWorktrees: (repoId: string) => Promise + fetchLineage: () => Promise +}): Promise { + if (!(await args.refreshStatus(args.environmentId, 5_000))) { + return false + } + const repos = await args.fetchRepos(args.environmentId) + await Promise.all(repos.map((repo) => args.fetchWorktrees(repo.id))) + await args.fetchLineage() + return true +} + export function SshStatusSegment({ compact, iconOnly @@ -159,7 +175,6 @@ export function SshStatusSegment({ const settings = useAppStore((s) => s.settings) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) - const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) const hydrateRuntimeEnvironmentStatuses = useAppStore((s) => s.hydrateRuntimeEnvironmentStatuses) const refreshRuntimeEnvironmentStatus = useAppStore((s) => s.refreshRuntimeEnvironmentStatus) @@ -214,7 +229,14 @@ export function SshStatusSegment({ const disconnectedTargets = targets.filter((target) => target.status !== 'connected') const connectRuntimeHost = useCallback( async (environmentId: string): Promise => { - const reachable = await refreshRuntimeEnvironmentStatus(environmentId, 5_000) + const store = useAppStore.getState() + const reachable = await connectRuntimeHostForNavigation({ + environmentId, + refreshStatus: refreshRuntimeEnvironmentStatus, + fetchRepos: store.fetchRuntimeEnvironmentRepos, + fetchWorktrees: store.fetchWorktrees, + fetchLineage: store.fetchWorktreeLineage + }) if (!reachable) { toast.error( translate( @@ -224,22 +246,13 @@ export function SshStatusSegment({ ) return } - const switched = await switchRuntimeEnvironment(environmentId) - if (switched) { - recordFeatureInteraction('ssh') - } + recordFeatureInteraction('ssh') }, - [recordFeatureInteraction, refreshRuntimeEnvironmentStatus, switchRuntimeEnvironment] + [recordFeatureInteraction, refreshRuntimeEnvironmentStatus] ) const disconnectRuntimeHost = useCallback( - async (environmentId: string, isActive: boolean): Promise => { + async (environmentId: string): Promise => { try { - if (isActive) { - const switched = await switchRuntimeEnvironment(null) - if (!switched) { - return - } - } await window.api.runtimeEnvironments.disconnect({ selector: environmentId }) setRuntimeEnvironmentStatus(environmentId, { status: null, checkedAt: Date.now() }) recordFeatureInteraction('ssh') @@ -254,7 +267,7 @@ export function SshStatusSegment({ ) } }, - [recordFeatureInteraction, setRuntimeEnvironmentStatus, switchRuntimeEnvironment] + [recordFeatureInteraction, setRuntimeEnvironmentStatus] ) if (targets.length === 0 && runtimeHosts.length === 0) { @@ -355,7 +368,7 @@ export function SshStatusSegment({ state={host.state} detail={runtimeHostConnectionDetail(host.remoteControl)} onConnect={() => connectRuntimeHost(host.id)} - onDisconnect={() => disconnectRuntimeHost(host.id, host.active)} + onDisconnect={() => disconnectRuntimeHost(host.id)} /> ))} {connectedTargets.map((t) => ( @@ -374,7 +387,7 @@ export function SshStatusSegment({ state={host.state} detail={runtimeHostConnectionDetail(host.remoteControl)} onConnect={() => connectRuntimeHost(host.id)} - onDisconnect={() => disconnectRuntimeHost(host.id, host.active)} + onDisconnect={() => disconnectRuntimeHost(host.id)} /> ))} {disconnectedTargets.map((t) => ( diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index be45ff42e98..61d4bf65bf2 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1824,7 +1824,7 @@ describe('useIpcEvents updater integration', () => { tabsByWorktree: {} as Record, folderWorkspaces: [], projectGroups: [], - repos: [{ id: 'repo-1', connectionId: null }], + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }], worktreesByRepo: { 'repo-1': [{ id: 'wt-2', repoId: 'repo-1' }] }, openFiles: [], browserTabsByWorktree: {}, @@ -2179,14 +2179,52 @@ describe('useIpcEvents updater integration', () => { expect(createTab).toHaveBeenCalledWith('wt-1') expect(setActiveTabType).toHaveBeenCalledWith('terminal') + // Exact regression sequence: Local default -> connect/navigate Windows 2 -> + // reveal a local terminal -> restart. Connection and navigation are transient. + storeState.repos.push({ + id: 'windows-2-repo', + connectionId: null, + executionHostId: 'runtime:windows-2' + }) + storeState.worktreesByRepo['windows-2-repo'] = [ + { id: 'windows-2-worktree', repoId: 'windows-2-repo' } + ] + storeState.activeWorktreeId = 'windows-2-worktree' + createTab.mockClear() + replyTerminalCreate.mockClear() + createTerminalListenerRef.current({ + requestId: 'local-reveal-after-remote-navigation', + worktreeId: 'wt-2', + title: 'Local shell', + presentation: 'focused' + }) + expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, undefined) + expect(replyTerminalCreate).toHaveBeenCalledWith({ + requestId: 'local-reveal-after-remote-navigation', + tabId: 'tab-new', + title: 'Local shell' + }) + expect(storeState.settings.activeRuntimeEnvironmentId).toBeUndefined() + delete storeState.worktreesByRepo['windows-2-repo'] + storeState.repos = storeState.repos.filter((repo) => repo.id !== 'windows-2-repo') + storeState.activeWorktreeId = 'wt-1' + expect(storeState.settings.activeRuntimeEnvironmentId).toBeUndefined() + createWebRuntimeSessionTerminal.mockClear() createTab.mockClear() setActiveView.mockClear() setActiveWorktree.mockClear() + markWorktreeVisited.mockClear() + recordWorktreeVisit.mockClear() setActiveTabType.mockClear() setActiveTab.mockClear() revealWorktreeInSidebar.mockClear() + storeState.settings = { + ...storeState.settings, + activeRuntimeEnvironmentId: 'windows-2' + } + createTerminalListenerRef.current({ worktreeId: 'wt-2', title: 'Runner', @@ -2210,6 +2248,12 @@ describe('useIpcEvents updater integration', () => { recordInteraction: false }) expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'opencode' }) + expect(storeState.settings.activeRuntimeEnvironmentId).toBe('windows-2') + + storeState.settings = { + ...storeState.settings, + activeRuntimeEnvironmentId: undefined + } createTab.mockClear() setActiveView.mockClear() @@ -2391,9 +2435,29 @@ describe('useIpcEvents updater integration', () => { title: 'Blocked Local Terminal' }) - expect(createTab).not.toHaveBeenCalled() + expect(createTab).toHaveBeenCalled() expect(replyTerminalCreate).toHaveBeenCalledWith({ requestId: 'req-runtime-blocked', + tabId: 'tab-new', + title: 'Blocked Local Terminal' + }) + + createTab.mockClear() + replyTerminalCreate.mockClear() + storeState.repos.push({ + id: 'repo-remote', + connectionId: null, + executionHostId: 'runtime:focused-runtime' + }) + storeState.worktreesByRepo['repo-remote'] = [{ id: 'wt-remote', repoId: 'repo-remote' }] + requestTerminalCreateListenerRef.current({ + requestId: 'req-remote-owner-blocked', + worktreeId: 'wt-remote', + title: 'Remote-owned Terminal' + }) + expect(createTab).not.toHaveBeenCalled() + expect(replyTerminalCreate).toHaveBeenCalledWith({ + requestId: 'req-remote-owner-blocked', error: 'Local terminal creation is unavailable while a remote runtime is active' }) storeState.settings.activeRuntimeEnvironmentId = undefined diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index fea0057583b..237be326206 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1486,18 +1486,6 @@ export function useIpcEvents(): void { splitTelemetrySource }) => { try { - if (isRuntimeEnvironmentActive()) { - if (requestId) { - window.api.ui.replyTerminalCreate({ - requestId, - error: translate( - 'auto.hooks.useIpcEvents.60428567b4', - 'Local terminal reveal is unavailable while a remote runtime is active' - ) - }) - } - return - } const store = useAppStore.getState() const terminalPresentation = resolveTerminalPresentation({ presentation, activate }) const shouldActivate = terminalPresentation === 'focused' @@ -1710,17 +1698,6 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onRequestTerminalCreate((data) => { try { - // Why: runtime-session requests are host-owned tabs materialized by this renderer, not ordinary local creates. - if (isRuntimeEnvironmentActive() && data.source !== 'runtime-session') { - window.api.ui.replyTerminalCreate({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.7a64b31991', - 'Local terminal creation is unavailable while a remote runtime is active' - ) - }) - return - } const store = useAppStore.getState() const worktreeId = data.worktreeId ?? store.activeWorktreeId if (!worktreeId) { @@ -1730,6 +1707,20 @@ export function useIpcEvents(): void { }) return } + // Why: runtime-session requests are host-owned tabs materialized by this renderer, not ordinary local creates. + if ( + getRuntimeEnvironmentIdForWorktree(store, worktreeId) && + data.source !== 'runtime-session' + ) { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.7a64b31991', + 'Local terminal creation is unavailable while a remote runtime is active' + ) + }) + return + } const terminalPresentation = resolveTerminalPresentation(data) const shouldActivate = terminalPresentation === 'focused' const shouldSurfaceOwner = terminalPresentation !== 'background' diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index d6e3779efbd..9ac0b48dfc2 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: browser slice behavior shares one mocked store harness; splitting only the tests would duplicate more setup than it saves. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' -import { createBrowserSlice } from './browser' +import { createBrowserSlice, isLocalBrowserPageOwner } from './browser' import type { AppState } from '../types' import { createCompatibleRuntimeStatusResponseIfNeeded, @@ -602,6 +602,159 @@ describe('createBrowserSlice runtime guard', () => { expect(store.getState().browserSessionProfiles[0]?.id).toBe('local-default') }) + it('routes browser settings per client without changing the durable Active Server', async () => { + runtimeEnvironmentCall.mockImplementation((request: RuntimeEnvironmentCallRequest) => { + const { selector, method } = request as RuntimeEnvironmentCallRequest & { selector: string } + return Promise.resolve({ + id: `${selector}-${method}`, + ok: true, + result: + method === 'browser.profileList' + ? { + profiles: [ + { + id: `${selector}-default`, + scope: 'default', + partition: `persist:${selector}`, + label: `${selector} Default`, + source: null + } + ] + } + : { browsers: [] }, + _meta: { runtimeId: `runtime-${selector}` } + }) + }) + const firstClient = createTestStore() + const secondClient = createTestStore() + + void firstClient.getState().setBrowserSessionHostId('runtime:windows-2') + void secondClient.getState().setBrowserSessionHostId('runtime:linux-3') + + await vi.waitFor(() => { + expect(firstClient.getState().browserSessionProfiles[0]?.id).toBe('windows-2-default') + expect(secondClient.getState().browserSessionProfiles[0]?.id).toBe('linux-3-default') + }) + expect(firstClient.getState().settings?.activeRuntimeEnvironmentId).toBeNull() + expect(secondClient.getState().settings?.activeRuntimeEnvironmentId).toBeNull() + + const restartedClient = createTestStore() + expect(restartedClient.getState().browserSessionHostIdOverride).toBeNull() + expect(restartedClient.getState().settings?.activeRuntimeEnvironmentId).toBeNull() + }) + + it('does not let a slower server response overwrite the newly selected host', async () => { + let resolveWindowsProfiles: ((value: unknown) => void) | undefined + runtimeEnvironmentCall.mockImplementation((request: RuntimeEnvironmentCallRequest) => { + const { selector, method } = request as RuntimeEnvironmentCallRequest & { + selector: string + } + if (method !== 'browser.profileList') { + return Promise.resolve({ + id: `${selector}-${method}`, + ok: true, + result: { browsers: [] }, + _meta: { runtimeId: `runtime-${selector}` } + }) + } + if (selector === 'windows-2') { + return new Promise((resolve) => { + resolveWindowsProfiles = resolve + }) + } + return Promise.resolve({ + id: 'linux-profiles', + ok: true, + result: { + profiles: [ + { + id: 'linux-default', + scope: 'default', + partition: 'persist:linux', + label: 'Linux Default', + source: null + } + ] + }, + _meta: { runtimeId: 'runtime-linux' } + }) + }) + const store = createTestStore() + + void store.getState().setBrowserSessionHostId('runtime:windows-2') + void store.getState().setBrowserSessionHostId('runtime:linux-3') + await vi.waitFor(() => + expect(store.getState().browserSessionProfiles[0]?.id).toBe('linux-default') + ) + resolveWindowsProfiles?.({ + id: 'windows-profiles', + ok: true, + result: { + profiles: [ + { + id: 'windows-default', + scope: 'default', + partition: 'persist:windows', + label: 'Windows Default', + source: null + } + ] + }, + _meta: { runtimeId: 'runtime-windows' } + }) + await vi.waitFor(() => + expect(store.getState().browserSessionProfilesByHostId['runtime:windows-2']?.[0]?.id).toBe( + 'windows-default' + ) + ) + + expect(store.getState().browserSessionHostIdOverride).toBe('runtime:linux-3') + expect(store.getState().browserSessionProfiles[0]?.id).toBe('linux-default') + expect(store.getState().settings?.activeRuntimeEnvironmentId).toBeNull() + }) + + it('does not let an import completion refresh or overwrite a newly selected host', async () => { + let resolveImport: ((value: unknown) => void) | undefined + runtimeEnvironmentCall.mockImplementation((request: RuntimeEnvironmentCallRequest) => { + const { selector, method } = request as RuntimeEnvironmentCallRequest & { selector: string } + if (selector === 'windows-2' && method === 'browser.profileImportFromBrowser') { + return new Promise((resolve) => { + resolveImport = resolve + }) + } + return Promise.resolve({ + id: `${selector}-${method}`, + ok: true, + result: method === 'browser.profileList' ? { profiles: [] } : { browsers: [] }, + _meta: { runtimeId: `runtime-${selector}` } + }) + }) + const store = createTestStore() + store.setState({ browserSessionHostIdOverride: 'runtime:windows-2' }) + + const importing = store + .getState() + .importCookiesFromBrowser('windows-profile', 'chrome', 'Default') + await vi.waitFor(() => expect(resolveImport).toBeDefined()) + await store.getState().setBrowserSessionHostId('runtime:linux-3') + const callsBeforeCompletion = runtimeEnvironmentCall.mock.calls.length + resolveImport?.({ + id: 'windows-import', + ok: true, + result: { + ok: true, + profileId: 'windows-profile', + summary: { totalCookies: 2, importedCookies: 2, skippedCookies: 0, domains: [] } + }, + _meta: { runtimeId: 'runtime-windows' } + }) + + await expect(importing).resolves.toMatchObject({ ok: true, profileId: 'windows-profile' }) + expect(store.getState().browserSessionHostIdOverride).toBe('runtime:linux-3') + expect(store.getState().browserSessionImportState).toBeNull() + expect(runtimeEnvironmentCall.mock.calls).toHaveLength(callsBeforeCompletion) + }) + it('uses the target worktree host default profile when creating a browser tab', () => { const store = createTestStore() store.setState({ @@ -652,6 +805,42 @@ describe('createBrowserSlice runtime guard', () => { expect(tab.sessionProfileId).toBe('remote-default') }) + it('routes browser bridge ownership from the workspace instead of Active Server', () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'windows-2' } as AppState['settings'], + repos: [ + { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000000', + addedAt: 1, + connectionId: null, + executionHostId: 'local' + }, + { + id: 'remote-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#000000', + addedAt: 2, + connectionId: null, + executionHostId: 'runtime:windows-2' + } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-wt', repoId: 'local-repo' }] as never, + 'remote-repo': [{ id: 'remote-wt', repoId: 'remote-repo' }] as never + } + }) + + expect(isLocalBrowserPageOwner(store.getState(), 'local-wt', undefined)).toBe(true) + expect(isLocalBrowserPageOwner(store.getState(), 'remote-wt', undefined)).toBe(false) + expect(isLocalBrowserPageOwner(store.getState(), 'local-wt', 'windows-2')).toBe(false) + expect(isLocalBrowserPageOwner(store.getState(), 'remote-wt', null)).toBe(true) + }) + it('stores a runtime-resolved browser partition without a renderer profile mirror', () => { const store = createTestStore() store.setState({ browserSessionProfiles: [] }) diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index fcc58a5fd83..2a322e0f6eb 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -25,11 +25,7 @@ import { import { pickNeighbor } from './tab-group-state' import { destroyWorkspaceWebviews } from './browser-webview-cleanup' import { pushRecentlyClosedTabKind } from './recently-closed-tabs' -import { - callRuntimeRpc, - getActiveRuntimeTarget, - type RuntimeClientTarget -} from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import type { BrowserDetectProfilesResult, @@ -44,6 +40,7 @@ import { translate } from '@/i18n/i18n' import { getSettingsFocusedExecutionHostId, LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, toRuntimeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' @@ -176,6 +173,8 @@ export type BrowserSlice = { ) => void browserSessionProfiles: BrowserSessionProfile[] browserSessionProfilesByHostId: Partial> + browserSessionHostIdOverride: ExecutionHostId | null + setBrowserSessionHostId: (hostId: ExecutionHostId) => Promise browserSessionImportState: { profileId: string status: 'idle' | 'importing' | 'success' | 'error' @@ -235,12 +234,17 @@ function normalizeBrowserTitle(title: string | null | undefined, url: string): s return title } -function isRuntimeEnvironmentActive(state: AppState): boolean { - return Boolean(state.settings?.activeRuntimeEnvironmentId?.trim()) +function getBrowserSettingsHostId( + state: Pick +): ExecutionHostId { + return state.browserSessionHostIdOverride ?? getSettingsFocusedExecutionHostId(state.settings) } -function getBrowserSettingsHostId(state: Pick): ExecutionHostId { - return getSettingsFocusedExecutionHostId(state.settings) +function getBrowserSettingsRuntimeEnvironmentId( + state: Pick +): string | null { + const parsed = parseExecutionHostId(getBrowserSettingsHostId(state)) + return parsed?.kind === 'runtime' ? parsed.environmentId : null } function getBrowserWorktreeHostId(state: AppState, worktreeId: string): ExecutionHostId { @@ -264,19 +268,60 @@ function getBrowserSessionProfileHostId( return getBrowserWorktreeHostId(state, worktreeId) } +export function isLocalBrowserPageOwner( + state: AppState, + worktreeId: string, + browserRuntimeEnvironmentId: string | null | undefined +): boolean { + return ( + parseExecutionHostId( + getBrowserSessionProfileHostId(state, worktreeId, browserRuntimeEnvironmentId) + )?.kind !== 'runtime' + ) +} + function profileListByHostUpdate( - state: Pick, - profiles: BrowserSessionProfile[] + state: Pick< + AppState, + 'browserSessionHostIdOverride' | 'browserSessionProfilesByHostId' | 'settings' + >, + profiles: BrowserSessionProfile[], + hostId: ExecutionHostId = getBrowserSettingsHostId(state) ): Partial { return { - browserSessionProfiles: profiles, + ...(getBrowserSettingsHostId(state) === hostId ? { browserSessionProfiles: profiles } : {}), browserSessionProfilesByHostId: { ...state.browserSessionProfilesByHostId, - [getBrowserSettingsHostId(state)]: profiles + [hostId]: profiles } } } +function getBrowserProfilesForHost( + state: AppState, + hostId: ExecutionHostId +): BrowserSessionProfile[] { + return ( + state.browserSessionProfilesByHostId[hostId] ?? + (getBrowserSettingsHostId(state) === hostId ? state.browserSessionProfiles : []) + ) +} + +function getDefaultBrowserProfileForHost(state: AppState, hostId: ExecutionHostId): string | null { + return ( + state.defaultBrowserSessionProfileIdByHostId[hostId] ?? + (getBrowserSettingsHostId(state) === hostId ? state.defaultBrowserSessionProfileId : null) + ) +} + +function browserImportStateForHostUpdate( + state: AppState, + hostId: ExecutionHostId, + browserSessionImportState: BrowserSlice['browserSessionImportState'] +): Partial { + return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : {} +} + function closeRemoteBrowserPageInOwningEnvironment( worktreeId: string, handle: RemoteBrowserPageHandle @@ -468,11 +513,29 @@ export const createBrowserSlice: StateCreator = pendingAddressBarFocusByPageId: {}, browserSessionProfiles: [], browserSessionProfilesByHostId: {}, + browserSessionHostIdOverride: null, browserSessionImportState: null, browserUrlHistory: [], defaultBrowserSessionProfileId: null, defaultBrowserSessionProfileIdByHostId: {}, + setBrowserSessionHostId: async (hostId) => { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind !== 'local' && parsed?.kind !== 'runtime') { + return + } + const nextHostId = parsed.id + set((s) => ({ + browserSessionHostIdOverride: nextHostId, + browserSessionProfiles: s.browserSessionProfilesByHostId[nextHostId] ?? [], + defaultBrowserSessionProfileId: s.defaultBrowserSessionProfileIdByHostId[nextHostId] ?? null, + browserSessionImportState: null, + detectedBrowsers: [], + detectedBrowsersLoaded: false + })) + await Promise.all([get().fetchBrowserSessionProfiles(), get().fetchDetectedBrowsers()]) + }, + setDefaultBrowserSessionProfileId: (profileId) => { set((s) => ({ defaultBrowserSessionProfileId: profileId, @@ -885,9 +948,18 @@ export const createBrowserSlice: StateCreator = // Why: notify the CDP bridge of the active guest; it keys on page IDs not workspace IDs, so resolve the workspace's active page. const workspace = findWorkspace(get().browserTabsByWorktree, tabId) + const activePage = workspace?.activePageId + ? (get().browserPagesByWorkspace[workspace.id] ?? []).find( + (page) => page.id === workspace.activePageId + ) + : undefined if ( workspace?.activePageId && - !isRuntimeEnvironmentActive(get()) && + isLocalBrowserPageOwner( + get(), + workspace.worktreeId, + activePage?.browserRuntimeEnvironmentId + ) && typeof window !== 'undefined' && window.api?.browser ) { @@ -1128,15 +1200,22 @@ export const createBrowserSlice: StateCreator = }) // Why: switching the active page changes which guest webContents the CDP bridge targets for agent commands. + const activePage = (get().browserPagesByWorkspace[workspaceId] ?? []).find( + (page) => page.id === pageId + ) + const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId) if ( - !isRuntimeEnvironmentActive(get()) && + workspace && + isLocalBrowserPageOwner( + get(), + workspace.worktreeId, + activePage?.browserRuntimeEnvironmentId + ) && typeof window !== 'undefined' && window.api?.browser ) { window.api.browser.notifyActiveTabChanged({ browserPageId: pageId }).catch(() => {}) } - - const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId) if (!workspace) { return } @@ -1189,8 +1268,9 @@ export const createBrowserSlice: StateCreator = }) // Why: notify the CDP bridge which guest webContents is active so agent commands target the correct page. + const focusedPage = pages.find((page) => page.id === browserPageId) if ( - !isRuntimeEnvironmentActive(get()) && + isLocalBrowserPageOwner(get(), worktreeId, focusedPage?.browserRuntimeEnvironmentId) && typeof window !== 'undefined' && window.api?.browser ) { @@ -1711,33 +1791,37 @@ export const createBrowserSlice: StateCreator = }, fetchBrowserSessionProfiles: async () => { - if (isRuntimeEnvironmentActive(get())) { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { try { const result = await callRuntimeRpc( - getActiveRuntimeTarget(get().settings), + { kind: 'environment', environmentId: runtimeEnvironmentId }, 'browser.profileList', undefined, { timeoutMs: 15_000 } ) - set((s) => profileListByHostUpdate(s, result.profiles)) + set((s) => profileListByHostUpdate(s, result.profiles, hostId)) } catch { - set((s) => profileListByHostUpdate(s, [])) + set((s) => profileListByHostUpdate(s, [], hostId)) } return } try { const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[] - set((s) => profileListByHostUpdate(s, profiles)) + set((s) => profileListByHostUpdate(s, profiles, hostId)) } catch { /* best-effort — stale profile list is preferable to a crash */ } }, createBrowserSessionProfile: async (scope, label) => { - if (isRuntimeEnvironmentActive(get())) { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { try { const result = await callRuntimeRpc( - getActiveRuntimeTarget(get().settings), + { kind: 'environment', environmentId: runtimeEnvironmentId }, 'browser.profileCreate', { scope, label }, { timeoutMs: 15_000 } @@ -1745,7 +1829,11 @@ export const createBrowserSlice: StateCreator = const profile = result.profile if (profile) { set((s) => ({ - ...profileListByHostUpdate(s, [...s.browserSessionProfiles, profile]) + ...profileListByHostUpdate( + s, + [...getBrowserProfilesForHost(s, hostId), profile], + hostId + ) })) } return profile @@ -1760,7 +1848,7 @@ export const createBrowserSlice: StateCreator = })) as BrowserSessionProfile | null if (profile) { set((s) => ({ - ...profileListByHostUpdate(s, [...s.browserSessionProfiles, profile]) + ...profileListByHostUpdate(s, [...getBrowserProfilesForHost(s, hostId), profile], hostId) })) } return profile @@ -1770,10 +1858,12 @@ export const createBrowserSlice: StateCreator = }, deleteBrowserSessionProfile: async (profileId) => { - if (isRuntimeEnvironmentActive(get())) { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { try { const result = await callRuntimeRpc( - getActiveRuntimeTarget(get().settings), + { kind: 'environment', environmentId: runtimeEnvironmentId }, 'browser.profileDelete', { profileId }, { timeoutMs: 15_000 } @@ -1782,14 +1872,17 @@ export const createBrowserSlice: StateCreator = set((s) => ({ ...profileListByHostUpdate( s, - s.browserSessionProfiles.filter((p) => p.id !== profileId) + getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId), + hostId ), - ...(s.defaultBrowserSessionProfileId === profileId + ...(getDefaultBrowserProfileForHost(s, hostId) === profileId ? { - defaultBrowserSessionProfileId: null, + ...(getBrowserSettingsHostId(s) === hostId + ? { defaultBrowserSessionProfileId: null } + : {}), defaultBrowserSessionProfileIdByHostId: { ...s.defaultBrowserSessionProfileIdByHostId, - [getBrowserSettingsHostId(s)]: null + [hostId]: null } } : {}) @@ -1806,14 +1899,17 @@ export const createBrowserSlice: StateCreator = set((s) => ({ ...profileListByHostUpdate( s, - s.browserSessionProfiles.filter((p) => p.id !== profileId) + getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId), + hostId ), - ...(s.defaultBrowserSessionProfileId === profileId + ...(getDefaultBrowserProfileForHost(s, hostId) === profileId ? { - defaultBrowserSessionProfileId: null, + ...(getBrowserSettingsHostId(s) === hostId + ? { defaultBrowserSessionProfileId: null } + : {}), defaultBrowserSessionProfileIdByHostId: { ...s.defaultBrowserSessionProfileIdByHostId, - [getBrowserSettingsHostId(s)]: null + [hostId]: null } } : {}) @@ -1826,64 +1922,67 @@ export const createBrowserSlice: StateCreator = }, importCookiesToProfile: async (profileId) => { - if (isRuntimeEnvironmentActive(get())) { + const hostId = getBrowserSettingsHostId(get()) + if (getBrowserSettingsRuntimeEnvironmentId(get())) { const reason = 'Manual cookie file import is unavailable while a remote runtime is active.' - set({ - browserSessionImportState: { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: 'error', summary: null, error: reason - } - }) + }) + ) return { ok: false as const, reason } } - set({ - browserSessionImportState: { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: 'importing', summary: null, error: null - } - }) + }) + ) try { const result = (await window.api.browser.sessionImportCookies({ profileId })) as BrowserCookieImportResult if (result.ok) { get().recordFeatureInteraction?.('cookie-import') - set({ - browserSessionImportState: { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: 'success', summary: result.summary, error: null - } - }) - await get() - .fetchBrowserSessionProfiles() - .catch(() => {}) + }) + ) + if (getBrowserSettingsHostId(get()) === hostId) { + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } } else { - set({ - browserSessionImportState: { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: result.reason === 'canceled' ? 'idle' : 'error', summary: null, error: result.reason === 'canceled' ? null : result.reason - } - }) + }) + ) } return result } catch (err) { const reason = String((err as Error)?.message ?? err) - set({ - browserSessionImportState: { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: 'error', summary: null, error: reason - } - }) + }) + ) return { ok: false as const, reason } } }, @@ -1896,17 +1995,27 @@ export const createBrowserSlice: StateCreator = detectedBrowsersLoaded: false, fetchDetectedBrowsers: async () => { - if (isRuntimeEnvironmentActive(get())) { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { try { const result = await callRuntimeRpc( - getActiveRuntimeTarget(get().settings), + { kind: 'environment', environmentId: runtimeEnvironmentId }, 'browser.profileDetectBrowsers', undefined, { timeoutMs: 15_000 } ) - set({ detectedBrowsers: result.browsers, detectedBrowsersLoaded: true }) + set((s) => + getBrowserSettingsHostId(s) === hostId + ? { detectedBrowsers: result.browsers, detectedBrowsersLoaded: true } + : {} + ) } catch { - set({ detectedBrowsers: [], detectedBrowsersLoaded: true }) + set((s) => + getBrowserSettingsHostId(s) === hostId + ? { detectedBrowsers: [], detectedBrowsersLoaded: true } + : {} + ) } return } @@ -1920,74 +2029,82 @@ export const createBrowserSlice: StateCreator = profiles: { name: string; directory: string }[] selectedProfile: string }[] - set({ detectedBrowsers: browsers, detectedBrowsersLoaded: true }) + set((s) => + getBrowserSettingsHostId(s) === hostId + ? { detectedBrowsers: browsers, detectedBrowsersLoaded: true } + : {} + ) } catch { /* best-effort — empty list is acceptable fallback */ - set({ detectedBrowsersLoaded: true }) + set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : {})) } }, importCookiesFromBrowser: async (profileId, browserFamily, browserProfile?) => { - if (isRuntimeEnvironmentActive(get())) { - set({ - browserSessionImportState: { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: 'importing', summary: null, error: null - } - }) + }) + ) try { const result = await callRuntimeRpc( - getActiveRuntimeTarget(get().settings), + { kind: 'environment', environmentId: runtimeEnvironmentId }, 'browser.profileImportFromBrowser', { profileId, browserFamily, browserProfile }, { timeoutMs: 30_000 } ) if (result.ok) { - set({ - browserSessionImportState: { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: 'success', summary: result.summary, error: null - } - }) - await get() - .fetchBrowserSessionProfiles() - .catch(() => {}) + }) + ) + if (getBrowserSettingsHostId(get()) === hostId) { + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } } else { - set({ - browserSessionImportState: { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: 'error', summary: null, error: result.reason - } - }) + }) + ) } return result } catch (err) { const reason = String((err as Error)?.message ?? err) - set({ - browserSessionImportState: { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: 'error', summary: null, error: reason - } - }) + }) + ) return { ok: false as const, reason } } } - set({ - browserSessionImportState: { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: 'importing', summary: null, error: null - } - }) + }) + ) try { const result = (await window.api.browser.sessionImportFromBrowser({ profileId, @@ -1996,52 +2113,56 @@ export const createBrowserSlice: StateCreator = })) as BrowserCookieImportResult if (result.ok) { get().recordFeatureInteraction?.('cookie-import') - set({ - browserSessionImportState: { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: 'success', summary: result.summary, error: null - } - }) - await get() - .fetchBrowserSessionProfiles() - .catch(() => {}) + }) + ) + if (getBrowserSettingsHostId(get()) === hostId) { + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } } else { - set({ - browserSessionImportState: { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: 'error', summary: null, error: result.reason - } - }) + }) + ) } return result } catch (err) { const reason = String((err as Error)?.message ?? err) - set({ - browserSessionImportState: { + set((state) => + browserImportStateForHostUpdate(state, hostId, { profileId, status: 'error', summary: null, error: reason - } - }) + }) + ) return { ok: false as const, reason } } }, clearDefaultSessionCookies: async () => { - if (isRuntimeEnvironmentActive(get())) { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { try { const result = await callRuntimeRpc( - getActiveRuntimeTarget(get().settings), + { kind: 'environment', environmentId: runtimeEnvironmentId }, 'browser.profileClearDefaultCookies', undefined, { timeoutMs: 15_000 } ) - if (result.cleared) { + if (result.cleared && getBrowserSettingsHostId(get()) === hostId) { await get().fetchBrowserSessionProfiles() } return result.cleared @@ -2051,7 +2172,7 @@ export const createBrowserSlice: StateCreator = } try { const ok = await window.api.browser.sessionClearDefaultCookies() - if (ok) { + if (ok && getBrowserSettingsHostId(get()) === hostId) { get().recordFeatureInteraction?.('cookie-import') await get().fetchBrowserSessionProfiles() } diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index ad4c4983b58..283e3ff251a 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -22,6 +22,7 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentGetStatus = vi.fn() const settingsSet = vi.fn().mockResolvedValue(undefined) +const setActiveRuntimeEnvironmentPreference = vi.fn().mockResolvedValue(undefined) const worktreesListDetected = vi.fn() const env2Lineage: WorktreeLineage = { @@ -132,7 +133,7 @@ beforeEach(() => { }) vi.stubGlobal('window', { api: { - settings: { set: settingsSet }, + settings: { set: settingsSet, setActiveRuntimeEnvironmentPreference }, runtimeEnvironments: { call: runtimeEnvironmentCall, getStatus: runtimeEnvironmentGetStatus }, worktrees: { listDetected: worktreesListDetected } } @@ -251,9 +252,11 @@ describe('createSettingsSlice runtime switching', () => { jiraIssueCache: { 'JIRA-1': { data: { key: 'JIRA-1' } as never, fetchedAt: Date.now() } } }) - await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true) + await expect(store.getState().setActiveRuntimeEnvironmentPreference('env-2')).resolves.toBe( + true + ) - expect(settingsSet).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: 'env-2' }) + expect(setActiveRuntimeEnvironmentPreference).toHaveBeenCalledWith({ environmentId: 'env-2' }) expect(runtimeEnvironmentGetStatus).toHaveBeenCalledWith({ selector: 'env-2', timeoutMs: 15_000 @@ -372,9 +375,11 @@ describe('createSettingsSlice runtime switching', () => { } }) - await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true) + await expect(store.getState().setActiveRuntimeEnvironmentPreference('env-2')).resolves.toBe( + true + ) - expect(settingsSet).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: 'env-2' }) + expect(setActiveRuntimeEnvironmentPreference).toHaveBeenCalledWith({ environmentId: 'env-2' }) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-1', method: 'terminal.close' }) ) @@ -437,7 +442,9 @@ describe('createSettingsSlice runtime switching', () => { } }) - await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true) + await expect(store.getState().setActiveRuntimeEnvironmentPreference('env-2')).resolves.toBe( + true + ) // No teardown RPC was issued against the previous host's live resources. expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( @@ -496,9 +503,11 @@ describe('createSettingsSlice runtime switching', () => { editorDrafts: { '/env-1/repo/dirty.md': 'draft' } }) - await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true) + await expect(store.getState().setActiveRuntimeEnvironmentPreference('env-2')).resolves.toBe( + true + ) - expect(settingsSet).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: 'env-2' }) + expect(setActiveRuntimeEnvironmentPreference).toHaveBeenCalledWith({ environmentId: 'env-2' }) expect(runtimeEnvironmentCall).toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-2', method: 'repo.list' }) ) @@ -520,9 +529,11 @@ describe('createSettingsSlice runtime switching', () => { ptyIdsByTabId: { tab1: ['remote:env-1@@terminal-a'] } }) - await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(false) + await expect(store.getState().setActiveRuntimeEnvironmentPreference('env-2')).resolves.toBe( + false + ) - expect(settingsSet).not.toHaveBeenCalled() + expect(setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled() expect(runtimeEnvironmentGetStatus).toHaveBeenCalledWith({ selector: 'env-2', timeoutMs: 15_000 @@ -558,9 +569,11 @@ describe('createSettingsSlice runtime switching', () => { openFiles: [] }) - await expect(store.getState().switchRuntimeEnvironment('env-old')).resolves.toBe(false) + await expect(store.getState().setActiveRuntimeEnvironmentPreference('env-old')).resolves.toBe( + false + ) - expect(settingsSet).not.toHaveBeenCalled() + expect(setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled() expect(runtimeEnvironmentGetStatus).toHaveBeenCalledWith({ selector: 'env-old', timeoutMs: 15_000 diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index f9d4d4f3699..7b0d623a09a 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -28,7 +28,7 @@ export type SettingsSlice = SettingsSearchState & { settings: GlobalSettings | null fetchSettings: () => Promise updateSettings: (updates: Partial) => Promise - switchRuntimeEnvironment: (environmentId: string | null) => Promise + setActiveRuntimeEnvironmentPreference: (environmentId: string | null) => Promise } type LegacyTerminalScrollbackSettingsUpdate = Partial & { @@ -142,7 +142,7 @@ export const createSettingsSlice: StateCreator } }, - switchRuntimeEnvironment: async (environmentId) => { + setActiveRuntimeEnvironmentPreference: async (environmentId) => { const nextId = normalizeRuntimeEnvironmentId(environmentId) const previousId = normalizeRuntimeEnvironmentId(get().settings?.activeRuntimeEnvironmentId) if (previousId === nextId) { @@ -151,8 +151,8 @@ export const createSettingsSlice: StateCreator try { clearRuntimeCompatibilityCache(nextId) await verifyRuntimeEnvironmentReachable(nextId) - const nextSettings = await window.api.settings.set({ - activeRuntimeEnvironmentId: nextId + const nextSettings = await window.api.settings.setActiveRuntimeEnvironmentPreference({ + environmentId: nextId }) bumpProviderRuntimeSessionGeneration() set((s) => ({ diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index f0022040b9c..ea3e3679f82 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -213,6 +213,59 @@ describe('web runtime environment identity', () => { ).rejects.toThrow('Unknown Orca runtime environment: web-server-a') }) + it('keeps pairing state separate from generic Active Server settings writes', async () => { + const globals = installBrowserGlobals('Linux') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Windows 2', + pairingCode: encodePairingCode({ publicKeyB64: 'windows-2-key' }) + }) + + const settings = await globals.window.api.settings.set({ activeRuntimeEnvironmentId: null }) + + await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([ + { id: paired.environment.id, name: 'Windows 2' } + ]) + expect(settings.activeRuntimeEnvironmentId).toBeNull() + expect(globals.window.api.settings.getSync()?.activeRuntimeEnvironmentId).toBeNull() + expect(JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}')).not.toHaveProperty( + 'activeRuntimeEnvironmentId' + ) + await expect( + globals.window.api.runtimeEnvironments.remove({ selector: paired.environment.id }) + ).resolves.toMatchObject({ removed: { id: paired.environment.id } }) + await expect(globals.window.api.runtimeEnvironments.list()).resolves.toEqual([]) + }) + + it('persists an explicit Active Server choice across unrelated web settings writes', async () => { + const globals = installBrowserGlobals('Linux') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Windows 2', + pairingCode: encodePairingCode({ publicKeyB64: 'windows-2-key' }) + }) + + await globals.window.api.settings.setActiveRuntimeEnvironmentPreference({ + environmentId: paired.environment.id + }) + await globals.window.api.settings.set({ terminalFontSize: 15 }) + expect(JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}')).toMatchObject({ + activeRuntimeEnvironmentId: paired.environment.id, + terminalFontSize: 15 + }) + + await globals.window.api.settings.setActiveRuntimeEnvironmentPreference({ + environmentId: null + }) + await globals.window.api.settings.set({ terminalFontSize: 16 }) + expect(JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}')).toMatchObject({ + activeRuntimeEnvironmentId: null, + terminalFontSize: 16 + }) + }) + it('keeps old selectors only when re-pairing proves the same server key', async () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') @@ -479,7 +532,12 @@ describe('web settings preload API', () => { return Promise.resolve({ id: `call-${runtimeCalls.length}`, ok: true, - result: { settings: { compactWorktreeCards: true } }, + result: { + settings: { + compactWorktreeCards: true, + activeRuntimeEnvironmentId: 'host-internal-default' + } + }, _meta: { runtimeId: 'runtime-1' } }) } @@ -499,7 +557,9 @@ describe('web settings preload API', () => { } expect(settings.compactWorktreeCards).toBe(true) + expect(settings.activeRuntimeEnvironmentId).toBeNull() expect(stored.compactWorktreeCards).toBe(true) + expect(stored).not.toHaveProperty('activeRuntimeEnvironmentId') expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }]) }, 15_000) @@ -615,7 +675,12 @@ describe('web settings preload API', () => { return Promise.resolve({ id: `call-${runtimeCalls.length}`, ok: true, - result: { settings: { compactWorktreeCards: true } }, + result: { + settings: { + compactWorktreeCards: true, + activeRuntimeEnvironmentId: 'host-internal-default' + } + }, _meta: { runtimeId: 'runtime-1' } }) } @@ -636,7 +701,9 @@ describe('web settings preload API', () => { } expect(settings.compactWorktreeCards).toBe(true) + expect(settings.activeRuntimeEnvironmentId).toBeNull() expect(stored.compactWorktreeCards).toBe(true) + expect(stored).not.toHaveProperty('activeRuntimeEnvironmentId') expect(runtimeCalls).toEqual([ { method: 'settings.update', params: { compactWorktreeCards: true } } ]) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index e3d08d72e2a..ca58947a267 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -629,19 +629,25 @@ function createWebPreloadApi(): Partial { // Why: localStorage-backed settings are synchronous, so the pre-hydration kill-switch read works the same as desktop. getSync: () => getStoredSettings(), set: async (updates) => { - if (updates.activeRuntimeEnvironmentId === null) { - disconnectActiveRuntimeEnvironment() - } const sanitizedUpdates = { ...updates } + delete sanitizedUpdates.activeRuntimeEnvironmentId if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) { sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true } const next = mergeSettings(getStoredSettings(), sanitizedUpdates, { preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates }) - writeJson(SETTINGS_STORAGE_KEY, next) + writeStoredSettings(next) return syncRuntimeBackedSettings(sanitizedUpdates, next) }, + setActiveRuntimeEnvironmentPreference: async ({ environmentId }) => { + const activeRuntimeEnvironmentId = environmentId?.trim() || null + const next = mergeSettings(getStoredSettings(), { + activeRuntimeEnvironmentId + }) + writeStoredSettings(next, activeRuntimeEnvironmentId) + return next + }, updatePRBotAuthorOverride: (args) => updateRuntimePRBotAuthorOverride(args), listFonts: () => Promise.resolve([]), onChanged: () => noopUnsubscribe @@ -3318,7 +3324,7 @@ function updateEnvironmentFromResponse( } function getStoredSettings(): GlobalSettings { - const environment = (activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment()) + activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment() const defaults = getDefaultSettings('~') const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY) const stored = readJson>(SETTINGS_STORAGE_KEY, {}) @@ -3354,12 +3360,30 @@ function getStoredSettings(): GlobalSettings { ...defaults, floatingTerminalEnabled: false, rightSidebarOpenByDefault: false, - activeRuntimeEnvironmentId: environment?.id ?? null + activeRuntimeEnvironmentId: null }, migratedStored ) } +function writeStoredSettings( + settings: GlobalSettings, + explicitActiveRuntimeEnvironmentId?: string | null +): void { + const durable = { ...settings } + if (explicitActiveRuntimeEnvironmentId !== undefined) { + durable.activeRuntimeEnvironmentId = explicitActiveRuntimeEnvironmentId + } else { + const stored = readJson>(SETTINGS_STORAGE_KEY, {}) + if (Object.hasOwn(stored, 'activeRuntimeEnvironmentId')) { + durable.activeRuntimeEnvironmentId = stored.activeRuntimeEnvironmentId ?? null + } else { + delete durable.activeRuntimeEnvironmentId + } + } + writeJson(SETTINGS_STORAGE_KEY, durable) +} + async function getRuntimeBackedStoredSettings(): Promise { const local = getStoredSettings() if (!requireActiveEnvironmentOrNull()) { @@ -3391,7 +3415,7 @@ async function getRuntimeBackedStoredSettings(): Promise { ) } const next = mergeSettings(local, runtimeSettings) - writeJson(SETTINGS_STORAGE_KEY, next) + writeStoredSettings(next) return next } catch { // Why: unpaired/offline web clients keep a local settings fallback. @@ -3433,8 +3457,10 @@ async function syncRuntimeBackedSettings( runtimeUpdates, 15_000 ) - const next = mergeSettings(localNext, result.settings) - writeJson(SETTINGS_STORAGE_KEY, next) + const runtimeSettings = { ...result.settings } + delete runtimeSettings.activeRuntimeEnvironmentId + const next = mergeSettings(localNext, runtimeSettings) + writeStoredSettings(next) return next } catch { // Why: unpaired/offline web clients still need local settings persistence. @@ -3457,7 +3483,7 @@ async function updateRuntimePRBotAuthorOverride(args: { const next = mergeSettings(local, { prBotAuthorOverrides: normalizePRBotAuthorOverrides(result.settings.prBotAuthorOverrides) }) - writeJson(SETTINGS_STORAGE_KEY, next) + writeStoredSettings(next) return next } const next = mergeSettings(local, { @@ -3467,7 +3493,7 @@ async function updateRuntimePRBotAuthorOverride(args: { args.isBot ) }) - writeJson(SETTINGS_STORAGE_KEY, next) + writeStoredSettings(next) return next } @@ -3647,7 +3673,9 @@ function mergeSettings( ...(base.voice ?? defaults.voice), ...updates.voice } as NonNullable, - activeRuntimeEnvironmentId: activeEnvironment?.id ?? updates.activeRuntimeEnvironmentId ?? null, + activeRuntimeEnvironmentId: Object.hasOwn(updates, 'activeRuntimeEnvironmentId') + ? (updates.activeRuntimeEnvironmentId ?? null) + : (base.activeRuntimeEnvironmentId ?? null), terminalCustomThemes: normalizeTerminalCustomThemes( updates.terminalCustomThemes ?? base.terminalCustomThemes ),