From c9730df4c8f11faa5f9faebc3ec474324b8b83a1 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 15:34:24 -0700 Subject: [PATCH] refactor(renderer): no rebound-settings owner pattern (#27607) * refactor(renderer): no rebound-settings owner pattern * chore: lower focus-read baseline for composer owner scalar --------- Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 9 +- ...repo-owner-settings-selector-benchmark.mjs | 207 ------------------ ...ewWorkspaceComposerCard.file-drop.test.tsx | 3 +- .../use-automations-page-setup-state.test.tsx | 64 ++++++ .../use-automations-page-setup-state.ts | 14 +- .../composer-state/async-composer-state.ts | 10 +- .../src/hooks/composer-state/async-model.ts | 2 - .../attachment-drop-failure.test.tsx | 12 +- .../attachment-drop-lifetime.test.tsx | 2 +- .../composer-state/attachment-drop-state.ts | 16 +- .../composer-attachment-drop-state.ts | 3 +- .../composer-attachment-target.test.ts | 13 +- .../composer-attachment-target.ts | 11 +- .../composer-remote-server-drop.test.tsx | 3 +- .../composer-state/composer-source-model.ts | 3 +- .../composer-state/composer-target-state.ts | 3 +- .../composer-state/runtime-target-model.ts | 4 +- .../runtime-target-selection.ts | 21 +- .../server-ssh-repo-connect.test.ts | 2 +- .../workspace-identity-state.ts | 6 +- .../src/lib/repo-runtime-owner.test.ts | 127 +++-------- src/renderer/src/lib/repo-runtime-owner.ts | 51 ----- 22 files changed, 141 insertions(+), 445 deletions(-) delete mode 100644 config/scripts/repo-owner-settings-selector-benchmark.mjs create mode 100644 src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 3e72c682343..6f39a744c56 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -277,12 +277,9 @@ 2 src/renderer/src/components/use-worktree-jump-palette-store-state.ts 1 src/renderer/src/components/use-worktree-jump-palette-task-url.ts 1 src/renderer/src/components/worktree-jump-palette-create-worktree.ts -1 src/renderer/src/hooks/composer-state/attachment-drop-state.ts -1 src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-navigation-actions.ts -2 src/renderer/src/hooks/composer-state/composer-source-state.ts -1 src/renderer/src/hooks/composer-state/composer-target-state.ts -1 src/renderer/src/hooks/composer-state/workspace-identity-state.ts +1 src/renderer/src/hooks/composer-state/composer-source-state.ts +1 src/renderer/src/hooks/composer-state/runtime-target-selection.ts 1 src/renderer/src/hooks/editor-external-watch-target-rows.ts 3 src/renderer/src/hooks/editor-external-watch-targets.ts 1 src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts @@ -345,7 +342,7 @@ 2 src/renderer/src/lib/provider-runtime-context.ts 2 src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts 1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts -3 src/renderer/src/lib/repo-runtime-owner.ts +2 src/renderer/src/lib/repo-runtime-owner.ts 2 src/renderer/src/lib/resume-sleeping-agent-session.ts 1 src/renderer/src/lib/runtime-session-mirror-owners.ts 1 src/renderer/src/lib/runtime-session-mirror-targets.ts diff --git a/config/scripts/repo-owner-settings-selector-benchmark.mjs b/config/scripts/repo-owner-settings-selector-benchmark.mjs deleted file mode 100644 index def882252f9..00000000000 --- a/config/scripts/repo-owner-settings-selector-benchmark.mjs +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env node -// Benchmark: cost of the owner-routed settings selector per store write. -// -// getSettingsForRepoRuntimeOwner() is called from useShallow selectors at ~43 -// sites across PullRequestPage and TaskPage. Zustand re-runs every subscribed -// selector on every store write, so before the fix each unrelated write -// allocated one settings-sized object per row and then shallow-compared every -// field to conclude nothing had changed. -// -// The fix caches by repo id and reuses the reference while the settings object, -// repo list, and resolved owner are unchanged, so useShallow's equality check -// short-circuits on Object.is. -// -// The selector body is mirrored here (node cannot import the .ts source, matching -// the sibling benchmarks). The settings field count is read from the real -// GlobalSettings type so a drifted shape fails loudly instead of flattering the -// result with a stale, smaller object. -import { readFileSync } from 'node:fs' -import { performance } from 'node:perf_hooks' -import { fileURLToPath } from 'node:url' - -const TYPES_SOURCE = readFileSync( - fileURLToPath(new URL('../../src/shared/types.ts', import.meta.url)), - 'utf8' -) - -function countGlobalSettingsFields(source) { - const block = source.match(/export type GlobalSettings = \{([\s\S]*?)\n\}/) - if (!block) { - throw new Error('types.ts no longer declares GlobalSettings in the expected shape') - } - const fields = block[1].match(/^\s{2}\w+\??:/gm) ?? [] - if (fields.length < 50) { - throw new Error(`GlobalSettings parsed as only ${fields.length} fields; re-sync this benchmark`) - } - return fields.length -} - -const SETTINGS_FIELDS = countGlobalSettingsFields(TYPES_SOURCE) -const ROWS = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_ROWS ?? '43', 10) -const WRITES = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_WRITES ?? '2000', 10) -const WARMUP = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_WARMUP ?? '200', 10) - -for (const [name, value] of [ - ['ORCA_OWNER_SETTINGS_BENCH_ROWS', ROWS], - ['ORCA_OWNER_SETTINGS_BENCH_WRITES', WRITES], - ['ORCA_OWNER_SETTINGS_BENCH_WARMUP', WARMUP] -]) { - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`${name} must be a positive integer, received ${value}`) - } -} - -function makeSettings() { - const settings = { activeRuntimeEnvironmentId: 'focused-runtime' } - for (let index = 0; index < SETTINGS_FIELDS - 1; index += 1) { - settings[`field${index}`] = index % 3 === 0 ? `value-${index}` : index % 3 === 1 ? index : true - } - return settings -} - -function makeRepos(rows) { - return Array.from({ length: rows }, (_value, index) => ({ - id: `repo-${index}`, - connectionId: null, - executionHostId: `runtime:env-${index % 4}` - })) -} - -function resolveEnvironmentId(state, repoId) { - if (!repoId) { - return null - } - const matching = state.repos.filter((entry) => entry.id === repoId) - const repo = matching.length === 1 ? matching[0] : null - const hasOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) - if (repo && hasOwner) { - const hostId = repo.executionHostId ?? '' - return hostId.startsWith('runtime:') ? hostId.slice('runtime:'.length) : null - } - return state.settings?.activeRuntimeEnvironmentId?.trim() || null -} - -// Pre-fix: a fresh object every call. -function selectorBefore(state, repoId) { - return { ...state.settings, activeRuntimeEnvironmentId: resolveEnvironmentId(state, repoId) } -} - -// Post-fix: reuse the reference while nothing it derives from changed. -function makeCachedSelector() { - const cache = new Map() - return (state, repoId) => { - const environmentId = resolveEnvironmentId(state, repoId) - const cacheKey = repoId ?? '' - const cached = cache.get(cacheKey) - if ( - cached && - cached.settingsSource === state.settings && - cached.reposSource === state.repos && - cached.environmentId === environmentId - ) { - return cached.value - } - const value = { ...state.settings, activeRuntimeEnvironmentId: environmentId } - cache.set(cacheKey, { - settingsSource: state.settings, - reposSource: state.repos, - environmentId, - value - }) - if (cache.size > 256) { - const oldest = cache.keys().next() - if (!oldest.done) { - cache.delete(oldest.value) - } - } - return value - } -} - -// Mirror of zustand's useShallow comparison. -function shallowEqual(a, b) { - if (Object.is(a, b)) { - return true - } - if (a === null || b === null || a === undefined || b === undefined) { - return false - } - const keysA = Object.keys(a) - const keysB = Object.keys(b) - if (keysA.length !== keysB.length) { - return false - } - for (const key of keysA) { - if (!Object.is(a[key], b[key])) { - return false - } - } - return true -} - -// One unrelated store write: every subscribed row re-runs its selector and the -// result is compared against the previous one to decide whether to re-render. -function simulateWrite(selector, state, rows, previous) { - let changed = 0 - for (let row = 0; row < rows; row += 1) { - const next = selector(state, `repo-${row}`) - if (!shallowEqual(previous[row], next)) { - changed += 1 - } - previous[row] = next - } - return changed -} - -function measure(selector, state, rows) { - const previous = Array.from({ length: rows }, () => null) - for (let index = 0; index < WARMUP; index += 1) { - simulateWrite(selector, state, rows, previous) - } - const samples = [] - for (let round = 0; round < 5; round += 1) { - const start = performance.now() - for (let index = 0; index < WRITES; index += 1) { - simulateWrite(selector, state, rows, previous) - } - samples.push((performance.now() - start) / WRITES) - } - samples.sort((a, b) => a - b) - return samples[2] -} - -const settings = makeSettings() -const rowCounts = [1, 10, ROWS, 100] -const rows = [] -for (const rowCount of rowCounts) { - const state = { repos: makeRepos(Math.max(rowCount, ROWS)), settings } - const cached = makeCachedSelector() - // Equivalence: both selectors must produce the same value for every row. - for (let row = 0; row < rowCount; row += 1) { - const before = selectorBefore(state, `repo-${row}`) - const after = cached(state, `repo-${row}`) - if (!shallowEqual(before, after)) { - throw new Error(`selector mismatch at repo-${row}`) - } - } - rows.push({ - rowCount, - beforeMs: measure(selectorBefore, state, rowCount), - afterMs: measure(makeCachedSelector(), state, rowCount) - }) -} - -const pad = (value, width) => String(value).padStart(width) -console.log(`Owner-routed settings selector, per unrelated store write`) -console.log( - `GlobalSettings fields=${SETTINGS_FIELDS} writes=${WRITES} warmup=${WARMUP} (median of 5 rounds)` -) -console.log(`${pad('rows', 6)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('speedup', 9)}`) -for (const row of rows) { - console.log( - `${pad(row.rowCount, 6)} ${pad(row.beforeMs.toFixed(4), 11)} ${pad(row.afterMs.toFixed(4), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(0)}x`, 9)}` - ) -} -console.log( - `\nrows = subscribed selector call sites on screen (~${ROWS} across PullRequestPage/TaskPage).\nThe cost is paid on EVERY store write, including writes that touch nothing\nthese selectors read.` -) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx index 9366a83b4b1..1ebc60eb014 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx @@ -136,7 +136,6 @@ describe('new workspace card file drop ownership', () => { selectedRepo: { id: 'source-repo' }, selectedRepoPath: source, selectedRepoExecutionHostId: 'ssh:ssh-a', - selectedRepoSettings: {}, connectionId: 'ssh-a' }) const dropState = useAttachmentDropState({ @@ -146,7 +145,7 @@ describe('new workspace card file drop ownership', () => { promptTextareaRef: createRef(), connectionId: target.connectionId, selectedRepoPath: target.path ?? undefined, - selectedRepoSettings: target.settings, + selectedRepoRuntimeEnvironmentId: target.runtimeEnvironmentId, selectedWorktreeId: target.worktreeId, setAgentPrompt: () => {}, setAttachmentPaths: attach diff --git a/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx b/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx new file mode 100644 index 00000000000..47befa87f4f --- /dev/null +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { useAutomationsPageSetupState } from './use-automations-page-setup-state' + +type HookInput = Parameters[0] + +function cacheKeyFor(repos: Partial[], focused: string | null, repoId = 'repo-1'): string { + const { result } = renderHook(() => + useAutomationsPageSetupState({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cache key reads only repos and settings. + store: { + repos, + settings: { activeRuntimeEnvironmentId: focused }, + projectHostSetups: [], + worktreesByRepo: {} + } as unknown as HookInput['store'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: inert for the cache key. + local: { + draft: { projectId: repoId }, + automationHostTargetKey: null, + selectedAutomationRuns: { rowKey: null, ownerKey: null, notice: null }, + selectedAutomationRunPageId: null + } as unknown as HookInput['local'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: inert for the cache key. + list: { + selected: null, + selectedRow: null, + selectedAutomationRunsWithWorkspaceNames: [], + capturedAutomationOwners: {}, + hostCatalog: { entries: [] } + } as unknown as HookInput['list'] + }) + ) + return result.current.getAutomationHooksCacheKey(repoId) +} + +describe('automation hooks cache key', () => { + it.each<[string, Partial[], string | null, string]>([ + [ + 'runtime owner', + [{ id: 'repo-1', executionHostId: 'runtime:owner' }], + 'focused', + 'owner:repo-1' + ], + ['local owner', [{ id: 'repo-1', executionHostId: 'local' }], 'focused', 'local:repo-1'], + ['unstamped row follows focus', [{ id: 'repo-1' }], 'focused', 'focused:repo-1'], + ['missing row follows focus', [], 'focused', 'focused:repo-1'], + ['missing row, local focus', [], null, 'local:repo-1'], + [ + 'duplicates fall back to focus', + [ + { id: 'repo-1', executionHostId: 'local' }, + { id: 'repo-1', executionHostId: 'runtime:owner' } + ], + 'other', + 'other:repo-1' + ] + ])('%s', (_name, repos, focused, expected) => { + expect(cacheKeyFor(repos, focused)).toBe(expected) + }) +}) diff --git a/src/renderer/src/components/automations/use-automations-page-setup-state.ts b/src/renderer/src/components/automations/use-automations-page-setup-state.ts index 5ada1d683d2..bf35d6f686d 100644 --- a/src/renderer/src/components/automations/use-automations-page-setup-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import type { AutomationDraft } from './AutomationEditorDialog' import { getAutomationHostTargetFromKey, type AutomationHostTarget } from './automation-host-client' @@ -72,14 +72,7 @@ export function useAutomationsPageSetupState({ (setup) => setup.repoId !== candidate.projectId || setup.hostId === setupHostId ) : projectHostSetups - const settingsForRepo = setupHostId - ? { - ...settings, - activeRuntimeEnvironmentId: - selectedEditAuthority?.kind === 'runtime' ? selectedEditAuthority.environmentId : null - } - : getSettingsForRepoRuntimeOwner({ repos, settings }, candidate.projectId) - const hookKey = `${setupHostId ?? settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}` + const hookKey = `${setupHostId ?? getRuntimeEnvironmentIdForRepo({ repos, settings }, candidate.projectId) ?? 'local'}:${candidate.projectId}` return getVisibleAutomationSetupDecision({ createTarget, workspaceMode: candidate.workspaceMode, @@ -105,8 +98,7 @@ export function useAutomationsPageSetupState({ if (hostId) { return `${hostId}:${repoId}` } - const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId) - return `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${repoId}` + return `${getRuntimeEnvironmentIdForRepo({ repos, settings }, repoId) ?? 'local'}:${repoId}` }, [repos, settings] ) diff --git a/src/renderer/src/hooks/composer-state/async-composer-state.ts b/src/renderer/src/hooks/composer-state/async-composer-state.ts index bf77df8cf4f..280087587c8 100644 --- a/src/renderer/src/hooks/composer-state/async-composer-state.ts +++ b/src/renderer/src/hooks/composer-state/async-composer-state.ts @@ -19,7 +19,6 @@ type ComposerAsyncStateInput = Pick< | 'selectedRepoConnectionId' | 'selectedRepoHookContextKey' | 'selectedRepoIsGit' - | 'selectedRepoSettings' | 'setName' > @@ -55,7 +54,6 @@ export function useComposerAsyncState(input: ComposerAsyncStateInput) { selectedRepoConnectionId, selectedRepoHookContextKey, selectedRepoIsGit, - selectedRepoSettings, setName } = input const { getInitialAutoManagedWorkspaceName, getInitialGitHubPrStartPointSelection } = decisions @@ -209,15 +207,12 @@ export function useComposerAsyncState(input: ComposerAsyncStateInput) { const selectedRepoPathRef = useRef(selectedRepoPath) - const selectedRepoSettingsRef = useRef(selectedRepoSettings) - useEffect(() => { agentPromptRef.current = agentPrompt connectionIdRef.current = connectionId selectedRepoConnectionIdRef.current = selectedRepoConnectionId selectedRepoPathRef.current = selectedRepoPath - selectedRepoSettingsRef.current = selectedRepoSettings - }, [agentPrompt, connectionId, selectedRepoConnectionId, selectedRepoPath, selectedRepoSettings]) + }, [agentPrompt, connectionId, selectedRepoConnectionId, selectedRepoPath]) return { yamlHooks, @@ -279,7 +274,6 @@ export function useComposerAsyncState(input: ComposerAsyncStateInput) { selectedRepoSlug, setSelectedRepoSlug, selectedRepoPath, - selectedRepoPathRef, - selectedRepoSettingsRef + selectedRepoPathRef } } diff --git a/src/renderer/src/hooks/composer-state/async-model.ts b/src/renderer/src/hooks/composer-state/async-model.ts index 2495a4d012b..91b2ad784cc 100644 --- a/src/renderer/src/hooks/composer-state/async-model.ts +++ b/src/renderer/src/hooks/composer-state/async-model.ts @@ -2,7 +2,6 @@ import type * as React from 'react' import type { RefObject } from 'react' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import type { OrcaHooks, SetupAgentStartupPolicy } from '../../../../shared/orca-yaml-hook-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { GitHubRepositoryIdentity } from '../../../../shared/github/pull-request-types' import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format' import type { IssueCommandReadResult } from '@/runtime/runtime-hooks-client' @@ -78,5 +77,4 @@ export type ComposerAsyncModel = { setSelectedRepoSlug: React.Dispatch> selectedRepoPath: string | undefined selectedRepoPathRef: RefObject - selectedRepoSettingsRef: RefObject | null> } diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx b/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx index 7aa4acd1c90..8e7a2fc5381 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx +++ b/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx @@ -52,7 +52,7 @@ function renderDropState(setAttachmentPaths: Dispatch>) promptCaretFrameRef: { current: null }, promptTextareaRef: createRef(), selectedRepoPath: '/repo', - selectedRepoSettings: null, + selectedRepoRuntimeEnvironmentId: null, selectedWorktreeId: null, setAgentPrompt: () => {}, setAttachmentPaths @@ -106,7 +106,7 @@ describe('local composer drop failures', () => { }) // Why: the upload branch returns early unless a runtime environment or connection is resolved. -const RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: 'env-1' } +const RUNTIME_ENVIRONMENT_ID = 'env-1' describe('composer upload failures', () => { it('aggregates a mixed runtime import into one toast, and withholds a reason that is not shared', async () => { @@ -128,7 +128,7 @@ describe('composer upload failures', () => { await act(async () => { await result.current.uploadComposerPaths( ['/a.png', '/b.png', '/c.png'], - RUNTIME_SETTINGS, + RUNTIME_ENVIRONMENT_ID, null, '/repo' ) @@ -155,7 +155,7 @@ describe('composer upload failures', () => { const { result } = renderDropState(() => {}) await act(async () => { - await result.current.uploadComposerPaths(['/a.png'], RUNTIME_SETTINGS, null, '/repo') + await result.current.uploadComposerPaths(['/a.png'], RUNTIME_ENVIRONMENT_ID, null, '/repo') }) expect(mocks.toastError).not.toHaveBeenCalled() @@ -170,7 +170,7 @@ describe('composer upload failures', () => { await act(async () => { await result.current.uploadComposerPaths( ['/b.png'], - RUNTIME_SETTINGS, + RUNTIME_ENVIRONMENT_ID, null, '/repo', null, @@ -193,7 +193,7 @@ describe('composer upload failures', () => { await act(async () => { await result.current.uploadComposerPaths( ['/b.png', '/c.png'], - RUNTIME_SETTINGS, + RUNTIME_ENVIRONMENT_ID, null, '/repo' ) diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx b/src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx index 6b2c285c320..ff88e335463 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx +++ b/src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx @@ -25,7 +25,7 @@ function renderDrop(strict = false) { promptCaretFrameRef: { current: null }, promptTextareaRef: createRef(), selectedRepoPath: '/folder-workspace', - selectedRepoSettings: null, + selectedRepoRuntimeEnvironmentId: null, selectedWorktreeId: null, setAgentPrompt: prompt, setAttachmentPaths: attach diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts index 599f6f579ea..435c5c690cb 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts +++ b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts @@ -8,7 +8,7 @@ type AttachmentDropStateInput = Pick< | 'promptCaretFrameRef' | 'promptTextareaRef' | 'selectedRepoPath' - | 'selectedRepoSettings' + | 'selectedRepoRuntimeEnvironmentId' | 'setAgentPrompt' | 'setAttachmentPaths' > & { @@ -54,7 +54,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { promptCaretFrameRef, promptTextareaRef, selectedRepoPath, - selectedRepoSettings, + selectedRepoRuntimeEnvironmentId, selectedWorktreeId, setAgentPrompt, setAttachmentPaths @@ -126,14 +126,14 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { const uploadComposerPaths = useCallback( async ( sourcePaths: string[], - targetSettings = selectedRepoSettings, + targetEnvironmentId = selectedRepoRuntimeEnvironmentId, targetConnectionId: string | null | undefined = connectionId, targetRepoPath: string | null | undefined = selectedRepoPath, targetWorktreeId: string | null | undefined = selectedWorktreeId, canReportFailure: () => boolean = () => true ): Promise<{ filePaths: string[]; folderPaths: string[] } | null> => { - // Repo-owner settings: the selected repo's host, not the focused server. - const targetRuntimeEnvironmentId = targetSettings?.activeRuntimeEnvironmentId?.trim() || null + // The selected repo's host, not the focused server. + const targetRuntimeEnvironmentId = targetEnvironmentId?.trim() || null if (!targetRuntimeEnvironmentId && !targetConnectionId) { return null } @@ -198,7 +198,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { } return { filePaths: uploadResult.filePaths, folderPaths: uploadResult.folderPaths } }, - [connectionId, selectedRepoPath, selectedRepoSettings, selectedWorktreeId] + [connectionId, selectedRepoPath, selectedRepoRuntimeEnvironmentId, selectedWorktreeId] ) const handleAddAttachment = useCallback(async (): Promise => { @@ -264,7 +264,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { uploadPaths: (sourcePaths) => uploadComposerPaths( sourcePaths, - selectedRepoSettings, + selectedRepoRuntimeEnvironmentId, connectionId, selectedRepoPath, selectedWorktreeId, @@ -283,7 +283,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { connectionId, insertComposerFolderPaths, selectedRepoPath, - selectedRepoSettings, + selectedRepoRuntimeEnvironmentId, selectedWorktreeId, uploadComposerPaths ] diff --git a/src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts b/src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts index 123b8d72956..74f18623dfc 100644 --- a/src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts @@ -8,7 +8,6 @@ export function useComposerAttachmentDropState(target: ComposerTargetState) { selectedRepoPath: target.asyncComposerState.selectedRepoPath, selectedRepo: target.runtimeTargetSelection.selectedRepo, selectedRepoExecutionHostId: target.runtimeTargetSelection.selectedRepoExecutionHostId, - selectedRepoSettings: target.runtimeTargetSelection.selectedRepoSettings, connectionId: target.workspaceIdentityState.connectionId }) return useAttachmentDropState({ @@ -18,7 +17,7 @@ export function useComposerAttachmentDropState(target: ComposerTargetState) { promptCaretFrameRef: target.asyncComposerState.promptCaretFrameRef, promptTextareaRef: target.asyncComposerState.promptTextareaRef, selectedRepoPath: destination.path ?? undefined, - selectedRepoSettings: destination.settings, + selectedRepoRuntimeEnvironmentId: destination.runtimeEnvironmentId, selectedWorktreeId: destination.worktreeId, setAgentPrompt: target.sourceContextState.setAgentPrompt, setAttachmentPaths: target.sourceContextState.setAttachmentPaths diff --git a/src/renderer/src/hooks/composer-state/composer-attachment-target.test.ts b/src/renderer/src/hooks/composer-state/composer-attachment-target.test.ts index 53dc63b646f..9e5d321179b 100644 --- a/src/renderer/src/hooks/composer-state/composer-attachment-target.test.ts +++ b/src/renderer/src/hooks/composer-state/composer-attachment-target.test.ts @@ -19,25 +19,24 @@ const selected = { selectedRepo: { id: 'repo-1' }, selectedRepoPath: '/repo', selectedRepoExecutionHostId: 'local' as const, - selectedRepoSettings: { activeRuntimeEnvironmentId: 'focused-runtime' }, connectionId: null } describe('composer attachment destination', () => { - it('uses the selected local host even when a runtime is focused', () => { + it('uses the selected local host', () => { expect(resolveComposerAttachmentTarget(selected)).toEqual({ hostId: 'local', path: '/repo', worktreeId: 'repo-1::/repo', connectionId: null, - settings: { activeRuntimeEnvironmentId: null } + runtimeEnvironmentId: null }) }) - it('uses the selected runtime without borrowing the focused runtime', () => { + it('uses the selected runtime', () => { expect( resolveComposerAttachmentTarget({ ...selected, selectedRepoExecutionHostId: 'runtime:selected' - }).settings.activeRuntimeEnvironmentId + }).runtimeEnvironmentId ).toBe('selected') }) it('uses a folder project path and recorded SSH host', () => { @@ -52,7 +51,7 @@ describe('composer attachment destination', () => { path: '/folder', worktreeId: null, connectionId: 'ssh-a', - settings: { activeRuntimeEnvironmentId: null } + runtimeEnvironmentId: null }) }) it('uses a folder project runtime owner', () => { @@ -67,7 +66,7 @@ describe('composer attachment destination', () => { path: '/folder', worktreeId: null, connectionId: null, - settings: { activeRuntimeEnvironmentId: 'folder-host' } + runtimeEnvironmentId: 'folder-host' }) }) it.each(['/folder/source-repo', '/outside/source-repo'])( diff --git a/src/renderer/src/hooks/composer-state/composer-attachment-target.ts b/src/renderer/src/hooks/composer-state/composer-attachment-target.ts index ec96fa312d8..60001929e35 100644 --- a/src/renderer/src/hooks/composer-state/composer-attachment-target.ts +++ b/src/renderer/src/hooks/composer-state/composer-attachment-target.ts @@ -5,11 +5,7 @@ import type { ComposerModel } from './composer-model' type TargetInput = Pick< ComposerModel, - | 'selectedProjectGroup' - | 'selectedRepoPath' - | 'selectedRepoExecutionHostId' - | 'selectedRepoSettings' - | 'connectionId' + 'selectedProjectGroup' | 'selectedRepoPath' | 'selectedRepoExecutionHostId' | 'connectionId' > & { selectedRepo: Pick | undefined } export function resolveComposerAttachmentTarget(input: TargetInput) { @@ -30,9 +26,6 @@ export function resolveComposerAttachmentTarget(input: TargetInput) { ? null : (group.connectionId ?? null) : input.connectionId, - settings: { - ...input.selectedRepoSettings, - activeRuntimeEnvironmentId: host?.kind === 'runtime' ? host.environmentId : null - } + runtimeEnvironmentId: host?.kind === 'runtime' ? host.environmentId : null } } diff --git a/src/renderer/src/hooks/composer-state/composer-remote-server-drop.test.tsx b/src/renderer/src/hooks/composer-state/composer-remote-server-drop.test.tsx index 0458dc74b26..a32a8c42e5f 100644 --- a/src/renderer/src/hooks/composer-state/composer-remote-server-drop.test.tsx +++ b/src/renderer/src/hooks/composer-state/composer-remote-server-drop.test.tsx @@ -118,7 +118,6 @@ function renderComposerDrop(attached: string[]) { selectedRepo: REPO, selectedRepoPath: REPO.path, selectedRepoExecutionHostId: `runtime:${ENVIRONMENT_ID}`, - selectedRepoSettings: { activeRuntimeEnvironmentId: null }, connectionId: null }) return useAttachmentDropState({ @@ -128,7 +127,7 @@ function renderComposerDrop(attached: string[]) { promptCaretFrameRef: { current: null }, promptTextareaRef: createRef(), selectedRepoPath: target.path ?? undefined, - selectedRepoSettings: target.settings, + selectedRepoRuntimeEnvironmentId: target.runtimeEnvironmentId, selectedWorktreeId: target.worktreeId, setAgentPrompt: () => {}, setAttachmentPaths: (next) => { diff --git a/src/renderer/src/hooks/composer-state/composer-source-model.ts b/src/renderer/src/hooks/composer-state/composer-source-model.ts index a73377928c8..c06a753fa29 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-model.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-model.ts @@ -5,7 +5,6 @@ import type { JiraIssue } from '../../../../shared/jira-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { LinearIssue } from '../../../../shared/linear/issue-types' import type { SparsePreset } from '../../../../shared/worktree/create-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/smart-workspace-name-field-model' export type ComposerSourceModel = { @@ -68,7 +67,7 @@ export type ComposerSourceModel = { smartNameSelection: SmartWorkspaceNameSelection | null uploadComposerPaths: ( sourcePaths: string[], - targetSettings?: Pick | null, + targetEnvironmentId?: string | null, targetConnectionId?: string | null, targetRepoPath?: string | null, targetWorktreeId?: string | null, diff --git a/src/renderer/src/hooks/composer-state/composer-target-state.ts b/src/renderer/src/hooks/composer-state/composer-target-state.ts index 8c8fdf3fcdf..1f82e27ddd0 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-state.ts @@ -83,7 +83,7 @@ export function useComposerTargetState( newWorkspaceDraft: composerTargetStore.newWorkspaceDraft, persistDraft: composerTargetStore.persistDraft, selectedRepoConnectionId: runtimeTargetSelection.selectedRepoConnectionId, - selectedRepoSettings: runtimeTargetSelection.selectedRepoSettings, + selectedRepoRuntimeEnvironmentId: runtimeTargetSelection.selectedRepoRuntimeEnvironmentId, settings: composerTargetStore.settings }) const asyncComposerState = useComposerAsyncState({ @@ -104,7 +104,6 @@ export function useComposerTargetState( selectedRepoConnectionId: runtimeTargetSelection.selectedRepoConnectionId, selectedRepoHookContextKey: runtimeTargetSelection.selectedRepoHookContextKey, selectedRepoIsGit: runtimeTargetSelection.selectedRepoIsGit, - selectedRepoSettings: runtimeTargetSelection.selectedRepoSettings, setName: sourceContextState.setName }) const providerRuntimeSync = useComposerProviderRuntimeSync({ diff --git a/src/renderer/src/hooks/composer-state/runtime-target-model.ts b/src/renderer/src/hooks/composer-state/runtime-target-model.ts index df4da929d57..f767256fa43 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-model.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-model.ts @@ -5,7 +5,6 @@ import type { Repo } from '../../../../shared/repo-types' import type { SshConnectionState, SshConnectionStatus } from '../../../../shared/ssh-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { OrcaVmRecipe } from '../../../../shared/orca-yaml-hook-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' import type { ExecutionHostPlatformFact } from '@/lib/execution-host-facts' import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' @@ -45,7 +44,8 @@ export type ComposerRuntimeTargetModel = { selectedProjectHostSetupId: string | null projectHostSetupOptions: ProjectHostSetupOption[] projectOptions: NewWorkspaceProjectOption[] - selectedRepoSettings: Pick | null + /** Runtime that owns the selected repo; an unstamped repo follows focus. */ + selectedRepoRuntimeEnvironmentId: string | null /** Transport to the selected repo's owner; `null` with no repo selected. */ selectedRepoTarget: RuntimeClientTarget | null selectedRecipeRepoId: string | null diff --git a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts index 5aa0a191c10..3a2cb98e48b 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts @@ -22,7 +22,7 @@ import { useAppStore } from '@/store' import { repoIsRemote } from '../../../../shared/agent-launch-remote' import { buildProjectHostSetupOptions } from '@/lib/project-host-setup-options' import { buildNewWorkspaceCreateTargetOptions } from '@/lib/new-workspace-project-options' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' import { useEphemeralVmRecipeOptions } from '@/hooks/useEphemeralVmRecipeOptions' export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSelectionInput) { @@ -212,16 +212,13 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe [eligibleRepos, hostOptions, projectGroups, projectHostSetups, projects] ) - const selectedRepoSettings = useMemo(() => { - if (!settings) { - return settings - } - // Why: probes and attachment uploads inspect the selected repo, even though creation defaults still follow host scope. - return getSettingsForRepoRuntimeOwner( - { repos: selectedRepo ? [selectedRepo] : [], settings }, - selectedRepo?.id ?? null - ) - }, [selectedRepo, settings]) + // Why: probes and attachment uploads inspect the selected repo, even though creation defaults still follow host scope. + const selectedRepoRuntimeEnvironmentId = settings + ? getRuntimeEnvironmentIdForRepo( + { repos: selectedRepo ? [selectedRepo] : [], settings }, + selectedRepo?.id ?? null + ) + : null const selectedRepoTarget = useMemo( () => (selectedRepo ? runtimeTargetForOwnerHostId(getRepoExecutionHostId(selectedRepo)) : null), @@ -304,7 +301,7 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe selectedProjectHostSetupId, projectHostSetupOptions, projectOptions, - selectedRepoSettings, + selectedRepoRuntimeEnvironmentId, selectedRepoTarget, selectedRecipeRepoId, selectedRecipeRepoConnectionId, diff --git a/src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts b/src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts index 490fe7c25a8..79926ba46d6 100644 --- a/src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts +++ b/src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts @@ -109,7 +109,7 @@ describe("agents for a project on a paired server's SSH host", () => { persistDraft: false, newWorkspaceDraft: null, selectedRepoConnectionId: 'ssh-dike', - selectedRepoSettings: { activeRuntimeEnvironmentId: 'env-1' }, + selectedRepoRuntimeEnvironmentId: 'env-1', settings: null } as unknown as Parameters[0]) ) diff --git a/src/renderer/src/hooks/composer-state/workspace-identity-state.ts b/src/renderer/src/hooks/composer-state/workspace-identity-state.ts index c11400ee361..94a63881ce8 100644 --- a/src/renderer/src/hooks/composer-state/workspace-identity-state.ts +++ b/src/renderer/src/hooks/composer-state/workspace-identity-state.ts @@ -11,7 +11,7 @@ type WorkspaceIdentityStateInput = Pick< | 'newWorkspaceDraft' | 'persistDraft' | 'selectedRepoConnectionId' - | 'selectedRepoSettings' + | 'selectedRepoRuntimeEnvironmentId' | 'settings' > @@ -51,7 +51,7 @@ export function useWorkspaceIdentityState(input: WorkspaceIdentityStateInput) { newWorkspaceDraft, persistDraft, selectedRepoConnectionId, - selectedRepoSettings, + selectedRepoRuntimeEnvironmentId, settings } = input @@ -176,7 +176,7 @@ export function useWorkspaceIdentityState(input: WorkspaceIdentityStateInput) { ) // Why: for a repo on an SSH host or runtime env, read the per-host agent list so the dialog shows the host's installed agents, not local. - const runtimeEnvironmentId = selectedRepoSettings?.activeRuntimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = selectedRepoRuntimeEnvironmentId?.trim() || null // Kept as-is: uploads fence on the worktree's real host, which for a server repo is its target. const connectionId = selectedRepoConnectionId diff --git a/src/renderer/src/lib/repo-runtime-owner.test.ts b/src/renderer/src/lib/repo-runtime-owner.test.ts index d01025c8842..db85a7de548 100644 --- a/src/renderer/src/lib/repo-runtime-owner.test.ts +++ b/src/renderer/src/lib/repo-runtime-owner.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from 'vitest' -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { RepoRuntimeOwnerState } from './repo-runtime-owner' import { getExplicitRuntimeOwnerEnvironmentId, getRuntimeEnvironmentIdForRepo, - getSettingsForRepoRuntimeOwner, runtimeTargetForRepoOwner } from './repo-runtime-owner' @@ -86,17 +84,33 @@ describe('getRuntimeEnvironmentIdForRepo', () => { ) ).toBe('other-runtime') }) +}) - it('returns settings scoped to an explicit local repo owner', () => { - expect( - getSettingsForRepoRuntimeOwner( - { - settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, - repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] - }, - 'repo-1' - ) - ).toEqual({ activeRuntimeEnvironmentId: null }) +type OwnerRow = NonNullable[number] +const row = ( + executionHostId: OwnerRow['executionHostId'], + connectionId: string | null = null +): OwnerRow => ({ + id: 'repo-1', + connectionId, + executionHostId +}) + +// Composer passes a singleton or empty list; automations pass the whole catalog. +describe('getRuntimeEnvironmentIdForRepo owner matrix', () => { + it.each<[string, OwnerRow[], string | null, string | null]>([ + ['local row', [row('local')], 'focused', null], + ['direct SSH row', [row(null, 'ssh-1')], 'focused', null], + ['nested runtime row', [row('runtime:owner', 'ssh-1')], 'focused', 'owner'], + ['unstamped row', [row(null)], 'focused', 'focused'], + ['missing row', [], 'focused', 'focused'], + ['duplicates, local focus', [row('local'), row('runtime:owner')], null, null], + ['duplicates, matching focus', [row('local'), row('runtime:owner')], 'owner', 'owner'], + ['duplicates, other focus', [row('local'), row('runtime:owner')], 'other', 'other'], + ['duplicates, deleted focus', [row('runtime:a'), row('runtime:b')], 'deleted', 'deleted'] + ])('%s', (_name, repos, focused, expected) => { + const settings = { activeRuntimeEnvironmentId: focused } + expect(getRuntimeEnvironmentIdForRepo({ repos, settings }, 'repo-1')).toBe(expected) }) }) @@ -194,95 +208,6 @@ describe('getExplicitRuntimeOwnerEnvironmentId', () => { }) }) -describe('getSettingsForRepoRuntimeOwner identity', () => { - type OwnerRepo = NonNullable[number] - const repos: OwnerRepo[] = [ - { id: 'repo-1', connectionId: null, executionHostId: 'runtime:env-a' } - ] - const settings = { - activeRuntimeEnvironmentId: 'focused', - sourceControlViewMode: 'list' - } as unknown as GlobalSettings - - // Why identity matters: these run inside useShallow selectors, so a fresh - // object per store write costs a full settings-wide compare on every row. - it('returns the same reference while settings and repos are unchanged', () => { - const state = { repos, settings } - expect(getSettingsForRepoRuntimeOwner(state, 'repo-1')).toBe( - getSettingsForRepoRuntimeOwner(state, 'repo-1') - ) - }) - - it('keeps separate identities per repo id', () => { - const state = { repos, settings } - const first = getSettingsForRepoRuntimeOwner(state, 'repo-1') - const second = getSettingsForRepoRuntimeOwner(state, 'repo-2') - expect(first).not.toBe(second) - expect(first.activeRuntimeEnvironmentId).toBe('env-a') - expect(second.activeRuntimeEnvironmentId).toBe('focused') - // Interleaving must not evict either entry. - expect(getSettingsForRepoRuntimeOwner(state, 'repo-1')).toBe(first) - expect(getSettingsForRepoRuntimeOwner(state, 'repo-2')).toBe(second) - }) - - it('returns a new value when the settings object changes', () => { - const first = getSettingsForRepoRuntimeOwner({ repos, settings }, 'repo-1') - const nextSettings = { ...settings, sourceControlViewMode: 'tree' } as unknown as GlobalSettings - const second = getSettingsForRepoRuntimeOwner({ repos, settings: nextSettings }, 'repo-1') - expect(second).not.toBe(first) - expect((second as { sourceControlViewMode?: string }).sourceControlViewMode).toBe('tree') - }) - - it('returns a new value when the repo owner changes', () => { - const first = getSettingsForRepoRuntimeOwner({ repos, settings }, 'repo-1') - const movedRepos: OwnerRepo[] = [ - { id: 'repo-1', connectionId: null, executionHostId: 'runtime:env-b' } - ] - const second = getSettingsForRepoRuntimeOwner({ repos: movedRepos, settings }, 'repo-1') - expect(second).not.toBe(first) - expect(second.activeRuntimeEnvironmentId).toBe('env-b') - }) - - // Why isolate each source: a fixture that changes the repo list AND the - // resolved owner together still passes when only one of the two checks - // survives, so each gets a case that holds the other constant. - it('returns a new value when the repo list changes but the owner does not', () => { - const first = getSettingsForRepoRuntimeOwner({ repos, settings }, 'repo-1') - // Same owner for repo-1; only the array identity and an unrelated row differ. - const grownRepos: OwnerRepo[] = [ - ...repos, - { id: 'repo-9', connectionId: null, executionHostId: 'local' } - ] - const second = getSettingsForRepoRuntimeOwner({ repos: grownRepos, settings }, 'repo-1') - expect(second.activeRuntimeEnvironmentId).toBe('env-a') - expect(second).not.toBe(first) - }) - - it('returns a new value when the owner changes under a stable repo list identity', () => { - // Mutate in place so the array reference and settings both stay identical - // and only the resolved environment id differs. - const mutableRepos: OwnerRepo[] = [ - { id: 'repo-1', connectionId: null, executionHostId: 'runtime:env-a' } - ] - const state = { repos: mutableRepos, settings } - const first = getSettingsForRepoRuntimeOwner(state, 'repo-1') - expect(first.activeRuntimeEnvironmentId).toBe('env-a') - mutableRepos[0]!.executionHostId = 'runtime:env-c' - const second = getSettingsForRepoRuntimeOwner(state, 'repo-1') - expect(second.activeRuntimeEnvironmentId).toBe('env-c') - expect(second).not.toBe(first) - }) - - it('bounds the cache so unbounded repo ids cannot leak', () => { - const state = { repos, settings } - const first = getSettingsForRepoRuntimeOwner(state, 'repo-evictable') - for (let index = 0; index < 300; index += 1) { - getSettingsForRepoRuntimeOwner(state, `repo-filler-${index}`) - } - expect(getSettingsForRepoRuntimeOwner(state, 'repo-evictable')).not.toBe(first) - }) -}) - describe('runtimeTargetForRepoOwner', () => { const focused = { activeRuntimeEnvironmentId: 'focused-runtime' } diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts index b2cb3a8ee6a..e8b35695ea3 100644 --- a/src/renderer/src/lib/repo-runtime-owner.ts +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -95,54 +95,3 @@ export function getHostlessSourceRepoOwnerEnvironmentId( const parsed = parseExecutionHostId(getRepoExecutionHostId(matches[0])) return parsed?.kind === 'runtime' ? parsed.environmentId : null } - -type OwnerRoutedSettingsCacheEntry = { - settingsSource: RepoRuntimeOwnerState['settings'] - reposSource: RepoRuntimeOwnerState['repos'] - environmentId: string | null - value: Pick -} - -// Why keyed by repo id: every PR/task row calls this with its own repo while -// sharing one settings object, so a single-entry cache would thrash. -const ownerRoutedSettingsCache = new Map() -// Why bounded: repo ids come from remote listings, so the key space is not closed. -const MAX_OWNER_ROUTED_SETTINGS_CACHE_KEYS = 256 - -export function getSettingsForRepoRuntimeOwner( - state: RepoRuntimeOwnerState, - repoId: string | null | undefined -): Pick { - const environmentId = getRuntimeEnvironmentIdForRepo(state, repoId) - // Why a stable identity: this runs inside useShallow selectors on every store - // write, and spreading the whole settings object per row made unrelated writes - // allocate and re-compare every field. Reusing the reference when nothing it - // derives from changed lets useShallow settle on the Object.is fast path. - const cacheKey = repoId ?? '' - const cached = ownerRoutedSettingsCache.get(cacheKey) - if ( - cached && - cached.settingsSource === state.settings && - cached.reposSource === state.repos && - cached.environmentId === environmentId - ) { - return cached.value - } - const value = { - ...state.settings, - activeRuntimeEnvironmentId: environmentId - } - ownerRoutedSettingsCache.set(cacheKey, { - settingsSource: state.settings, - reposSource: state.repos, - environmentId, - value - }) - if (ownerRoutedSettingsCache.size > MAX_OWNER_ROUTED_SETTINGS_CACHE_KEYS) { - const oldest = ownerRoutedSettingsCache.keys().next() - if (!oldest.done) { - ownerRoutedSettingsCache.delete(oldest.value) - } - } - return value -}