Fix Checks panel local branch empty state (#2768)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-05-25 13:27:02 -07:00
committed by GitHub
co-authored by Orca
parent 7c7bb38c12
commit adeff3e245
5 changed files with 889 additions and 54 deletions
@@ -42,6 +42,33 @@ import {
shouldCommitChecksPanelAsyncResult
} from './checks-panel-async-result-key'
import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller'
import {
getChecksPanelEmptyStateCopy,
shouldShowChecksPanelPublishBranchAction
} from './checks-panel-empty-state'
import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client'
import {
buildChecksPanelGitStatusContextKey,
readChecksPanelPublishActionGitStatus,
readChecksPanelGitStatusSnapshot,
shouldClearChecksPanelGitStatusSnapshot,
shouldCoalesceChecksPanelGitStatusSnapshotRefresh,
shouldCommitChecksPanelGitStatusSnapshot,
shouldPollChecksPanelRuntimeSshStatus,
type ChecksPanelGitStatusSnapshot
} from './checks-panel-git-status-snapshot'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
const RUNTIME_SSH_STATUS_REFRESH_MS = 3000
const GIT_STATUS_FAILURE_RETRY_MS = 3000
type HostedReviewCreationSnapshot = {
requestKey: string
repoId: string
worktreeId: string | null
branch: string
data: HostedReviewCreationEligibility
}
export default function ChecksPanel(): React.JSX.Element {
const activeWorktree = useActiveWorktree()
@@ -58,12 +85,13 @@ export default function ChecksPanel(): React.JSX.Element {
const conflictOperation = useAppStore((s) =>
activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown'
)
const hasUncommittedChanges = useAppStore((s) =>
activeWorktreeId ? (s.gitStatusByWorktree[activeWorktreeId]?.length ?? 0) > 0 : false
const gitStatusInvalidation = useAppStore((s) =>
activeWorktreeId ? s.gitStatusByWorktree[activeWorktreeId] : undefined
)
const remoteStatus = useAppStore((s) =>
const remoteStatusInvalidation = useAppStore((s) =>
activeWorktreeId ? s.remoteStatusesByWorktree[activeWorktreeId] : undefined
)
const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive)
const pushBranch = useAppStore((s) => s.pushBranch)
const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus)
const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
@@ -90,10 +118,15 @@ export default function ChecksPanel(): React.JSX.Element {
const [conflictDetailsRefreshing, setConflictDetailsRefreshing] = useState(false)
const [createPrDialogOpen, setCreatePrDialogOpen] = useState(false)
const [createPrPushFirst, setCreatePrPushFirst] = useState(false)
const [isPublishingBranch, setIsPublishingBranch] = useState(false)
const [isResolvingConflictsWithAI, setIsResolvingConflictsWithAI] = useState(false)
const [isFixingChecksWithAI, setIsFixingChecksWithAI] = useState(false)
const [hostedReviewCreation, setHostedReviewCreation] =
useState<HostedReviewCreationEligibility | null>(null)
const [hostedReviewCreationSnapshot, setHostedReviewCreationSnapshot] =
useState<HostedReviewCreationSnapshot | null>(null)
const [gitStatusSnapshot, setGitStatusSnapshot] = useState<ChecksPanelGitStatusSnapshot | null>(
null
)
const [gitStatusRefreshNonce, setGitStatusRefreshNonce] = useState(0)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
const [titleSaving, setTitleSaving] = useState(false)
@@ -104,31 +137,67 @@ export default function ChecksPanel(): React.JSX.Element {
const asyncResultKeyRef = useRef<string>('')
const refreshRequestKeyRef = useRef<string | null>(null)
const refreshContextKeyRef = useRef<string | null>(null)
const gitStatusSnapshotInFlightContextRef = useRef<string | null>(null)
const gitStatusSnapshotRerunContextRef = useRef<string | null>(null)
const gitStatusSnapshotRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const branch = activeWorktree ? activeWorktree.branch.replace(/^refs\/heads\//, '') : ''
const activeWorktreePath = activeWorktree?.path ?? null
const activeWorktreePushTarget = activeWorktree?.pushTarget ?? null
const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null
const repoConnectionId = repo?.connectionId?.trim() || null
const sshConnectionStatus = useAppStore((s) =>
repoConnectionId ? s.sshConnectionStates.get(repoConnectionId)?.status : undefined
)
const panelContextKey = buildChecksPanelGitStatusContextKey({
repoId: repo?.id,
worktreeId: activeWorktreeId,
worktreePath: activeWorktreePath,
branch,
runtimeEnvironmentId,
repoConnectionId,
pushTarget: activeWorktreePushTarget
})
const panelContextKeyRef = useRef(panelContextKey)
panelContextKeyRef.current = panelContextKey
// Why: the sidebar no longer uses key={activeWorktreeId} to force a full
// remount on worktree switch (that caused an IPC storm on Windows).
// Reset worktree-specific local state so stale UI from the previous
// worktree doesn't leak (e.g. mid-edit title, stale loading indicators).
// remount on worktree switch (that caused an IPC storm on Windows). Reset
// branch-specific local state so stale UI from the previous context doesn't
// leak (e.g. mid-edit title, stale loading indicators, PR dialog fields).
// Done during render (not useEffect) so the reset takes effect on the same
// paint as the worktree change useEffect would leave one render with the
// previous worktree's stale title/loading state visible.
const [prevActiveWorktreeId, setPrevActiveWorktreeId] = useState(activeWorktreeId)
if (activeWorktreeId !== prevActiveWorktreeId) {
setPrevActiveWorktreeId(activeWorktreeId)
// paint as the context change; useEffect would leave one stale render.
const [prevPanelContextKey, setPrevPanelContextKey] = useState(panelContextKey)
if (panelContextKey !== prevPanelContextKey) {
setPrevPanelContextKey(panelContextKey)
setEditingTitle(false)
setTitleDraft('')
setTitleSaving(false)
setChecks([])
setChecksLoading(false)
setComments([])
setCommentsLoading(false)
setIsRefreshing(false)
setEmptyRefreshing(false)
setConflictDetailsRefreshing(false)
setCreatePrDialogOpen(false)
setCreatePrPushFirst(false)
setIsPublishingBranch(false)
setIsResolvingConflictsWithAI(false)
setIsFixingChecksWithAI(false)
setHostedReviewCreationSnapshot(null)
setGitStatusSnapshot(null)
setGitStatusRefreshNonce((value) => value + 1)
pollIntervalRef.current = 30_000
prevChecksRef.current = ''
conflictSummaryRefreshKeyRef.current = null
refreshRequestKeyRef.current = null
if (gitStatusSnapshotRetryTimerRef.current) {
clearTimeout(gitStatusSnapshotRetryTimerRef.current)
gitStatusSnapshotRetryTimerRef.current = null
}
}
// Find active worktree and repo
const branch = activeWorktree ? activeWorktree.branch.replace(/^refs\/heads\//, '') : ''
const isFolder = repo ? isFolderRepo(repo) : false
const prCacheKey =
repo && branch
@@ -184,7 +253,58 @@ export default function ChecksPanel(): React.JSX.Element {
const linkedPR = activeWorktree?.linkedPR ?? null
const fallbackGitHubPRNumber = linkedPR == null ? (pr?.number ?? null) : null
const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null
const activeWorktreePath = activeWorktree?.path ?? null
const hostedReviewCreationRequestKey =
repo && branch
? JSON.stringify({
repoId: repo.id,
repoPath: repo.path,
worktreeId: activeWorktreeId ?? null,
worktreePath: activeWorktreePath,
runtimeEnvironmentId,
connectionId: repoConnectionId,
branch,
base: repo.worktreeBaseRef ?? null,
hasUncommittedChanges:
gitStatusSnapshot?.contextKey === panelContextKey
? gitStatusSnapshot.hasUncommittedChanges
: null,
hasUpstream:
gitStatusSnapshot?.contextKey === panelContextKey
? (gitStatusSnapshot.remoteStatus?.hasUpstream ?? null)
: null,
ahead:
gitStatusSnapshot?.contextKey === panelContextKey
? (gitStatusSnapshot.remoteStatus?.ahead ?? null)
: null,
behind:
gitStatusSnapshot?.contextKey === panelContextKey
? (gitStatusSnapshot.remoteStatus?.behind ?? null)
: null,
linkedGitHubPR: linkedPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR
})
: ''
const gitStatusInputs = readChecksPanelGitStatusSnapshot(gitStatusSnapshot, panelContextKey)
const gitStatusReadyForPanelContext = gitStatusInputs.hasUncommittedChanges !== undefined
const hasUncommittedChanges = gitStatusInputs.hasUncommittedChanges
const remoteStatus = gitStatusInputs.remoteStatus
// Why: Create PR eligibility waits for the stricter panel snapshot, but the
// Publish affordance can use the active worktree poller when SSH snapshot
// refresh is delayed; publishing is still blocked for dirty fallback status.
const publishActionGitStatusInputs = readChecksPanelPublishActionGitStatus({
snapshot: gitStatusSnapshot,
contextKey: panelContextKey,
fallbackEntries: gitStatusInvalidation,
fallbackRemoteStatus: remoteStatusInvalidation
})
const publishActionHasUncommittedChanges =
publishActionGitStatusInputs.hasUncommittedChanges ?? true
const publishActionRemoteStatus = publishActionGitStatusInputs.remoteStatus
const hostedReviewCreation =
hostedReviewCreationSnapshot?.requestKey === hostedReviewCreationRequestKey
? hostedReviewCreationSnapshot.data
: null
const stateRequestKey =
repo && branch
? checksPanelAsyncResultKey(prCacheKey, branch, prNumber, pr?.prRepo, pr?.headSha)
@@ -197,16 +317,185 @@ export default function ChecksPanel(): React.JSX.Element {
[]
)
useEffect(() => {
if (isPanelVisible && repo && !isFolder && branch) {
if (isPanelVisible && repo && !isFolder && branch && prCacheKey) {
if (activeWorktreeId) {
enqueueGitHubPRRefresh(activeWorktreeId, 'swr', 30)
}
}
}, [repo, isFolder, branch, activeWorktreeId, enqueueGitHubPRRefresh, isPanelVisible])
}, [repo, isFolder, branch, prCacheKey, activeWorktreeId, enqueueGitHubPRRefresh, isPanelVisible])
useEffect(() => {
if (!repo || isFolder || !branch || !isPanelVisible) {
setHostedReviewCreation(null)
if (
!shouldPollChecksPanelRuntimeSshStatus({
isPanelVisible,
runtimeEnvironmentId,
repoConnectionId
})
) {
return undefined
}
let skippedInitialRun = false
return installWindowVisibilityInterval({
run: () => {
if (!skippedInitialRun) {
skippedInitialRun = true
return
}
const currentContextKey = panelContextKeyRef.current
if (
shouldCoalesceChecksPanelGitStatusSnapshotRefresh(
gitStatusSnapshotInFlightContextRef.current,
currentContextKey
)
) {
gitStatusSnapshotRerunContextRef.current = currentContextKey
return
}
setGitStatusRefreshNonce((value) => value + 1)
},
intervalMs: RUNTIME_SSH_STATUS_REFRESH_MS
})
}, [isPanelVisible, repoConnectionId, runtimeEnvironmentId])
useEffect(() => {
if (
!repo ||
isFolder ||
!branch ||
!isPanelVisible ||
!activeWorktreeId ||
!activeWorktreePath ||
(!runtimeEnvironmentId && repoConnectionId && sshConnectionStatus !== 'connected')
) {
if (gitStatusSnapshotRetryTimerRef.current) {
clearTimeout(gitStatusSnapshotRetryTimerRef.current)
gitStatusSnapshotRetryTimerRef.current = null
}
// Why: hiding the panel or temporarily losing SSH should stop new work,
// not erase same-context Create PR eligibility that can still be retried.
return
}
let stale = false
const requestContextKey = panelContextKey
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
if (
shouldCoalesceChecksPanelGitStatusSnapshotRefresh(
gitStatusSnapshotInFlightContextRef.current,
requestContextKey
)
) {
gitStatusSnapshotRerunContextRef.current = requestContextKey
return () => {
stale = true
}
}
gitStatusSnapshotInFlightContextRef.current = requestContextKey
// Why: global status maps are keyed only by worktree. Use their changes as
// invalidation signals, then fetch a local snapshot for the active boundary.
if (gitStatusSnapshotRetryTimerRef.current) {
clearTimeout(gitStatusSnapshotRetryTimerRef.current)
gitStatusSnapshotRetryTimerRef.current = null
}
setGitStatusSnapshot((snapshot) =>
shouldClearChecksPanelGitStatusSnapshot(snapshot, requestContextKey) ? null : snapshot
)
const context = {
settings: useAppStore.getState().settings,
worktreeId: activeWorktreeId,
worktreePath: activeWorktreePath,
connectionId
}
void (async () => {
const status = await getRuntimeGitStatus(context)
let freshRemoteStatus = status.upstreamStatus
if (activeWorktreePushTarget) {
freshRemoteStatus = await getRuntimeGitUpstreamStatus(context, activeWorktreePushTarget)
} else if (
!freshRemoteStatus ||
(freshRemoteStatus.ahead > 0 &&
freshRemoteStatus.behind > 0 &&
freshRemoteStatus.behindCommitsArePatchEquivalent === undefined)
) {
freshRemoteStatus = await getRuntimeGitUpstreamStatus(context)
}
return { status, remoteStatus: freshRemoteStatus }
})()
.then(({ status, remoteStatus }) => {
if (
!stale &&
shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey)
) {
setGitStatusSnapshot({
contextKey: requestContextKey,
hasUncommittedChanges: status.entries.length > 0,
remoteStatus
})
}
})
.catch((error) => {
console.warn('[ChecksPanel] git status refresh before eligibility failed', error)
if (!stale) {
// Why: transient SSH/runtime flakes should not hide an already-valid
// Create PR state for this same branch; retry while the panel stays visible.
setGitStatusSnapshot((snapshot) =>
shouldClearChecksPanelGitStatusSnapshot(snapshot, requestContextKey) ? null : snapshot
)
gitStatusSnapshotRetryTimerRef.current = setTimeout(() => {
gitStatusSnapshotRetryTimerRef.current = null
if (
shouldCommitChecksPanelGitStatusSnapshot(
panelContextKeyRef.current,
requestContextKey
)
) {
setGitStatusRefreshNonce((value) => value + 1)
}
}, GIT_STATUS_FAILURE_RETRY_MS)
}
})
.finally(() => {
if (gitStatusSnapshotInFlightContextRef.current === requestContextKey) {
gitStatusSnapshotInFlightContextRef.current = null
}
if (gitStatusSnapshotRerunContextRef.current === requestContextKey) {
gitStatusSnapshotRerunContextRef.current = null
if (
shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey)
) {
setGitStatusRefreshNonce((value) => value + 1)
}
}
})
return () => {
stale = true
if (gitStatusSnapshotRetryTimerRef.current) {
clearTimeout(gitStatusSnapshotRetryTimerRef.current)
gitStatusSnapshotRetryTimerRef.current = null
}
}
}, [
activeWorktreePushTarget,
activeWorktreeId,
activeWorktreePath,
branch,
gitStatusInvalidation,
gitStatusRefreshNonce,
isFolder,
isPanelVisible,
panelContextKey,
repo,
repoConnectionId,
remoteStatusInvalidation,
runtimeEnvironmentId,
sshConnectionStatus
])
useEffect(() => {
if (!repo || isFolder || !branch) {
setHostedReviewCreationSnapshot(null)
return
}
if (!isPanelVisible || !gitStatusReadyForPanelContext) {
return
}
let stale = false
@@ -228,22 +517,31 @@ export default function ChecksPanel(): React.JSX.Element {
})
.then((result) => {
if (!stale) {
setHostedReviewCreation(result)
setHostedReviewCreationSnapshot({
requestKey: hostedReviewCreationRequestKey,
repoId: repo.id,
worktreeId: activeWorktreeId,
branch,
data: result
})
}
})
.catch(() => {
if (!stale) {
setHostedReviewCreation(null)
setHostedReviewCreationSnapshot(null)
}
})
return () => {
stale = true
}
}, [
activeWorktreeId,
activeWorktreePath,
branch,
getHostedReviewCreationEligibility,
gitStatusReadyForPanelContext,
hasUncommittedChanges,
hostedReviewCreationRequestKey,
isFolder,
isPanelVisible,
linkedPR,
@@ -509,6 +807,7 @@ export default function ChecksPanel(): React.JSX.Element {
refreshRequestKeyRef.current = refreshRequestKey
const isCurrentRequest = (): boolean => refreshRequestKeyRef.current === refreshRequestKey
setIsRefreshing(true)
setGitStatusRefreshNonce((value) => value + 1)
try {
const refreshedPR = await fetchPRForBranch(repo.path, branch, {
force: true,
@@ -663,9 +962,7 @@ export default function ChecksPanel(): React.JSX.Element {
// duplicate fetches from rapid show/hide toggles. See
// docs/refresh-on-checks-tab.md.
const entryKey =
isPanelVisible && repo && !isFolder && branch
? `${activeWorktreeId ?? ''}::${repo.id}::${branch}`
: ''
isPanelVisible && repo && !isFolder && branch ? `${activeWorktreeId ?? ''}::${prCacheKey}` : ''
const lastEntryKeyRef = useRef<string>('')
useEffect(() => {
if (!entryKey) {
@@ -935,6 +1232,48 @@ export default function ChecksPanel(): React.JSX.Element {
}
}, [activeWorktree, activeWorktreeId, fetchUpstreamStatus, pushBranch])
const handlePublishBranch = useCallback(async (): Promise<void> => {
if (
!activeWorktreeId ||
!activeWorktree?.path ||
isPublishingBranch ||
isRemoteOperationActive
) {
return
}
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
setIsPublishingBranch(true)
try {
await pushBranch(
activeWorktreeId,
activeWorktree.path,
true,
connectionId,
activeWorktree.pushTarget
)
await fetchUpstreamStatus(
activeWorktreeId,
activeWorktree.path,
connectionId,
activeWorktree.pushTarget
)
} catch {
// Store remote actions already surface the publish failure toast.
} finally {
// Why: publishing changes the upstream boundary the Checks panel uses to
// decide between Publish, Create PR, and Push & Create PR.
setGitStatusRefreshNonce((value) => value + 1)
setIsPublishingBranch(false)
}
}, [
activeWorktree,
activeWorktreeId,
fetchUpstreamStatus,
isPublishingBranch,
isRemoteOperationActive,
pushBranch
])
const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise<void> => {
if (!activeWorktreeId || !activeWorktree?.path) {
return
@@ -1132,13 +1471,21 @@ export default function ChecksPanel(): React.JSX.Element {
: conflictOperation === 'cherry-pick'
? 'Cherry-pick'
: null
const isQueuedPRRefresh = prRefreshState?.status === 'queued'
const isInFlightPRRefresh = prRefreshState?.status === 'in-flight'
const isPausedPRRefresh = prRefreshState?.status === 'paused'
const isErroredPRRefresh = prRefreshState?.status === 'error'
const canCreate = hostedReviewCreation?.canCreate
const canPushCreate = hostedReviewCreation?.blockedReason === 'needs_push'
const canPublishBranch =
isPublishingBranch ||
(!publishActionHasUncommittedChanges &&
shouldShowChecksPanelPublishBranchAction({
hostedReviewBlockedReason: hostedReviewCreation?.blockedReason,
hasUpstream: publishActionRemoteStatus?.hasUpstream
}))
const emptyStateCopy = getChecksPanelEmptyStateCopy({
operationLabel,
prRefreshStatus: prRefreshState?.status,
hostedReviewBlockedReason: hostedReviewCreation?.blockedReason,
hasUpstream: publishActionRemoteStatus?.hasUpstream
})
return (
<>
{repo && (
@@ -1158,32 +1505,19 @@ export default function ChecksPanel(): React.JSX.Element {
/>
)}
<div className="px-4 py-6">
<div className="text-sm font-medium text-foreground">
{operationInProgress
? `${operationLabel} in progress`
: isErroredPRRefresh
? 'Could not refresh pull request'
: isQueuedPRRefresh || isInFlightPRRefresh
? 'Checking for pull request'
: 'No pull request found'}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{operationInProgress
? 'PR checks will be available after the operation completes'
: isErroredPRRefresh
? 'GitHub status could not be refreshed. Existing cached data was preserved.'
: 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'
: canPushCreate
? 'Push your branch before creating a pull request.'
: 'Create a pull request to start checks and review.'}
</div>
<div className="text-sm font-medium text-foreground">{emptyStateCopy.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{emptyStateCopy.description}</div>
{!operationInProgress && (
<div className="mt-3 flex flex-wrap gap-2">
{canPublishBranch && (
<Button
size="xs"
disabled={isPublishingBranch || isRemoteOperationActive}
onClick={handlePublishBranch}
>
{isPublishingBranch ? 'Publishing…' : 'Publish Branch'}
</Button>
)}
{(canCreate || canPushCreate) && (
<Button
size="xs"
@@ -1198,7 +1532,7 @@ export default function ChecksPanel(): React.JSX.Element {
<Button
size="xs"
variant="outline"
disabled={emptyRefreshing}
disabled={emptyRefreshing || isPublishingBranch || isRemoteOperationActive}
onClick={() => {
if (!activeWorktreeId) {
return
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import {
getChecksPanelEmptyStateCopy,
shouldShowChecksPanelPublishBranchAction
} from './checks-panel-empty-state'
describe('getChecksPanelEmptyStateCopy', () => {
it('shows a local-only branch message instead of a refresh error', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: 'error',
hostedReviewBlockedReason: 'no_upstream',
hasUpstream: false
})
).toEqual({
title: 'Branch not published',
description: 'Publish this branch before creating a pull request.'
})
})
it('uses remote status as a fallback before eligibility finishes', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: 'error',
hostedReviewBlockedReason: undefined,
hasUpstream: false
}).title
).toBe('Branch not published')
})
it('uses remote status as a fallback when eligibility has no concrete blocker', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: 'error',
hostedReviewBlockedReason: null,
hasUpstream: false
}).title
).toBe('Branch not published')
})
it('shows unpushed commits before a refresh error', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: 'error',
hostedReviewBlockedReason: 'needs_push',
hasUpstream: true
})
).toEqual({
title: 'Branch has unpushed commits',
description: 'Push your branch before creating a pull request.'
})
})
it('shows unpublished branch copy even when PR provider eligibility has another blocker', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: 'error',
hostedReviewBlockedReason: 'unsupported_provider',
hasUpstream: false
}).title
).toBe('Branch not published')
})
it('keeps the generic refresh error when no local branch action is known', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: 'error',
hostedReviewBlockedReason: null,
hasUpstream: true
}).title
).toBe('Could not refresh pull request')
})
})
describe('shouldShowChecksPanelPublishBranchAction', () => {
it('shows publish when eligibility reports no upstream', () => {
expect(
shouldShowChecksPanelPublishBranchAction({
hostedReviewBlockedReason: 'no_upstream',
hasUpstream: undefined
})
).toBe(true)
})
it('uses remote status even when provider eligibility has a separate blocker', () => {
expect(
shouldShowChecksPanelPublishBranchAction({
hostedReviewBlockedReason: undefined,
hasUpstream: false
})
).toBe(true)
expect(
shouldShowChecksPanelPublishBranchAction({
hostedReviewBlockedReason: 'unsupported_provider',
hasUpstream: false
})
).toBe(true)
})
})
@@ -0,0 +1,84 @@
import type { HostedReviewCreationBlockedReason } from '../../../../shared/hosted-review'
type PRRefreshStatus = 'queued' | 'in-flight' | 'paused' | 'error' | 'skipped' | undefined
type ChecksPanelEmptyStateInput = {
operationLabel: string | null
prRefreshStatus: PRRefreshStatus
hostedReviewBlockedReason: HostedReviewCreationBlockedReason | undefined
hasUpstream: boolean | undefined
}
type ChecksPanelEmptyStateCopy = {
title: string
description: string
}
export function getChecksPanelEmptyStateCopy(
input: ChecksPanelEmptyStateInput
): ChecksPanelEmptyStateCopy {
if (input.operationLabel) {
return {
title: `${input.operationLabel} in progress`,
description: 'PR checks will be available after the operation completes'
}
}
const blockedReason = input.hostedReviewBlockedReason
if (
shouldShowChecksPanelPublishBranchAction({
hostedReviewBlockedReason: blockedReason,
hasUpstream: input.hasUpstream
})
) {
// Why: a local-only branch cannot have GitHub PR status yet; surfacing a
// refresh error here makes a normal pre-publish state look broken.
return {
title: 'Branch not published',
description: 'Publish this branch before creating a pull request.'
}
}
if (blockedReason === 'needs_push') {
return {
title: 'Branch has unpushed commits',
description: 'Push your branch before creating a pull request.'
}
}
switch (input.prRefreshStatus) {
case 'error':
return {
title: 'Could not refresh pull request',
description: 'GitHub status could not be refreshed. Existing cached data was preserved.'
}
case 'queued':
return {
title: 'Checking for pull request',
description: 'Waiting to refresh GitHub status for this branch'
}
case 'in-flight':
return {
title: 'Checking for pull request',
description: 'Refreshing GitHub status for this branch'
}
case 'paused':
return {
title: 'No pull request found',
description: 'GitHub refresh is paused by the current rate-limit budget'
}
default:
return {
title: 'No pull request found',
description: 'Create a pull request to start checks and review.'
}
}
}
export function shouldShowChecksPanelPublishBranchAction(input: {
hostedReviewBlockedReason: HostedReviewCreationBlockedReason | undefined
hasUpstream: boolean | undefined
}): boolean {
const blockedReason = input.hostedReviewBlockedReason
return input.hasUpstream === false || blockedReason === 'no_upstream'
}
@@ -0,0 +1,204 @@
import { describe, expect, it } from 'vitest'
import {
buildChecksPanelGitStatusContextKey,
readChecksPanelPublishActionGitStatus,
readChecksPanelGitStatusSnapshot,
shouldClearChecksPanelGitStatusSnapshot,
shouldCoalesceChecksPanelGitStatusSnapshotRefresh,
shouldCommitChecksPanelGitStatusSnapshot,
shouldPollChecksPanelRuntimeSshStatus,
type ChecksPanelGitStatusSnapshot
} from './checks-panel-git-status-snapshot'
const SNAPSHOT: ChecksPanelGitStatusSnapshot = {
contextKey: 'runtime:env-1::repo::worktree::branch',
hasUncommittedChanges: true,
remoteStatus: {
hasUpstream: false,
ahead: 0,
behind: 0
}
}
describe('buildChecksPanelGitStatusContextKey', () => {
it('changes when an explicit push target changes', () => {
const base = {
repoId: 'repo-1',
worktreeId: 'worktree-1',
worktreePath: 'repo-worktree',
branch: 'feature/checks',
runtimeEnvironmentId: 'runtime-1',
repoConnectionId: 'ssh-1'
}
expect(
buildChecksPanelGitStatusContextKey({
...base,
pushTarget: { remoteName: 'origin', branchName: 'feature/checks' }
})
).not.toBe(
buildChecksPanelGitStatusContextKey({
...base,
pushTarget: { remoteName: 'fork', branchName: 'feature/checks' }
})
)
})
})
describe('readChecksPanelGitStatusSnapshot', () => {
it('returns status inputs for the matching panel context', () => {
expect(readChecksPanelGitStatusSnapshot(SNAPSHOT, SNAPSHOT.contextKey)).toEqual({
hasUncommittedChanges: true,
remoteStatus: {
hasUpstream: false,
ahead: 0,
behind: 0
}
})
})
it('withholds worktree-keyed status after a runtime or SSH context change', () => {
expect(
readChecksPanelGitStatusSnapshot(SNAPSHOT, 'runtime:env-2::repo::worktree::branch')
).toEqual({
hasUncommittedChanges: undefined,
remoteStatus: undefined
})
})
})
describe('readChecksPanelPublishActionGitStatus', () => {
it('uses the matching panel snapshot before worktree-keyed fallback status', () => {
expect(
readChecksPanelPublishActionGitStatus({
snapshot: SNAPSHOT,
contextKey: SNAPSHOT.contextKey,
fallbackEntries: [],
fallbackRemoteStatus: {
hasUpstream: true,
ahead: 0,
behind: 0
}
})
).toEqual({
hasUncommittedChanges: true,
remoteStatus: {
hasUpstream: false,
ahead: 0,
behind: 0
}
})
})
it('falls back to active worktree status when the panel snapshot is unavailable', () => {
expect(
readChecksPanelPublishActionGitStatus({
snapshot: null,
contextKey: SNAPSHOT.contextKey,
fallbackEntries: [],
fallbackRemoteStatus: {
hasUpstream: false,
ahead: 0,
behind: 0
}
})
).toEqual({
hasUncommittedChanges: false,
remoteStatus: {
hasUpstream: false,
ahead: 0,
behind: 0
}
})
})
it('does not synthesize publish inputs without fallback upstream status', () => {
expect(
readChecksPanelPublishActionGitStatus({
snapshot: null,
contextKey: SNAPSHOT.contextKey,
fallbackEntries: [],
fallbackRemoteStatus: undefined
})
).toEqual({
hasUncommittedChanges: undefined,
remoteStatus: undefined
})
})
})
describe('shouldCommitChecksPanelGitStatusSnapshot', () => {
it('suppresses stale status refresh completions from an older execution boundary', () => {
expect(
shouldCommitChecksPanelGitStatusSnapshot(
'runtime:env-2::repo::worktree::branch',
'runtime:env-1::repo::worktree::branch'
)
).toBe(false)
})
})
describe('shouldCoalesceChecksPanelGitStatusSnapshotRefresh', () => {
it('coalesces only requests for the same panel context', () => {
expect(
shouldCoalesceChecksPanelGitStatusSnapshotRefresh(
'runtime:env-1::repo::worktree::branch',
'runtime:env-1::repo::worktree::branch'
)
).toBe(true)
expect(
shouldCoalesceChecksPanelGitStatusSnapshotRefresh(
'runtime:env-1::repo::worktree::branch',
'runtime:env-2::repo::worktree::branch'
)
).toBe(false)
})
})
describe('shouldClearChecksPanelGitStatusSnapshot', () => {
it('keeps the current snapshot while a same-context refresh is in flight', () => {
expect(shouldClearChecksPanelGitStatusSnapshot(SNAPSHOT, SNAPSHOT.contextKey)).toBe(false)
})
it('clears snapshots from another execution boundary', () => {
expect(
shouldClearChecksPanelGitStatusSnapshot(SNAPSHOT, 'runtime:env-2::repo::worktree::branch')
).toBe(true)
})
})
describe('shouldPollChecksPanelRuntimeSshStatus', () => {
it('polls while a runtime environment is driving an SSH-backed repo', () => {
expect(
shouldPollChecksPanelRuntimeSshStatus({
isPanelVisible: true,
runtimeEnvironmentId: 'runtime-1',
repoConnectionId: 'ssh-1'
})
).toBe(true)
})
it('does not poll when the Checks panel is hidden or execution is not runtime-routed SSH', () => {
expect(
shouldPollChecksPanelRuntimeSshStatus({
isPanelVisible: false,
runtimeEnvironmentId: 'runtime-1',
repoConnectionId: 'ssh-1'
})
).toBe(false)
expect(
shouldPollChecksPanelRuntimeSshStatus({
isPanelVisible: true,
runtimeEnvironmentId: null,
repoConnectionId: 'ssh-1'
})
).toBe(false)
expect(
shouldPollChecksPanelRuntimeSshStatus({
isPanelVisible: true,
runtimeEnvironmentId: 'runtime-1',
repoConnectionId: null
})
).toBe(false)
})
})
@@ -0,0 +1,108 @@
import type { GitPushTarget, GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types'
export type ChecksPanelGitStatusContextInput = {
repoId: string | null | undefined
worktreeId: string | null | undefined
worktreePath: string | null | undefined
branch: string
runtimeEnvironmentId: string | null
repoConnectionId: string | null
pushTarget: GitPushTarget | null | undefined
}
export type ChecksPanelGitStatusSnapshot = {
contextKey: string
hasUncommittedChanges: boolean
remoteStatus: GitUpstreamStatus | undefined
}
export type ChecksPanelGitStatusInputs = {
hasUncommittedChanges: boolean | undefined
remoteStatus: GitUpstreamStatus | undefined
}
export function buildChecksPanelGitStatusContextKey(
input: ChecksPanelGitStatusContextInput
): string {
return JSON.stringify({
repoId: input.repoId ?? '',
worktreeId: input.worktreeId ?? '',
worktreePath: input.worktreePath ?? '',
branch: input.branch,
runtimeEnvironmentId: input.runtimeEnvironmentId ?? '',
repoConnectionId: input.repoConnectionId ?? '',
pushTarget: input.pushTarget
? {
remoteName: input.pushTarget.remoteName,
branchName: input.pushTarget.branchName,
remoteUrl: input.pushTarget.remoteUrl ?? null,
remoteCreated: input.pushTarget.remoteCreated ?? false
}
: null
})
}
export function shouldPollChecksPanelRuntimeSshStatus(input: {
isPanelVisible: boolean
runtimeEnvironmentId: string | null
repoConnectionId: string | null
}): boolean {
return (
input.isPanelVisible && input.runtimeEnvironmentId !== null && input.repoConnectionId !== null
)
}
export function shouldCommitChecksPanelGitStatusSnapshot(
currentContextKey: string,
requestContextKey: string
): boolean {
return currentContextKey === requestContextKey
}
export function shouldCoalesceChecksPanelGitStatusSnapshotRefresh(
inFlightContextKey: string | null,
requestContextKey: string
): boolean {
return inFlightContextKey === requestContextKey
}
export function shouldClearChecksPanelGitStatusSnapshot(
snapshot: ChecksPanelGitStatusSnapshot | null,
contextKey: string
): boolean {
return snapshot?.contextKey !== contextKey
}
export function readChecksPanelGitStatusSnapshot(
snapshot: ChecksPanelGitStatusSnapshot | null,
contextKey: string
): ChecksPanelGitStatusInputs {
if (!snapshot || snapshot.contextKey !== contextKey) {
return {
hasUncommittedChanges: undefined,
remoteStatus: undefined
}
}
return {
hasUncommittedChanges: snapshot.hasUncommittedChanges,
remoteStatus: snapshot.remoteStatus
}
}
export function readChecksPanelPublishActionGitStatus(input: {
snapshot: ChecksPanelGitStatusSnapshot | null
contextKey: string
fallbackEntries: GitStatusEntry[] | undefined
fallbackRemoteStatus: GitUpstreamStatus | undefined
}): ChecksPanelGitStatusInputs {
const snapshotInputs = readChecksPanelGitStatusSnapshot(input.snapshot, input.contextKey)
if (snapshotInputs.hasUncommittedChanges !== undefined || !input.fallbackRemoteStatus) {
return snapshotInputs
}
return {
hasUncommittedChanges: (input.fallbackEntries?.length ?? 0) > 0,
remoteStatus: input.fallbackRemoteStatus
}
}