diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 43d59d877fd..3e9ef1c81e2 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -421,7 +421,6 @@ 1 src/renderer/src/store/slices/editor/actions/open-file-action.ts 1 src/renderer/src/store/slices/editor/actions/open-file-apply.ts 1 src/renderer/src/store/slices/editor/create-editor-slice.ts -1 src/renderer/src/store/slices/github-cache-key.ts 1 src/renderer/src/store/slices/hosted-review-cache-identity.ts 5 src/renderer/src/store/slices/jira-collection-read-actions.ts 31 src/renderer/src/store/slices/jira-connection-actions.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e507d04e9ae..27ec067ef84 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,7 +1,7 @@ import type { RuntimeLinearCommandSurface } from './runtime-linear-command-surface' import { installRuntimeLinearCommandSurface } from './runtime-linear-command-surface' import { publishWorkspaceViewChanges } from './runtime-workspace-view-change-events' -import { OrcaRuntimeWithWorkspaceActivityFacts } from './sidebar-inputs/orca-runtime-workspace-activity-facts' +import { OrcaRuntimeWithWorkspaceReviewFacts } from './sidebar-inputs/orca-runtime-workspace-review-facts' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import type { AgentLaunchTabPublished, @@ -36,7 +36,7 @@ import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-session-contrac import { getHostDisplayLabelOverrides } from '../../shared/host-setting-overrides' // oxlint-disable-next-line typescript/no-unsafe-declaration-merging -- SAFETY: The existing Linear installer supplies the canonical prototype surface. -class OrcaRuntimeService extends OrcaRuntimeWithWorkspaceActivityFacts { +class OrcaRuntimeService extends OrcaRuntimeWithWorkspaceReviewFacts { // Idle until relay hosts attach. The fallback refresh runs while a window shows the sidebar. readonly pairedServerRows = new PairedServerRowsProducer({ hasDemand: () => @@ -99,7 +99,7 @@ class OrcaRuntimeService extends OrcaRuntimeWithWorkspaceActivityFacts { }) } - constructor(...args: ConstructorParameters) { + constructor(...args: ConstructorParameters) { super(...args) // Why: the runtime listing re-runs a scan the worktree-change generation overtook and re-lists // through this runtime's scan cache, so a worktree change must reach both. The desktop IPC diff --git a/src/main/runtime/rpc/methods/sidebar-inputs.ts b/src/main/runtime/rpc/methods/sidebar-inputs.ts index 1a5cbb7fdff..b4b20627d66 100644 --- a/src/main/runtime/rpc/methods/sidebar-inputs.ts +++ b/src/main/runtime/rpc/methods/sidebar-inputs.ts @@ -27,5 +27,11 @@ export const SIDEBAR_INPUT_METHODS = [ permission: 'workspace', params: null, handler: async (_params, { runtime }) => runtime.pairedServerRows.snapshot() + }), + defineMethod({ + name: 'sidebarInputs.reviewFacts', + permission: 'workspace', + params: null, + handler: async (_params, { runtime }) => runtime.getWorkspaceReviewFacts() }) ] diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.test.ts b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.test.ts new file mode 100644 index 00000000000..5aed0303b49 --- /dev/null +++ b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.test.ts @@ -0,0 +1,75 @@ +// Runs on plain Node, as orcad does: facts come from the host's persisted PR cache. +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import type { PRInfo } from '../../../shared/github/pull-request-types' +import type { RuntimeStore } from '../runtime-store-contract' +import type { ResolvedWorktreeSnapshot } from '../runtime-resolved-worktree-cache' +import type { ResolvedWorktree } from '../runtime-worktree-path-identity' + +const repo = { id: 'repo-1', path: '/r', displayName: 'r', badgeColor: 'blue', addedAt: 1 } + +function worktree(id: string, branch: string): ResolvedWorktree { + return { + id, + repoId: repo.id, + path: id, + branch, + head: '', + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { path: id, head: '', branch, isBare: false, isMainWorktree: false } + } +} + +describe('getWorkspaceReviewFacts', () => { + it('keys the persisted PR cache by workspace', async () => { + const merged: PRInfo = { + number: 3, + title: 'Ship it', + state: 'merged', + url: 'https://github.com/o/r/pull/3', + checksStatus: 'success', + updatedAt: '2026-10-17T00:00:00Z', + mergeable: 'UNKNOWN' + } + const pr = { 'repo-1::a': { data: merged, fetchedAt: 1 } } + const store: Pick = { + getRepos: () => [repo], + getGitHubCache: () => ({ pr, issue: {} }) + } + class Probe extends OrcaRuntimeService { + protected override async listResolvedWorktreeSnapshot(): Promise { + return { + worktrees: [worktree('repo-1::/r/a', 'refs/heads/a'), worktree('repo-1::/r/b', 'b')], + platformByRepoId: new Map() + } + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the read touches only repos and the GitHub cache. + const runtime = new Probe(store as RuntimeStore) + await expect(runtime.getWorkspaceReviewFacts()).resolves.toEqual({ + facts: { 'repo-1::/r/a': { provider: 'github', reviewState: 'merged' } } + }) + }) + + it('publishes nothing without a store', async () => { + await expect(new OrcaRuntimeService().getWorkspaceReviewFacts()).resolves.toEqual({ + facts: {} + }) + }) +}) diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.ts b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.ts new file mode 100644 index 00000000000..785fe924565 --- /dev/null +++ b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.ts @@ -0,0 +1,26 @@ +import { OrcaRuntimeWithWorkspaceActivityFacts } from './orca-runtime-workspace-activity-facts' +import { + buildWorkspaceReviewFacts, + type WorkspaceReviewFactsSnapshot +} from '../../../shared/sidebar-list/workspace-review-facts' + +/** + * PR facts per workspace, read on demand from this host's copy of the PR cache. The window + * persists its cache here within a second of any change; orcad has no window, so it reads only + * what its profile already holds. + */ +export abstract class OrcaRuntimeWithWorkspaceReviewFacts extends OrcaRuntimeWithWorkspaceActivityFacts { + async getWorkspaceReviewFacts(): Promise { + if (!this.store) { + return { facts: {} } + } + const { worktrees } = await this.listResolvedWorktreeSnapshot() + return { + facts: buildWorkspaceReviewFacts( + worktrees, + this.store.getRepos(), + this.store.getGitHubCache().pr + ) + } + } +} diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts index 47d03b253c9..ff5e9b3fe7f 100644 --- a/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts +++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts @@ -3,7 +3,7 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { PRInfo } from '../../../../shared/github/pull-request-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getGitHubPRCacheKey } from '../../../../shared/github/github-cache-key' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { searchWorktrees } from '@/lib/worktree-palette-search' diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts index ab35ff74a35..86c98f13562 100644 --- a/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts +++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts @@ -1,6 +1,6 @@ import { resolveWorktreeBranchLabel } from '@/lib/worktree-default-display-name' import { resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution' -import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getGitHubPRCacheKey } from '../../../../shared/github/github-cache-key' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import type { AppState } from '@/store/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' diff --git a/src/renderer/src/components/dashboard/dashboard-card-context.test.ts b/src/renderer/src/components/dashboard/dashboard-card-context.test.ts index c1de10ae18a..a2b47f53a2c 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-context.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-context.test.ts @@ -3,7 +3,7 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { PRInfo } from '../../../../shared/github/pull-request-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getGitHubPRCacheKey } from '../../../../shared/github/github-cache-key' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { resolveDashboardCardContext, 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 8227ac17fd7..64c740c7764 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 @@ -6,7 +6,7 @@ import { import { gitHubPRToChecksPanelReview } from '@/components/right-sidebar/checks-panel-review' import { getWorktreeGitIdentityDisplay } from '../../../../shared/worktree/git-identity-display' import { useAppStore } from '@/store' -import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getGitHubPRCacheKey } from '../../../../shared/github/github-cache-key' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { findWorktreeById } from '@/store/slices/worktree-helpers' import type { HostedReviewInfo } from '../../../../shared/hosted-review' diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx index 9a634120ca0..790fc33227c 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx @@ -11,7 +11,7 @@ import type { Worktree } from '../../../../shared/worktree/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' -import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' +import { getGitHubRepoCacheKey } from '../../../../shared/github/github-cache-key' import { prChecksCacheSuffix } from '@/store/github/cache-identity' type MockStoreState = { 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 9f1e08a03ba..3365e3a447e 100644 --- a/src/renderer/src/components/right-sidebar/active-checks-status.ts +++ b/src/renderer/src/components/right-sidebar/active-checks-status.ts @@ -1,7 +1,7 @@ import type { AppState } from '../../store/types' import { getRepoMapFromState, getWorktreeMapFromState } from '../../store/selectors' import type { CheckStatus } from '../../../../shared/github/pull-request-types' -import { getGitHubPRCacheKey } from '../../store/slices/github-cache-key' +import { getGitHubPRCacheKey } from '../../../../shared/github/github-cache-key' import { getHostedReviewCacheKey } from '../../store/slices/hosted-review-cache-identity' import { isGitHubPRSuppressed } from '../../../../shared/worktree/github-pr-suppression' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx index dcd7199587a..5ee0f4e891f 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { useAppStore } from '@/store' import { useNow } from '@/hooks/use-now' import { isFolderRepo } from '../../../../../shared/repo-kind' -import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getGitHubPRCacheKey } from '../../../../../shared/github/github-cache-key' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { selectReviewCacheEntry } from '../review-cache-entry-selection' import { selectChecksPanelReview, type ChecksPanelReview } from '../checks-panel-review' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx index 9b6c147724e..902603bab55 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef } from 'react' import { useAppStore } from '@/store' import { prChecksCacheSuffix, prCommentsCacheSuffix } from '@/store/github/cache-identity' -import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' +import { getGitHubRepoCacheKey } from '../../../../../shared/github/github-cache-key' import { buildChecksPanelEligibilityGitFingerprint, readChecksPanelGitStatusSnapshot, diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-github-pr-cache.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-github-pr-cache.ts index 609fb6fd56f..9f28978f095 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-github-pr-cache.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-github-pr-cache.ts @@ -9,7 +9,10 @@ import { } from '../../../../shared/execution-host' import { isCachedMergedBranchPRCurrentForWorktree } from '@/components/sidebar/worktree-card-pr-display' import type { AppState } from '@/store/types' -import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { + getGitHubPRCacheKey, + getLegacyGitHubPRCacheKey +} from '../../../../shared/github/github-cache-key' import type { ParentPrChecksCacheEntry } from './parent-pr-checks-row-types' export function canUseParentPrChecksGitHubPRCacheEntry( diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts index af76ad86d87..6d4af405aff 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts @@ -9,7 +9,7 @@ import { getGitHubPRCacheKey, getGitHubRepoCacheKey, getLegacyGitHubPRCacheKey -} from '@/store/slices/github-cache-key' +} from '../../../../shared/github/github-cache-key' import { prChecksCacheSuffix } from '@/store/github/cache-identity' import { buildParentPrChecksProjection, 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 6e52bd4c6df..cb17cbba194 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 @@ -7,7 +7,7 @@ import { isFolderRepo } from '../../../../shared/repo-kind' import { isGitHubPRSuppressed } from '../../../../shared/worktree/github-pr-suppression' import { getWorktreeCardPrDisplay } from '@/components/sidebar/worktree-card-pr-display' import { getWorktreeGitIdentityDisplay } from '../../../../shared/worktree/git-identity-display' -import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' +import { getGitHubRepoCacheKey } from '../../../../shared/github/github-cache-key' import { getHostedReviewCacheKey, linkedReviewHintKey diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts index 7f234a8915c..bf035487e0f 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts @@ -5,7 +5,7 @@ import { resolveRepoWorktreeLaunchPlatform } from '@/lib/execution-host-facts' import { getWorktreeGitIdentityDisplay } from '../../../../../../shared/worktree/git-identity-display' import { useAppStore } from '@/store' import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors' -import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getGitHubPRCacheKey } from '../../../../../../shared/github/github-cache-key' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' diff --git a/src/renderer/src/components/sidebar/agent-send-picker-collapse.test.tsx b/src/renderer/src/components/sidebar/agent-send-picker-collapse.test.tsx index 3c5e8719227..f0026d63ee7 100644 --- a/src/renderer/src/components/sidebar/agent-send-picker-collapse.test.tsx +++ b/src/renderer/src/components/sidebar/agent-send-picker-collapse.test.tsx @@ -98,7 +98,6 @@ for (const hostId of ['ssh:builder', 'runtime:builder'] as const) { worktreeLineageById: {}, prCache: null, workspaceStatuses, - settings, projectGroups: [], projectGrouping, folderWorkspaces: [], @@ -153,7 +152,6 @@ for (const hostId of ['local', 'ssh:builder', 'runtime:builder'] as const) { worktreeLineageById, prCache: null, workspaceStatuses, - settings, projectGroups: [], projectGrouping, folderWorkspaces: [], @@ -212,7 +210,6 @@ it.each([true, false])('reveals the target with pinned ancestors visible=%s', (v worktreeLineageById, prCache: null, workspaceStatuses, - settings, projectGroups: [], projectGrouping, folderWorkspaces: [], diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts index c4caf54464e..8fb13e2f13f 100644 --- a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts @@ -78,7 +78,7 @@ export function computeRenderedSidebarRows( groupBy: state.groupBy, worktrees: [...visibleWorktrees], repoMap, - prGroupOf: createSidebarPRGroupReader(repoMap, prCache, state.settings), + prGroupOf: createSidebarPRGroupReader(repoMap, prCache), repoOrder: getLogicalRepoOrderRankById(state.repos.map((repo) => repo.id)), workspaceStatuses: state.workspaceStatuses, projectOrderBy: state.projectOrderBy, diff --git a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts index 81a12fd438c..d9c91bed8b0 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts @@ -5,7 +5,7 @@ import { getWorkspaceAttachmentSourceContext } from './workspace-attachment-sour import { getWorkspaceReferenceLinearWorkspaceId } from './workspace-reference-details' import { getWorktreeGitIdentityDisplay } from '../../../../shared/worktree/git-identity-display' import { useAppStore } from '@/store' -import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getGitHubPRCacheKey } from '../../../../shared/github/github-cache-key' import { issueCacheKey as getIssueCacheKey } from '@/store/github/cache-identity' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test-fixture.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test-fixture.ts index e186629f475..212e88fb618 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test-fixture.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test-fixture.ts @@ -21,6 +21,7 @@ import type { WorktreeGroupBy } from '../../../../../../shared/sidebar-list/row-types' import { planSidebarRows } from '../../../../../../shared/sidebar-list/sidebar-row-plan' +import type { PRCacheView } from '../../../../../../shared/sidebar-list/workspace-review-facts' import { createSidebarPRGroupReader } from './pr-group-key' /** Test-only positional entry to the shared row builder, with the window's defaults. */ @@ -28,7 +29,7 @@ export function buildSidebarRowPlan( groupBy: WorktreeGroupBy, worktrees: Worktree[], repoMap: Map, - prCache: Record | null, + prCache: PRCacheView | null, _collapsedGroups: Set, repoOrder?: Map, workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), @@ -56,7 +57,7 @@ export function buildSidebarRowPlan( groupBy, worktrees, repoMap, - prGroupOf: createSidebarPRGroupReader(repoMap, prCache, settings), + prGroupOf: createSidebarPRGroupReader(repoMap, prCache), repoOrder, workspaceStatuses, projectOrderBy, diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts index c4a58a64fcf..f97b9bf9206 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts @@ -65,20 +65,6 @@ describe('getPRGroupKey', () => { expect(getPRGroupKey(worktree, repoMap, prCache)).toBe('closed') }) - 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' } - } - } - - expect( - getPRGroupKey(worktree, repoMap, prCache, { - activeRuntimeEnvironmentId: 'env-1' - } as never) - ).toBe('done') - }) - it('uses SSH-scoped PR cache entries instead of local entries for SSH repos', () => { const sshRepo = { ...repo, connectionId: 'ssh-1' } const sshRepoMap = new Map([[sshRepo.id, sshRepo]]) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts index 57c34d5ca0d..bad2645ee05 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts @@ -184,7 +184,6 @@ describe('cross-project sidebar lineage', () => { pinnedDisplayPolicy: policy, prCache: null, workspaceStatuses: [], - settings: null, projectGroups: [group] }) for (const key of keys) { diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/pr-group-key.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/pr-group-key.ts index af734392662..1f3ff05d1cc 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/pr-group-key.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/pr-group-key.ts @@ -1,72 +1,27 @@ -import type { AppState } from '@/store/types' -import { branchName } from '@/lib/git-utils' -import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key' import type { Repo } from '../../../../../../shared/repo-types' import type { Worktree } from '../../../../../../shared/worktree/types' -import { isGitHubPRSuppressed } from '../../../../../../shared/worktree/github-pr-suppression' -import { - getPRGroupKeyForState, - type PRGroupKey -} from '../../../../../../shared/workspace-view/workspace-pr-groups' +import type { PRGroupKey } from '../../../../../../shared/workspace-view/workspace-pr-groups' import type { SidebarPRGroupReader } from '../../../../../../shared/sidebar-list/row-types' +import { + prGroupOfReviewFact, + readWorkspaceReviewFact, + type PRCacheView +} from '../../../../../../shared/sidebar-list/workspace-review-facts' export function getPRGroupKey( worktree: Worktree, repoMap: Map, - prCache: Record | null, - settings?: AppState['settings'] + prCache: PRCacheView | null ): PRGroupKey { - const repo = repoMap.get(worktree.repoId) - const branch = branchName(worktree.branch) - const repoScopedCacheKey = - repo && branch - ? getGitHubPRCacheKey( - repo.path, - repo.id, - branch, - settings, - repo.connectionId, - repo.executionHostId, - true - ) - : '' - const canUseLegacyPRCache = repo !== undefined && !repo.connectionId && !repo.executionHostId - const legacyRepoScopedCacheKey = - canUseLegacyPRCache && branch ? getLegacyGitHubPRCacheKey(repo.path, repo.id, branch) : '' - const legacyPathScopedCacheKey = - canUseLegacyPRCache && branch ? getLegacyGitHubPRCacheKey(repo.path, undefined, branch) : '' - // Why: PR refreshes now write repo-id scoped entries; legacy path entries may - // still exist from persisted cache, but must not override fresher repo data. - const prEntry = prCache - ? ((repoScopedCacheKey - ? (prCache[repoScopedCacheKey] as - | { data?: { number?: number; state?: string } } - | undefined) - : undefined) ?? - (legacyRepoScopedCacheKey - ? (prCache[legacyRepoScopedCacheKey] as - | { data?: { number?: number; state?: string } } - | undefined) - : undefined) ?? - (legacyPathScopedCacheKey - ? (prCache[legacyPathScopedCacheKey] as - | { data?: { number?: number; state?: string } } - | undefined) - : undefined)) - : undefined - const pr = prEntry?.data - - if (!pr || (typeof pr.number === 'number' && isGitHubPRSuppressed(worktree, pr.number))) { - return 'in-progress' - } - return getPRGroupKeyForState(pr.state ?? '') + return prGroupOfReviewFact( + prCache ? readWorkspaceReviewFact(worktree, repoMap.get(worktree.repoId), prCache) : null + ) } /** The PR lane reader the shared builder takes, bound to the window's PR cache. */ export function createSidebarPRGroupReader( repoMap: Map, - prCache: Record | null, - settings?: AppState['settings'] + prCache: PRCacheView | null ): SidebarPRGroupReader { - return (worktree) => getPRGroupKey(worktree, repoMap, prCache, settings) + return (worktree) => getPRGroupKey(worktree, repoMap, prCache) } diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts index 054b3e14fd3..05ccf990aa0 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts @@ -31,7 +31,6 @@ export function useEffectiveCollapsedGroups(args: { worktreeLineageById: Record prCache: AppState['prCache'] | null workspaceStatuses: readonly WorkspaceStatusDefinition[] - settings: AppState['settings'] projectGroups: readonly ProjectGroup[] projectGrouping: ProjectGroupingModel folderWorkspaces: readonly FolderWorkspace[] @@ -52,7 +51,6 @@ export function useEffectiveCollapsedGroups(args: { worktreeLineageById, prCache, workspaceStatuses, - settings, projectGroups, projectGrouping, folderWorkspaces, @@ -97,7 +95,6 @@ export function useEffectiveCollapsedGroups(args: { pinnedDisplayPolicy, prCache, workspaceStatuses, - settings, projectGroups, projectGrouping })) { @@ -118,7 +115,6 @@ export function useEffectiveCollapsedGroups(args: { projectGroups, projectGrouping, repoMap, - settings, workspaceStatuses, worktreeLineageById, worktreeMap, diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts index f19a56fd43d..b8128d570b3 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts @@ -149,7 +149,7 @@ export function useSidebarSectionRows(args: SectionRowsArgs) { groupBy: args.groupBy, worktrees, repoMap, - prGroupOf: createSidebarPRGroupReader(repoMap, args.prCache, args.settings), + prGroupOf: createSidebarPRGroupReader(repoMap, args.prCache), repoOrder, workspaceStatuses: args.workspaceStatuses, projectOrderBy: args.projectOrderBy, @@ -179,7 +179,6 @@ export function useSidebarSectionRows(args: SectionRowsArgs) { args.projectOrderBy, args.worktreeLineageById, args.worktreeMap, - args.settings, args.projectGrouping, args.visibleProjectGroupsForRows, args.visibleFolderWorkspacesForRows, @@ -207,7 +206,6 @@ export function useSidebarSectionRows(args: SectionRowsArgs) { worktreeLineageById: args.worktreeLineageById, prCache: args.prCache, workspaceStatuses: args.workspaceStatuses, - settings: args.settings, projectGroups: args.visibleProjectGroupsForRows, projectGrouping: args.projectGrouping, folderWorkspaces: args.visibleFolderWorkspacesForRows, diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/worktree-reveal-group-keys.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/worktree-reveal-group-keys.ts index 2b136071fd3..49e4838bbfb 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/worktree-reveal-group-keys.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/worktree-reveal-group-keys.ts @@ -34,7 +34,6 @@ export function getWorktreeRevealCollapsedGroupKeys(args: { pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy prCache: AppState['prCache'] | null workspaceStatuses: readonly WorkspaceStatusDefinition[] - settings: AppState['settings'] projectGroups: readonly ProjectGroup[] projectGrouping?: ProjectGroupingModel }): string[] { @@ -49,7 +48,7 @@ export function getWorktreeRevealCollapsedGroupKeys(args: { groupBy: args.groupBy, worktrees: [...(args.visibleWorktrees ?? args.worktrees)], repoMap: args.repoMap, - prGroupOf: createSidebarPRGroupReader(args.repoMap, args.prCache, args.settings), + prGroupOf: createSidebarPRGroupReader(args.repoMap, args.prCache), workspaceStatuses: args.workspaceStatuses, projectOrderBy: 'manual', lineageById: { ...args.worktreeLineageById }, diff --git a/src/renderer/src/lib/sidebar-shadow/workspace-review-shadow.ts b/src/renderer/src/lib/sidebar-shadow/workspace-review-shadow.ts new file mode 100644 index 00000000000..ab2fe85c587 --- /dev/null +++ b/src/renderer/src/lib/sidebar-shadow/workspace-review-shadow.ts @@ -0,0 +1,40 @@ +import { useAppStore } from '@/store' +import { getAllWorktreesFromState } from '@/store/selectors' +import { + buildWorkspaceReviewFacts, + workspaceReviewFactsEqual, + WorkspaceReviewFactsSnapshotSchema, + type WorkspaceReviewFact +} from '../../../../shared/sidebar-list/workspace-review-facts' +import { defineSidebarShadowDomain } from './sidebar-shadow-comparison' +import { toLocallyOwnedRows } from './workspace-activity-shadow' + +/** + * Checks main's copy of the PR cache, and how it keys rows, against the window's own cache. An SSH + * host that was down when main started has no rows in main yet, so its PR rows can read as mismatches. + */ +export const workspaceReviewShadow = defineSidebarShadowDomain({ + name: 'workspace-review', + readWindow: () => { + const state = useAppStore.getState() + const facts = buildWorkspaceReviewFacts( + getAllWorktreesFromState(state), + state.repos, + state.prCache + ) + return toLocallyOwnedRows(state, new Map(Object.entries(facts))) + }, + readMain: async () => { + const response = await window.api.runtime.call({ method: 'sidebarInputs.reviewFacts' }) + const parsed = response.ok + ? WorkspaceReviewFactsSnapshotSchema.safeParse(response.result) + : null + if (!parsed?.success) { + return null + } + return { + values: toLocallyOwnedRows(useAppStore.getState(), new Map(Object.entries(parsed.data.facts))) + } + }, + equals: workspaceReviewFactsEqual +}) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index c0148f81c34..a31220ff135 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -32,6 +32,7 @@ import { pairedServerRowsShadow } from './lib/sidebar-shadow/paired-server-rows-shadow' import { desktopFactsShadow } from './lib/sidebar-shadow/desktop-facts-shadow' +import { workspaceReviewShadow } from './lib/sidebar-shadow/workspace-review-shadow' const disposeOsFileDropGuard = installOsFileDropCancellationGuard() import.meta.hot?.dispose(disposeOsFileDropGuard) @@ -43,7 +44,8 @@ const disposeSidebarShadow = installSidebarShadowComparison([ workspaceActivityShadow, pairedServerRowsShadow, pairedServerHostsShadow, - desktopFactsShadow + desktopFactsShadow, + workspaceReviewShadow ]) import.meta.hot?.dispose(disposeSidebarShadow) diff --git a/src/renderer/src/store/github/cache-identity.ts b/src/renderer/src/store/github/cache-identity.ts index 2d62d265ed9..cdf53ef07f3 100644 --- a/src/renderer/src/store/github/cache-identity.ts +++ b/src/renderer/src/store/github/cache-identity.ts @@ -10,7 +10,10 @@ import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId } from '../../../../shared/execution-host' -import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '../slices/github-cache-key' +import { + getGitHubPRCacheKey, + getGitHubRepoCacheKey +} from '../../../../shared/github/github-cache-key' import type { AppState } from '../types' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import type { CacheEntry } from './cache-model' diff --git a/src/renderer/src/store/github/cache-persistence.test.ts b/src/renderer/src/store/github/cache-persistence.test.ts index 70f21372dab..0a3c0e3b695 100644 --- a/src/renderer/src/store/github/cache-persistence.test.ts +++ b/src/renderer/src/store/github/cache-persistence.test.ts @@ -1,7 +1,6 @@ -import { setImmediate } from 'node:timers/promises' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { createTestStore } from '../slices/store-test-helpers' -import { debouncedSaveCache } from './cache-persistence' +import { persistGitHubCacheChanges } from './cache-persistence' const setGitHub = vi.fn() @@ -17,48 +16,34 @@ afterEach(() => { vi.unstubAllGlobals() }) -it('releases unrelated discarded renderer state while the cache save is pending', async () => { +const prEntry = (fetchedAt: number) => ({ data: null, fetchedAt }) + +it('writes main within a second of a change, never before one', () => { const store = createTestStore() - const schedule = () => { - const drafts = { closedFile: Buffer.alloc(8 * 1024 * 1024, 65).toString('utf8') } - const state = { ...store.getState(), editorDrafts: drafts } - debouncedSaveCache(state) - return { state: new WeakRef(state), drafts: new WeakRef(drafts) } - } - const discarded = schedule() - if (!global.gc) { - throw new Error('config/vitest.config.ts must pass --expose-gc') - } - for (let turn = 0; turn < 3; turn += 1) { - await setImmediate() - global.gc() - } - + persistGitHubCacheChanges(store) + vi.advanceTimersByTime(5000) expect(setGitHub).not.toHaveBeenCalled() - expect(discarded.state.deref() === undefined).toBe(true) - expect(discarded.drafts.deref() === undefined).toBe(true) - vi.advanceTimersByTime(1000) - expect(setGitHub).toHaveBeenCalledOnce() -}) -it('persists the latest requested cache maps once after the trailing second', () => { - const state = createTestStore().getState() - const first = { ...state, prCache: {}, issueCache: {} } - const latest = { ...state, prCache: {}, issueCache: {} } - debouncedSaveCache(first) + store.setState({ prCache: { a: prEntry(1) } }) vi.advanceTimersByTime(999) - expect(setGitHub).not.toHaveBeenCalled() - - debouncedSaveCache(latest) - vi.advanceTimersByTime(999) - expect(setGitHub).not.toHaveBeenCalled() + // A later change rides the open window instead of pushing the write back. + store.setState({ prCache: { a: prEntry(2) } }) vi.advanceTimersByTime(1) expect(setGitHub).toHaveBeenCalledOnce() - expect(setGitHub.mock.calls[0][0]).toEqual({ - cache: { pr: latest.prCache, issue: latest.issueCache } - }) - expect(setGitHub.mock.calls[0][0].cache.pr).toBe(latest.prCache) - expect(setGitHub.mock.calls[0][0].cache.issue).toBe(latest.issueCache) + expect(setGitHub.mock.calls[0][0].cache.pr).toBe(store.getState().prCache) + expect(setGitHub.mock.calls[0][0].cache.issue).toBe(store.getState().issueCache) +}) + +it('ignores state that is not a GitHub cache and installs once per store', () => { + const store = createTestStore() + persistGitHubCacheChanges(store) + persistGitHubCacheChanges(store) + + store.setState({ checksCache: {} }) + vi.advanceTimersByTime(1000) + expect(setGitHub).not.toHaveBeenCalled() + + store.setState({ issueCache: {} }) vi.advanceTimersByTime(1000) expect(setGitHub).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/store/github/cache-persistence.ts b/src/renderer/src/store/github/cache-persistence.ts index 10c0c67ef13..1a01d7f3837 100644 --- a/src/renderer/src/store/github/cache-persistence.ts +++ b/src/renderer/src/store/github/cache-persistence.ts @@ -1,19 +1,32 @@ +import type { StoreApi } from 'zustand' import type { AppState } from '../types' -let saveTimer: ReturnType | null = null +const SAVE_WINDOW_MS = 1000 +const persistedStores = new WeakSet() -/** Schedules the renderer's single trailing GitHub cache persistence write. */ -export function debouncedSaveCache(state: AppState): void { - clearTimeout(saveTimer ?? undefined) - // Keep unrelated renderer state out of the pending timer's closure. - const { prCache, issueCache } = state - saveTimer = setTimeout(() => { - saveTimer = null - window.api.cache.setGitHub({ - cache: { - pr: prCache, - issue: issueCache - } - }) - }, 1000) +/** + * Mirrors the PR and issue caches to main whenever either changes, so every writer is covered. + * Main derives the sidebar's PR facts from this copy. A fixed window, not a resettable debounce: + * a steady stream of results still reaches main within a second. Once per store. + */ +export function persistGitHubCacheChanges( + store: Pick, 'getState' | 'subscribe'> +): void { + if (persistedStores.has(store)) { + return + } + persistedStores.add(store) + let timer: ReturnType | null = null + const schedule = (): void => { + timer ??= setTimeout(() => { + timer = null + const { prCache, issueCache } = store.getState() + window.api.cache.setGitHub({ cache: { pr: prCache, issue: issueCache } }) + }, SAVE_WINDOW_MS) + } + store.subscribe((state, previous) => { + if (state.prCache !== previous.prCache || state.issueCache !== previous.issueCache) { + schedule() + } + }) } diff --git a/src/renderer/src/store/github/check-actions.ts b/src/renderer/src/store/github/check-actions.ts index ccdb84223e1..218609e7e99 100644 --- a/src/renderer/src/store/github/check-actions.ts +++ b/src/renderer/src/store/github/check-actions.ts @@ -18,7 +18,6 @@ import { sourceScopedRepoCacheKey } from './cache-identity' import { isFresh, withBoundedCacheEntry } from './cache-policy' -import { debouncedSaveCache } from './cache-persistence' import { inflightChecksRequests, nextProviderRequestId, @@ -93,7 +92,6 @@ export const createCheckActions = ( ) if (prStatusUpdate) { set(prStatusUpdate) - debouncedSaveCache(get()) } return cachedChecks } @@ -194,7 +192,6 @@ export const createCheckActions = ( return nextState }) - debouncedSaveCache(get()) return checks })().finally(() => { if (ownsInflightRequest(inflightChecksRequests, inflightKey, requestId)) { diff --git a/src/renderer/src/store/github/issue-actions.ts b/src/renderer/src/store/github/issue-actions.ts index c881994a0f6..93c6bbcfd66 100644 --- a/src/renderer/src/store/github/issue-actions.ts +++ b/src/renderer/src/store/github/issue-actions.ts @@ -5,7 +5,6 @@ import type { IssueInfo } from '../../../../shared/github/pull-request-types' import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { sourceScopedRepoCacheKey } from './cache-identity' import { isFresh, withBoundedCacheEntry } from './cache-policy' -import { debouncedSaveCache } from './cache-persistence' import { inflightIssueRequests } from './request-coordination' import { findRepoForGitHubOwner } from './repository-routing' import { @@ -71,7 +70,6 @@ export const createIssueActions = ( fetchedAt: Date.now() }) })) - debouncedSaveCache(get()) return issue } catch (err) { console.error('Failed to fetch issue:', err) @@ -81,7 +79,6 @@ export const createIssueActions = ( fetchedAt: Date.now() }) })) - debouncedSaveCache(get()) return null } finally { inflightIssueRequests.delete(cacheKey) diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts index 320094f7466..10a74ea23e5 100644 --- a/src/renderer/src/store/github/pull-request-execution.ts +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -14,7 +14,6 @@ import type { import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github/pull-request-for-branch-outcome' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' -import { debouncedSaveCache } from './cache-persistence' import { inflightPRRequests, prRequestGenerations } from './request-coordination' import { getRuntimeRepoTarget } from './repository-routing' import { applyCachedChecksStatus } from './pr-check-status-cache' @@ -153,7 +152,6 @@ export function startPullRequestLookup(args: { } if (prRequestGenerations.get(cacheKey) === generation) { let skippedStaleLinkedPRLookup = false - let didUpdatePRCache = false set((s) => { // Why: unlinking a PR mid exact-linked-PR-lookup must stop the older result from restoring the manual link UI. if (isStaleExactLinkedPRLookup(s, options?.worktreeId, linkedPRNumber)) { @@ -189,15 +187,11 @@ export function startPullRequestLookup(args: { requestStartedAt, requestStartedEntry: requestStartedHostedReviewEntry }) - didUpdatePRCache = updates.prCache !== undefined return updates.prCache || updates.hostedReviewCache ? updates : s }) if (skippedStaleLinkedPRLookup) { return null } - if (didUpdatePRCache) { - debouncedSaveCache(get()) - } const linkedPRWorktree = options?.worktreeId && linkedPRNumber != null ? findUniqueWorktreeById( diff --git a/src/renderer/src/store/github/refresh-event-actions.ts b/src/renderer/src/store/github/refresh-event-actions.ts index c345bb3c41d..41654288a3b 100644 --- a/src/renderer/src/store/github/refresh-event-actions.ts +++ b/src/renderer/src/store/github/refresh-event-actions.ts @@ -3,7 +3,6 @@ import type { AppState } from '../types' import type { GitHubSlice } from './slice-types' import { getHostedReviewCacheKey } from '../slices/hosted-review-cache-identity' import { applyCachedChecksStatus } from './pr-check-status-cache' -import { debouncedSaveCache } from './cache-persistence' import { capPrRefreshSequences, capPrRefreshStates, @@ -48,7 +47,6 @@ export const createRefreshEventActions = ( requestHeadOid: string | null executionHostId: string }[] = [] - let didUpdatePRCache = false set((s) => { let linkedWorktreeLookupIndex: WorktreeLookupIndex | undefined const nextSequences = { ...s.prRefreshSequences } @@ -188,7 +186,6 @@ export const createRefreshEventActions = ( requestStartedAt: event.requestStartedAt, requestStartedEntry }) - didUpdatePRCache = didUpdatePRCache || nextCaches.prCache !== nextPRCache nextPRCache = nextCaches.prCache nextHostedReviewCache = nextCaches.hostedReviewCache continue @@ -243,9 +240,6 @@ export const createRefreshEventActions = ( } : s }) - if (didUpdatePRCache && event.outcome && event.outcome.kind !== 'upstream-error') { - debouncedSaveCache(get()) - } for (const clear of divergedLinkedPRClears) { void get().updateWorktreeMeta( clear.worktreeId, diff --git a/src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts b/src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts index 8176bf64f6e..72c9773bcd2 100644 --- a/src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts +++ b/src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts @@ -214,7 +214,8 @@ describe('createGitHubSlice cache bounds', () => { vi.useRealTimers() }) - it('bounds restored PR and issue caches', async () => { + it('bounds restored PR and issue caches, and tells main', async () => { + vi.useFakeTimers() const store = createTestStore() const pr = Object.fromEntries( Array.from({ length: 505 }, (_, index) => [ @@ -236,6 +237,10 @@ describe('createGitHubSlice cache bounds', () => { expect(Object.keys(store.getState().issueCache)).toHaveLength(500) expect(store.getState().prCache['repo-id::branch-0']).toBeUndefined() expect(store.getState().issueCache['repo-id::0']).toBeUndefined() + vi.advanceTimersByTime(1000) + expect(mockApi.cache.setGitHub).toHaveBeenLastCalledWith({ + cache: { pr: store.getState().prCache, issue: store.getState().issueCache } + }) }) it('bounds PR and issue caches as fetches add entries', async () => { diff --git a/src/renderer/src/store/slices/github-checks.ts b/src/renderer/src/store/slices/github-checks.ts index 900c482ac7d..9668d159eee 100644 --- a/src/renderer/src/store/slices/github-checks.ts +++ b/src/renderer/src/store/slices/github-checks.ts @@ -1,7 +1,7 @@ import type { AppState } from '../types' import type { PRCheckDetail } from '../../../../shared/github/check-types' import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' -import { getGitHubPRCacheKey } from './github-cache-key' +import { getGitHubPRCacheKey } from '../../../../shared/github/github-cache-key' import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' import { derivePRCheckStatus } from '../../../../shared/pr-check-status' diff --git a/src/renderer/src/store/slices/github-issue-state-machine.test.ts b/src/renderer/src/store/slices/github-issue-state-machine.test.ts index 4df798c799e..cfb9d79beb8 100644 --- a/src/renderer/src/store/slices/github-issue-state-machine.test.ts +++ b/src/renderer/src/store/slices/github-issue-state-machine.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { persistGitHubCacheChanges } from '../github/cache-persistence' import type { IssueInfo } from '../../../../shared/github/pull-request-types' import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' import { issueCacheKey } from '../github/cache-identity' @@ -57,6 +58,7 @@ describe('createGitHubSlice.fetchIssue state machine', () => { const sourceContext = githubSourceContext('local', 'source-repo') const cacheKey = `${getTaskSourceCacheScope(sourceContext)}::repo-1::42` mockApi.gh.issue.mockRejectedValueOnce(new Error('offline')) + persistGitHubCacheChanges(store) await expect( store.getState().fetchIssue('/repo/one', 42, { repoId: 'repo-1', sourceContext }) diff --git a/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts b/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts index ff493301e35..74d29b79d53 100644 --- a/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts +++ b/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { persistGitHubCacheChanges } from '../github/cache-persistence' import { prChecksCacheSuffix, prCommentsCacheSuffix } from '../github/cache-identity' import { createTestStore, @@ -227,6 +228,7 @@ describe('createGitHubSlice.fetchPRChecks', () => { mockApi.gh.prChecks.mockResolvedValue([ { name: 'build', status: 'completed', conclusion: 'success', url: null } ]) + persistGitHubCacheChanges(store) await store .getState() @@ -266,6 +268,7 @@ describe('createGitHubSlice.fetchPRChecks', () => { } }) + persistGitHubCacheChanges(store) await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId }) await vi.advanceTimersByTimeAsync(1000) diff --git a/src/renderer/src/store/slices/github-pr-rejected-cache.test.ts b/src/renderer/src/store/slices/github-pr-rejected-cache.test.ts index 921828e75c1..edf6cfbb419 100644 --- a/src/renderer/src/store/slices/github-pr-rejected-cache.test.ts +++ b/src/renderer/src/store/slices/github-pr-rejected-cache.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { persistGitHubCacheChanges } from '../github/cache-persistence' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' import { _clearGitHubPRRefreshStartedEntriesForTest } from '../github/request-coordination' import { getHostedReviewCacheKey } from './hosted-review-cache-identity' @@ -82,6 +83,7 @@ describe.each(['manual', 'coordinator'] as const)('%s rejected PR cache results' } }) const outcome = { kind: 'no-pr' as const, fetchedAt: 2, rejectedPRUrls: [rejectedUrl] } + persistGitHubCacheChanges(store) if (source === 'manual') { mockApi.gh.refreshPRNow.mockResolvedValueOnce(outcome) await expect( diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index c8efa24ff46..4d3b2f5335d 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -7,6 +7,7 @@ import { isExpiredActivePRRefreshState } from '../github/pr-refresh-state' import { evictStaleEntries } from '../github/cache-policy' +import { persistGitHubCacheChanges } from '../github/cache-persistence' import { createProjectActions } from '../github/project-actions' import { createProjectRowActions } from '../github/project-row-actions' import { createWorkItemFetchActions } from '../github/work-item-fetch-actions' @@ -25,7 +26,11 @@ import { createRefreshEventActions } from '../github/refresh-event-actions' import { createRefreshSweepActions } from '../github/refresh-sweep-actions' export type GitHubSlice = GitHubSliceContract -export const createGitHubSlice: StateCreator = (set, get) => ({ +export const createGitHubSlice: StateCreator = ( + set, + get, + store +) => ({ prCache: {}, issueCache: {}, checksCache: {}, @@ -91,16 +96,20 @@ export const createGitHubSlice: StateCreator = (s ...createWorkItemMutationActions(set, get), initGitHubCache: async () => { + let persisted: Awaited> | null = null try { - const persisted = await window.api.cache.getGitHub() - if (persisted) { - set({ - prCache: evictStaleEntries(persisted.pr || {}), - issueCache: evictStaleEntries(persisted.issue || {}) - }) - } + persisted = await window.api.cache.getGitHub() } catch (err) { console.error('Failed to load GitHub cache from disk:', err) } + // Why after the read: an earlier write would replace main's copy with a partial cache. + // Before hydrating, so main also drops entries hydration evicts. + persistGitHubCacheChanges(store) + if (persisted) { + set({ + prCache: evictStaleEntries(persisted.pr || {}), + issueCache: evictStaleEntries(persisted.issue || {}) + }) + } } }) diff --git a/src/renderer/src/store/slices/hosted-review-pr-cache.ts b/src/renderer/src/store/slices/hosted-review-pr-cache.ts index b58d3e01994..957438d8f4b 100644 --- a/src/renderer/src/store/slices/hosted-review-pr-cache.ts +++ b/src/renderer/src/store/slices/hosted-review-pr-cache.ts @@ -1,7 +1,10 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { Repo } from '../../../../shared/repo-types' import type { AppState } from '../types' -import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' +import { + getGitHubPRCacheKey, + getLegacyGitHubPRCacheKey +} from '../../../../shared/github/github-cache-key' export function clearHostedReviewConflictingPrCache(args: { cache: NonNullable diff --git a/src/renderer/src/store/slices/repo-owner-cache-identity.test.ts b/src/renderer/src/store/slices/repo-owner-cache-identity.test.ts index 8580a184cf8..e29f5ae3359 100644 --- a/src/renderer/src/store/slices/repo-owner-cache-identity.test.ts +++ b/src/renderer/src/store/slices/repo-owner-cache-identity.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getGitHubPRCacheKey } from './github-cache-key' +import { getGitHubPRCacheKey } from '../../../../shared/github/github-cache-key' import { getHostedReviewCacheKey } from './hosted-review-cache-identity' const focusedRuntime = { activeRuntimeEnvironmentId: 'env-focused' } diff --git a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts index 9b1579b7ee5..3f6d3db1776 100644 --- a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts +++ b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts @@ -3,7 +3,10 @@ import type { AppState } from '../types' import { toast } from 'sonner' import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' import { getHostedReviewCacheKey } from './hosted-review-cache-identity' -import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' +import { + getGitHubPRCacheKey, + getLegacyGitHubPRCacheKey +} from '../../../../shared/github/github-cache-key' import { makeWorktree } from './worktrees-slice-test-fixtures' import { createTestStore, diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts index 6ab8967102f..50f142883be 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -16,7 +16,7 @@ import { displayNameUpdatePinsLabel } from '../../../../../../shared/worktree/di import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' import { getHostedReviewCacheKey } from '../../hosted-review-cache-identity' -import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../github-cache-key' +import { getGitHubPRCacheKeyCandidates } from '../../../../../../shared/github/github-cache-key' import { applyDetectedWorktreeUpdates, findKnownWorktreeById, @@ -201,26 +201,9 @@ export function createUpdateWorktreeMeta( true ) : null - const prCacheKey = - reviewRepo && reviewBranch - ? getGitHubPRCacheKey( - reviewRepo.path, - reviewRepo.id, - reviewBranch, - s.settings, - reviewRepo.connectionId, - reviewRepo.executionHostId, - true - ) - : null const prCacheKeys = - reviewRepo && reviewBranch - ? [ - prCacheKey, - getLegacyGitHubPRCacheKey(reviewRepo.path, reviewRepo.id, reviewBranch), - getLegacyGitHubPRCacheKey(reviewRepo.path, undefined, reviewBranch) - ].filter((key): key is string => Boolean(key)) - : [] + reviewRepo && reviewBranch ? getGitHubPRCacheKeyCandidates(reviewRepo, reviewBranch) : [] + const prCacheKey = prCacheKeys[0] ?? null const hostedReviewCache = s.hostedReviewCache ?? {} const prCache = s.prCache ?? {} if ( diff --git a/src/renderer/src/store/slices/github-cache-key.ts b/src/shared/github/github-cache-key.ts similarity index 78% rename from src/renderer/src/store/slices/github-cache-key.ts rename to src/shared/github/github-cache-key.ts index 1f5a22908c5..4bc08dd1bac 100644 --- a/src/renderer/src/store/slices/github-cache-key.ts +++ b/src/shared/github/github-cache-key.ts @@ -1,9 +1,9 @@ -import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { GlobalSettings } from '../global-settings-types' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, toSshExecutionHostId -} from '../../../../shared/execution-host' +} from '../execution-host' type RuntimeFocusSettings = Pick | null | undefined @@ -87,3 +87,23 @@ export function getLegacyGitHubPRCacheKey( ): string { return getLegacyGitHubRepoCacheKey(repoPath, repoId, branch) } + +/** Every key a repo's branch PR may sit under in the cache, the repo-scoped one first. */ +export function getGitHubPRCacheKeyCandidates( + repo: { path: string; id: string; connectionId?: string | null; executionHostId?: string | null }, + branch: string +): string[] { + return [ + getGitHubPRCacheKey( + repo.path, + repo.id, + branch, + null, + repo.connectionId, + repo.executionHostId, + true + ), + getLegacyGitHubPRCacheKey(repo.path, repo.id, branch), + getLegacyGitHubPRCacheKey(repo.path, undefined, branch) + ] +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index da8fd28f489..6c72ccbb952 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -1247,6 +1247,7 @@ export const RPC_PARAMS_BY_METHOD = { 'sidebarInputs.activityFacts': null, 'sidebarInputs.desktopFacts': null, 'sidebarInputs.pairedServerRows': null, + 'sidebarInputs.reviewFacts': null, 'skills.beginUpload': SkillUploadBeginRequestSchema, 'skills.cancelInstall': SkillsCancelInstallParams, 'skills.cancelUpload': SkillUploadCommitRequestSchema, diff --git a/src/shared/sidebar-list/workspace-review-facts.test.ts b/src/shared/sidebar-list/workspace-review-facts.test.ts new file mode 100644 index 00000000000..60c130753fc --- /dev/null +++ b/src/shared/sidebar-list/workspace-review-facts.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + buildWorkspaceReviewFacts, + prGroupOfReviewFact, + readWorkspaceReviewFact +} from './workspace-review-facts' + +const repo = { id: 'repo-1', path: '/r', connectionId: null, executionHostId: null } +const row = { id: 'repo-1::/r/a', repoId: 'repo-1', branch: 'refs/heads/a', linkedPR: null } +const pr = (state: string, number = 7) => ({ data: { number, state } }) + +describe('readWorkspaceReviewFact', () => { + it('prefers the repo-scoped entry over legacy ones', () => { + const cache = { 'repo-1::a': pr('merged'), '/r::a': pr('closed') } + expect(readWorkspaceReviewFact(row, repo, cache)).toEqual({ + provider: 'github', + reviewState: 'merged' + }) + expect(readWorkspaceReviewFact(row, repo, { '/r::a': pr('closed') })?.reviewState).toBe( + 'closed' + ) + }) + + it('reads only host-scoped entries for SSH and paired-server repos', () => { + const cache = { 'repo-1::a': pr('merged'), 'ssh:c1::repo-1::a': pr('open') } + expect(readWorkspaceReviewFact(row, { ...repo, connectionId: 'c1' }, cache)?.reviewState).toBe( + 'open' + ) + expect( + readWorkspaceReviewFact(row, { ...repo, executionHostId: 'runtime:env' }, cache) + ).toBeNull() + }) + + it('drops a PR the user unlinked, and rows with no repo, branch or PR', () => { + const cache = { 'repo-1::a': pr('open') } + expect(readWorkspaceReviewFact({ ...row, suppressedGitHubPR: 7 }, repo, cache)).toBeNull() + expect( + readWorkspaceReviewFact({ ...row, linkedPR: 7, suppressedGitHubPR: 7 }, repo, cache) + ).not.toBeNull() + expect(readWorkspaceReviewFact(row, undefined, cache)).toBeNull() + expect(readWorkspaceReviewFact({ ...row, branch: '' }, repo, cache)).toBeNull() + expect(readWorkspaceReviewFact(row, repo, { 'repo-1::a': { data: null } })).toBeNull() + }) +}) + +describe('prGroupOfReviewFact', () => { + it('keeps rows without a PR in progress and lanes the rest by state', () => { + expect(prGroupOfReviewFact(null)).toBe('in-progress') + expect(prGroupOfReviewFact({ provider: 'github', reviewState: 'draft' })).toBe('in-progress') + expect(prGroupOfReviewFact({ provider: 'github', reviewState: '' })).toBe('in-review') + expect(prGroupOfReviewFact({ provider: 'github', reviewState: 'merged' })).toBe('done') + }) +}) + +it('lists only rows with a PR', () => { + const other = { ...row, id: 'repo-1::/r/b', branch: 'b' } + expect(buildWorkspaceReviewFacts([row, other], [repo], { 'repo-1::a': pr('open') })).toEqual({ + [row.id]: { provider: 'github', reviewState: 'open' } + }) +}) diff --git a/src/shared/sidebar-list/workspace-review-facts.ts b/src/shared/sidebar-list/workspace-review-facts.ts new file mode 100644 index 00000000000..56806f0e4f3 --- /dev/null +++ b/src/shared/sidebar-list/workspace-review-facts.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' +import type { Repo } from '../repo-types' +import type { Worktree } from '../worktree/types' +import { getGitHubPRCacheKeyCandidates } from '../github/github-cache-key' +import { isGitHubPRSuppressed } from '../worktree/github-pr-suppression' +import { getPRGroupKeyForState, type PRGroupKey } from '../workspace-view/workspace-pr-groups' + +/** Grouping by PR reads GitHub PRs only, so only a row with a GitHub PR carries a fact. */ +const WorkspaceReviewFactSchema = z.object({ + provider: z.literal('github'), + reviewState: z.string() +}) + +/** Read on demand, so it carries no revision. An absent id has no PR. */ +export const WorkspaceReviewFactsSnapshotSchema = z.object({ + facts: z.record(z.string(), WorkspaceReviewFactSchema) +}) + +export type WorkspaceReviewFact = z.infer +export type WorkspaceReviewFactsSnapshot = z.infer + +/** The window's PR cache, or main's persisted copy of it. */ +export type PRCacheView = Readonly< + Record +> + +/** A worktree's PR as the cache holds it, minus a PR the user unlinked. */ +export function readWorkspaceReviewFact( + worktree: Pick, + repo: Pick | undefined, + prCache: PRCacheView +): WorkspaceReviewFact | null { + const branch = worktree.branch.replace(/^refs\/heads\//, '') + if (!repo || !branch) { + return null + } + const keys = getGitHubPRCacheKeyCandidates(repo, branch) + // Why: legacy keys carry no host, so only a local repo may read them. + const lookup = !repo.connectionId && !repo.executionHostId ? keys : keys.slice(0, 1) + const entry = lookup.map((key) => prCache[key]).find((candidate) => candidate != null) + const pr = entry?.data + if (!pr || (typeof pr.number === 'number' && isGitHubPRSuppressed(worktree, pr.number))) { + return null + } + return { provider: 'github', reviewState: pr.state ?? '' } +} + +export function prGroupOfReviewFact(fact: WorkspaceReviewFact | null | undefined): PRGroupKey { + return fact ? getPRGroupKeyForState(fact.reviewState) : 'in-progress' +} + +export function workspaceReviewFactsEqual( + a: WorkspaceReviewFact | null | undefined, + b: WorkspaceReviewFact | null | undefined +): boolean { + return a?.provider === b?.provider && a?.reviewState === b?.reviewState +} + +/** Facts for every worktree with a PR; the window and main run this over their own copies. */ +export function buildWorkspaceReviewFacts( + worktrees: Iterable< + Pick + >, + repos: Iterable>, + prCache: PRCacheView +): Record { + const repoById = new Map([...repos].map((repo) => [repo.id, repo])) + const facts: Record = {} + for (const worktree of worktrees) { + const fact = readWorkspaceReviewFact(worktree, repoById.get(worktree.repoId), prCache) + if (fact) { + facts[worktree.id] = fact + } + } + return facts +} diff --git a/tests/e2e/sidebar-review-facts.spec.ts b/tests/e2e/sidebar-review-facts.spec.ts new file mode 100644 index 00000000000..4ac1c7ec9a6 --- /dev/null +++ b/tests/e2e/sidebar-review-facts.spec.ts @@ -0,0 +1,65 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { expectNoSidebarShadowMismatches } from './helpers/sidebar-shadow' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { WorkspaceReviewFactsSnapshotSchema } from '../../src/shared/sidebar-list/workspace-review-facts' + +/** Writes a PR into the window's cache the way a fetch result lands; returns the row's id. */ +async function setWindowPRState(page: Page, state: 'open' | 'merged' | null): Promise { + return page.evaluate((state) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const current = store.getState() + const worktree = Object.values(current.worktreesByRepo) + .flat() + .find((entry) => entry.branch.replace(/^refs\/heads\//, '') === 'e2e-secondary') + if (!worktree) { + throw new Error('seeded e2e-secondary worktree not found') + } + const key = `${worktree.repoId}::e2e-secondary` + const pr = state && { + number: 81, + title: 'Review facts from E2E', + state, + url: 'https://github.com/acme/orca/pull/81', + checksStatus: 'pending' as const, + updatedAt: '2026-10-17T00:00:00.000Z', + mergeable: 'UNKNOWN' as const + } + store.setState({ prCache: { ...current.prCache, [key]: { data: pr, fetchedAt: Date.now() } } }) + return worktree.id + }, state) +} + +async function readMainReviewFact(page: Page, worktreeId: string): Promise { + const response = await page.evaluate(() => + window.api.runtime.call({ method: 'sidebarInputs.reviewFacts' }) + ) + if (!response.ok) { + throw new Error('sidebarInputs.reviewFacts failed') + } + return WorkspaceReviewFactsSnapshotSchema.parse(response.result).facts[worktreeId] ?? null +} + +test('main follows the window PR cache for each workspace', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + + const worktreeId = await setWindowPRState(orcaPage, 'open') + // The window mirrors its cache to main within a second of a change. + await expect + .poll(() => readMainReviewFact(orcaPage, worktreeId), { timeout: 5_000 }) + .toEqual({ provider: 'github', reviewState: 'open' }) + + await setWindowPRState(orcaPage, 'merged') + await expect + .poll(() => readMainReviewFact(orcaPage, worktreeId), { timeout: 5_000 }) + .toEqual({ provider: 'github', reviewState: 'merged' }) + await expectNoSidebarShadowMismatches(orcaPage) + + await setWindowPRState(orcaPage, null) + await expect.poll(() => readMainReviewFact(orcaPage, worktreeId), { timeout: 5_000 }).toBeNull() + await expectNoSidebarShadowMismatches(orcaPage) +})