mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
fix(runtime): isolate durable active server preference
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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'] })
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: [] })
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 } }
|
||||
])
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user