diff --git a/src/main/github/client.ts b/src/main/github/client.ts
index b714c675744..6717cf5451f 100644
--- a/src/main/github/client.ts
+++ b/src/main/github/client.ts
@@ -1159,66 +1159,72 @@ export async function getPRChecks(
const ownerRepo = headSha ? await getOwnerRepo(repoPath) : null
if (ownerRepo && headSha) {
await assertRateLimitBudget('core')
- }
- // Why: even the REST check-runs path can fall back to `gh pr checks`, so
- // guard GraphQL before acquiring the global gh lock.
- await assertRateLimitBudget('graphql')
- await acquire()
- try {
- if (ownerRepo && headSha) {
+ await acquire()
+ try {
// Why: --cache 60s saves rate-limit budget during polling, but when the
// user explicitly clicks refresh we must skip it so gh fetches fresh data.
const cacheArgs = options?.noCache ? [] : ['--cache', '60s']
- try {
- const { stdout } = await ghExecFileAsync(
- [
- 'api',
- ...cacheArgs,
- `repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodeURIComponent(headSha)}/check-runs?per_page=100`
- ],
- { cwd: repoPath }
- )
- noteRateLimitSpend('core')
- const data = JSON.parse(stdout) as {
- check_runs: {
- name: string
- status: string
- conclusion: string | null
- html_url: string
- details_url: string | null
- }[]
- }
- return data.check_runs.map((d) => ({
- name: d.name,
- status: mapCheckRunRESTStatus(d.status),
- conclusion: mapCheckRunRESTConclusion(d.status, d.conclusion),
- url: d.details_url || d.html_url || null
- }))
- } catch (err) {
- // Why: a PR can outlive the cached head SHA after force-pushes or remote
- // rewrites. Falling back to `gh pr checks` keeps the panel populated
- // instead of rendering a false "no checks" state from a stale commit.
- console.warn('getPRChecks via head SHA failed, falling back to gh pr checks:', err)
+ const { stdout } = await ghExecFileAsync(
+ [
+ 'api',
+ ...cacheArgs,
+ `repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodeURIComponent(headSha)}/check-runs?per_page=100`
+ ],
+ { cwd: repoPath }
+ )
+ noteRateLimitSpend('core')
+ const data = JSON.parse(stdout) as {
+ check_runs: {
+ name: string
+ status: string
+ conclusion: string | null
+ html_url: string
+ details_url: string | null
+ }[]
}
+ return data.check_runs.map((d) => ({
+ name: d.name,
+ status: mapCheckRunRESTStatus(d.status),
+ conclusion: mapCheckRunRESTConclusion(d.status, d.conclusion),
+ url: d.details_url || d.html_url || null
+ }))
+ } catch (err) {
+ // Why: a PR can outlive the cached head SHA after force-pushes or remote
+ // rewrites. Falling back to `gh pr checks` keeps the panel populated
+ // instead of rendering a false "no checks" state from a stale commit.
+ console.warn('getPRChecks via head SHA failed, falling back to gh pr checks:', err)
+ } finally {
+ release()
+ }
+ }
+
+ // Fallback: no branch provided or non-GitHub remote
+ // Why: the REST check-runs path spends core only. Guard GraphQL only when
+ // we actually fall back to `gh pr checks`, so a low GraphQL bucket does not
+ // block fresh REST check data. Keep this outside the gh lock because the
+ // guard may need its own `gh api rate_limit` call.
+ try {
+ await assertRateLimitBudget('graphql')
+ await acquire()
+ try {
+ const { stdout } = await ghExecFileAsync(
+ ['pr', 'checks', String(prNumber), '--json', 'name,state,link'],
+ { cwd: repoPath }
+ )
+ noteRateLimitSpend('graphql')
+ const data = JSON.parse(stdout) as { name: string; state: string; link: string }[]
+ return data.map((d) => ({
+ name: d.name,
+ status: mapCheckStatus(d.state),
+ conclusion: mapCheckConclusion(d.state),
+ url: d.link || null
+ }))
+ } finally {
+ release()
}
- // Fallback: no branch provided or non-GitHub remote
- const { stdout } = await ghExecFileAsync(
- ['pr', 'checks', String(prNumber), '--json', 'name,state,link'],
- { cwd: repoPath }
- )
- noteRateLimitSpend('graphql')
- const data = JSON.parse(stdout) as { name: string; state: string; link: string }[]
- return data.map((d) => ({
- name: d.name,
- status: mapCheckStatus(d.state),
- conclusion: mapCheckConclusion(d.state),
- url: d.link || null
- }))
} catch (err) {
console.warn('getPRChecks failed:', err)
return []
- } finally {
- release()
}
}
diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts
index e23303981ee..0b7f0b25532 100644
--- a/src/main/github/pr-refresh-coordinator.ts
+++ b/src/main/github/pr-refresh-coordinator.ts
@@ -547,7 +547,7 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise
message: `Cannot refresh PR for this worktree: ${skippedReason}`,
fetchedAt: Date.now()
}
- broadcast({ aliases, reason: 'manual', status: 'skipped', skippedReason })
+ broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason })
return outcome
}
diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts
index a148aa2d6f2..689a00e051c 100644
--- a/src/main/ipc/github.ts
+++ b/src/main/ipc/github.ts
@@ -166,7 +166,13 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
'gh:refreshPRNow',
async (_event, args: { candidate: GitHubPRRefreshCandidate }) => {
const repo = assertRegisteredRepo(args.candidate.repoPath, store)
- const outcome = await refreshPRNow({ ...args.candidate, repoPath: repo.path })
+ const outcome = await refreshPRNow({
+ ...args.candidate,
+ repoPath: repo.path,
+ repoId: repo.id,
+ connectionId: repo.connectionId ?? args.candidate.connectionId,
+ connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState
+ })
recordPRIfNeeded(repo, outcome)
return outcome
}
@@ -184,7 +190,13 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
) => {
const repo = assertRegisteredRepo(args.candidate.repoPath, store)
enqueuePRRefresh(
- { ...args.candidate, repoPath: repo.path, repoId: repo.id },
+ {
+ ...args.candidate,
+ repoPath: repo.path,
+ repoId: repo.id,
+ connectionId: repo.connectionId ?? args.candidate.connectionId,
+ connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState
+ },
args.reason,
args.priority ?? 0
)
@@ -197,7 +209,13 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
(event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => {
const candidates = args.candidates.map((candidate) => {
const repo = assertRegisteredRepo(candidate.repoPath, store)
- return { ...candidate, repoPath: repo.path, repoId: repo.id }
+ return {
+ ...candidate,
+ repoPath: repo.path,
+ repoId: repo.id,
+ connectionId: repo.connectionId ?? candidate.connectionId,
+ connectionState: repo.connectionId ? 'connected' : candidate.connectionState
+ }
})
reportVisiblePRRefreshCandidates(candidates, args.generation, event.sender.id)
return true
diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
index af7bb77c176..91bdde3c32e 100644
--- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
@@ -77,6 +77,7 @@ export default function ChecksPanel(): React.JSX.Element {
// Find active worktree and repo
const branch = activeWorktree ? activeWorktree.branch.replace(/^refs\/heads\//, '') : ''
const isFolder = repo ? isFolderRepo(repo) : false
+ const isRemoteRepo = Boolean(repo?.connectionId)
const prCacheKey = repo && branch ? `${repo.path}::${branch}` : ''
const refreshContextKey = `${activeWorktreeId ?? ''}::${repo?.path ?? ''}::${branch}`
if (refreshContextKey !== refreshContextKeyRef.current) {
@@ -549,24 +550,28 @@ export default function ChecksPanel(): React.JSX.Element {
{operationInProgress
? `${operationLabel} in progress`
- : isQueuedPRRefresh
- ? 'Checking for pull request'
- : isInFlightPRRefresh
+ : isRemoteRepo
+ ? 'Checks unavailable'
+ : isQueuedPRRefresh
? 'Checking for pull request'
- : 'No pull request found'}
+ : isInFlightPRRefresh
+ ? 'Checking for pull request'
+ : 'No pull request found'}
{operationInProgress
? 'PR checks will be available after the operation completes'
- : isQueuedPRRefresh
- ? 'Waiting to refresh GitHub status for this branch'
- : isInFlightPRRefresh
- ? 'Refreshing GitHub status for this branch'
- : isPausedPRRefresh
- ? 'GitHub refresh is paused by the current rate-limit budget'
- : 'Push your branch and open a PR to see checks here'}
+ : isRemoteRepo
+ ? 'PR refresh is not supported for remote worktrees yet'
+ : isQueuedPRRefresh
+ ? 'Waiting to refresh GitHub status for this branch'
+ : isInFlightPRRefresh
+ ? 'Refreshing GitHub status for this branch'
+ : isPausedPRRefresh
+ ? 'GitHub refresh is paused by the current rate-limit budget'
+ : 'Push your branch and open a PR to see checks here'}
- {!operationInProgress && (
+ {!operationInProgress && !isRemoteRepo && (
{
+ if (document.visibilityState !== 'visible') {
+ lastVisibleRefreshKeyRef.current = '__document_hidden__'
+ return
+ }
if (groupBy !== 'pr-status' && !cardProps.includes('pr') && !cardProps.includes('ci')) {
if (lastVisibleRefreshKeyRef.current !== '__hidden__') {
lastVisibleRefreshKeyRef.current = '__hidden__'
diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts
index 775239af3a7..a95b0d5f97d 100644
--- a/src/renderer/src/store/slices/github.test.ts
+++ b/src/renderer/src/store/slices/github.test.ts
@@ -261,6 +261,36 @@ describe('createGitHubSlice.fetchPRForBranch', () => {
expect(store.getState().prCache[prCacheKey]?.data).toMatchObject({ number: 99 })
})
+
+ it('does not call GitHub refresh IPC for SSH-backed repos', async () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const branch = 'feature/test'
+
+ store.setState({
+ repos: [
+ {
+ id: 'repo-1',
+ path: repoPath,
+ name: 'repo',
+ kind: 'git',
+ connectionId: 'ssh-1'
+ }
+ ],
+ prCache: {
+ [`${repoPath}::${branch}`]: {
+ data: makePR({ number: 44 }),
+ fetchedAt: Date.now()
+ }
+ }
+ } as unknown as Partial)
+
+ await expect(
+ store.getState().fetchPRForBranch(repoPath, branch, { force: true })
+ ).resolves.toMatchObject({ number: 44 })
+ expect(mockApi.gh.prForBranch).not.toHaveBeenCalled()
+ expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled()
+ })
})
describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => {
@@ -290,6 +320,7 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => {
}
]
},
+ worktreeCardProperties: ['pr'],
prCache: {
[`${repoPath}::${branch}`]: {
data: makePR({ state: 'open' }),
@@ -311,6 +342,158 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => {
priority: 80
})
})
+
+ it('does not enqueue active PR refresh when no PR-related surface is visible', () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const branch = 'feature/test'
+ const worktreeId = 'wt-1'
+
+ store.setState({
+ repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
+ groupBy: 'repo',
+ worktreeCardProperties: ['comment'],
+ rightSidebarOpen: false,
+ rightSidebarTab: 'source-control',
+ worktreesByRepo: {
+ 'repo-1': [
+ {
+ id: worktreeId,
+ repoId: 'repo-1',
+ path: '/repo/worktrees/test',
+ branch,
+ displayName: 'test',
+ isMainWorktree: false,
+ isBare: false,
+ isArchived: false
+ }
+ ]
+ }
+ } as unknown as Partial)
+
+ store.getState().refreshGitHubForWorktreeIfStale(worktreeId)
+
+ expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled()
+ })
+
+ it('skips active PR refresh IPC for SSH-backed repos', () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const branch = 'feature/test'
+ const worktreeId = 'wt-1'
+
+ store.setState({
+ repos: [
+ {
+ id: 'repo-1',
+ path: repoPath,
+ name: 'repo',
+ kind: 'git',
+ connectionId: 'ssh-1'
+ }
+ ],
+ groupBy: 'pr-status',
+ sshConnectionStates: new Map([['ssh-1', { status: 'connected' }]]),
+ worktreesByRepo: {
+ 'repo-1': [
+ {
+ id: worktreeId,
+ repoId: 'repo-1',
+ path: '/repo/worktrees/test',
+ branch,
+ displayName: 'test',
+ isMainWorktree: false,
+ isBare: false,
+ isArchived: false
+ }
+ ]
+ }
+ } as unknown as Partial)
+
+ store.getState().refreshGitHubForWorktreeIfStale(worktreeId)
+
+ expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled()
+ })
+
+ it('enqueues active PR refresh when source control is the visible PR surface', () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const branch = 'feature/test'
+ const worktreeId = 'wt-1'
+
+ store.setState({
+ repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
+ groupBy: 'repo',
+ worktreeCardProperties: ['comment'],
+ rightSidebarOpen: true,
+ rightSidebarTab: 'source-control',
+ worktreesByRepo: {
+ 'repo-1': [
+ {
+ id: worktreeId,
+ repoId: 'repo-1',
+ path: '/repo/worktrees/test',
+ branch,
+ displayName: 'test',
+ isMainWorktree: false,
+ isBare: false,
+ isArchived: false
+ }
+ ]
+ }
+ } as unknown as Partial)
+
+ store.getState().refreshGitHubForWorktreeIfStale(worktreeId)
+
+ expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledWith({
+ candidate: expect.objectContaining({ repoPath, branch }),
+ reason: 'active',
+ priority: 80
+ })
+ })
+})
+
+describe('createGitHubSlice.refreshAllGitHub', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('refreshes stale PR data when source control is the visible PR surface', () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const branch = 'feature/test'
+
+ store.setState({
+ repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
+ groupBy: 'repo',
+ worktreeCardProperties: ['comment'],
+ rightSidebarOpen: true,
+ rightSidebarTab: 'source-control',
+ worktreesByRepo: {
+ 'repo-1': [
+ {
+ id: 'wt-1',
+ repoId: 'repo-1',
+ path: '/repo/worktrees/test',
+ branch,
+ displayName: 'test',
+ isMainWorktree: false,
+ isBare: false,
+ isArchived: false,
+ lastActivityAt: 1
+ }
+ ]
+ }
+ } as unknown as Partial)
+
+ store.getState().refreshAllGitHub()
+
+ expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledWith({
+ candidate: expect.objectContaining({ repoPath, branch }),
+ reason: 'swr',
+ priority: 10
+ })
+ })
})
describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts
index cb47259ee7d..0abaf66bb8d 100644
--- a/src/renderer/src/store/slices/github.ts
+++ b/src/renderer/src/store/slices/github.ts
@@ -394,6 +394,12 @@ function buildPRRefreshCandidate(
const sshStatus = repo.connectionId
? state.sshConnectionStates.get(repo.connectionId)?.status
: null
+ if (repo.connectionId) {
+ // Why: main currently runs gh against local repo paths only. Avoid sending
+ // remote worktrees into a queue that can only skip them until SSH gh support
+ // exists.
+ return null
+ }
return {
repoId: repo.id,
repoPath: repoPath ?? repo.path,
@@ -1257,6 +1263,10 @@ export const createGitHubSlice: StateCreator = (s
fetchPRForBranch: async (repoPath, branch, options): Promise => {
const cacheKey = prCacheKey(repoPath, branch)
const cached = get().prCache[cacheKey]
+ const repo = (get().repos ?? []).find((r) => r.path === repoPath)
+ if (repo?.connectionId) {
+ return cached?.data ?? null
+ }
// 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
// return null forever. Refetch when the cached miss could now resolve via
@@ -1623,10 +1633,16 @@ export const createGitHubSlice: StateCreator = (s
const state = get()
const now = Date.now()
const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = []
+ const cardProps = state.worktreeCardProperties ?? []
+ const isPRStatusGrouping = state.groupBy === 'pr-status'
+ const rightSidebarShowsPR =
+ state.rightSidebarOpen &&
+ (state.rightSidebarTab === 'checks' || state.rightSidebarTab === 'source-control')
const shouldRefreshPRs =
- state.groupBy === 'pr-status' ||
- state.worktreeCardProperties.includes('pr') ||
- state.worktreeCardProperties.includes('ci')
+ isPRStatusGrouping ||
+ rightSidebarShowsPR ||
+ cardProps.includes('pr') ||
+ cardProps.includes('ci')
for (const worktrees of Object.values(state.worktreesByRepo)) {
for (const wt of worktrees) {
@@ -1660,7 +1676,10 @@ export const createGitHubSlice: StateCreator = (s
}
}
}
- for (const { candidate } of stalePRCandidates.sort((a, b) => b.score - a.score).slice(0, 5)) {
+ const candidatesToRefresh = stalePRCandidates
+ .sort((a, b) => b.score - a.score)
+ .slice(0, isPRStatusGrouping ? stalePRCandidates.length : 5)
+ for (const { candidate } of candidatesToRefresh) {
void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'swr', priority: 10 })
}
},
@@ -1833,8 +1852,15 @@ export const createGitHubSlice: StateCreator = (s
}
const branch = worktree.branch.replace(/^refs\/heads\//, '')
+ const cardProps = state.worktreeCardProperties ?? []
+ const shouldRefreshPR =
+ state.groupBy === 'pr-status' ||
+ cardProps.includes('pr') ||
+ cardProps.includes('ci') ||
+ (state.rightSidebarOpen &&
+ (state.rightSidebarTab === 'checks' || state.rightSidebarTab === 'source-control'))
- if (!worktree.isBare && branch) {
+ if (shouldRefreshPR && !worktree.isBare && branch) {
const candidate = buildPRRefreshCandidate(state, worktree)
if (candidate) {
void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'active', priority: 80 })