fix(runtime): isolate durable active server preference

This commit is contained in:
OrcaWin
2026-07-22 22:16:50 -07:00
parent 98632b9093
commit cdb572b9a7
26 changed files with 834 additions and 529 deletions
-2
View File
@@ -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)
@@ -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.
+2 -4
View File
@@ -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 () => {
+19 -6
View File
@@ -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<undefined, { id: string; name: string }[]>('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)
+4 -2
View File
@@ -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) }
}
)
+37
View File
@@ -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<unknown>
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'] })
+18
View File
@@ -92,6 +92,9 @@ export function registerSettingsHandlers(
ipcMain.handle('settings:set', async (event, args: Partial<GlobalSettings>) => {
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()
})
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> = {}
if (activeRuntimeEnvironmentId !== undefined) {
settings.activeRuntimeEnvironmentId = activeRuntimeEnvironmentId
}
const updateSettings = vi.fn((updates: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>) => {
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 }
)
})
})
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'>
updateSettings: (
updates: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>,
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}`)
}
+3
View File
@@ -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<GlobalSettings>) => Promise<GlobalSettings>
setActiveRuntimeEnvironmentPreference: (args: {
environmentId: string | null
}) => Promise<GlobalSettings>
updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }) => Promise<GlobalSettings>
listFonts: () => Promise<string[]>
previewGhosttyImport: () => Promise<GhosttyImportPreview>
+5
View File
@@ -1839,6 +1839,11 @@ const api = {
set: (args: Record<string, unknown>): Promise<unknown> =>
ipcRenderer.invoke('settings:set', args),
setActiveRuntimeEnvironmentPreference: (args: {
environmentId: string | null
}): Promise<unknown> =>
ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args),
updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise<unknown> =>
ipcRenderer.invoke('settings:update-pr-bot-author-override', args),
@@ -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 => {
@@ -67,7 +67,7 @@ const NO_RUNTIME_VALUE = '__none__'
type RuntimeEnvironmentsPaneProps = {
settings: GlobalSettings
switchRuntimeEnvironment: (environmentId: string | null) => Promise<boolean>
setActiveRuntimeEnvironmentPreference: (environmentId: string | null) => Promise<boolean>
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({
<Label id="runtime-active-server-label">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.64b6bea541',
'Default runtime'
'Active Server'
)}
</Label>
<p className="text-xs text-muted-foreground">
@@ -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') ? (
<RuntimeEnvironmentsPane
settings={settings}
switchRuntimeEnvironment={switchRuntimeEnvironment}
setActiveRuntimeEnvironmentPreference={setActiveRuntimeEnvironmentPreference}
canGeneratePairingUrl={!isWebClient}
allowLocalRuntime={!isWebClient}
addServerIntentSignal={remoteServerAddIntentSignal}
@@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({
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()
})
})
@@ -31,7 +31,6 @@ export function useAddRepoHostSelection({
handleConnectAddProjectHost: (hostId: ExecutionHostId) => Promise<void>
} {
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 {
@@ -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()
})
})
@@ -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<boolean>
fetchRepos: (environmentId: string) => Promise<{ id: string }[]>
fetchWorktrees: (repoId: string) => Promise<unknown>
fetchLineage: () => Promise<unknown>
}): Promise<boolean> {
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<void> => {
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<void> => {
async (environmentId: string): Promise<void> => {
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) => (
+66 -2
View File
@@ -1824,7 +1824,7 @@ describe('useIpcEvents updater integration', () => {
tabsByWorktree: {} as Record<string, { id: string; ptyId?: string | null; title?: string }[]>,
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
+14 -23
View File
@@ -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'
+190 -1
View File
@@ -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: [] })
+232 -111
View File
@@ -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<Record<ExecutionHostId, BrowserSessionProfile[]>>
browserSessionHostIdOverride: ExecutionHostId | null
setBrowserSessionHostId: (hostId: ExecutionHostId) => Promise<void>
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<AppState, 'browserSessionHostIdOverride' | 'settings'>
): ExecutionHostId {
return state.browserSessionHostIdOverride ?? getSettingsFocusedExecutionHostId(state.settings)
}
function getBrowserSettingsHostId(state: Pick<AppState, 'settings'>): ExecutionHostId {
return getSettingsFocusedExecutionHostId(state.settings)
function getBrowserSettingsRuntimeEnvironmentId(
state: Pick<AppState, 'browserSessionHostIdOverride' | 'settings'>
): 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<AppState, 'browserSessionProfilesByHostId' | 'settings'>,
profiles: BrowserSessionProfile[]
state: Pick<
AppState,
'browserSessionHostIdOverride' | 'browserSessionProfilesByHostId' | 'settings'
>,
profiles: BrowserSessionProfile[],
hostId: ExecutionHostId = getBrowserSettingsHostId(state)
): Partial<BrowserSlice> {
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<BrowserSlice> {
return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : {}
}
function closeRemoteBrowserPageInOwningEnvironment(
worktreeId: string,
handle: RemoteBrowserPageHandle
@@ -468,11 +513,29 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
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<AppState, [], [], BrowserSlice> =
// 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<AppState, [], [], BrowserSlice> =
})
// 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<AppState, [], [], BrowserSlice> =
})
// 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<AppState, [], [], BrowserSlice> =
},
fetchBrowserSessionProfiles: async () => {
if (isRuntimeEnvironmentActive(get())) {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
try {
const result = await callRuntimeRpc<BrowserProfileListResult>(
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<BrowserProfileCreateResult>(
getActiveRuntimeTarget(get().settings),
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileCreate',
{ scope, label },
{ timeoutMs: 15_000 }
@@ -1745,7 +1829,11 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
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<AppState, [], [], BrowserSlice> =
})) 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<AppState, [], [], BrowserSlice> =
},
deleteBrowserSessionProfile: async (profileId) => {
if (isRuntimeEnvironmentActive(get())) {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
try {
const result = await callRuntimeRpc<BrowserProfileDeleteResult>(
getActiveRuntimeTarget(get().settings),
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileDelete',
{ profileId },
{ timeoutMs: 15_000 }
@@ -1782,14 +1872,17 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
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<AppState, [], [], BrowserSlice> =
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<AppState, [], [], BrowserSlice> =
},
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<AppState, [], [], BrowserSlice> =
detectedBrowsersLoaded: false,
fetchDetectedBrowsers: async () => {
if (isRuntimeEnvironmentActive(get())) {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
try {
const result = await callRuntimeRpc<BrowserDetectProfilesResult>(
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<AppState, [], [], BrowserSlice> =
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<BrowserProfileImportFromBrowserResult>(
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<AppState, [], [], BrowserSlice> =
})) 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<BrowserProfileClearDefaultCookiesResult>(
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<AppState, [], [], BrowserSlice> =
}
try {
const ok = await window.api.browser.sessionClearDefaultCookies()
if (ok) {
if (ok && getBrowserSettingsHostId(get()) === hostId) {
get().recordFeatureInteraction?.('cookie-import')
await get().fetchBrowserSessionProfiles()
}
+25 -12
View File
@@ -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
+4 -4
View File
@@ -28,7 +28,7 @@ export type SettingsSlice = SettingsSearchState & {
settings: GlobalSettings | null
fetchSettings: () => Promise<void>
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
switchRuntimeEnvironment: (environmentId: string | null) => Promise<boolean>
setActiveRuntimeEnvironmentPreference: (environmentId: string | null) => Promise<boolean>
}
type LegacyTerminalScrollbackSettingsUpdate = Partial<GlobalSettings> & {
@@ -142,7 +142,7 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
}
},
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<AppState, [], [], SettingsSlice>
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) => ({
+69 -2
View File
@@ -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 } }
])
+40 -12
View File
@@ -629,19 +629,25 @@ function createWebPreloadApi(): Partial<PreloadApi> {
// 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<Partial<GlobalSettings>>(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<Partial<GlobalSettings>>(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<GlobalSettings> {
const local = getStoredSettings()
if (!requireActiveEnvironmentOrNull()) {
@@ -3391,7 +3415,7 @@ async function getRuntimeBackedStoredSettings(): Promise<GlobalSettings> {
)
}
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<GlobalSettings['voice']>,
activeRuntimeEnvironmentId: activeEnvironment?.id ?? updates.activeRuntimeEnvironmentId ?? null,
activeRuntimeEnvironmentId: Object.hasOwn(updates, 'activeRuntimeEnvironmentId')
? (updates.activeRuntimeEnvironmentId ?? null)
: (base.activeRuntimeEnvironmentId ?? null),
terminalCustomThemes: normalizeTerminalCustomThemes(
updates.terminalCustomThemes ?? base.terminalCustomThemes
),