diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 84d2754d556a..237228b0eab9 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -14,6 +14,7 @@ import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call- import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' import type { Store } from '../persistence' +import { readSettingsWithRuntimeEnvironmentPreference } from './runtime-environment-preference' import { clearBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-runtime' import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-retirement' import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification' @@ -65,9 +66,11 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ ipcMain.handle('runtimeEnvironments:getStatusSnapshots', () => getRuntimeEnvironmentStatusSnapshots() ) - ipcMain.handle('runtimeEnvironments:list', () => - listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment) - ) + ipcMain.handle('runtimeEnvironments:list', () => { + const environments = listEnvironments(getUserDataPath()) + readSettingsWithRuntimeEnvironmentPreference(store, getUserDataPath()) + return environments.map(redactRuntimeEnvironment) + }) ipcMain.handle( 'runtimeEnvironments:addFromPairingCode', ( diff --git a/src/main/ipc/runtime-environment-preference.test.ts b/src/main/ipc/runtime-environment-preference.test.ts new file mode 100644 index 000000000000..1b5c9cd84edb --- /dev/null +++ b/src/main/ipc/runtime-environment-preference.test.ts @@ -0,0 +1,179 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import { encodePairingOffer } from '../../shared/pairing' +import { + addEnvironmentFromPairingCode, + getEnvironmentStorePath, + listEnvironments, + removeEnvironment +} from '../../shared/runtime-environment-store' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Store } from '../persistence' +import { + readSettingsWithRuntimeEnvironmentPreference, + watchRuntimeEnvironmentPreference +} from './runtime-environment-preference' + +let userDataPath: string + +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-removed-preference-')) +}) + +afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) +}) + +function preferenceStore(activeId: string | null) { + let settings = { ...getDefaultSettings(userDataPath), activeRuntimeEnvironmentId: activeId } + const updateSettings = vi.fn((updates) => { + settings = { ...settings, ...updates } + return settings + }) + return { getSettings: () => settings, updateSettings } +} + +function saveEnvironment(name = 'server') { + return addEnvironmentFromPairingCode(userDataPath, { + name, + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:59999', + deviceToken: 'test-token', + publicKeyB64: Buffer.alloc(32, 1).toString('base64') + }) + }) +} + +describe('Active Server after a saved host is removed', () => { + it('survives a failed watch installation and repairs on the next settings read', () => { + const environment = saveEnvironment() + const store = preferenceStore(environment.id) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let stopWatching: (() => void) | undefined + rmSync(userDataPath, { recursive: true }) + try { + expect(() => { + stopWatching = watchRuntimeEnvironmentPreference(store, userDataPath) + }).not.toThrow() + expect(store.getSettings().activeRuntimeEnvironmentId).toBe(environment.id) + mkdirSync(userDataPath) + writeFileSync(getEnvironmentStorePath(userDataPath), '{"version":1,"environments":[]}') + expect( + readSettingsWithRuntimeEnvironmentPreference(store, userDataPath).activeRuntimeEnvironmentId + ).toBeNull() + } finally { + stopWatching?.() + warning.mockRestore() + } + }) + + it('notifies the running renderer when the CLI atomically replaces the registry', async () => { + const environment = saveEnvironment() + const store = preferenceStore(environment.id) + const stopWatching = watchRuntimeEnvironmentPreference(store, userDataPath) + try { + await new Promise((resolve) => setImmediate(resolve)) + removeEnvironment(userDataPath, environment.id) + await vi.waitFor(() => expect(store.getSettings().activeRuntimeEnvironmentId).toBeNull(), { + timeout: 5_000 + }) + expect(store.updateSettings).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: null }, + { notifyListeners: true } + ) + } finally { + stopWatching() + } + }) + + it('repairs a CLI removal before settings can route to the deleted pairing', () => { + const environment = saveEnvironment() + const store = preferenceStore(environment.id) + const before = store.getSettings() + removeEnvironment(userDataPath, environment.id) + + const settings = readSettingsWithRuntimeEnvironmentPreference(store, userDataPath) + + expect(settings).toEqual({ ...before, activeRuntimeEnvironmentId: null }) + expect(store.getSettings().activeRuntimeEnvironmentId).toBeNull() + expect(store.updateSettings).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: null }, + { notifyListeners: true } + ) + readSettingsWithRuntimeEnvironmentPreference(store, userDataPath) + expect(store.updateSettings).toHaveBeenCalledOnce() + }) + + it('does not select a same-name replacement or another saved server', () => { + const removed = saveEnvironment() + const store = preferenceStore(removed.id) + removeEnvironment(userDataPath, removed.id) + const replacement = saveEnvironment() + saveEnvironment('other') + + expect(replacement.id).not.toBe(removed.id) + expect( + readSettingsWithRuntimeEnvironmentPreference(store, userDataPath).activeRuntimeEnvironmentId + ).toBeNull() + }) + + it('keeps a saved host even when its endpoint is offline', () => { + const environment = saveEnvironment() + const store = preferenceStore(environment.id) + const settings = store.getSettings() + + expect(readSettingsWithRuntimeEnvironmentPreference(store, userDataPath)).toBe(settings) + expect(store.updateSettings).not.toHaveBeenCalled() + }) + + it.each(['missing', 'corrupt', 'unsupported'] as const)( + 'preserves the preference when the registry is %s', + (kind) => { + const environment = saveEnvironment() + const store = preferenceStore(environment.id) + const registryPath = getEnvironmentStorePath(userDataPath) + if (kind === 'missing') { + rmSync(registryPath) + } else { + writeFileSync(registryPath, kind === 'corrupt' ? '{' : '{"version":99,"environments":[]}') + } + + expect(readSettingsWithRuntimeEnvironmentPreference(store, userDataPath)).toBe( + store.getSettings() + ) + expect(store.updateSettings).not.toHaveBeenCalled() + } + ) + + it('keeps local defaults without requiring a pairing registry', () => { + const store = preferenceStore(null) + expect(readSettingsWithRuntimeEnvironmentPreference(store, userDataPath)).toBe( + store.getSettings() + ) + expect(store.updateSettings).not.toHaveBeenCalled() + }) + + it('does not swallow a settings write failure after confirming removal', () => { + const environment = saveEnvironment() + const store = preferenceStore(environment.id) + removeEnvironment(userDataPath, environment.id) + store.updateSettings.mockImplementation((): GlobalSettings => { + throw new Error('settings write failed') + }) + + expect(() => readSettingsWithRuntimeEnvironmentPreference(store, userDataPath)).toThrow( + 'settings write failed' + ) + }) + + it('distinguishes a new profile from an unavailable registry for repair reads', () => { + expect(listEnvironments(userDataPath)).toEqual([]) + expect(() => listEnvironments(userDataPath, { requireStoreFile: true })).toThrow( + 'Could not read Orca environments' + ) + }) +}) diff --git a/src/main/ipc/runtime-environment-preference.ts b/src/main/ipc/runtime-environment-preference.ts new file mode 100644 index 000000000000..49a2e44817a1 --- /dev/null +++ b/src/main/ipc/runtime-environment-preference.ts @@ -0,0 +1,73 @@ +import { watch, type FSWatcher } from 'node:fs' +import { basename } from 'node:path' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { getEnvironmentStorePath, listEnvironments } from '../../shared/runtime-environment-store' +import type { Store } from '../persistence' + +type PreferenceStore = Pick + +function reconcileRuntimeEnvironmentPreference( + store: PreferenceStore, + environments: readonly { id: string }[] +): GlobalSettings { + const settings = store.getSettings() + const activeId = settings?.activeRuntimeEnvironmentId?.trim() + if (!activeId || environments.some((environment) => environment.id === activeId)) { + return settings + } + // A deleted default must not keep routing unrelated requests to its retired pairing. + return store.updateSettings({ activeRuntimeEnvironmentId: null }, { notifyListeners: true }) +} + +export function readSettingsWithRuntimeEnvironmentPreference( + store: PreferenceStore, + userDataPath: string +): GlobalSettings { + const settings = store.getSettings() + if (!settings?.activeRuntimeEnvironmentId?.trim()) { + return settings + } + let environments: { id: string }[] + try { + // An unavailable registry cannot prove the selected host was removed. + environments = listEnvironments(userDataPath, { requireStoreFile: true }) + } catch { + return settings + } + return reconcileRuntimeEnvironmentPreference(store, environments) +} + +export function watchRuntimeEnvironmentPreference( + store: PreferenceStore, + userDataPath: string +): () => void { + const registryName = basename(getEnvironmentStorePath(userDataPath)) + const repairPreference = (): void => { + try { + readSettingsWithRuntimeEnvironmentPreference(store, userDataPath) + } catch (error) { + console.warn('[runtime-environments] Active Server preference repair failed:', error) + } + } + // Reconcile changes made while the native watch was being installed. + const initialCheck = setImmediate(repairPreference) + let watcher: FSWatcher | undefined + try { + // Watch the directory because registry writes atomically replace the file. + watcher = watch(userDataPath, { persistent: false }, (_event, filename) => { + if (filename === null || filename.toString() === registryName) { + repairPreference() + } + }) + watcher.on('error', (error) => { + watcher?.close() + console.warn('[runtime-environments] Active Server preference watch failed:', error) + }) + } catch (error) { + console.warn('[runtime-environments] Active Server preference watch failed:', error) + } + return () => { + clearImmediate(initialCheck) + watcher?.close() + } +} diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index d76bae70f71e..30db92fd48a5 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -22,6 +22,7 @@ import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-hand import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime' import { registerRuntimeEnvironmentBrowserClientHostHandler } from './runtime-environment-browser-client-host-handler' import { advanceRuntimeEnvironmentCapabilityIncarnation } from './runtime-environment-capability-evidence' +import { watchRuntimeEnvironmentPreference } from './runtime-environment-preference' const remoteRuntimeSubscriptions = new Map() const getUserDataPath = (): string => app.getPath('userData') @@ -75,8 +76,11 @@ export function invalidateRuntimeEnvironmentTransport(environmentId: string): Pr } const pendingSubscriptions = new Map() +let stopPreferenceWatch: (() => void) | undefined export function registerRuntimeEnvironmentHandlers(store: Store): void { + stopPreferenceWatch?.() + stopPreferenceWatch = watchRuntimeEnvironmentPreference(store, getUserDataPath()) for (const pending of pendingSubscriptions.values()) { pending.close() } diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index e2a27ad6aabb..3089af28e74a 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -12,6 +12,7 @@ const { previewWarpThemeImportMock, prepareLocalWorktreeRootsForReposMock, resolveEnvironmentMock, + listEnvironmentsMock, rebuildAppMenuMock, applyBrowserSessionProxiesMock, applySessionSearchSettingsChangeMock, @@ -27,6 +28,7 @@ const { previewWarpThemeImportMock: vi.fn(), prepareLocalWorktreeRootsForReposMock: vi.fn(), resolveEnvironmentMock: vi.fn(), + listEnvironmentsMock: vi.fn(), rebuildAppMenuMock: vi.fn(), applyBrowserSessionProxiesMock: vi.fn(), applySessionSearchSettingsChangeMock: vi.fn(), @@ -81,7 +83,8 @@ vi.mock('../menu/register-app-menu', () => ({ })) vi.mock('../../shared/runtime-environment-store', () => ({ - resolveEnvironment: resolveEnvironmentMock + resolveEnvironment: resolveEnvironmentMock, + listEnvironments: listEnvironmentsMock })) import { registerSettingsHandlers } from './settings' @@ -126,6 +129,28 @@ describe('registerSettingsHandlers', () => { store.getSettings.mockReset() store.updateSettings.mockReset() store.onSettingsChanged.mockClear() + listEnvironmentsMock.mockReset().mockReturnValue([]) + }) + + it('repairs removed Active Server preferences on asynchronous and synchronous reads', () => { + store.getSettings.mockReturnValue({ activeRuntimeEnvironmentId: 'removed' }) + store.updateSettings.mockImplementation((updates) => { + const next = { ...store.getSettings(), ...updates } + store.getSettings.mockReturnValue(next) + return next + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This handler only uses the settings methods supplied by the fixture. + registerSettingsHandlers(store as never) + const get = handleMock.mock.calls.find(([channel]) => channel === 'settings:get')?.[1] + const getSync = onMock.mock.calls.find(([channel]) => channel === 'settings:get-sync')?.[1] + + expect(get()).toEqual({ activeRuntimeEnvironmentId: null }) + store.getSettings.mockReturnValue({ activeRuntimeEnvironmentId: 'removed-again' }) + const event = { returnValue: undefined } + getSync(event) + expect(event.returnValue).toEqual({ activeRuntimeEnvironmentId: null }) + expect(store.updateSettings).toHaveBeenCalledTimes(2) + expect(listEnvironmentsMock).toHaveBeenCalledWith('/test/user-data', { requireStoreFile: true }) }) it('registers settings:previewGhosttyImport handler', () => { diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index d2a79ef1885c..e2a173e21b16 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -28,6 +28,7 @@ import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation' import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides' import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { readSettingsWithRuntimeEnvironmentPreference } from './runtime-environment-preference' import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection' import { normalizeMobilePairingCustomAddress, @@ -98,7 +99,7 @@ export function registerSettingsHandlers( }) ipcMain.handle('settings:get', () => { - return store.getSettings() + return readSettingsWithRuntimeEnvironmentPreference(store, app.getPath('userData')) }) ipcMain.handle( @@ -120,7 +121,7 @@ export function registerSettingsHandlers( // synchronously or pre-hydration bindings would always pick main authority // (terminal-side-effect-authority.md, migration switch). ipcMain.on('settings:get-sync', (event) => { - event.returnValue = store.getSettings() + event.returnValue = readSettingsWithRuntimeEnvironmentPreference(store, app.getPath('userData')) }) ipcMain.handle('settings:set', async (event, args: Partial) => { diff --git a/src/renderer/src/lib/editor-file-operation-owner.ts b/src/renderer/src/lib/editor-file-operation-owner.ts index 0c9bcdfdef40..a1805976d51a 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.ts @@ -1,7 +1,8 @@ -import { parseExecutionHostId } from '../../../shared/execution-host' +import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { AppState } from '@/store/types' +import { findFolderWorkspaceOwner } from './folder-workspace-runtime-owner' import { assertWorktreeOperationGenerationSnapshotCurrent, captureWorktreeOperationGenerationSnapshot, @@ -23,6 +24,8 @@ export type EditorFileOperationProvenance = { type EditorOwnerState = Pick< AppState, | 'settings' + | 'activeWorktreeId' + | 'activeWorkspaceExecutionHostId' | 'repos' | 'worktreesByRepo' | 'detectedWorktreesByRepo' @@ -66,7 +69,7 @@ export function captureEditorFileOperationProvenance( : ('local' as const), runtimeEnvironmentId: hintedRuntimeEnvironmentId } - : resolveWorktreeOperationRoute(state, worktreeId) + : resolveEditorOwnerRoute(state, worktreeId) if (!route || (ownerHintProvided && (ownerHint?.trim() || null) !== route.runtimeEnvironmentId)) { throw new Error(OWNER_CHANGED_MESSAGE) } @@ -78,6 +81,29 @@ export function captureEditorFileOperationProvenance( } } +function resolveEditorOwnerRoute( + state: EditorOwnerState, + worktreeId: string, + capturedHostId?: ExecutionHostId | null +): WorktreeOperationRoute | null { + // Workspace focus cannot replace a file's catalog owner. + const ownerState = { ...state, activeWorktreeId: null, activeWorkspaceExecutionHostId: null } + const route = resolveWorktreeOperationRoute(ownerState, worktreeId) + const scope = parseWorkspaceKey(worktreeId) + const hostId = + capturedHostId ?? + (state.activeWorktreeId === worktreeId ? state.activeWorkspaceExecutionHostId : null) + if (route || scope?.type !== 'folder' || !hostId) { + return route + } + // Same-id folders may use a host qualifier only when the catalog confirms that owner. + const folder = findFolderWorkspaceOwner(ownerState, scope.folderWorkspaceId, hostId) + const qualifiedRoute = folder + ? resolveWorktreeOperationRoute({ ...ownerState, folderWorkspaces: [folder] }, worktreeId) + : null + return qualifiedRoute?.executionHostId === hostId ? qualifiedRoute : null +} + export function assertEditorFileOperationCurrent( state: EditorOwnerState, worktreeId: string, @@ -115,7 +141,7 @@ function resolveCurrentEditorRoute( // Why: ordinary folder workspaces have no published worktree row, so re-resolve their live // folder owner after preserving the explicit-owner fail-closed contract above (#10251). if (parseWorkspaceKey(worktreeId)?.type === 'folder') { - return resolveWorktreeOperationRoute(state, worktreeId) + return resolveEditorOwnerRoute(state, worktreeId, provenance.generation.route.executionHostId) } return isWorktreePublished(state, worktreeId) ? provenance.generation.route : null } diff --git a/src/renderer/src/lib/unified-tab-host-ownership.ts b/src/renderer/src/lib/unified-tab-host-ownership.ts index 9eeac23c1232..3edb6a9b4de5 100644 --- a/src/renderer/src/lib/unified-tab-host-ownership.ts +++ b/src/renderer/src/lib/unified-tab-host-ownership.ts @@ -75,20 +75,23 @@ export function isOpenFileOwnedByWorktree( if (file.worktreeId !== worktree.id) { return false } + return isExecutionHostAliasForWorktree(getOpenFileExecutionHostId(file), worktree) +} + +export function getOpenFileExecutionHostId( + file: Pick +): ExecutionHostId { const operationHost = file.operationProvenance?.generation.route.executionHostId if (operationHost) { - return isExecutionHostAliasForWorktree(operationHost, worktree) + return operationHost } if (file.externalSshTargetId) { - return isExecutionHostAliasForWorktree(toSshExecutionHostId(file.externalSshTargetId), worktree) + return toSshExecutionHostId(file.externalSshTargetId) } if (file.runtimeEnvironmentId) { - return isExecutionHostAliasForWorktree( - toRuntimeExecutionHostId(file.runtimeEnvironmentId), - worktree - ) + return toRuntimeExecutionHostId(file.runtimeEnvironmentId) } - return isExecutionHostAliasForWorktree(LOCAL_EXECUTION_HOST_ID, worktree) + return LOCAL_EXECUTION_HOST_ID } export function hasOpenFileExecutionHostEvidence( diff --git a/src/renderer/src/store/slices/editor-tab-host-ownership.test.ts b/src/renderer/src/store/slices/editor-tab-host-ownership.test.ts new file mode 100644 index 000000000000..20ef622011de --- /dev/null +++ b/src/renderer/src/store/slices/editor-tab-host-ownership.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { createEditorTabsStore } from './editor-slice-test-harness' +import { makeWorktree, TEST_REPO } from './store-test-helpers' +import { getDefaultSettings } from '../../../../shared/constants' +import { + assertEditorFileOperationCurrent, + captureEditorFileOperationProvenance +} from '@/lib/editor-file-operation-owner' + +describe('editor tabs retain their captured file owner', () => { + it.each([ + { hostId: 'local', runtimeEnvironmentId: null }, + { hostId: 'runtime:wsl', runtimeEnvironmentId: 'wsl' }, + { hostId: 'ssh:linux', runtimeEnvironmentId: null }, + { hostId: 'ssh:linux', runtimeEnvironmentId: 'wsl' } + ] as const)('keeps $hostId via $runtimeEnvironmentId while another host is focused', (owner) => { + const store = createEditorTabsStore() + store.setState({ + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/repo', executionHostId: owner.hostId }], + worktreesByRepo: { + 'repo-1': [ + makeWorktree({ + id: 'wt-1', + repoId: 'repo-1', + path: '/repo', + hostId: owner.hostId, + ...(owner.runtimeEnvironmentId + ? { runtimeOwnerEnvironmentId: owner.runtimeEnvironmentId } + : {}) + }) + ] + }, + activeWorktreeId: 'wt-1', + activeWorkspaceExecutionHostId: 'runtime:mac', + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map() + }) + + store.getState().openFile({ + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + + const file = store.getState().openFiles[0] + expect(file?.operationProvenance?.generation.route).toEqual({ + executionHostId: owner.hostId, + runtimeEnvironmentId: owner.runtimeEnvironmentId + }) + expect( + store.getState().unifiedTabsByWorktree['wt-1']?.find((tab) => tab.entityId === file?.id) + ?.executionHostId + ).toBe(owner.hostId) + }) +}) + +describe('editor owners ignore unrelated workspace focus', () => { + it('captures an unstamped local worktree independently of the focused host', () => { + const store = createEditorTabsStore() + store.setState({ + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/repo' }], + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo' })] + }, + activeWorkspaceExecutionHostId: 'runtime:mac', + settings: { ...getDefaultSettings('/tmp/orca-test'), activeRuntimeEnvironmentId: null } + }) + + expect( + captureEditorFileOperationProvenance(store.getState(), 'wt-1', undefined, false).generation + .route + ).toEqual({ + executionHostId: 'local', + runtimeEnvironmentId: null + }) + }) + + function folderStore() { + const store = createEditorTabsStore() + store.setState({ + activeWorktreeId: 'folder:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + settings: { ...getDefaultSettings('/tmp/orca-test'), activeRuntimeEnvironmentId: null }, + folderWorkspaces: [ + { + id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + projectGroupId: 'group', + name: 'Folder', + folderPath: '/folder', + connectionId: null, + executionHostId: 'local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + ], + projectGroups: [ + { + id: 'group', + name: 'Group', + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + executionHostId: 'local', + createdAt: 0, + updatedAt: 0 + } + ] + }) + return store + } + + it('captures a folder owner while another host is focused', () => { + const store = folderStore() + store.setState({ activeWorkspaceExecutionHostId: 'runtime:mac' }) + const route = captureEditorFileOperationProvenance( + store.getState(), + 'folder:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + undefined, + false + ).generation.route + expect(route).toEqual({ executionHostId: 'local', runtimeEnvironmentId: null }) + }) + + it('disambiguates same-id folders from catalog evidence and retains the captured owner', () => { + const store = folderStore() + const folderKey = 'folder:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const localFolder = store.getState().folderWorkspaces[0] + if (!localFolder) { + throw new Error('Missing folder fixture') + } + store.setState({ + folderWorkspaces: [localFolder, { ...localFolder, executionHostId: 'runtime:wsl' }], + activeWorkspaceExecutionHostId: 'local' + }) + const provenance = captureEditorFileOperationProvenance( + store.getState(), + folderKey, + undefined, + false + ) + expect(provenance.generation.route).toEqual({ + executionHostId: 'local', + runtimeEnvironmentId: null + }) + store.setState({ activeWorkspaceExecutionHostId: 'runtime:wsl' }) + expect(assertEditorFileOperationCurrent(store.getState(), folderKey, provenance)).toEqual( + provenance.generation.route + ) + store.setState({ activeWorkspaceExecutionHostId: 'runtime:absent' }) + expect(() => + captureEditorFileOperationProvenance(store.getState(), folderKey, undefined, false) + ).toThrow('Reopen the file') + }) + + it('keeps a captured folder readable after focus changes but rejects an owner change', () => { + const store = folderStore() + const folderKey = 'folder:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const provenance = captureEditorFileOperationProvenance( + store.getState(), + folderKey, + undefined, + false + ) + store.setState({ activeWorkspaceExecutionHostId: 'runtime:mac' }) + expect(assertEditorFileOperationCurrent(store.getState(), folderKey, provenance)).toEqual( + provenance.generation.route + ) + + store.setState({ + folderWorkspaces: store.getState().folderWorkspaces.map((folder) => ({ + ...folder, + executionHostId: 'runtime:wsl' + })) + }) + expect(() => assertEditorFileOperationCurrent(store.getState(), folderKey, provenance)).toThrow( + 'Reopen the file' + ) + }) +}) diff --git a/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts b/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts index e26a2526975d..57f4ac783f96 100644 --- a/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts +++ b/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts @@ -4,6 +4,7 @@ import type { EditorTabSelection, OpenFile } from '../types/open-file' import { resolveEditorOpenTargetGroupId } from './editor-open-target-group' import { areEditorPreviewTabsEnabled } from './editor-preview-tab-setting' import { isEditorTabContentType } from './editor-tab-content-type' +import { getOpenFileExecutionHostId } from '@/lib/unified-tab-host-ownership' export function openWorkspaceEditorItem( state: AppState, @@ -34,10 +35,16 @@ export function openWorkspaceEditorItem( return existing.id } } + const file = + contentType === 'editor' + ? state.openFiles.find((entry) => entry.id === fileId && entry.worktreeId === worktreeId) + : undefined const created = state.createUnifiedTab?.(worktreeId, contentType, { entityId: fileId, label, isPreview, + // The tab follows the captured file owner even when another host has UI focus. + ...(file ? { executionHostId: getOpenFileExecutionHostId(file) } : {}), ...(resolvedGroupId ? { targetGroupId: resolvedGroupId } : {}), ...(selection === 'none' ? { activate: false } : {}), ...(selection === 'background' ? { recordFocus: false } : {}) diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index e784dc802813..4ad3e128ef82 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -36,8 +36,11 @@ export function getEnvironmentStorePath(userDataPath: string): string { return join(userDataPath, ENVIRONMENTS_FILE) } -export function listEnvironments(userDataPath: string): KnownRuntimeEnvironment[] { - return readEnvironmentStore(userDataPath).environments +export function listEnvironments( + userDataPath: string, + options: { requireStoreFile?: boolean } = {} +): KnownRuntimeEnvironment[] { + return readEnvironmentStore(userDataPath, options).environments } export function addEnvironmentFromPairingCode( @@ -226,9 +229,12 @@ function resolveEnvironmentFromStore( throw new RuntimeEnvironmentStoreError('invalid_argument', `Unknown environment: ${selector}`) } -function readEnvironmentStore(userDataPath: string): RuntimeEnvironmentStore { +function readEnvironmentStore( + userDataPath: string, + options: { requireStoreFile?: boolean } = {} +): RuntimeEnvironmentStore { const path = getEnvironmentStorePath(userDataPath) - if (!existsSync(path)) { + if (!options.requireStoreFile && !existsSync(path)) { return { version: 1, environments: [] } } try { diff --git a/src/shared/structured-agent-session-live-turn.test.ts b/src/shared/structured-agent-session-live-turn.test.ts index 5a4d1f7d43c8..389e6b2aff38 100644 --- a/src/shared/structured-agent-session-live-turn.test.ts +++ b/src/shared/structured-agent-session-live-turn.test.ts @@ -39,9 +39,13 @@ describe('isStructuredAgentSessionThinking', () => { state, ...(state === 'completed' ? { completedAt: 3 } : {}) }) - expect(isStructuredAgentSessionThinking([turnStart, withState('running')])).toBe(true) + expect(isStructuredAgentSessionThinking({ items: [turnStart, withState('running')] })).toBe( + true + ) // Ended reasoning stays the newest row while Claude streams a tool's input after it. - expect(isStructuredAgentSessionThinking([turnStart, withState('completed')])).toBe(false) + expect(isStructuredAgentSessionThinking({ items: [turnStart, withState('completed')] })).toBe( + false + ) }) it('is false once a tool call, a message or a diff lands after the reasoning', () => {