diff --git a/src/renderer/src/components/editor/check-run-details-fix-context.ts b/src/renderer/src/components/editor/check-run-details-fix-context.ts index 7f0881109..f0e99b12e 100644 --- a/src/renderer/src/components/editor/check-run-details-fix-context.ts +++ b/src/renderer/src/components/editor/check-run-details-fix-context.ts @@ -58,7 +58,8 @@ export function resolveHostedReviewForCheckRunDetailsFix( branch, settings, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) const hostedReviewCacheKey = getHostedReviewCacheKey( repo.path, @@ -66,7 +67,8 @@ export function resolveHostedReviewForCheckRunDetailsFix( settings, repo.id, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) const pr = prCacheKey ? (store.prCache[prCacheKey]?.data ?? null) : null const hostedReview = hostedReviewCacheKey diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 50f180a5d..7ea4eef93 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -607,7 +607,8 @@ export default function ChecksPanel(): React.JSX.Element { branch, settings, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) : '' const hostedReviewCacheKey = @@ -618,7 +619,8 @@ export default function ChecksPanel(): React.JSX.Element { settings, repo.id, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) : '' const refreshContextKey = `${activeWorktreeId ?? ''}::${prCacheKey}::${branch}` @@ -726,7 +728,8 @@ export default function ChecksPanel(): React.JSX.Element { prChecksCacheSuffix(prNumber, pr?.prRepo), settings, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) : '' const commentsCacheKey = @@ -737,7 +740,8 @@ export default function ChecksPanel(): React.JSX.Element { prCommentsCacheSuffix(prNumber, pr?.prRepo), settings, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) : '' const checksFetchedAt = useAppStore((s) => diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 74fa804f4..60453f6c1 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -1324,7 +1324,8 @@ function SourceControlInner(): React.JSX.Element { settings, activeRepo.id, activeRepo.connectionId, - activeRepo.executionHostId + activeRepo.executionHostId, + true ) : null const hostedReviewEntry = hostedReviewCacheKey @@ -1338,7 +1339,8 @@ function SourceControlInner(): React.JSX.Element { branchName, settings, activeRepo.connectionId, - activeRepo.executionHostId + activeRepo.executionHostId, + true ) : null const activePrFromQueue = activePrCacheKey ? (prCache[activePrCacheKey]?.data ?? null) : null diff --git a/src/renderer/src/components/right-sidebar/active-checks-status.ts b/src/renderer/src/components/right-sidebar/active-checks-status.ts index 604e0cd4f..a7af63812 100644 --- a/src/renderer/src/components/right-sidebar/active-checks-status.ts +++ b/src/renderer/src/components/right-sidebar/active-checks-status.ts @@ -40,7 +40,8 @@ export function getActiveChecksStatus(state: ActiveChecksStatusState): CheckStat branch, state.settings, activeRepo.connectionId, - activeRepo.executionHostId + activeRepo.executionHostId, + true ) const hostedReviewCacheKey = getHostedReviewCacheKey( activeRepo.path, @@ -48,7 +49,8 @@ export function getActiveChecksStatus(state: ActiveChecksStatusState): CheckStat state.settings, activeRepo.id, activeRepo.connectionId, - activeRepo.executionHostId + activeRepo.executionHostId, + true ) const hostedReview = state.hostedReviewCache?.[hostedReviewCacheKey]?.data ?? null if (hostedReview && hostedReview.provider !== 'github') { diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts index fa2682edf..dca4da1e6 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts @@ -223,7 +223,8 @@ function getGitHubChecksEntry( prChecksCacheSuffix(review.number, prRepo, review.headSha), args.settings, args.repo.connectionId, - args.repo.executionHostId + args.repo.executionHostId, + true ) const withoutHead = getGitHubRepoCacheKey( args.repo.path, @@ -231,7 +232,8 @@ function getGitHubChecksEntry( prChecksCacheSuffix(review.number, prRepo), args.settings, args.repo.connectionId, - args.repo.executionHostId + args.repo.executionHostId, + true ) return args.checksCache[withHead] ?? args.checksCache[withoutHead] } @@ -246,7 +248,8 @@ function getHostedReviewKey( args.settings, args.repo.id, args.repo.connectionId, - args.repo.executionHostId + args.repo.executionHostId, + true ) } @@ -257,7 +260,8 @@ function getPRKey(args: BuildParentPrChecksRowsArgs & { repo: Repo }, branch: st branch, args.settings, args.repo.connectionId, - args.repo.executionHostId + args.repo.executionHostId, + true ) } diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index d46fc5172..9aa2f31b5 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -21,6 +21,7 @@ const cacheTimerMocks = vi.hoisted(() => ({ let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'ports'] let hostedReviewCache: Record = {} +let issueCache: Record = {} let projectGroups: unknown[] = [] let workspacePortScan: { key: string; result: WorkspacePortScanResult } | null = null let settings: Partial | null = { compactWorktreeCards: true } @@ -39,7 +40,7 @@ vi.mock('@/store', () => ({ fetchLinearIssue, gitConflictOperationByWorktree: {}, hostedReviewCache, - issueCache: {}, + issueCache, linearIssueCache: {}, openModal, openTaskPage, @@ -185,6 +186,7 @@ describe('WorktreeCard compact hover details', () => { vi.clearAllMocks() worktreeCardProperties = ['status', 'ports'] hostedReviewCache = {} + issueCache = {} projectGroups = [] workspacePortScan = null settings = { compactWorktreeCards: true } @@ -296,6 +298,38 @@ describe('WorktreeCard compact hover details', () => { expect(markup).not.toContain('data-worktree-card-meta-row=""') }, 30_000) + it('reads linked issue details from the local repo-owner cache while a runtime is focused', async () => { + settings = { + activeRuntimeEnvironmentId: 'env-1', + compactWorktreeCards: true, + experimentalNewWorktreeCardStyle: true + } + worktreeCardProperties = ['status'] + issueCache = { + 'repo-1::123': { + data: { number: 123, title: 'Local owner issue', state: 'open', url: null }, + fetchedAt: Date.now() + }, + 'runtime:env-1::repo-1::123': { + data: { number: 123, title: 'Runtime fallback issue', state: 'open', url: null }, + fetchedAt: Date.now() + } + } + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Local owner issue') + expect(markup).not.toContain('Runtime fallback issue') + expect(markup).not.toContain('Loading issue') + }, 30_000) + it('shows selected task and note metadata on the compact card title row', async () => { settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment'] diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index 92282083b..965fa499c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -20,6 +20,7 @@ const updateWorktreeMeta = vi.fn() let worktreeCardProperties: WorktreeCardProperty[] = ['status'] let hostedReviewCache: Record = {} +let issueCache: Record = {} let prCache: Record = {} let workspacePortScan: WorkspacePortScanResult | null = null let settings: Partial | null = null @@ -33,7 +34,7 @@ vi.mock('@/store', () => ({ fetchLinearIssue, gitConflictOperationByWorktree: {}, hostedReviewCache, - issueCache: {}, + issueCache, linearIssueCache: {}, openModal, prCache, @@ -161,6 +162,7 @@ describe('WorktreeCard linked PR display', () => { vi.clearAllMocks() worktreeCardProperties = ['status'] hostedReviewCache = {} + issueCache = {} prCache = {} workspacePortScan = null settings = null @@ -669,6 +671,42 @@ describe('WorktreeCard linked PR display', () => { expect(markup).not.toContain('lucide-git-branch') }) + it('reads the local branch PR cache for a known local repo while a runtime is focused', async () => { + settings = { + activeRuntimeEnvironmentId: 'env-win', + experimentalNewWorktreeCardStyle: true + } + worktreeCardProperties = ['status'] + prCache = { + 'repo-1::feature/local-branch': { + data: makePRInfo({ + number: 6341, + title: 'Keep local PR status visible', + state: 'open', + checksStatus: 'pending' + }), + fetchedAt: Date.now() + }, + 'runtime:env-win::repo-1::feature/local-branch': { + data: null, + fetchedAt: Date.now() + } + } + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderWorktreeCardMarkup( + + ) + + expect(markup).toContain('PR checks: Pending') + expect(markup).not.toContain('Branch') + expect(markup).not.toContain('lucide-git-branch') + }) + it('keeps the detailed right-side PR badge during a transient hosted-review miss', async () => { settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false } worktreeCardProperties = ['pr'] diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index f88f48c1e..e306d25ca 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -412,7 +412,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ settings, repo.id, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) : '' const prCacheKey = @@ -423,7 +424,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ branch, settings, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) : '' const issueCacheKey = @@ -434,7 +436,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ worktree.linkedIssue, settings, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) : '' // Why: use 'all' to fetch from all Linear workspaces. The issue might belong diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts index ae9e67b8b..fb43b506d 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts @@ -172,7 +172,8 @@ function getAttachedWorktreePrDisplay({ settings, repo.id, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) const hostedReviewEntry = hostedReviewCache?.[hostedReviewCacheKey] as | HostedReviewCacheEntry @@ -241,10 +242,10 @@ function getCachedGitHubPr({ branch, settings, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) - const canUseLegacyPRCache = - !settings?.activeRuntimeEnvironmentId?.trim() && !repo.connectionId && !repo.executionHostId + const canUseLegacyPRCache = !repo.connectionId && !repo.executionHostId const legacyRepoScopedCacheKey = canUseLegacyPRCache ? getLegacyGitHubPRCacheKey(repo.path, repo.id, branch) : '' diff --git a/src/renderer/src/components/sidebar/use-worktree-issue-link.ts b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts index 92c18aca8..eb8867e69 100644 --- a/src/renderer/src/components/sidebar/use-worktree-issue-link.ts +++ b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts @@ -46,7 +46,8 @@ export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: str issueNumber, s.settings, issueRepo.connectionId, - issueRepo.executionHostId + issueRepo.executionHostId, + true ) ]?.data?.url ?? null ) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index f0ef7e339..269df529b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -165,7 +165,7 @@ describe('getPRGroupKey', () => { expect(getPRGroupKey(worktree, repoMap, prCache)).toBe('closed') }) - it('does not fall back to local PR cache while runtime scoped data is loading', () => { + it('uses local PR cache for a known local repo while a runtime is focused', () => { const prCache = { 'repo-1::feature/super-critical': { data: { state: 'merged' } @@ -176,7 +176,7 @@ describe('getPRGroupKey', () => { getPRGroupKey(worktree, repoMap, prCache, { activeRuntimeEnvironmentId: 'env-1' } as never) - ).toBe('in-progress') + ).toBe('done') }) it('uses SSH-scoped PR cache entries instead of local entries for SSH repos', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 1cc0111f9..06168aabf 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -361,11 +361,11 @@ export function getPRGroupKey( branch, settings, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) : '' - const canUseLegacyPRCache = - repo !== undefined && !settings?.activeRuntimeEnvironmentId?.trim() && !repo.connectionId + const canUseLegacyPRCache = repo !== undefined && !repo.connectionId && !repo.executionHostId const legacyRepoScopedCacheKey = canUseLegacyPRCache && branch ? getLegacyGitHubPRCacheKey(repo.path, repo.id, branch) : '' const legacyPathScopedCacheKey = diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index 2090a9e70..441d8a97a 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -183,7 +183,7 @@ function getBranchStatus( return parts.join(', ') } -function getWorkspaceDecisionDetails( +export function getWorkspaceDecisionDetails( worktree: WorkspaceSpaceWorktree, inputs: WorkspaceDecisionInputs ): WorkspaceDecisionDetails { @@ -197,11 +197,15 @@ function getWorkspaceDecisionDetails( const branch = workspaceRecord ? branchDisplayName(workspaceRecord.branch) : getWorkspaceSpaceBranchLabel(worktree) + const repo = inputs.repoMap.get(worktree.repoId) const reviewCacheKey = getHostedReviewCacheKey( worktree.repoPath, branch, inputs.settings, - worktree.repoId + worktree.repoId, + repo?.connectionId, + repo?.executionHostId, + repo !== undefined ) const hostedReview = inputs.hostedReviewCache[reviewCacheKey]?.data const linkedPR = workspaceRecord?.linkedPR ?? null @@ -214,7 +218,6 @@ function getWorkspaceDecisionDetails( ? `PR #${linkedPR}` : null const linkedIssue = workspaceRecord?.linkedIssue ?? null - const repo = inputs.repoMap.get(worktree.repoId) const issue = linkedIssue && repo ? inputs.issueCache[ @@ -224,7 +227,8 @@ function getWorkspaceDecisionDetails( linkedIssue, inputs.settings, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) ]?.data : null diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts index 0804d1596..702f3c1d7 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -16,7 +16,9 @@ import { resolveWorkspaceSpaceTreemapZoomWorktreeId, sortWorkspaceSpaceRows } from './workspace-space-presentation' +import { getWorkspaceDecisionDetails } from './WorkspaceSpaceManagerPanel' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { Repo, Worktree } from '../../../../shared/types' function row(overrides: Partial): WorkspaceSpaceWorktree { return { @@ -74,6 +76,69 @@ function activeAgent(overrides: Partial = {}): AgentStatusEntr } } +function repo(overrides: Partial = {}): Repo { + return { + id: 'repo', + path: '/repo', + displayName: 'Repo', + badgeColor: '#999999', + addedAt: 1, + ...overrides + } +} + +function worktreeRecord(overrides: Partial = {}): Worktree { + return { + id: 'wt', + repoId: 'repo', + path: '/workspace', + displayName: 'workspace', + branch: 'refs/heads/feature/local', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + ...overrides + } +} + +function decisionInputs( + overrides: Partial[1]> = {} +): Parameters[1] { + const defaultRepo = repo() + const defaultWorktree = worktreeRecord() + return { + repoMap: new Map([[defaultRepo.id, defaultRepo]]), + worktreeMap: new Map([[defaultWorktree.id, defaultWorktree]]), + tabsByWorktree: {}, + ptyIdsByTabId: {}, + agentStatusByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimePaneTitlesByTabId: {}, + retainedAgentsByPaneKey: {}, + openFiles: [], + editorDrafts: {}, + browserTabsByWorktree: {}, + gitStatusByWorktree: {}, + remoteStatusesByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + settings: null, + activeWorktreeId: null, + now: 1_000, + ...overrides + } +} + describe('workspace space presentation helpers', () => { it('sorts rows by the selected key and direction', () => { const rows = [ @@ -238,6 +303,37 @@ describe('workspace space presentation helpers', () => { ).toBe(0) }) + it('reads review and issue details from local owner cache while a runtime is focused', () => { + const details = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + hostedReviewCache: { + 'local::repo::feature/local': { + data: { number: 12, state: 'open', status: 'success', title: 'Local owner PR' } + }, + 'runtime:env-1::repo::feature/local': { + data: { number: 99, state: 'open', status: 'failure', title: 'Runtime fallback PR' } + } + }, + issueCache: { + 'repo::123': { + data: { number: 123, title: 'Local owner issue', state: 'open' } + }, + 'runtime:env-1::repo::123': { + data: { number: 123, title: 'Runtime fallback issue', state: 'closed' } + } + }, + worktreeMap: new Map([ + ['wt', worktreeRecord({ branch: 'refs/heads/feature/local', linkedIssue: 123 })] + ]) + }) + ) + + expect(details.reviewLabel).toBe('PR #12 Open, success') + expect(details.issueLabel).toBe('#123 open: Local owner issue') + }) + it('counts migration-unsupported agent entries by worktree id', () => { const count = countWorkspaceSpaceActiveAgents({ worktreeId: 'wt', diff --git a/src/renderer/src/store/slices/github-cache-key.ts b/src/renderer/src/store/slices/github-cache-key.ts index 1bab2b109..e9117f1f6 100644 --- a/src/renderer/src/store/slices/github-cache-key.ts +++ b/src/renderer/src/store/slices/github-cache-key.ts @@ -13,10 +13,11 @@ export function getGitHubRepoCacheKey( suffix: string, settings?: RuntimeFocusSettings, connectionId?: string | null, - executionHostId?: string | null + executionHostId?: string | null, + hasRepoOwner = false ): string { const owner = repoId ?? repoPath - const scope = getGitHubCacheHostScope(settings, connectionId, executionHostId) + const scope = getGitHubCacheHostScope(settings, connectionId, executionHostId, hasRepoOwner) // Why: runtime/SSH lookups can observe different remotes than the local repo // path, so cache keys include the repo's owning execution boundary. if (scope) { @@ -28,18 +29,27 @@ export function getGitHubRepoCacheKey( function getGitHubCacheHostScope( settings?: RuntimeFocusSettings, connectionId?: string | null, - executionHostId?: string | null + executionHostId?: string | null, + hasRepoOwner = false ): string | null { const hostId = normalizeExecutionHostId(executionHostId) if (hostId) { return hostId === LOCAL_EXECUTION_HOST_ID ? null : hostId } + const sshConnectionId = connectionId?.trim() + if (sshConnectionId) { + return toSshExecutionHostId(sshConnectionId) + } + // Why: an existing repo with no remote/runtime owner is local; only missing + // owner context should inherit the focused runtime fallback. + if (hasRepoOwner) { + return null + } const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() if (runtimeEnvironmentId) { return `runtime:${encodeURIComponent(runtimeEnvironmentId)}` } - const sshConnectionId = connectionId?.trim() - return sshConnectionId ? toSshExecutionHostId(sshConnectionId) : null + return null } export function getLegacyGitHubRepoCacheKey( @@ -56,9 +66,18 @@ export function getGitHubPRCacheKey( branch: string, settings?: RuntimeFocusSettings, connectionId?: string | null, - executionHostId?: string | null + executionHostId?: string | null, + hasRepoOwner = false ): string { - return getGitHubRepoCacheKey(repoPath, repoId, branch, settings, connectionId, executionHostId) + return getGitHubRepoCacheKey( + repoPath, + repoId, + branch, + settings, + connectionId, + executionHostId, + hasRepoOwner + ) } export function getLegacyGitHubPRCacheKey( diff --git a/src/renderer/src/store/slices/github-checks.test.ts b/src/renderer/src/store/slices/github-checks.test.ts index df5c4135d..a67a6b7c3 100644 --- a/src/renderer/src/store/slices/github-checks.test.ts +++ b/src/renderer/src/store/slices/github-checks.test.ts @@ -44,4 +44,36 @@ describe('syncPRChecksStatus', () => { ]) expect(result?.prCache?.['repo-id::main']?.data?.checksStatus).toBe('success') }) + + it('updates the local repo key while a runtime is focused when repo owner is known', () => { + const state = { + prCache: { + 'repo-id::main': { + fetchedAt: 0, + data: { checksStatus: 'neutral' as const } + }, + 'runtime:env-win::repo-id::main': { + fetchedAt: 0, + data: { checksStatus: 'neutral' as const } + } + } + } as unknown as AppState + + const result = syncPRChecksStatus( + state, + '/repo', + 'repo-id', + 'main', + [{ name: 'build', status: 'completed', conclusion: 'success', url: null }], + undefined, + undefined, + { activeRuntimeEnvironmentId: 'env-win' } as AppState['settings'], + null, + null, + true + ) + + expect(result?.prCache?.['repo-id::main']?.data?.checksStatus).toBe('success') + expect(result?.prCache?.['runtime:env-win::repo-id::main']?.data?.checksStatus).toBe('neutral') + }) }) diff --git a/src/renderer/src/store/slices/github-checks.ts b/src/renderer/src/store/slices/github-checks.ts index 067d18d65..8d53226a7 100644 --- a/src/renderer/src/store/slices/github-checks.ts +++ b/src/renderer/src/store/slices/github-checks.ts @@ -44,7 +44,8 @@ export function syncPRChecksStatus( prRepo?: GitHubOwnerRepo | null, settings?: AppState['settings'], connectionId?: string | null, - executionHostId?: string | null + executionHostId?: string | null, + hasRepoOwner = false ): Partial | null { const normalized = branch ? normalizeBranchName(branch) : '' if (!normalized) { @@ -57,7 +58,8 @@ export function syncPRChecksStatus( normalized, settings, connectionId, - executionHostId + executionHostId, + hasRepoOwner ) const prEntry = state.prCache[prCacheKey] if (!prEntry?.data) { diff --git a/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts b/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts index e2a0839f9..b18cd4839 100644 --- a/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts +++ b/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts @@ -76,6 +76,17 @@ describe('enqueueGitHubPRRefresh host guard', () => { expect(enqueuePRRefresh).toHaveBeenCalledTimes(1) }) + it('enqueues the local handler for a known local repo while a runtime is focused', () => { + const store = createTestStore() + seed(store, { id: 'local-1', path: '/Users/me/code/local-1', name: 'local-1', kind: 'git' }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-win' } as never }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).toHaveBeenCalledTimes(1) + expect(mockApi.runtimeEnvironments.call).not.toHaveBeenCalled() + }) + it('enqueues the local handler for a repo with an explicit local executionHostId', () => { const store = createTestStore() seed(store, { diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index ceb3c4d62..c6d5025e5 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -630,7 +630,15 @@ describe('createGitHubSlice.fetchPRChecks', () => { store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], - repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + repos: [ + { + id: repoId, + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ], prCache: { [runtimePrCacheKey]: { data: makePR({ checksStatus: 'pending' }), @@ -656,6 +664,51 @@ describe('createGitHubSlice.fetchPRChecks', () => { expect(store.getState().prCache[runtimePrCacheKey]?.data?.checksStatus).toBe('success') }) + it('keeps known local repo checks on local cache keys when a runtime is focused', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-id' + const branch = 'feature/local-checks' + const localPrCacheKey = `${repoId}::${branch}` + const localChecksCacheKey = `${repoId}::pr-checks::12` + const runtimeChecksCacheKey = `runtime:env-1::${repoId}::pr-checks::12` + + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + prCache: { + [localPrCacheKey]: { + data: makePR({ checksStatus: 'pending' }), + fetchedAt: 1 + } + } + } as unknown as Partial) + + mockApi.gh.prChecks.mockResolvedValueOnce([ + { name: 'build', status: 'completed', conclusion: 'success', url: null } + ]) + + await store + .getState() + .fetchPRChecks(repoPath, 12, branch, undefined, null, { force: true, repoId }) + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.gh.prChecks).toHaveBeenCalledWith({ + repoPath, + repoId, + prNumber: 12, + headSha: undefined, + prRepo: null, + noCache: true, + sourceContext: undefined + }) + expect(store.getState().checksCache[localChecksCacheKey]?.data).toEqual([ + { name: 'build', status: 'completed', conclusion: 'success', url: null } + ]) + expect(store.getState().checksCache[runtimeChecksCacheKey]).toBeUndefined() + expect(store.getState().prCache[localPrCacheKey]?.data?.checksStatus).toBe('success') + }) + it('marks the PR cache entry as failure when any check fails', async () => { const store = createTestStore() const repoPath = '/repo' @@ -1064,7 +1117,15 @@ describe('createGitHubSlice.fetchPRComments', () => { store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], - repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + repos: [ + { + id: repoId, + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ] } as unknown as Partial) await store.getState().fetchPRComments(repoPath, 12, { @@ -1093,6 +1154,42 @@ describe('createGitHubSlice.fetchPRComments', () => { ).toBeUndefined() }) + it('keeps known local repo comments on local cache keys when a runtime is focused', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-id' + + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + } as unknown as Partial) + mockApi.gh.prComments.mockResolvedValueOnce([ + { id: 1, author: 'local', authorAvatarUrl: '', body: '', createdAt: '', url: '' } + ]) + + await store.getState().fetchPRComments(repoPath, 12, { + force: true, + repoId, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }) + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.gh.prComments).toHaveBeenCalledWith({ + repoPath, + repoId, + prNumber: 12, + prRepo: { owner: 'Acme', repo: 'Widgets' }, + noCache: true, + sourceContext: undefined + }) + expect( + store.getState().commentsCache[`${repoId}::pr-comments::acme/widgets::12`]?.data?.[0].author + ).toBe('local') + expect( + store.getState().commentsCache[`runtime:env-1::${repoId}::pr-comments::acme/widgets::12`] + ).toBeUndefined() + }) + it('routes explicit source-context PR comments through the source runtime', async () => { runtimeEnvironmentCall.mockResolvedValueOnce({ id: 'rpc-source-comments', @@ -1249,7 +1346,15 @@ describe('createGitHubSlice.fetchPRCheckDetails', () => { store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], - repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + repos: [ + { + id: repoId, + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ] } as unknown as Partial) await store.getState().fetchPRCheckDetails( @@ -1277,6 +1382,39 @@ describe('createGitHubSlice.fetchPRCheckDetails', () => { }) expect(mockApi.gh.prCheckDetails).not.toHaveBeenCalled() }) + + it('loads known local repo check details through local IPC when a runtime is focused', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-id' + + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + } as unknown as Partial) + + await store.getState().fetchPRCheckDetails( + repoPath, + { + checkRunId: 123, + checkName: 'build', + prRepo: { owner: 'Acme', repo: 'Widgets' } + }, + { repoId } + ) + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.gh.prCheckDetails).toHaveBeenCalledWith({ + repoPath, + repoId, + checkRunId: 123, + workflowRunId: undefined, + checkName: 'build', + url: undefined, + prRepo: { owner: 'Acme', repo: 'Widgets' }, + sourceContext: undefined + }) + }) }) describe('createGitHubSlice PR comment mutations', () => { @@ -1428,7 +1566,15 @@ describe('createGitHubSlice PR comment mutations', () => { const repoId = 'repo-id' store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], - repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + repos: [ + { + id: repoId, + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ] } as unknown as Partial) await store.getState().addPRReviewCommentReply(repoPath, 12, 99, 'reply', { @@ -2937,8 +3083,28 @@ describe('createGitHubSlice.fetchPRForBranch', () => { const settings = { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'] const runtimeHostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, settings, repoId) const localHostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, null, repoId) + const localChecksCacheKey = `${repoId}::${prChecksCacheSuffix(12, null, 'head-oid')}` + const runtimeChecksCacheKey = `runtime:env-1::${repoId}::${prChecksCacheSuffix( + 12, + null, + 'head-oid' + )}` - store.setState({ settings } as Partial) + store.setState({ + settings, + checksCache: { + [localChecksCacheKey]: { + data: [{ name: 'test', status: 'completed', conclusion: 'failure', url: null }], + fetchedAt: 1, + headSha: 'head-oid' + }, + [runtimeChecksCacheKey]: { + data: [{ name: 'test', status: 'completed', conclusion: 'success', url: null }], + fetchedAt: 1, + headSha: 'head-oid' + } + } + } as Partial) store.getState().applyGitHubPRRefreshEvent({ sequence: 1, @@ -2946,14 +3112,15 @@ describe('createGitHubSlice.fetchPRForBranch', () => { reason: 'visible', outcome: { kind: 'found', - pr: makePR({ number: 12, title: 'Local PR status' }), + pr: makePR({ number: 12, title: 'Local PR status', checksStatus: 'pending' }), fetchedAt: 2 } }) expect(store.getState().prCache[cacheKey]?.data).toMatchObject({ number: 12, - title: 'Local PR status' + title: 'Local PR status', + checksStatus: 'failure' }) expect(store.getState().prRefreshSequences[cacheKey]).toBe(1) expect(store.getState().hostedReviewCache[localHostedReviewCacheKey]?.data).toMatchObject({ @@ -3653,12 +3820,23 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], - 'repo-1' + 'repo-1', + null, + 'runtime:env-1', + true ) store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], - repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + repos: [ + { + id: 'repo-1', + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ], groupBy: 'pr-status', worktreeCardProperties: ['status'], worktreesByRepo: { @@ -3934,7 +4112,15 @@ describe('createGitHubSlice.refreshAllGitHub', () => { store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], - repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + repos: [ + { + id: 'repo-1', + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ], groupBy: 'repo', worktreeCardProperties: ['comment'], activeWorktreeId: 'wt-1', @@ -4062,7 +4248,15 @@ describe('createGitHubSlice.refreshGitHubForWorktree', () => { store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], - repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + repos: [ + { + id: 'repo-1', + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ], worktreesByRepo: { 'repo-1': [ { diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 46212a556..28618d3a8 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -214,7 +214,7 @@ function settingsForGitHubRepoOwner( settings: AppState['settings'], repo: Pick | undefined ): AppState['settings'] { - if (!repo?.executionHostId && !repo?.connectionId) { + if (!repo) { return settings } const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) @@ -230,6 +230,16 @@ function settingsForGitHubRepoOwner( : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) } +function settingsForGitHubFocusedRepoOwner( + settings: AppState['settings'], + repo: Pick | undefined +): AppState['settings'] { + if (!repo?.executionHostId && !repo?.connectionId) { + return settings + } + return settingsForGitHubRepoOwner(settings, repo) +} + function getRefreshAliasExecutionHostId(alias: GitHubPRRefreshAlias): string { const explicitHostId = normalizeExecutionHostId(alias.executionHostId) if (explicitHostId) { @@ -249,7 +259,7 @@ function findRepoForGitHubOwner( ) } -function getGitHubRepoOwnerHostId( +function getGitHubFocusedRepoOwnerHostId( settings: AppState['settings'], repo: Pick | undefined ): string { @@ -271,7 +281,7 @@ function getWorkItemsCacheKeyForOwner( repoId, limit, query, - repo ? getGitHubRepoOwnerHostId(state.settings ?? null, repo) : undefined + repo ? getGitHubFocusedRepoOwnerHostId(state.settings ?? null, repo) : undefined ) } @@ -284,7 +294,7 @@ function getGitHubWorkItemSourceHostId( return sourceContext.hostId } return repo - ? (normalizeExecutionHostId(getGitHubRepoOwnerHostId(state.settings, repo)) ?? undefined) + ? (normalizeExecutionHostId(getGitHubFocusedRepoOwnerHostId(state.settings, repo)) ?? undefined) : undefined } @@ -303,6 +313,20 @@ function getGitHubWorkItemSourceSettings( settings: AppState['settings'], repo: Pick | undefined, sourceContext?: TaskSourceContext | null +): AppState['settings'] { + if (sourceContext?.provider === 'github') { + return { + ...settings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as AppState['settings'] + } + return settingsForGitHubFocusedRepoOwner(settings, repo) +} + +function getGitHubRepoSourceSettings( + settings: AppState['settings'], + repo: Pick | undefined, + sourceContext?: TaskSourceContext | null ): AppState['settings'] { if (sourceContext?.provider === 'github') { return { @@ -761,7 +785,8 @@ export function issueCacheKey( issueNumber: number | string, settings?: Pick | null, connectionId?: string | null, - executionHostId?: string | null + executionHostId?: string | null, + hasRepoOwner = false ): string { return getGitHubRepoCacheKey( repoPath, @@ -769,7 +794,8 @@ export function issueCacheKey( String(issueNumber), settings, connectionId, - executionHostId + executionHostId, + hasRepoOwner ) } @@ -779,9 +805,18 @@ function runtimeScopedRepoCacheKey( suffix: string, settings?: AppState['settings'], connectionId?: string | null, - executionHostId?: string | null + executionHostId?: string | null, + hasRepoOwner = false ): string { - return getGitHubRepoCacheKey(repoPath, repoId, suffix, settings, connectionId, executionHostId) + return getGitHubRepoCacheKey( + repoPath, + repoId, + suffix, + settings, + connectionId, + executionHostId, + hasRepoOwner + ) } function sourceScopedRepoCacheKey( @@ -791,7 +826,8 @@ function sourceScopedRepoCacheKey( settings?: AppState['settings'], connectionId?: string | null, executionHostId?: string | null, - sourceContext?: TaskSourceContext | null + sourceContext?: TaskSourceContext | null, + hasRepoOwner = false ): string { if (sourceContext?.provider === 'github') { return `${getTaskSourceCacheScope(sourceContext)}::${repoId ?? repoPath}::${suffix}` @@ -802,7 +838,8 @@ function sourceScopedRepoCacheKey( suffix, settings, connectionId, - executionHostId + executionHostId, + hasRepoOwner ) } @@ -812,9 +849,18 @@ function prCacheKey( branch: string, settings?: AppState['settings'], connectionId?: string | null, - executionHostId?: string | null + executionHostId?: string | null, + hasRepoOwner = false ): string { - return getGitHubPRCacheKey(repoPath, repoId, branch, settings, connectionId, executionHostId) + return getGitHubPRCacheKey( + repoPath, + repoId, + branch, + settings, + connectionId, + executionHostId, + hasRepoOwner + ) } function repoCacheKeyPrefixes(repoId: string, repoPath?: string): string[] { @@ -978,7 +1024,8 @@ function buildPRRefreshCandidate( branch, settingsForGitHubRepoOwner(state.settings, repo), repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) const cachedPR = state.prCache[cacheKey]?.data ?? null const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber( @@ -987,7 +1034,8 @@ function buildPRRefreshCandidate( repo.id, branch, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) const cachedFallbackPRNumber = cachedPR?.number ?? null const fallbackPRNumber = @@ -1037,7 +1085,8 @@ function githubHostedReviewFallbackPRNumber( repoId: string | undefined, branch: string, connectionId?: string | null, - executionHostId?: string | null + executionHostId?: string | null, + hasRepoOwner = false ): number | null { const hostedReviewCacheKey = getHostedReviewCacheKey( repoPath, @@ -1045,7 +1094,8 @@ function githubHostedReviewFallbackPRNumber( state.settings, repoId, connectionId, - executionHostId + executionHostId, + hasRepoOwner ) const hostedReview = state.hostedReviewCache[hostedReviewCacheKey]?.data return hostedReview?.provider === 'github' ? hostedReview.number : null @@ -1109,6 +1159,7 @@ function syncHostedReviewCacheFromGitHubPRResult(args: { repoId?: string connectionId?: string | null executionHostId?: string | null + hasRepoOwner?: boolean pr: PRInfo | null fetchedAt: number linkedPRNumber?: number | null @@ -1123,7 +1174,8 @@ function syncHostedReviewCacheFromGitHubPRResult(args: { args.settings, args.repoId, args.connectionId, - args.executionHostId + args.executionHostId, + args.hasRepoOwner === true ) if ( args.requestStartedAt !== undefined && @@ -1294,6 +1346,7 @@ function setGitHubPRResultCaches( repoId?: string connectionId?: string | null executionHostId?: string | null + hasRepoOwner?: boolean pr: PRInfo | null fetchedAt: number worktreeId?: string @@ -1312,6 +1365,7 @@ function setGitHubPRResultCaches( repoId: args.repoId, connectionId: args.connectionId, executionHostId: args.executionHostId, + hasRepoOwner: args.hasRepoOwner, pr: args.pr, fetchedAt: args.fetchedAt, linkedPRNumber: args.linkedPRNumber, @@ -1326,7 +1380,8 @@ function setGitHubPRResultCaches( args.settings, args.repoId, args.connectionId, - args.executionHostId + args.executionHostId, + args.hasRepoOwner === true ) const nextPRCache = applyPRCacheResult( state.prCache, @@ -1368,6 +1423,7 @@ function applyGitHubPRResultToCaches(args: { repoId?: string connectionId?: string | null executionHostId?: string | null + hasRepoOwner?: boolean pr: PRInfo | null fetchedAt: number state: AppState @@ -1389,6 +1445,7 @@ function applyGitHubPRResultToCaches(args: { repoId: args.repoId, connectionId: args.connectionId, executionHostId: args.executionHostId, + hasRepoOwner: args.hasRepoOwner, pr: args.pr, fetchedAt: args.fetchedAt, linkedPRNumber: args.linkedPRNumber, @@ -1403,7 +1460,8 @@ function applyGitHubPRResultToCaches(args: { args.settings, args.repoId, args.connectionId, - args.executionHostId + args.executionHostId, + args.hasRepoOwner === true ) return { prCache: applyPRCacheResult( @@ -2813,7 +2871,8 @@ export const createGitHubSlice: StateCreator = (s branch, requestSettings, repo?.connectionId, - repo?.executionHostId + repo?.executionHostId, + repo !== undefined ) const cached = get().prCache[cacheKey] const hostedReviewCacheKey = getHostedReviewCacheKey( @@ -2822,7 +2881,8 @@ export const createGitHubSlice: StateCreator = (s requestSettings, repoId, repo?.connectionId, - repo?.executionHostId + repo?.executionHostId, + repo !== undefined ) // Why: if a prior caller without a linkedPR cached `null` for this branch, // the worktree-card lookup (which has a linked PR fallback) would otherwise @@ -2836,7 +2896,8 @@ export const createGitHubSlice: StateCreator = (s repoId, branch, repo?.connectionId, - repo?.executionHostId + repo?.executionHostId, + repo !== undefined ) const fallbackPRNumber = linkedPRNumber == null ? (explicitFallbackPRNumber ?? hostedReviewFallbackPRNumber) : null @@ -2954,6 +3015,7 @@ export const createGitHubSlice: StateCreator = (s repoId, connectionId: repo?.connectionId, executionHostId: repo?.executionHostId, + hasRepoOwner: repo !== undefined, pr, fetchedAt: outcome.fetchedAt, worktreeId: options?.worktreeId, @@ -3016,7 +3078,7 @@ export const createGitHubSlice: StateCreator = (s fetchIssue: async (repoPath, number, options) => { const repo = findRepoForGitHubOwner(get(), options?.repoId, repoPath) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubWorkItemSourceSettings( + const requestSettings = getGitHubRepoSourceSettings( get().settings, repo, options?.sourceContext @@ -3028,7 +3090,8 @@ export const createGitHubSlice: StateCreator = (s requestSettings, repo?.connectionId, repo?.executionHostId, - options?.sourceContext + options?.sourceContext, + repo !== undefined ) const cached = get().issueCache[cacheKey] if (isFresh(cached)) { @@ -3102,7 +3165,7 @@ export const createGitHubSlice: StateCreator = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubWorkItemSourceSettings( + const requestSettings = getGitHubRepoSourceSettings( get().settings, repo, options?.sourceContext @@ -3114,7 +3177,8 @@ export const createGitHubSlice: StateCreator = (s requestSettings, repo?.connectionId, repo?.executionHostId, - options?.sourceContext + options?.sourceContext, + repo !== undefined ) const legacyCacheKey = headSha ? sourceScopedRepoCacheKey( @@ -3124,7 +3188,8 @@ export const createGitHubSlice: StateCreator = (s requestSettings, repo?.connectionId, repo?.executionHostId, - options?.sourceContext + options?.sourceContext, + repo !== undefined ) : cacheKey const inflightKey = cacheKey @@ -3146,7 +3211,8 @@ export const createGitHubSlice: StateCreator = (s prRepo, requestSettings, repo?.connectionId, - repo?.executionHostId + repo?.executionHostId, + repo !== undefined ) if (prStatusUpdate) { set(prStatusUpdate) @@ -3218,7 +3284,8 @@ export const createGitHubSlice: StateCreator = (s prRepo, requestSettings, repo?.connectionId, - repo?.executionHostId + repo?.executionHostId, + repo !== undefined ) if (prStatusUpdate?.prCache) { nextState.prCache = prStatusUpdate.prCache @@ -3253,7 +3320,7 @@ export const createGitHubSlice: StateCreator = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubWorkItemSourceSettings( + const requestSettings = getGitHubRepoSourceSettings( get().settings, repo, options?.sourceContext @@ -3296,7 +3363,7 @@ export const createGitHubSlice: StateCreator = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubWorkItemSourceSettings( + const requestSettings = getGitHubRepoSourceSettings( get().settings, repo, options?.sourceContext @@ -3308,7 +3375,8 @@ export const createGitHubSlice: StateCreator = (s requestSettings, repo?.connectionId, repo?.executionHostId, - options?.sourceContext + options?.sourceContext, + repo !== undefined ) const cached = get().commentsCache[cacheKey] if (!options?.force && isFresh(cached)) { @@ -3374,7 +3442,7 @@ export const createGitHubSlice: StateCreator = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubWorkItemSourceSettings( + const requestSettings = getGitHubRepoSourceSettings( get().settings, repo, options?.sourceContext @@ -3386,7 +3454,8 @@ export const createGitHubSlice: StateCreator = (s requestSettings, repo?.connectionId, repo?.executionHostId, - options?.sourceContext + options?.sourceContext, + repo !== undefined ) const requestContext = getGitHubWorkItemRequestContext( get(), @@ -3452,7 +3521,7 @@ export const createGitHubSlice: StateCreator = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubWorkItemSourceSettings( + const requestSettings = getGitHubRepoSourceSettings( get().settings, repo, options?.sourceContext @@ -3464,7 +3533,8 @@ export const createGitHubSlice: StateCreator = (s requestSettings, repo?.connectionId, repo?.executionHostId, - options?.sourceContext + options?.sourceContext, + repo !== undefined ) const requestContext = getGitHubWorkItemRequestContext( get(), @@ -3542,7 +3612,7 @@ export const createGitHubSlice: StateCreator = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubWorkItemSourceSettings( + const requestSettings = getGitHubRepoSourceSettings( get().settings, repo, options?.sourceContext @@ -3554,7 +3624,8 @@ export const createGitHubSlice: StateCreator = (s requestSettings, repo?.connectionId, repo?.executionHostId, - options?.sourceContext + options?.sourceContext, + repo !== undefined ) // Optimistic update: toggle isResolved on all comments in this thread immediately @@ -3740,7 +3811,8 @@ export const createGitHubSlice: StateCreator = (s prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), s.settings, alias.connectionId, - aliasExecutionHostId + aliasExecutionHostId, + true ) ] : []), @@ -3750,7 +3822,8 @@ export const createGitHubSlice: StateCreator = (s prChecksCacheSuffix(pr.number, pr.prRepo), s.settings, alias.connectionId, - aliasExecutionHostId + aliasExecutionHostId, + true ) ] : []), @@ -3762,7 +3835,8 @@ export const createGitHubSlice: StateCreator = (s prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), s.settings, alias.connectionId, - aliasExecutionHostId + aliasExecutionHostId, + true ) ] : []), @@ -3772,7 +3846,8 @@ export const createGitHubSlice: StateCreator = (s prChecksCacheSuffix(pr.number, pr.prRepo), s.settings, alias.connectionId, - aliasExecutionHostId + aliasExecutionHostId, + true ), `${alias.repoPath}::pr-checks::${pr.number}` ] @@ -3807,6 +3882,7 @@ export const createGitHubSlice: StateCreator = (s repoId: alias.repoId, connectionId: alias.connectionId, executionHostId: aliasExecutionHostId, + hasRepoOwner: true, pr: data, fetchedAt: event.outcome.fetchedAt, state: s, @@ -3834,7 +3910,8 @@ export const createGitHubSlice: StateCreator = (s s.settings, alias.repoId, alias.connectionId, - aliasExecutionHostId + aliasExecutionHostId, + true ) setPRRefreshStartedHostedReviewEntry( prRefreshStartedEntryKey(event.sequence, alias.cacheKey), @@ -3947,7 +4024,8 @@ export const createGitHubSlice: StateCreator = (s wt.linkedIssue, ownerSettings, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) const issueEntry = state.issueCache[issueKey] if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { @@ -4014,7 +4092,8 @@ export const createGitHubSlice: StateCreator = (s worktree.linkedIssue, ownerSettings, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) : '' @@ -4257,7 +4336,8 @@ export const createGitHubSlice: StateCreator = (s worktree.linkedIssue, ownerSettings, repo.connectionId, - repo.executionHostId + repo.executionHostId, + true ) const issueEntry = state.issueCache[issueKey] if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { diff --git a/src/renderer/src/store/slices/hosted-review-cache-identity.ts b/src/renderer/src/store/slices/hosted-review-cache-identity.ts index db099a117..f1b852ff7 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-identity.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-identity.ts @@ -20,16 +20,18 @@ export function getHostedReviewCacheKey( settings?: Pick | null, repoId?: string | null, connectionId?: string | null, - executionHostId?: string | null + executionHostId?: string | null, + hasRepoOwner = false ): string { - const scope = getHostedReviewCacheHostScope(settings, connectionId, executionHostId) + const scope = getHostedReviewCacheHostScope(settings, connectionId, executionHostId, hasRepoOwner) return `${scope}::${repoId ?? repoPath}::${branch}` } function getHostedReviewCacheHostScope( settings?: Pick | null, connectionId?: string | null, - executionHostId?: string | null + executionHostId?: string | null, + hasRepoOwner = false ): string { const hostId = normalizeExecutionHostId(executionHostId) if (hostId) { @@ -39,6 +41,11 @@ function getHostedReviewCacheHostScope( if (sshConnectionId) { return toSshExecutionHostId(sshConnectionId) } + // Why: a known repo owner with no SSH/runtime marker is local; absent owner + // context keeps the focused-runtime fallback for active-host operations. + if (hasRepoOwner) { + return 'local' + } return getSettingsFocusedExecutionHostId(settings) } diff --git a/src/renderer/src/store/slices/hosted-review-cache-race.test.ts b/src/renderer/src/store/slices/hosted-review-cache-race.test.ts index d91951aec..42fcc983b 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-race.test.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-race.test.ts @@ -90,7 +90,15 @@ describe('hosted review cache race protection', () => { }) mockApi.hostedReview.forBranch.mockReturnValueOnce(fetch) const store = makeStore() - const cacheKey = getHostedReviewCacheKey('/repo', 'feature/race') + const cacheKey = getHostedReviewCacheKey( + '/repo', + 'feature/race', + null, + 'repo-1', + null, + null, + true + ) const request = store.getState().fetchHostedReviewForBranch('/repo', 'feature/race') vi.setSystemTime(200) @@ -125,7 +133,15 @@ describe('hosted review cache race protection', () => { }) mockApi.hostedReview.forBranch.mockReturnValueOnce(fetch) const store = makeStore() - const cacheKey = getHostedReviewCacheKey('/repo', 'feature/error-race') + const cacheKey = getHostedReviewCacheKey( + '/repo', + 'feature/error-race', + null, + 'repo-1', + null, + null, + true + ) try { const request = store.getState().fetchHostedReviewForBranch('/repo', 'feature/error-race') @@ -167,7 +183,15 @@ describe('hosted review cache race protection', () => { }) mockApi.hostedReview.forBranch.mockReturnValueOnce(fetch) const store = makeStore() - const cacheKey = getHostedReviewCacheKey('/repo', 'feature/same-ms-race') + const cacheKey = getHostedReviewCacheKey( + '/repo', + 'feature/same-ms-race', + null, + 'repo-1', + null, + null, + true + ) const request = store.getState().fetchHostedReviewForBranch('/repo', 'feature/same-ms-race') store.setState({ @@ -202,7 +226,15 @@ describe('hosted review cache race protection', () => { } mockApi.hostedReview.forBranch.mockResolvedValueOnce(freshReview) const store = makeStore() - const cacheKey = getHostedReviewCacheKey('/repo', 'feature/same-ms-existing') + const cacheKey = getHostedReviewCacheKey( + '/repo', + 'feature/same-ms-existing', + null, + 'repo-1', + null, + null, + true + ) store.setState({ hostedReviewCache: { diff --git a/src/renderer/src/store/slices/hosted-review-cache.test.ts b/src/renderer/src/store/slices/hosted-review-cache.test.ts index f3c6c8be2..163dfbd33 100644 --- a/src/renderer/src/store/slices/hosted-review-cache.test.ts +++ b/src/renderer/src/store/slices/hosted-review-cache.test.ts @@ -139,7 +139,15 @@ describe('hosted review cache revalidation', () => { ).resolves.toEqual(review) expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) - const cacheKey = getHostedReviewCacheKey('/repo', 'feature/pr') + const cacheKey = getHostedReviewCacheKey( + '/repo', + 'feature/pr', + null, + 'repo-1', + null, + null, + true + ) expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(review) resolveRefresh(updatedReview) @@ -192,11 +200,14 @@ describe('hosted review cache revalidation', () => { } expect( - store.getState().hostedReviewCache[getHostedReviewCacheKey('/repo', 'feature/cache-0')] + store.getState().hostedReviewCache[ + getHostedReviewCacheKey('/repo', 'feature/cache-0', null, 'repo-1', null, null, true) + ] ).toBeUndefined() expect( - store.getState().hostedReviewCache[getHostedReviewCacheKey('/repo', 'feature/cache-500')] - ?.data + store.getState().hostedReviewCache[ + getHostedReviewCacheKey('/repo', 'feature/cache-500', null, 'repo-1', null, null, true) + ]?.data ).toMatchObject({ title: 'feature/cache-500' }) expect(Object.keys(store.getState().hostedReviewCache)).toHaveLength(500) expect(_getHostedReviewRequestGenerationCountForTest()).toBe(0) diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index afdc0c651..fb195f02c 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -159,6 +159,30 @@ describe('hosted review slice', () => { expect(store.getState().hostedReviewCache['local::repo-1::feature/gitlab']).toBeUndefined() }) + it('uses local hosted-review IPC for a known local repo while a runtime is focused', async () => { + mockApi.hostedReview.forBranch.mockResolvedValueOnce(review) + const store = makeStore({ + activeRuntimeEnvironmentId: 'env-win' + } as AppState['settings']) + + await expect( + store.getState().fetchHostedReviewForBranch('/repo', 'feature/local', { + repoId: 'repo-1' + }) + ).resolves.toEqual(review) + + expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled() + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/repo', branch: 'feature/local' }) + ) + expect(store.getState().hostedReviewCache['local::repo-1::feature/local']).toMatchObject({ + data: review + }) + expect( + store.getState().hostedReviewCache['runtime:env-win::repo-1::feature/local'] + ).toBeUndefined() + }) + it('routes active runtime review lookups through runtime RPC', async () => { runtimeRpc.callRuntimeRpc.mockResolvedValueOnce(review) const store = makeStore({ diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 3bc9f1d38..504d1144d 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -135,7 +135,7 @@ function settingsForHostedReviewRepoOwner( settings: AppState['settings'], repo: Pick | undefined ): AppState['settings'] { - if (!repo?.executionHostId && !repo?.connectionId) { + if (!repo) { return settings } const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) @@ -151,6 +151,16 @@ function settingsForHostedReviewRepoOwner( : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) } +function settingsForHostedReviewActionOwner( + settings: AppState['settings'], + repo: Pick | undefined +): AppState['settings'] { + if (!repo?.executionHostId && !repo?.connectionId) { + return settings + } + return settingsForHostedReviewRepoOwner(settings, repo) +} + export type HostedReviewSlice = { hostedReviewCache: Record> getHostedReviewCreationEligibility: ( @@ -205,7 +215,7 @@ export const createHostedReviewSlice: StateCreator { const settings = get().settings const repo = findHostedReviewRepoByPath(get().repos, args.repoPath, args.repoId) - const ownerSettings = settingsForHostedReviewRepoOwner(settings, repo) + const ownerSettings = settingsForHostedReviewActionOwner(settings, repo) const target = getActiveRuntimeTarget(ownerSettings) if (target.kind === 'environment') { const { repoPath: _repoPath, worktreePath, ...runtimeArgs } = args @@ -231,7 +241,7 @@ export const createHostedReviewSlice: StateCreator { const settings = get().settings const repo = findHostedReviewRepoByPath(get().repos, repoPath, input.repoId) - const ownerSettings = settingsForHostedReviewRepoOwner(settings, repo) + const ownerSettings = settingsForHostedReviewActionOwner(settings, repo) const target = getActiveRuntimeTarget(ownerSettings) const { repoId: inputRepoId, ...hostedReviewInput } = input if (target.kind === 'environment') { @@ -271,9 +281,10 @@ export const createHostedReviewSlice: StateCreator { + it('uses local PR and hosted-review keys for known local repos while a runtime is focused', () => { + expect( + getGitHubPRCacheKey('/repo', 'repo-1', 'feature/local', focusedRuntime, null, null, true) + ).toBe('repo-1::feature/local') + expect( + getHostedReviewCacheKey('/repo', 'feature/local', focusedRuntime, 'repo-1', null, null, true) + ).toBe('local::repo-1::feature/local') + }) + + it('preserves focused-runtime fallback when repo owner context is missing', () => { + expect(getGitHubPRCacheKey('/repo', 'repo-1', 'feature/local', focusedRuntime)).toBe( + 'runtime:env-focused::repo-1::feature/local' + ) + expect(getHostedReviewCacheKey('/repo', 'feature/local', focusedRuntime, 'repo-1')).toBe( + 'runtime:env-focused::repo-1::feature/local' + ) + }) + + it('keeps explicit runtime and SSH owners scoped to their owner host', () => { + expect( + getGitHubPRCacheKey( + '/repo', + 'repo-1', + 'feature/remote', + null, + null, + 'runtime:env-owner', + true + ) + ).toBe('runtime:env-owner::repo-1::feature/remote') + expect( + getHostedReviewCacheKey('/repo', 'feature/ssh', focusedRuntime, 'repo-1', 'ssh-1', null, true) + ).toBe('ssh:ssh-1::repo-1::feature/ssh') + }) +}) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index f63a5a0e0..df218d769 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -19,6 +19,7 @@ import { type RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' vi.mock('sonner', () => ({ toast: { @@ -3257,7 +3258,14 @@ describe('worktree remote runtime mutations', () => { }) store.setState({ repos: [ - { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + { + id: 'repo1', + path: '/repo1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0, + executionHostId: LOCAL_EXECUTION_HOST_ID + } ], worktreesByRepo: { repo1: [wt] } } as Partial) @@ -4146,8 +4154,42 @@ describe('worktree remote runtime mutations', () => { linkedPR: 456 }) const fetchHostedReviewForBranch = vi.fn().mockResolvedValue(null) - const cacheKey = getHostedReviewCacheKey('/repo1', 'pr-branch', undefined, 'repo1') - const prCacheKey = getGitHubPRCacheKey('/repo1', 'repo1', 'pr-branch') + runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => ({ + id: `test-${method}`, + ok: true, + result: method === 'worktrees.list' ? [] : null + })) + const focusedRuntimeSettings = { activeRuntimeEnvironmentId: 'env-win' } as AppState['settings'] + const cacheKey = getHostedReviewCacheKey( + '/repo1', + 'pr-branch', + focusedRuntimeSettings, + 'repo1', + null, + null, + true + ) + const runtimeCacheKey = getHostedReviewCacheKey( + '/repo1', + 'pr-branch', + focusedRuntimeSettings, + 'repo1' + ) + const prCacheKey = getGitHubPRCacheKey( + '/repo1', + 'repo1', + 'pr-branch', + focusedRuntimeSettings, + null, + null, + true + ) + const runtimePRCacheKey = getGitHubPRCacheKey( + '/repo1', + 'repo1', + 'pr-branch', + focusedRuntimeSettings + ) const legacyRepoPRCacheKey = getLegacyGitHubPRCacheKey('/repo1', 'repo1', 'pr-branch') const legacyPathPRCacheKey = getLegacyGitHubPRCacheKey('/repo1', undefined, 'pr-branch') const prData = { @@ -4160,6 +4202,7 @@ describe('worktree remote runtime mutations', () => { mergeable: 'MERGEABLE' as const } store.setState({ + settings: focusedRuntimeSettings, repos: [ { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } ], @@ -4177,6 +4220,10 @@ describe('worktree remote runtime mutations', () => { mergeable: 'MERGEABLE' }, fetchedAt: Date.now() + }, + [runtimeCacheKey]: { + data: null, + fetchedAt: Date.now() } }, prCache: { @@ -4184,6 +4231,10 @@ describe('worktree remote runtime mutations', () => { data: prData, fetchedAt: Date.now() }, + [runtimePRCacheKey]: { + data: { ...prData, title: 'Focused runtime PR' }, + fetchedAt: Date.now() + }, [legacyRepoPRCacheKey]: { data: { ...prData, title: 'Legacy repo-scoped PR' }, fetchedAt: Date.now() @@ -4200,7 +4251,9 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo.repo1[0]?.linkedPR).toBeNull() expect(store.getState().hostedReviewCache[cacheKey]).toBeUndefined() + expect(store.getState().hostedReviewCache[runtimeCacheKey]).toBeDefined() expect(store.getState().prCache[prCacheKey]).toBeUndefined() + expect(store.getState().prCache[runtimePRCacheKey]).toBeDefined() expect(store.getState().prCache[legacyRepoPRCacheKey]).toBeUndefined() expect(store.getState().prCache[legacyPathPRCacheKey]).toBeUndefined() expect(fetchHostedReviewForBranch).toHaveBeenCalledWith('/repo1', 'pr-branch', { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index c300dcc16..e55430dd4 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -2955,7 +2955,8 @@ export const createWorktreeSlice: StateCreator s.settings, reviewRepo.id, reviewRepo.connectionId, - reviewRepo.executionHostId + reviewRepo.executionHostId, + true ) : null const prCacheKey = @@ -2966,7 +2967,8 @@ export const createWorktreeSlice: StateCreator reviewBranch, s.settings, reviewRepo.connectionId, - reviewRepo.executionHostId + reviewRepo.executionHostId, + true ) : null const prCacheKeys =