Files
orca/src/renderer/src/components/right-sidebar/SourceControl.tsx
T
Jinjing 35c3f86c82 Style commit text area to match input tokens and fix disabled state (#6339)
* Style commit text area to match input tokens and fix disabled state

- Update borders and shadows to match standard input tokens.
- Style the disabled state explicitly to prevent Chromium's user-agent
  styles from washing out the field outline.
- Add dark mode background styling overrides.

* Improve timeline items assertions in work-item-details test

Extract timelineItems to a local variable and add an explicit null check to prevent TypeScript compilation errors when calling the .at method.
2026-06-25 01:18:31 -07:00

8099 lines
297 KiB
TypeScript

/* eslint-disable max-lines */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ArrowDownUp,
AlertTriangle,
ArrowUp,
ChevronDown,
CloudUpload,
Minus,
Plus,
Loader2,
RefreshCw,
Settings2,
Sparkle,
Sparkles,
SlidersHorizontal,
Square,
Undo2,
Check,
Copy,
Folder,
FolderOpen,
GitFork,
GitMerge,
GitPullRequestArrow,
MessageSquare,
Trash,
Trash2,
TriangleAlert,
CircleCheck,
MoreHorizontal,
type LucideIcon
} from 'lucide-react'
import { useAppStore } from '@/store'
import { resolveRemoteOperationErrorMessage } from '@/lib/source-control-remote-error'
import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { detectLanguage } from '@/lib/language-detect'
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
resolveCommitAreaPrimaryAction,
type PrimaryAction,
type RemoteOpKind
} from './source-control-primary-action'
import {
resolveDropdownItems,
type DropdownActionKind,
type DropdownEntry
} from './source-control-dropdown-items'
import { isCommitMessageFieldDisabled } from './source-control-commit-eligibility'
import { BulkActionBar } from './BulkActionBar'
import { useSourceControlSelection, type FlatEntry } from './useSourceControlSelection'
import {
getDiscardAllPaths,
getStageAllPaths,
getUnstageAllPaths,
isStageableStatusEntry,
isSubmoduleWorktreeOnlyChange,
runDiscardAllForArea,
type DiscardAllArea
} from './discard-all-sequence'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import {
buildGitStatusSourceControlTree,
buildSourceControlTree,
applyGitStatusEntryAreasToSourceControlTree,
collectSourceControlTreeFileEntries,
compactSourceControlTree,
flattenSourceControlTree,
namespaceSourceControlTreeDirectoryKeys,
type SourceControlTreeNode
} from './source-control-tree'
import {
buildSourceControlDisplaySections,
getSourceControlSectionViewAction,
resolveSourceControlGroupOrder,
SOURCE_CONTROL_AREAS,
type SourceControlDisplaySectionId,
type SourceControlEntryGroups,
type SourceControlSectionArea
} from './source-control-section-order'
import {
buildActiveOpenFileSignature,
buildActiveOpenRowKeys
} from './source-control-active-open-file-keys'
import {
filterSourceControlGroupedPathEntries,
filterSourceControlPathEntries,
getSourceControlFileFilterState
} from './source-control-file-filter'
import { getCommitMessageTextareaRows } from './source-control-commit-message-rows'
import {
SourceControlDiscardDialog,
type PendingDiscardConfirmation
} from './source-control-discard-dialog'
import {
refreshGitStatusForWorktree,
refreshGitStatusForWorktreeStrict
} from './git-status-refresh'
import { describeForkPushTarget } from './fork-push-target-label'
import { toast } from 'sonner'
import { SourceControlEntryContextMenu } from './source-control-entry-context-menu'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { BaseRefPicker } from '@/components/settings/BaseRefPicker'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { formatDiffComment, formatDiffComments } from '@/lib/diff-comments-format'
import { getDiffCommentLineLabel, getDiffCommentSource } from '@/lib/diff-comment-compat'
import { DiffNotesSendMenu } from '@/components/editor/DiffNotesSendMenu'
import {
countPendingDiffCommentsClear,
formatPendingDiffCommentsClearDescription,
resolvePendingDiffCommentsClear,
type PendingDiffCommentsClear
} from './diff-comments-clear-dialog-state'
import {
pickSourceControlLaunchAgent,
readSourceControlLaunchRecipeAgentId
} from '@/lib/source-control-launch-agent-selection'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import {
notifyEditorExternalFileChange,
requestEditorSaveQuiesce
} from '@/components/editor/editor-autosave'
import { getConnectionId } from '@/lib/connection-context'
import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner'
import {
abortRuntimeGitMerge,
abortRuntimeGitRebase,
bulkDiscardRuntimeGitPaths,
bulkStageRuntimeGitPaths,
bulkUnstageRuntimeGitPaths,
cancelRuntimeGenerateCommitMessage,
cancelRuntimeGeneratePullRequestFields,
commitRuntimeGit,
discardRuntimeGitPath,
generateRuntimeCommitMessage,
generateRuntimePullRequestFields,
getRuntimeGitBranchCompare,
getRuntimeGitHistory,
stageRuntimeGitPath,
unstageRuntimeGitPath,
type RuntimeGitContext,
type RuntimeGenerateCommitMessageOverrides,
type RuntimeGeneratePullRequestFieldsOverrides
} from '@/runtime/runtime-git-client'
import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client'
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
import { resolveCreateReviewDraftTitle } from './create-review-draft-title'
import { GitHistoryPanel, type GitHistoryPanelState } from './GitHistoryPanel'
import { useGitHistoryCommitActions } from './useGitHistoryCommitActions'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
import type {
DiffComment,
GitBranchChangeEntry,
GitBranchCompareSummary,
GitConflictOperation,
GitPushTarget,
GitStatusEntry,
GitUpstreamStatus,
SourceControlViewMode,
TuiAgent
} from '../../../../shared/types'
import type {
HostedReviewCreationEligibility,
HostedReviewInfo,
HostedReviewProvider
} from '../../../../shared/hosted-review'
import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers'
import { STATUS_COLORS, STATUS_LABELS } from './status-display'
import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec'
import {
type SourceControlActionRecipe,
type SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import {
loadSessionCommitDrafts,
saveSessionCommitDrafts
} from '@/lib/source-control-commit-draft-session'
import {
getCommitFailureDialogWorktreeKey,
shouldShowCommitFailureDialog,
syncCommitFailureDialogState,
type CommitFailureDialogState
} from './commit-failure-dialog-state'
import { hasExpandedCommitFailureDetails, summarizeCommitFailure } from './commit-failure-summary'
import {
isSourceControlSplitOpenModifier,
shouldOpenSourceControlRowAsPreview,
toPermanentSourceControlRowOpenEvent,
type SourceControlRowOpenEvent
} from './source-control-split-open'
import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog'
import {
isPullPolicyRemoteActionError,
PullPolicyRemoteActionNotice
} from './source-control-pull-policy-error-notice'
import { SourceControlTextGenerationDialog } from './SourceControlTextGenerationDialog'
import { CreateHostedReviewComposer } from './CreateHostedReviewComposer'
import {
hasConfiguredCommitMessageGenerationDefaults,
hasConfiguredSourceControlTextGenerationDefaults
} from './source-control-text-generation-defaults'
import { useSourceControlAi } from './use-source-control-ai'
import { translate } from '@/i18n/i18n'
import {
localizedHostedReviewCopy,
resolveSupportedHostedReviewCopyProvider
} from '@/i18n/hosted-review-localized-copy'
import {
createCreatePrIntentRunToken,
createPrIntentCurrentTargetConflictsWithToken,
createPrIntentGitStatusMatchesToken,
createPrIntentRunTokenMatches,
getCreatePrIntentStagePaths,
resolveCreatePrIntentReviewBase,
resolveCreatePrIntentRemoteStep,
type CreatePrIntentRunToken
} from './source-control-create-pr-intent-flow'
import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state'
import { resolveBlockedCreateReviewNoticeMessage } from './source-control-create-review-blocked-action'
import {
buildLoadingHostedReviewCreationEligibility,
resolveCreatePrHeaderAction,
resolveProvisionalHostedReviewProvider
} from './source-control-primary-create-pr-intent-action'
import {
getNextSourceControlViewMode,
shouldShowSourceControlCompareUnavailableCard,
SourceControlHeaderToolbar
} from './source-control-header-toolbar'
import {
hasPositiveHostedReviewNumberLink,
hasResolvableHostedReviewPushTargetLink,
hasUsableHostedReviewPushTarget,
resolveHostedReviewActionUpstreamStatus,
resolveHostedReviewStateForActions
} from './source-control-hosted-review-push-target'
export { HostedReviewHeaderLink } from './hosted-review-header-chrome'
import {
createRunningCommitMessageGenerationRecord,
getCommitMessageGenerationRecordKey,
markCommitMessageGenerationHydrated,
resolveCommitMessageGenerationCancel,
resolveCommitMessageGenerationFailure,
resolveCommitMessageGenerationSuccess,
type CommitMessageGenerationRecord
} from '@/store/slices/commit-message-generation'
import {
createRunningPullRequestGenerationRecord,
getPullRequestGenerationRecordKey,
getPullRequestGenerationSeedRestoreKey,
markPullRequestGenerationTerminalSeedRestored,
resolvePullRequestGenerationCancel,
resolvePullRequestGenerationFailure,
resolvePullRequestGenerationSuccess,
shouldHydratePullRequestGenerationResult,
type PullRequestFieldRevisions,
type PullRequestGenerationContext,
type PullRequestGenerationFields
} from '@/store/slices/pull-request-generation'
export {
appendCommitFailureCustomInstruction,
buildCommitFailureAgentCommandInput,
buildFixCommitFailurePrompt,
buildResolveConflictsPrompt,
buildResolvePullRequestConflictsPrompt
} from './source-control-ai-prompts'
export {
hasConfiguredCommitMessageGenerationDefaults,
hasConfiguredSourceControlTextGenerationDefaults
} from './source-control-text-generation-defaults'
type AbortConflictOperation = Extract<GitConflictOperation, 'merge' | 'rebase'>
type AbortActionErrorKind = 'abort_merge' | 'abort_rebase'
export type SourceControlActionError = {
kind: RemoteOpKind | AbortActionErrorKind
message: string
}
type SourceControlOperationTarget = RuntimeGitContext & {
worktreeId: string
pushTarget?: GitPushTarget
}
type HostedReviewCreatedContext = {
repoPath: string
repoId: string
branch: string
worktreeId: string | null
openChecks: boolean
}
type CreatePrIntentTone = 'muted' | 'destructive'
type CreatePrIntentNotice = {
message: string
tone: CreatePrIntentTone
action?: 'settings'
}
export function resolveSourceControlBaseRef(input: {
worktreeBaseRef?: string | null
reviewBaseRefName?: string | null
repoBaseRef?: string | null
defaultBaseRef?: string | null
}): string | null {
const worktreeBaseRef = input.worktreeBaseRef?.trim() || null
const hasReviewBaseRefName = Boolean(input.reviewBaseRefName?.trim())
const reviewBaseRef = resolveHostedReviewCompareBaseRef(input.reviewBaseRefName, [
input.repoBaseRef,
input.defaultBaseRef
])
if (worktreeBaseRef && isFullGitCommitOid(worktreeBaseRef) && hasReviewBaseRefName) {
return reviewBaseRef
}
return worktreeBaseRef || input.repoBaseRef?.trim() || input.defaultBaseRef?.trim() || null
}
export function resolveSourceControlPickerBaseRef(input: {
pinnedBaseRef?: string | null
effectiveBaseRef?: string | null
}): string | undefined {
const pinnedBaseRef = input.pinnedBaseRef?.trim()
if (!pinnedBaseRef) {
return undefined
}
return input.effectiveBaseRef?.trim() || pinnedBaseRef
}
function isFullGitCommitOid(value: string): boolean {
return /^[0-9a-f]{40}$/i.test(value)
}
function resolveHostedReviewCompareBaseRef(
baseRefName: string | null | undefined,
candidates: (string | null | undefined)[]
): string | null {
const branch = baseRefName?.trim()
if (!branch) {
return null
}
for (const candidate of candidates) {
const trimmed = candidate?.trim()
if (!trimmed) {
continue
}
if (getCompareBaseCandidateBranchName(trimmed) === branch) {
return trimmed
}
}
for (const candidate of candidates) {
const rewritten = rewriteCompareBaseBranchFromCandidate(candidate, branch)
if (rewritten) {
return rewritten
}
}
return null
}
function getCompareBaseCandidateBranchName(candidate: string): string {
const remoteRefPrefix = 'refs/remotes/'
if (candidate.startsWith(remoteRefPrefix)) {
const remoteAndBranch = candidate.slice(remoteRefPrefix.length)
const slashIndex = remoteAndBranch.indexOf('/')
return slashIndex > 0 ? remoteAndBranch.slice(slashIndex + 1) : remoteAndBranch
}
const headsRefPrefix = 'refs/heads/'
if (candidate.startsWith(headsRefPrefix)) {
return candidate.slice(headsRefPrefix.length)
}
const slashIndex = candidate.indexOf('/')
return slashIndex > 0 ? candidate.slice(slashIndex + 1) : candidate
}
function rewriteCompareBaseBranchFromCandidate(
candidate: string | null | undefined,
branch: string
): string | null {
const trimmed = candidate?.trim()
if (!trimmed) {
return null
}
const remoteRefPrefix = 'refs/remotes/'
if (trimmed.startsWith(remoteRefPrefix)) {
const remoteAndBranch = trimmed.slice(remoteRefPrefix.length)
const slashIndex = remoteAndBranch.indexOf('/')
return slashIndex > 0
? `${remoteRefPrefix}${remoteAndBranch.slice(0, slashIndex)}/${branch}`
: null
}
const headsRefPrefix = 'refs/heads/'
if (trimmed.startsWith(headsRefPrefix)) {
return `${headsRefPrefix}${branch}`
}
const slashIndex = trimmed.indexOf('/')
return slashIndex > 0 ? `${trimmed.slice(0, slashIndex)}/${branch}` : null
}
const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = []
const EMPTY_BRANCH_CHANGE_ENTRIES: GitBranchChangeEntry[] = []
// Why: the "too many changes — add folder to .gitignore?" warning shows at most
// once per worktree per session (the analog of a "Don't show again" gate), so a
// repo that stays huge across polls doesn't re-toast every refresh.
const hugeRepoWarningDismissed = new Set<string>()
// Why: directional signifiers ahead of each primary action label. Commit
// (✓) is affirmative; Push (↑) points in the direction data flows; Sync
// (↕) is bidirectional; Publish gets a cloud-up to distinguish the
// first-time publish from a subsequent push. Pull is intentionally
// icon-less — the down-arrow read as a download/save affordance and was
// removed. Keeping the mapping outside the render function avoids
// reallocating it on every render.
const PRIMARY_ICONS: Partial<
Record<
PrimaryAction['kind'],
React.ComponentType<{ className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>
>
> = {
commit: Check,
stage: Plus,
push: ArrowUp,
sync: ArrowDownUp,
publish: CloudUpload,
create_pr_intent: GitPullRequestArrow,
create_pr: GitPullRequestArrow
}
const SECTION_LABELS: Record<SourceControlSectionArea, { key: string; fallback: string }> = {
staged: {
key: 'auto.components.right.sidebar.SourceControl.48a003c1b1',
fallback: 'Staged Changes'
},
unstaged: {
key: 'auto.components.right.sidebar.SourceControl.d4ef4bafc5',
fallback: 'Changes'
},
untracked: {
key: 'auto.components.right.sidebar.SourceControl.522f44dce5',
fallback: 'Untracked Files'
}
}
const CONFLICTS_SECTION_LABEL = {
key: 'auto.components.right.sidebar.SourceControl.conflictsSection',
fallback: 'Conflicts'
}
// Why: 5s branch compare polling churned git subprocesses in large repos.
// Explicit commit, remote, manual, and base-ref refresh paths still run immediately.
export const BRANCH_REFRESH_INTERVAL_MS = 30_000
// Why: row action buttons host Radix Tooltip triggers. Keeping the overlay
// measurable prevents transient top-left tooltip placement during hover.
const SOURCE_CONTROL_ROW_ACTION_OVERLAY_CLASS =
'absolute right-0 top-0 bottom-0 flex shrink-0 items-center gap-1.5 bg-accent pr-3 pl-2 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto'
const SOURCE_CONTROL_TREE_INDENT_PX = 12
const SOURCE_CONTROL_TREE_DIRECTORY_PADDING_PX = 8
const SOURCE_CONTROL_TREE_FILE_PADDING_PX = 20
const EMPTY_GIT_HISTORY_STATE: GitHistoryPanelState = { status: 'idle' }
const DEFAULT_COLLAPSED_SECTIONS = ['history'] as const
const SUBMODULE_WORKTREE_ONLY_LABEL = 'Submodule changes - stage inside submodule'
const SUBMODULE_WORKTREE_ONLY_STAGE_TOOLTIP = 'Stage these changes inside the submodule'
function createDefaultCollapsedSections(): Set<string> {
return new Set(DEFAULT_COLLAPSED_SECTIONS)
}
function useCopyFeedbackState<T>(resetValue: T): [T, (value: T) => void] {
const [value, setValue] = useState(resetValue)
const resetTimerRef = useRef<number | null>(null)
const mountedRef = useRef(true)
const clearResetTimer = useCallback(() => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current)
resetTimerRef.current = null
}
}, [])
// Why: copy feedback timers are event-owned, but still need unmount cleanup
// so delayed clipboard/timer work cannot update a destroyed component.
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
clearResetTimer()
}
}, [clearResetTimer])
const showFeedback = useCallback(
(nextValue: T) => {
if (!mountedRef.current) {
return
}
clearResetTimer()
setValue(nextValue)
resetTimerRef.current = window.setTimeout(() => {
if (!mountedRef.current) {
return
}
setValue(resetValue)
resetTimerRef.current = null
}, 1500)
},
[clearResetTimer, resetValue]
)
return [value, showFeedback]
}
function cancelSourceControlEditorRevealFrames(frameIds: React.MutableRefObject<number[]>): void {
for (const frameId of frameIds.current) {
cancelAnimationFrame(frameId)
}
frameIds.current = []
}
function requestSourceControlEditorRevealFrame(
frameIds: React.MutableRefObject<number[]>,
callback: FrameRequestCallback
): void {
let completed = false
let frameId: number | undefined
frameId = requestAnimationFrame((timestamp) => {
completed = true
if (frameId !== undefined) {
frameIds.current = frameIds.current.filter((pendingFrameId) => pendingFrameId !== frameId)
}
callback(timestamp)
})
if (!completed) {
frameIds.current.push(frameId)
}
}
// Why: the pure state-machine logic now lives in
// ./source-control-primary-action.ts. It is imported directly by callers
// (tests and other components) instead of going through this module.
type CommitDraftsByWorktree = Record<string, string>
export function normalizeSourceControlViewMode(value: unknown): SourceControlViewMode {
return value === 'tree' || value === 'list' ? value : 'list'
}
type GitStatusSourceControlTreeNode = SourceControlTreeNode<
GitStatusEntry,
SourceControlSectionArea
>
type SourceControlTreeDirectoryNode = Extract<GitStatusSourceControlTreeNode, { type: 'directory' }>
type BranchSourceControlTreeNode = SourceControlTreeNode<GitBranchChangeEntry, 'branch'>
type BranchSourceControlTreeDirectoryNode = Extract<
BranchSourceControlTreeNode,
{ type: 'directory' }
>
type SourceControlDirectoryActionPaths = {
stagePaths: string[]
unstagePaths: string[]
discardPaths: string[]
}
function getSourceControlDirectoryActionPaths(
node: SourceControlTreeDirectoryNode
): SourceControlDirectoryActionPaths {
const entries = collectSourceControlTreeFileEntries(node)
return {
stagePaths: entries.filter(isStageableStatusEntry).map((entry) => entry.path),
unstagePaths: getUnstageAllPaths(entries),
discardPaths:
node.area === 'unstaged' || node.area === 'untracked'
? getDiscardAllPaths(entries, node.area)
: []
}
}
type HostedReviewCreationState = {
repoId: string
worktreeId: string
branch: string
data: HostedReviewCreationEligibility
}
type HostedReviewCreationRequestState = {
repoId: string
worktreeId: string
branch: string
status: 'loading' | 'failed'
}
type CreatedHostedReview = {
provider: HostedReviewProvider
number: number
url: string
}
export function readCommitDraftForWorktree(
drafts: CommitDraftsByWorktree,
worktreeId: string | null | undefined
): string {
return drafts[worktreeId ?? ''] ?? ''
}
export function writeCommitDraftForWorktree(
drafts: CommitDraftsByWorktree,
worktreeId: string,
value: string
): CommitDraftsByWorktree {
return { ...drafts, [worktreeId]: value }
}
export function shouldRenderCommitArea(
unresolvedConflictCount: number,
conflictOperation: GitConflictOperation
): boolean {
return unresolvedConflictCount === 0 && conflictOperation === 'unknown'
}
export function pickDefaultSourceControlAgent(
defaultAgent: TuiAgent | 'blank' | null | undefined,
detectedAgents: TuiAgent[],
disabledAgents?: TuiAgent[]
): TuiAgent | null {
return pickSourceControlLaunchAgent({
defaultAgent,
detectedAgents,
disabledAgents
})
}
function resolveRemoteActionError(kind: RemoteOpKind, error: unknown): string {
return resolveRemoteOperationErrorMessage(error, {
publish: kind === 'publish',
isPush: kind === 'push',
isForcePush: kind === 'force_push',
isSync: kind === 'sync',
isFetch: kind === 'fetch',
isFastForward: kind === 'fast_forward',
isRebase: kind === 'rebase'
})
}
export function refreshSourceControlAfterRemoteAction({
refreshGitStatus,
refreshBranchCompare,
refreshGitHistory,
onError = (error) => console.warn('[SourceControl] post-remote refresh failed', error)
}: {
refreshGitStatus: () => Promise<void>
refreshBranchCompare: () => Promise<void>
refreshGitHistory: () => Promise<void>
onError?: (error: unknown) => void
}): void {
// Why: fetch/sync can move the remote base ref without changing local files.
// Refresh all three visible git projections so the branch comparison table
// re-runs against the newly fetched base instead of waiting for polling.
void Promise.all([refreshGitStatus(), refreshBranchCompare(), refreshGitHistory()]).catch(onError)
}
function remoteActionErrorMatchesSettledConflictOperation(
kind: SourceControlActionError['kind'],
operation: GitConflictOperation
): boolean {
if (kind === 'rebase' || kind === 'abort_rebase') {
return operation === 'rebase'
}
if (kind === 'abort_merge') {
return operation === 'merge'
}
if (kind === 'pull' || kind === 'sync') {
return operation === 'merge' || operation === 'rebase'
}
return false
}
export function clearRemoteActionErrorsForCompletedConflictOperations({
remoteActionErrors,
previousConflictOperations,
currentConflictOperations
}: {
remoteActionErrors: Record<string, SourceControlActionError | null>
previousConflictOperations: Record<string, GitConflictOperation>
currentConflictOperations: Record<string, GitConflictOperation>
}): Record<string, SourceControlActionError | null> {
let next: Record<string, SourceControlActionError | null> | null = null
for (const [worktreeId, error] of Object.entries(remoteActionErrors)) {
if (!error) {
continue
}
const previousOperation = previousConflictOperations[worktreeId] ?? 'unknown'
const currentOperation = currentConflictOperations[worktreeId] ?? 'unknown'
if (
previousOperation === 'unknown' ||
currentOperation !== 'unknown' ||
!remoteActionErrorMatchesSettledConflictOperation(error.kind, previousOperation)
) {
continue
}
next ??= { ...remoteActionErrors }
next[worktreeId] = null
}
return next ?? remoteActionErrors
}
function SourceControlInner(): React.JSX.Element {
const sourceControlRef = useRef<HTMLDivElement | null>(null)
const isMac = useMemo(() => navigator.userAgent.includes('Mac'), [])
const pendingCommentEditorRevealFrameIdsRef = useRef<number[]>([])
// Why: React setState is async, so a rapid double-click on the Commit
// button can both pass the isCommitting state guard before the disabled
// state re-renders. A ref flipped synchronously at the start of
// handleCommit gives us a true single-flight lock.
const commitInFlightRef = useRef<Record<string, boolean>>({})
const activeWorktree = useActiveWorktree()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeGroupId = useAppStore((s) =>
activeWorktreeId ? s.activeGroupIdByWorktree[activeWorktreeId] : undefined
)
const worktreeMap = useWorktreeMap()
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const entries = useAppStore((s) =>
activeWorktreeId
? (s.gitStatusByWorktree[activeWorktreeId] ?? EMPTY_GIT_STATUS_ENTRIES)
: EMPTY_GIT_STATUS_ENTRIES
)
const activeGitStatusHead = useAppStore((s) =>
activeWorktreeId ? (s.gitStatusHeadByWorktree?.[activeWorktreeId] ?? null) : null
)
const repositoryHuge = useAppStore((s) =>
activeWorktreeId ? s.gitStatusHugeByWorktree?.[activeWorktreeId] : undefined
)
const branchEntries = useAppStore((s) =>
activeWorktreeId
? (s.gitBranchChangesByWorktree[activeWorktreeId] ?? EMPTY_BRANCH_CHANGE_ENTRIES)
: EMPTY_BRANCH_CHANGE_ENTRIES
)
const branchSummary = useAppStore((s) =>
activeWorktreeId ? (s.gitBranchCompareSummaryByWorktree[activeWorktreeId] ?? null) : null
)
const conflictOperation = useAppStore((s) =>
activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown'
)
const conflictOperationsByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree)
// Why: leave undefined until fetchUpstreamStatus resolves for this worktree.
// A synthetic "no upstream" flashes "Publish Branch" during worktree switches.
const remoteStatus = useAppStore((s) =>
activeWorktreeId ? s.remoteStatusesByWorktree[activeWorktreeId] : undefined
)
const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive)
const inFlightRemoteOpKind = useAppStore((s) => s.inFlightRemoteOpKind)
const settings = useAppStore((s) => s.settings)
// Why: git/file mutations and repo metadata requests belong to the repo
// OWNER host, not the currently focused host in the sidebar.
const activeRepoSettings = useMemo(
() => getRepoOwnerRoutedSettings(settings, activeRepo ?? null),
[activeRepo, settings]
)
const updateSettings = useAppStore((s) => s.updateSettings)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const hostedReviewCache = useAppStore((s) => s.hostedReviewCache)
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
const getHostedReviewCreationEligibility = useAppStore(
(s) => s.getHostedReviewCreationEligibility
)
const createHostedReview = useAppStore((s) => s.createHostedReview)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const prCache = useAppStore((s) => s.prCache)
const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh)
const updateRepo = useAppStore((s) => s.updateRepo)
const setGitStatus = useAppStore((s) => s.setGitStatus)
const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity)
const beginGitBranchCompareRequest = useAppStore((s) => s.beginGitBranchCompareRequest)
const setGitBranchCompareResult = useAppStore((s) => s.setGitBranchCompareResult)
const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus)
const ensureHostedReviewPushTarget = useAppStore((s) => s.ensureHostedReviewPushTarget)
const setUpstreamStatus = useAppStore((s) => s.setUpstreamStatus)
const pushBranch = useAppStore((s) => s.pushBranch)
const pullBranch = useAppStore((s) => s.pullBranch)
const fastForwardBranch = useAppStore((s) => s.fastForwardBranch)
const syncBranch = useAppStore((s) => s.syncBranch)
const rebaseFromBase = useAppStore((s) => s.rebaseFromBase)
const fetchBranch = useAppStore((s) => s.fetchBranch)
const revealInExplorer = useAppStore((s) => s.revealInExplorer)
const trackConflictPath = useAppStore((s) => s.trackConflictPath)
const openDiff = useAppStore((s) => s.openDiff)
const openFile = useAppStore((s) => s.openFile)
const setEditorViewMode = useAppStore((s) => s.setEditorViewMode)
const setMarkdownViewMode = useAppStore((s) => s.setMarkdownViewMode)
const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal)
const openConflictFile = useAppStore((s) => s.openConflictFile)
const openConflictReview = useAppStore((s) => s.openConflictReview)
const openBranchDiff = useAppStore((s) => s.openBranchDiff)
const createEmptySplitGroup = useAppStore((s) => s.createEmptySplitGroup)
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree)
const openAllDiffs = useAppStore((s) => s.openAllDiffs)
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
const deleteDiffComment = useAppStore((s) => s.deleteDiffComment)
const clearDiffComments = useAppStore((s) => s.clearDiffComments)
const clearDiffCommentsForFile = useAppStore((s) => s.clearDiffCommentsForFile)
const setScrollToDiffCommentId = useAppStore((s) => s.setScrollToDiffCommentId)
const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
// Why: pass activeWorktreeId directly (even when null/undefined) so the
// slice's getDiffComments returns its stable EMPTY_COMMENTS sentinel. An
// inline `[]` fallback would allocate a new array each store update, break
// Zustand's Object.is equality, and cause this component plus the
// diffCommentCountByPath memo to churn on every unrelated store change.
const diffCommentsForActive = useAppStore((s) => s.getDiffComments(activeWorktreeId))
const diffCommentCount = diffCommentsForActive.length
// Why: per-file counts are fed into each UncommittedEntryRow so a comment
// badge can appear next to the status letter. Compute once per render so
// rows don't each re-filter the full list.
const diffCommentCountByPath = useMemo(() => {
const map = new Map<string, number>()
for (const c of diffCommentsForActive) {
map.set(c.filePath, (map.get(c.filePath) ?? 0) + 1)
}
return map
}, [diffCommentsForActive])
const diffCommentsPrompt = useMemo(
() => formatDiffComments(diffCommentsForActive),
[diffCommentsForActive]
)
const [diffCommentsExpanded, setDiffCommentsExpanded] = useState(false)
const [diffCommentsCopied, showDiffCommentsCopied] = useCopyFeedbackState(false)
const [pendingDiffCommentsClear, setPendingDiffCommentsClear] =
useState<PendingDiffCommentsClear | null>(null)
const [isClearingDiffComments, setIsClearingDiffComments] = useState(false)
const setSourceControlRoot = useCallback((node: HTMLDivElement | null) => {
// Why: markdown-note reveal frames target the Source Control surface; cancel
// them when that surface unmounts instead of from a passive Effect.
if (node === null) {
cancelSourceControlEditorRevealFrames(pendingCommentEditorRevealFrameIdsRef)
}
sourceControlRef.current = node
}, [])
const handleCopyDiffComments = useCallback(async (): Promise<void> => {
if (diffCommentsForActive.length === 0) {
return
}
try {
await window.api.ui.writeClipboardText(diffCommentsPrompt)
showDiffCommentsCopied(true)
} catch {
// Why: swallow — clipboard write can fail when the window isn't focused.
// No dedicated error surface is warranted for a best-effort copy action.
}
}, [diffCommentsForActive, diffCommentsPrompt, showDiffCommentsCopied])
const pendingDiffCommentsClearCount = useMemo(() => {
return countPendingDiffCommentsClear(
pendingDiffCommentsClear,
activeWorktreeId,
diffCommentsForActive
)
}, [activeWorktreeId, diffCommentsForActive, pendingDiffCommentsClear])
const resolvedPendingDiffCommentsClear = resolvePendingDiffCommentsClear({
activeWorktreeId,
isClearing: isClearingDiffComments,
pending: pendingDiffCommentsClear,
pendingCount: pendingDiffCommentsClearCount
})
if (resolvedPendingDiffCommentsClear !== pendingDiffCommentsClear) {
// Why: the confirmation is purely local UI state; clear impossible
// confirmations before children observe a stale open dialog.
setPendingDiffCommentsClear(resolvedPendingDiffCommentsClear)
}
const pendingDiffCommentsClearDescription = formatPendingDiffCommentsClearDescription(
resolvedPendingDiffCommentsClear,
pendingDiffCommentsClearCount
)
const handleConfirmDiffCommentsClear = useCallback(async (): Promise<void> => {
const pending = resolvedPendingDiffCommentsClear
if (!pending || isClearingDiffComments || pending.worktreeId !== activeWorktreeId) {
return
}
if (pendingDiffCommentsClearCount === 0) {
setPendingDiffCommentsClear(null)
return
}
setIsClearingDiffComments(true)
try {
const ok =
pending.kind === 'all'
? await clearDiffComments(pending.worktreeId)
: await clearDiffCommentsForFile(pending.worktreeId, pending.filePath)
if (ok) {
setPendingDiffCommentsClear(null)
} else {
toast.error(
translate(
'auto.components.right.sidebar.SourceControl.eae7a1da5f',
'Failed to clear notes.'
)
)
}
} finally {
setIsClearingDiffComments(false)
}
}, [
activeWorktreeId,
clearDiffComments,
clearDiffCommentsForFile,
isClearingDiffComments,
resolvedPendingDiffCommentsClear,
pendingDiffCommentsClearCount
])
const [filterExpanded, setFilterExpanded] = useState(false)
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(
createDefaultCollapsedSections
)
const persistedSourceControlViewMode = normalizeSourceControlViewMode(
settings?.sourceControlViewMode
)
const sourceControlViewMode = persistedSourceControlViewMode
const sourceControlGroupOrder = resolveSourceControlGroupOrder(settings?.sourceControlGroupOrder)
const [collapsedTreeDirs, setCollapsedTreeDirs] = useState<Set<string>>(new Set())
const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false)
const [pendingDiscard, setPendingDiscard] = useState<PendingDiscardConfirmation | null>(null)
// Why: start null rather than 'origin/main' so branch compare doesn't fire
// with a fabricated ref before the IPC resolves. effectiveBaseRef stays
// falsy until we have a real answer from the main process.
const [defaultBaseRef, setDefaultBaseRef] = useState<string | null>(null)
const [filterQuery, setFilterQuery] = useState('')
// Why: Source Control unmounts when the user switches tabs, so keep commit
// drafts in a module-scoped session cache and restore them on remount.
const [commitDrafts, setCommitDrafts] = useState<CommitDraftsByWorktree>(() =>
loadSessionCommitDrafts()
)
const commitDraftsRef = useRef<CommitDraftsByWorktree>(commitDrafts)
const [commitErrors, setCommitErrors] = useState<Record<string, string | null>>({})
const [remoteActionErrors, setRemoteActionErrors] = useState<
Record<string, SourceControlActionError | null>
>({})
const previousConflictOperationsRef = useRef<Record<string, GitConflictOperation>>({})
// Why: keep commit-in-flight state per-worktree. A single boolean would be
// cleared when the user switched worktrees, letting them double-click Commit
// on worktree A after briefly navigating to B and back while A's original
// commit is still running.
const [commitInFlightByWorktree, setCommitInFlightByWorktree] = useState<Record<string, boolean>>(
{}
)
const [abortOperationInFlightByWorktree, setAbortOperationInFlightByWorktree] = useState<
Record<string, boolean>
>({})
const isAbortingOperation = abortOperationInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const confirmAction = useConfirmationDialog()
const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false
// Why: parallel state to commit. Same per-worktree shape so navigating between
// worktrees mid-generation never silently cancels the in-flight request.
const generateInFlightRef = useRef<Record<string, boolean>>({})
const [generateInFlightByWorktree, setGenerateInFlightByWorktree] = useState<
Record<string, boolean>
>({})
const [generateErrors, setGenerateErrors] = useState<Record<string, string | null>>({})
const [hostedReviewCreationState, setHostedReviewCreationState] =
useState<HostedReviewCreationState | null>(null)
const [hostedReviewCreationRequestState, setHostedReviewCreationRequestState] =
useState<HostedReviewCreationRequestState | null>(null)
const createPrInFlightRef = useRef<Record<string, boolean>>({})
const [createPrInFlightByWorktree, setCreatePrInFlightByWorktree] = useState<
Record<string, boolean>
>({})
const isCreatingPr = createPrInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const createPrIntentInFlightRef = useRef<Record<string, boolean>>({})
const createPrIntentRunTokenRef = useRef<Record<string, CreatePrIntentRunToken | null>>({})
const createPrIntentCurrentTargetRef = useRef({
repoId: null as string | null,
worktreeId: null as string | null,
worktreePath: null as string | null,
branch: null as string | null,
baseRef: null as string | null
})
const [createPrIntentInFlightByWorktree, setCreatePrIntentInFlightByWorktree] = useState<
Record<string, boolean>
>({})
const [createPrIntentNotices, setCreatePrIntentNotices] = useState<
Record<string, CreatePrIntentNotice | null>
>({})
const isCreatePrIntentInFlight = createPrIntentInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const createPrIntentNotice = createPrIntentNotices[activeWorktreeId ?? ''] ?? null
const setCreatePrIntentNoticeForWorktree = useCallback(
(worktreeId: string, notice: CreatePrIntentNotice | null): void => {
setCreatePrIntentNotices((prev) => ({ ...prev, [worktreeId]: notice }))
},
[]
)
const createPrIntentRunStillOwnsWorktree = useCallback(
(token: CreatePrIntentRunToken): boolean =>
createPrIntentRunTokenRef.current[token.worktreeId] === token,
[]
)
const createPrIntentActiveTargetConflicts = useCallback(
(token: CreatePrIntentRunToken): boolean =>
createPrIntentCurrentTargetConflictsWithToken(token, createPrIntentCurrentTargetRef.current),
[]
)
const getCreatePrIntentOperationTarget = useCallback(
(token: CreatePrIntentRunToken): SourceControlOperationTarget => ({
// Why: Create PR intent continues after navigation; keep git commands
// pinned to the worktree and runtime host that started the sequence.
settings: activeRepoSettings,
worktreeId: token.worktreeId,
worktreePath: token.worktreePath,
connectionId: getConnectionId(token.worktreeId) ?? undefined,
pushTarget: worktreeMap.get(token.worktreeId)?.pushTarget
}),
[activeRepoSettings, worktreeMap]
)
const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords)
const allocatePullRequestGenerationRequestId = useAppStore(
(s) => s.allocatePullRequestGenerationRequestId
)
const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord)
const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord)
const commitMessageGenerationRecords = useAppStore((s) => s.commitMessageGenerationRecords)
const allocateCommitMessageGenerationRequestId = useAppStore(
(s) => s.allocateCommitMessageGenerationRequestId
)
const setCommitMessageGenerationRecord = useAppStore((s) => s.setCommitMessageGenerationRecord)
const updateCommitMessageGenerationRecord = useAppStore(
(s) => s.updateCommitMessageGenerationRecord
)
const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId)
const commitError = commitErrors[activeWorktreeId ?? ''] ?? null
const remoteActionError = remoteActionErrors[activeWorktreeId ?? ''] ?? null
const [gitHistoryByWorktree, setGitHistoryByWorktree] = useState<
Record<string, GitHistoryPanelState>
>({})
const gitHistoryRequestSeqRef = useRef(0)
const gitHistoryRequestByWorktreeRef = useRef<Record<string, number>>({})
const gitHistoryState = activeWorktreeId
? (gitHistoryByWorktree[activeWorktreeId] ?? EMPTY_GIT_HISTORY_STATE)
: EMPTY_GIT_HISTORY_STATE
const isGitHistoryExpanded = !collapsedSections.has('history')
useEffect(() => {
commitDraftsRef.current = commitDrafts
}, [commitDrafts])
const updateCommitDrafts = useCallback(
(updater: (drafts: CommitDraftsByWorktree) => CommitDraftsByWorktree): void => {
const next = updater(commitDraftsRef.current)
// Why: Create PR intent reads this ref after awaits to avoid overwriting
// user edits made before React's passive state sync effect runs.
commitDraftsRef.current = next
setCommitDrafts(next)
},
[]
)
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
const worktreePath = activeWorktree?.path ?? null
const activeCommitMessageGenerationKey = getCommitMessageGenerationRecordKey(
activeWorktreeId,
worktreePath
)
const activeCommitMessageGenerationRecord: CommitMessageGenerationRecord | null =
activeCommitMessageGenerationKey
? (commitMessageGenerationRecords[activeCommitMessageGenerationKey] ?? null)
: null
const isGenerating =
activeCommitMessageGenerationRecord?.status === 'running' ||
(generateInFlightByWorktree[activeWorktreeId ?? ''] ?? false)
const generateError =
activeCommitMessageGenerationRecord?.error ?? generateErrors[activeWorktreeId ?? ''] ?? null
const activeConnectionId = activeWorktreeId
? (getConnectionId(activeWorktreeId) ?? activeRepo?.connectionId ?? null)
: null
const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({
connectionId: activeConnectionId,
worktreePath,
projectRuntime: activeConnectionId
? undefined
: getLocalProjectExecutionRuntimeContext(useAppStore.getState(), activeWorktreeId)
})
const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({
worktreeId: activeWorktreeId,
worktreePath,
repoId: activeRepo?.id,
branch: branchName
})
const activePullRequestGenerationRecordCandidate = activePullRequestGenerationKey
? (prGenerationRecords[activePullRequestGenerationKey] ?? null)
: null
const activePullRequestGenerationRecord =
activePullRequestGenerationRecordCandidate &&
activePullRequestGenerationRecordCandidate.context.repoId === activeRepo?.id &&
activePullRequestGenerationRecordCandidate.context.branch === branchName
? activePullRequestGenerationRecordCandidate
: null
const activePullRequestGenerationSeedRestoreKey = getPullRequestGenerationSeedRestoreKey({
recordKey: activePullRequestGenerationKey,
record: activePullRequestGenerationRecord
})
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
// Why: gate polling on both the active tab AND the sidebar being open.
// The sidebar now stays mounted when closed (for performance), so without
// this guard the branchCompare interval and PR fetch would keep running
// with no visible consumer, wasting git process spawns and API calls.
const isBranchVisible = rightSidebarTab === 'source-control' && rightSidebarOpen
const refreshActiveGitStatus = useCallback(async (): Promise<void> => {
if (!activeWorktreeId || !worktreePath || isFolder) {
return
}
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
await refreshGitStatusForWorktree({
// Why: route git status by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId,
pushTarget: activeWorktree?.pushTarget,
deps: {
setGitStatus,
updateWorktreeGitIdentity,
setUpstreamStatus,
fetchUpstreamStatus
}
})
}, [
activeRepoSettings,
activeWorktreeId,
activeWorktree?.pushTarget,
fetchUpstreamStatus,
isFolder,
setGitStatus,
setUpstreamStatus,
updateWorktreeGitIdentity,
worktreePath
])
const refreshActiveGitStatusAfterMutation = useCallback(async (): Promise<void> => {
try {
await refreshActiveGitStatus()
} catch (error) {
console.warn('[SourceControl] post-mutation git status refresh failed', error)
}
}, [refreshActiveGitStatus])
// Why: when status is truncated at the entry limit, offer (once per worktree)
// to .gitignore the folder most likely flooding it — the usual cause is a
// build/dependency dir that should have been ignored. Accepting writes the
// .gitignore and refreshes, which clears the huge flag and resumes polling.
// Local-only: the SSH huge-folder write path isn't wired, so skip remote.
useEffect(() => {
if (!repositoryHuge || !activeWorktreeId || !worktreePath || activeConnectionId) {
return
}
if (hugeRepoWarningDismissed.has(activeWorktreeId)) {
return
}
const worktreeId = activeWorktreeId
let cancelled = false
void window.api.git
.findHugeFoldersToIgnore({ worktreePath })
.then((folders) => {
if (cancelled || folders.length === 0 || hugeRepoWarningDismissed.has(worktreeId)) {
return
}
hugeRepoWarningDismissed.add(worktreeId)
const folderName = folders[0]
toast.warning(
translate(
'auto.components.right.sidebar.SourceControl.hugeRepoIgnorePrompt',
'This repository has too many active changes. Add "{{value0}}" to .gitignore?',
{ value0: folderName }
),
{
action: {
label: translate(
'auto.components.right.sidebar.SourceControl.hugeRepoIgnoreAction',
'Add to .gitignore'
),
onClick: () => {
void window.api.git
.appendGitignore({ worktreePath, folderName })
.then(() => refreshActiveGitStatus())
.catch((error) => console.warn('[SourceControl] add to .gitignore failed', error))
}
}
}
)
})
.catch((error) => console.warn('[SourceControl] findHugeFoldersToIgnore failed', error))
return () => {
cancelled = true
}
}, [repositoryHuge, activeWorktreeId, worktreePath, activeConnectionId, refreshActiveGitStatus])
const refreshGitStatusAfterPullRequestGeneration = useCallback(
async (context: PullRequestGenerationContext): Promise<void> => {
if (!context.worktreeId || isFolder) {
return
}
try {
await refreshGitStatusForWorktree({
// Why: generation can finish after the user switches hosts; refresh
// the same host that owned the generation request.
settings: context.runtimeTargetSettings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId,
pushTarget: worktreeMap.get(context.worktreeId)?.pushTarget,
deps: {
setGitStatus,
updateWorktreeGitIdentity,
setUpstreamStatus,
fetchUpstreamStatus
}
})
} catch (error) {
console.warn('[SourceControl] post-generation git status refresh failed', error)
}
},
[
fetchUpstreamStatus,
isFolder,
setGitStatus,
setUpstreamStatus,
updateWorktreeGitIdentity,
worktreeMap
]
)
useEffect(() => {
if (!isBranchVisible || !activeRepo || isFolder) {
return
}
// Why: reset to null so that effectiveBaseRef becomes falsy until the IPC
// resolves. This prevents the branch compare from firing with a stale
// defaultBaseRef left over from a *different* repo (e.g. 'origin/master'
// when the new repo uses 'origin/main'), which would cause a transient
// "invalid-base" error every time the user switches between repos.
setDefaultBaseRef(null)
let stale = false
void getRuntimeRepoBaseRefDefault(activeRepoSettings, activeRepo.id)
.then((result) => {
if (!stale) {
// Why: IPC now returns a `{ defaultBaseRef, remoteCount }` envelope;
// this component only needs `defaultBaseRef`. `remoteCount` is used
// by BaseRefPicker for the multi-remote hint.
setDefaultBaseRef(result.defaultBaseRef)
}
})
.catch((err) => {
console.error('[SourceControl] getBaseRefDefault failed', err)
// Why: leave defaultBaseRef null on failure instead of fabricating
// 'origin/main'. effectiveBaseRef stays falsy, so branch compare and
// PR fetch skip running against a ref that may not exist.
if (!stale) {
setDefaultBaseRef(null)
}
})
return () => {
stale = true
}
}, [activeRepo, activeRepoSettings, isBranchVisible, isFolder])
const normalizedWorktreeBaseRef = activeWorktree?.baseRef?.trim() || null
const normalizedRepoBaseRef = activeRepo?.worktreeBaseRef?.trim() || null
const baseRefOwnedByWorktree = normalizedWorktreeBaseRef !== null
const pinnedBaseRef = normalizedWorktreeBaseRef ?? normalizedRepoBaseRef
const hasUncommittedEntries = entries.length > 0
const hostedReviewCreation =
hostedReviewCreationState &&
activeRepo?.id === hostedReviewCreationState.repoId &&
activeWorktreeId === hostedReviewCreationState.worktreeId &&
branchName === hostedReviewCreationState.branch
? hostedReviewCreationState.data
: null
const hostedReviewCreateProvider = resolveHostedReviewCreationProvider(
hostedReviewCreation?.provider
)
const hostedReviewCreateCopy = localizedHostedReviewCopy(hostedReviewCreateProvider)
const hostedReviewCacheKey =
activeRepo && branchName
? getHostedReviewCacheKey(
activeRepo.path,
branchName,
settings,
activeRepo.id,
activeRepo.connectionId,
activeRepo.executionHostId
)
: null
const hostedReviewEntry = hostedReviewCacheKey
? hostedReviewCache[hostedReviewCacheKey]
: undefined
const activePrCacheKey =
activeRepo && branchName
? getGitHubPRCacheKey(
activeRepo.path,
activeRepo.id,
branchName,
settings,
activeRepo.connectionId,
activeRepo.executionHostId
)
: null
const activePrFromQueue = activePrCacheKey ? (prCache[activePrCacheKey]?.data ?? null) : null
const hostedReviewEntryData = hostedReviewEntry?.data ?? null
const hostedReview: HostedReviewInfo | null = useMemo(() => {
if (!hostedReviewCacheKey) {
return null
}
if (activePrFromQueue) {
return { provider: 'github', ...activePrFromQueue, status: activePrFromQueue.checksStatus }
}
return hostedReviewEntryData
}, [activePrFromQueue, hostedReviewCacheKey, hostedReviewEntryData])
const effectiveBaseRef = resolveSourceControlBaseRef({
worktreeBaseRef: normalizedWorktreeBaseRef,
reviewBaseRefName: hostedReview?.baseRefName,
repoBaseRef: normalizedRepoBaseRef,
defaultBaseRef
})
const pickerBaseRef = resolveSourceControlPickerBaseRef({
pinnedBaseRef,
effectiveBaseRef
})
useEffect(() => {
createPrIntentCurrentTargetRef.current = {
repoId: activeRepo?.id ?? null,
worktreeId: activeWorktreeId ?? null,
worktreePath,
branch: branchName,
baseRef: effectiveBaseRef ?? null
}
}, [activeRepo?.id, activeWorktreeId, branchName, effectiveBaseRef, worktreePath])
const linkedGitHubPR = activeWorktree?.linkedPR ?? null
const fallbackGitHubPRNumber = linkedGitHubPR == null ? (activePrFromQueue?.number ?? null) : null
const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null
const linkedBitbucketPR = activeWorktree?.linkedBitbucketPR ?? null
const linkedAzureDevOpsPR = activeWorktree?.linkedAzureDevOpsPR ?? null
const linkedGiteaPR = activeWorktree?.linkedGiteaPR ?? null
const shouldResolveHostedReviewCreation =
isBranchVisible &&
Boolean(activeRepo) &&
!isFolder &&
Boolean(branchName) &&
branchName !== 'HEAD' &&
Boolean(activeWorktreeId)
const hostedReviewCreationRequestMatchesCurrent =
hostedReviewCreationRequestState !== null &&
activeRepo?.id === hostedReviewCreationRequestState.repoId &&
activeWorktreeId === hostedReviewCreationRequestState.worktreeId &&
branchName === hostedReviewCreationRequestState.branch
const isHostedReviewCreationLoading =
shouldResolveHostedReviewCreation &&
hostedReviewCreationRequestMatchesCurrent &&
hostedReviewCreationRequestState.status === 'loading' &&
hostedReviewCreation === null &&
hostedReview === null
const hostedReviewCreationForHeader = useMemo(() => {
if (hostedReviewCreation) {
return hostedReviewCreation
}
if (!isHostedReviewCreationLoading) {
return null
}
const provider = resolveProvisionalHostedReviewProvider({
hostedReview,
hostedReviewCreationState,
activeRepoId: activeRepo?.id ?? null,
linkedGitHubPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR
})
return buildLoadingHostedReviewCreationEligibility(provider)
}, [
activeRepo?.id,
fallbackGitHubPRNumber,
hostedReview,
hostedReviewCreation,
hostedReviewCreationState,
isHostedReviewCreationLoading,
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGitHubPR,
linkedGitLabMR,
linkedGiteaPR
])
const hasHostedReviewLink = hasPositiveHostedReviewNumberLink({
linkedGitHubPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR
})
// Why: when activeRepo.connectionId is truthy, neither the SourceControl
// effect below nor WorktreeCard.tsx fetches hostedReview for this branch,
// so hostedReviewEntry would stay undefined forever and would permanently
// block Publish Branch on SSH-backed worktrees with linked review metadata
// and no upstream. Skip the loading state for those repos so the publish
// gate doesn't latch.
const isHostedReviewStateLoading =
!activeRepo?.connectionId && hasHostedReviewLink && hostedReviewEntry === undefined
const hasResolvableReviewPushTargetLink = hasResolvableHostedReviewPushTargetLink({
linkedGitHubPR,
linkedGitLabMR
})
useEffect(() => {
// Why: resolving review heads can hit provider/SSH APIs, so keep it tied
// to the visible Source Control branch view like the adjacent PR polling.
if (!isBranchVisible || isFolder || !activeWorktreeId || activeWorktree?.pushTarget) {
return
}
if (!hasResolvableReviewPushTargetLink) {
return
}
void ensureHostedReviewPushTarget(activeWorktreeId)
}, [
activeWorktree?.pushTarget,
activeWorktreeId,
ensureHostedReviewPushTarget,
hasResolvableReviewPushTargetLink,
isBranchVisible,
isFolder,
linkedGitHubPR,
linkedGitLabMR
])
const canUseHostedReviewPushTarget = hasUsableHostedReviewPushTarget({
pushTarget: activeWorktree?.pushTarget,
upstreamStatus: remoteStatus,
hasResolvableHostedReviewPushTargetLink: hasResolvableReviewPushTargetLink
})
const hostedReviewStateForActions = resolveHostedReviewStateForActions({
hostedReviewState: hostedReview?.state ?? null,
hasResolvableHostedReviewPushTargetLink: hasResolvableReviewPushTargetLink
})
const remoteStatusForActions: typeof remoteStatus = useMemo(
() =>
resolveHostedReviewActionUpstreamStatus({
hasHostedReviewLink,
hasResolvableHostedReviewPushTargetLink: hasResolvableReviewPushTargetLink,
hostedReviewState: hostedReviewStateForActions,
isHostedReviewStateLoading,
canUseHostedReviewPushTarget,
upstreamStatus: remoteStatus
}),
[
canUseHostedReviewPushTarget,
hasHostedReviewLink,
hasResolvableReviewPushTargetLink,
hostedReviewStateForActions,
isHostedReviewStateLoading,
remoteStatus
]
)
useEffect(() => {
if (
!isBranchVisible ||
!activeRepo ||
isFolder ||
!branchName ||
branchName === 'HEAD' ||
!activeWorktreeId
) {
return
}
// Why: the Source Control panel renders branch review status directly.
// When a terminal checkout moves this worktree onto a new branch, fetch
// immediately; carry a known PR number because branch lookup is lossy for
// fork/deleted-head PRs.
void fetchHostedReviewForBranch(activeRepo.path, branchName, {
repoId: activeRepo.id,
linkedGitHubPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR,
staleWhileRevalidate: true
})
// Why: the GitHub-specific cache powers grouping/check panels; keep that
// refresh behind the coordinator so Source Control does not bypass pacing.
enqueueGitHubPRRefresh(activeWorktreeId, 'swr', 30)
}, [
activeRepo,
activeWorktreeId,
branchName,
enqueueGitHubPRRefresh,
fetchHostedReviewForBranch,
isBranchVisible,
isFolder,
linkedGitHubPR,
fallbackGitHubPRNumber,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR
])
// Why: eligibility is recomputed below, after prGenerating / isCreatingPr are
// available, so the effect can pause refetches while a user-initiated PR flow
// is in flight. AI generation runs `git fetch` + `git rebase`, which mutates
// ahead/behind counts; without this guard the next refetch would return
// canCreate:false (typically needs_push), flip primaryAction.kind off
// create_pr, unmount the composer, and cancel the in-flight generation.
const grouped = useMemo(() => {
const groups: SourceControlEntryGroups = { staged: [], unstaged: [], untracked: [] }
for (const entry of entries) {
groups[entry.area].push(entry)
}
for (const area of SOURCE_CONTROL_AREAS) {
groups[area].sort(compareGitStatusEntries)
}
return groups
}, [entries])
const fileFilterState = useMemo(() => getSourceControlFileFilterState(filterQuery), [filterQuery])
const normalizedFilter = fileFilterState.normalizedFilter
const isGitHistoryVisible =
!normalizedFilter &&
!fileFilterState.tooLarge &&
Boolean(activeWorktreeId && worktreePath && !isFolder)
const filteredGrouped = useMemo(
() => filterSourceControlGroupedPathEntries(grouped, fileFilterState),
[fileFilterState, grouped]
)
const displaySections = useMemo(
() => buildSourceControlDisplaySections(filteredGrouped, sourceControlGroupOrder),
[filteredGrouped, sourceControlGroupOrder]
)
const unfilteredDisplaySections = useMemo(
() => buildSourceControlDisplaySections(grouped, sourceControlGroupOrder),
[grouped, sourceControlGroupOrder]
)
const unfilteredDisplaySectionsById = useMemo(
() => new Map(unfilteredDisplaySections.map((section) => [section.id, section])),
[unfilteredDisplaySections]
)
const filteredBranchEntries = useMemo(
() => filterSourceControlPathEntries(branchEntries, fileFilterState),
[branchEntries, fileFilterState]
)
const flatEntries = useMemo(() => {
const arr: FlatEntry[] = []
for (const section of displaySections) {
if (!collapsedSections.has(section.id)) {
for (const entry of section.items) {
arr.push({ key: `${entry.area}::${entry.path}`, entry, area: entry.area })
}
}
}
return arr
}, [collapsedSections, displaySections])
const treeRootsBySection = useMemo(() => {
const roots: Partial<Record<SourceControlDisplaySectionId, GitStatusSourceControlTreeNode[]>> =
{}
for (const section of displaySections) {
const sectionRoots = compactSourceControlTree(
buildGitStatusSourceControlTree(section.area, section.items)
)
roots[section.id] =
section.id === 'conflicts'
? applyGitStatusEntryAreasToSourceControlTree(
// Why: conflict rows can mirror normal paths, so their folder
// collapse keys must not share state with normal area sections.
namespaceSourceControlTreeDirectoryKeys(sectionRoots, 'conflicts')
)
: sectionRoots
}
return roots
}, [displaySections])
const visibleTreeRowsBySection = useMemo(() => {
const rows: Partial<Record<SourceControlDisplaySectionId, GitStatusSourceControlTreeNode[]>> =
{}
for (const section of displaySections) {
rows[section.id] = flattenSourceControlTree(
treeRootsBySection[section.id] ?? [],
collapsedTreeDirs
)
}
return rows
}, [collapsedTreeDirs, displaySections, treeRootsBySection])
const branchTreeRoots = useMemo(
() => compactSourceControlTree(buildSourceControlTree('branch', filteredBranchEntries)),
[filteredBranchEntries]
)
const visibleBranchTreeRows = useMemo(
() => flattenSourceControlTree(branchTreeRoots, collapsedTreeDirs),
[branchTreeRoots, collapsedTreeDirs]
)
const visibleSelectionEntries = useMemo(() => {
if (sourceControlViewMode === 'list') {
return flatEntries
}
const arr: FlatEntry[] = []
for (const section of displaySections) {
if (collapsedSections.has(section.id)) {
continue
}
for (const node of visibleTreeRowsBySection[section.id] ?? []) {
if (node.type === 'file') {
arr.push({ key: node.key, entry: node.entry, area: node.area })
}
}
}
return arr
}, [
collapsedSections,
displaySections,
flatEntries,
sourceControlViewMode,
visibleTreeRowsBySection
])
const [isExecutingBulk, setIsExecutingBulk] = useState(false)
const unresolvedConflicts = useMemo(
() => entries.filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind),
[entries]
)
const unresolvedConflictReviewEntries = useMemo(
() =>
unresolvedConflicts.map((entry) => ({
path: entry.path,
conflictKind: entry.conflictKind!
})),
[unresolvedConflicts]
)
const {
sourceControlAiDiscoveryHostKey,
sourceControlAiActionsVisible,
resolvedCommitMessageAi,
resolvedPrCreationDefaults,
resolveConflictsComposerOpen,
setResolveConflictsComposerOpen,
commitGenerationDialogOpen,
setCommitGenerationDialogOpen,
pullRequestGenerationDialogOpen,
setPullRequestGenerationDialogOpen,
openCommitGenerationDialog,
openPullRequestGenerationDialog,
isLaunchingCommitFailureAgent,
resolveConflictsPrompt,
commitFailureRecoveryPrompt,
getLaunchActionRecipe,
saveLaunchActionDefault,
handleResolveConflictsWithAI,
handleFixCommitFailureWithAI,
handleSaveCommitMessageGenerationDefaults,
handleSavePullRequestGenerationDefaults,
openSourceControlAiSettings
} = useSourceControlAi({
settings: activeRepoSettings,
activeRepo: activeRepo ?? null,
activeWorktreeId,
activeConnectionId,
activeGroupId,
activeSourceControlLaunchPlatform,
conflictOperation,
unresolvedConflicts,
stagedEntries: grouped.staged,
worktreePath,
commitMessage,
commitError,
updateSettings,
updateRepo,
openSettingsTarget,
openSettingsPage
})
useEffect(() => {
if (sourceControlAiActionsVisible) {
return
}
setResolveConflictsComposerOpen(false)
setCommitGenerationDialogOpen(false)
setPullRequestGenerationDialogOpen(false)
}, [
setCommitGenerationDialogOpen,
setPullRequestGenerationDialogOpen,
setResolveConflictsComposerOpen,
sourceControlAiActionsVisible
])
// Why: orphaned draft/error/in-flight entries accumulate when worktrees are
// removed from the store (long sessions with many create/destroy cycles).
// Prune them so a deleted-then-reused worktree ID doesn't inherit stale
// state — especially commitInFlightRef, which would permanently disable
// Commit for that ID if left stuck at `true`.
useEffect(() => {
const pruneRecord = <T,>(prev: Record<string, T>): Record<string, T> => {
let changed = false
const next: Record<string, T> = {}
for (const key of Object.keys(prev)) {
if (worktreeMap.has(key)) {
next[key] = prev[key]
} else {
changed = true
}
}
return changed ? next : prev
}
updateCommitDrafts((prev) => pruneRecord(prev))
setCommitErrors((prev) => pruneRecord(prev))
setRemoteActionErrors((prev) => pruneRecord(prev))
setCommitInFlightByWorktree((prev) => pruneRecord(prev))
setAbortOperationInFlightByWorktree((prev) => pruneRecord(prev))
setGenerateInFlightByWorktree((prev) => pruneRecord(prev))
setGenerateErrors((prev) => pruneRecord(prev))
setCreatePrIntentInFlightByWorktree((prev) => pruneRecord(prev))
setCreatePrIntentNotices((prev) => pruneRecord(prev))
setGitHistoryByWorktree((prev) => pruneRecord(prev))
// Refs don't need setState — mutate in place to drop stale keys.
for (const key of Object.keys(commitInFlightRef.current)) {
if (!worktreeMap.has(key)) {
delete commitInFlightRef.current[key]
}
}
for (const key of Object.keys(generateInFlightRef.current)) {
if (!worktreeMap.has(key)) {
delete generateInFlightRef.current[key]
}
}
for (const key of Object.keys(createPrIntentInFlightRef.current)) {
if (!worktreeMap.has(key)) {
delete createPrIntentInFlightRef.current[key]
delete createPrIntentRunTokenRef.current[key]
}
}
for (const key of Object.keys(gitHistoryRequestByWorktreeRef.current)) {
if (!worktreeMap.has(key)) {
delete gitHistoryRequestByWorktreeRef.current[key]
}
}
}, [updateCommitDrafts, worktreeMap])
useEffect(() => {
saveSessionCommitDrafts(commitDrafts)
}, [commitDrafts])
useEffect(() => {
// Why: users often finish merge/rebase conflicts in a terminal. Once git
// status observes that operation end, the old Source Control failure banner
// is stale and should not survive the successful external continue/abort.
const previousConflictOperations = previousConflictOperationsRef.current
setRemoteActionErrors((prev) =>
clearRemoteActionErrorsForCompletedConflictOperations({
remoteActionErrors: prev,
previousConflictOperations,
currentConflictOperations: conflictOperationsByWorktree
})
)
previousConflictOperationsRef.current = conflictOperationsByWorktree
}, [conflictOperationsByWorktree])
// Why: the sidebar no longer uses key={activeWorktreeId} to force a full
// remount on worktree switch (that caused an IPC storm on Windows).
// Instead, reset worktree-specific local state here so the previous
// worktree's UI state doesn't leak into the new one.
useEffect(() => {
setFilterExpanded(false)
setCollapsedSections(createDefaultCollapsedSections())
setCollapsedTreeDirs(new Set())
setBaseRefDialogOpen(false)
setPendingDiscard(null)
setPendingDiffCommentsClear(null)
setIsClearingDiffComments(false)
// Why: do NOT reset defaultBaseRef here. It is repo-scoped, not
// worktree-scoped, and is resolved by the effect above on activeRepo
// change. Resetting it to a hard-coded 'origin/main' on every worktree
// switch within the same repo clobbered the correct value (e.g.
// 'origin/master' for repos whose default branch isn't main), causing
// a persistent "Branch compare unavailable" until the user switched
// repos and back to re-trigger the resolver.
setFilterQuery('')
setIsExecutingBulk(false)
// Why: no reset for commit-in-flight state — it now lives in a per-worktree
// map, so it cannot leak across worktrees. Resetting here would actually
// clear in-flight state for the *incoming* worktree if the user is coming
// back to a worktree mid-commit, re-enabling the button while the commit
// still runs.
}, [activeWorktreeId])
// Why: returns true on success so compound actions ("Commit & Push" etc.)
// can skip the follow-up remote operation when the commit itself failed.
const handleCommit = useCallback(
async (
messageOverride?: string,
options?: {
skipStagedSnapshotCheck?: boolean
skipActiveConflictCheck?: boolean
target?: SourceControlOperationTarget
}
): Promise<boolean> => {
const target =
options?.target ??
(activeWorktreeId && worktreePath
? {
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId: getConnectionId(activeWorktreeId) ?? undefined,
pushTarget: activeWorktree?.pushTarget
}
: null)
if (!target) {
return false
}
const message = (messageOverride ?? commitMessage).trim()
if (
!message ||
(!options?.skipStagedSnapshotCheck && grouped.staged.length === 0) ||
(!options?.skipActiveConflictCheck && unresolvedConflicts.length > 0)
) {
return false
}
if (commitInFlightRef.current[target.worktreeId]) {
return false
}
commitInFlightRef.current[target.worktreeId] = true
setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: true }))
setCommitErrors((prev) => ({ ...prev, [target.worktreeId]: null }))
try {
const commitResult = await commitRuntimeGit(
{
// Why: route the commit by the repo OWNER host, not the focused runtime.
settings: target.settings,
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
connectionId: target.connectionId
},
message
)
if (!commitResult.success) {
setCommitErrors((prev) => ({
...prev,
[target.worktreeId]: commitResult.error ?? 'Commit failed'
}))
return false
}
// Why: the textarea stays enabled during the in-flight commit (only the
// button is disabled), so the user can keep typing after clicking Commit.
// Unconditionally clearing the draft here would silently discard those
// in-progress edits — the commit used the OLD `message` captured in this
// closure, so the dropped text would never have been committed either.
// Only clear when the current draft still matches what we committed.
updateCommitDrafts((prev) => {
const current = prev[target.worktreeId]
if (current !== undefined && current.trim() !== message) {
// User typed more after submit — preserve their in-progress edits.
return prev
}
return writeCommitDraftForWorktree(prev, target.worktreeId, '')
})
setCommitErrors((prev) => ({ ...prev, [target.worktreeId]: null }))
if (!options?.target) {
void refreshActiveGitStatusAfterMutation()
}
// Why: flip branchSummary to 'loading' synchronously so the empty-state
// guard
// (!hasUncommittedEntries && branchSummary.status === 'ready' &&
// branchEntries.length === 0)
// doesn't briefly read true between setGitStatus clearing the
// uncommitted list and the next branchCompare poll landing the new
// commit. Without this flip "No changes on this branch" flashes for
// the full poll-interval window.
//
// Then fire-and-forget refreshBranchCompare so the "Committed on
// Branch" section repopulates as soon as the IPC returns instead of
// waiting for the next poll. Unawaited on purpose:
// compound flows (runCompoundCommitAction) need handleCommit to
// resolve immediately so the push step starts without delay. Errors
// here are best-effort — the polling tick will retry.
if (!options?.target && effectiveBaseRef) {
beginGitBranchCompareRequest(
target.worktreeId,
`${target.worktreeId}:${effectiveBaseRef}:${Date.now()}:post-commit`,
effectiveBaseRef
)
}
if (!options?.target) {
void refreshBranchCompareRef.current()
void refreshGitHistoryRef.current()
}
return true
} catch (error) {
setCommitErrors((prev) => ({
...prev,
[target.worktreeId]: error instanceof Error ? error.message : 'Commit failed'
}))
return false
} finally {
setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: false }))
commitInFlightRef.current[target.worktreeId] = false
}
},
[
activeRepoSettings,
activeWorktree?.pushTarget,
activeWorktreeId,
beginGitBranchCompareRequest,
commitMessage,
effectiveBaseRef,
grouped.staged.length,
refreshActiveGitStatusAfterMutation,
updateCommitDrafts,
unresolvedConflicts.length,
worktreePath
]
)
const handleGenerate = useCallback(
async (overrides?: RuntimeGenerateCommitMessageOverrides): Promise<void> => {
if (!activeWorktreeId || !worktreePath || !activeCommitMessageGenerationKey) {
return
}
if (generateInFlightRef.current[activeWorktreeId]) {
return
}
if (!overrides?.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) {
return
}
if (
!overrides?.sourceControlAiResolvedParams &&
resolvedCommitMessageAi?.ok === true &&
isCustomAgentId(resolvedCommitMessageAi.value.params.agentId)
) {
const command = resolvedCommitMessageAi.value.params.customAgentCommand?.trim() ?? ''
if (!command) {
setGenerateErrors((prev) => ({
...prev,
[activeWorktreeId]:
'Custom command is empty. Add one in Settings -> Git -> Source Control AI.'
}))
return
}
}
generateInFlightRef.current[activeWorktreeId] = true
const requestId = allocateCommitMessageGenerationRequestId()
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
setCommitMessageGenerationRecord(
activeCommitMessageGenerationKey,
createRunningCommitMessageGenerationRecord({
worktreeId: activeWorktreeId,
worktreePath,
connectionId,
requestId,
runtimeTargetSettings: activeRepoSettings
})
)
setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
try {
const result = await generateRuntimeCommitMessage(
{
// Why: route generation by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
overrides
)
if (!result.success) {
// Why: cancellation is a deliberate user action, not a failure to
// surface. Clear any prior error and stay quiet.
if (result.canceled) {
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
updateCommitMessageGenerationRecord(activeCommitMessageGenerationKey, (record) =>
resolveCommitMessageGenerationFailure({
record,
requestId,
canceled: true,
error: null
})
)
return
}
setGenerateErrors((prev) => ({
...prev,
[activeWorktreeId]: result.error
}))
updateCommitMessageGenerationRecord(activeCommitMessageGenerationKey, (record) =>
resolveCommitMessageGenerationFailure({
record,
requestId,
error: result.error
})
)
return
}
updateCommitMessageGenerationRecord(activeCommitMessageGenerationKey, (record) =>
resolveCommitMessageGenerationSuccess({
record,
requestId,
message: result.message
})
)
// Why: race protection — the user may have started typing into the
// textarea while the agent was running. In that case we silently drop
// the generated message rather than overwrite their in-progress edits.
updateCommitDrafts((prev) => {
const current = prev[activeWorktreeId]
if (current && current.length > 0) {
return prev
}
return writeCommitDraftForWorktree(prev, activeWorktreeId, result.message)
})
useAppStore.getState().recordFeatureInteraction('ai-commit-generation')
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to generate commit message'
setGenerateErrors((prev) => ({
...prev,
[activeWorktreeId]: message
}))
updateCommitMessageGenerationRecord(activeCommitMessageGenerationKey, (record) =>
resolveCommitMessageGenerationFailure({
record,
requestId,
error: message
})
)
} finally {
setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
generateInFlightRef.current[activeWorktreeId] = false
}
},
[
activeCommitMessageGenerationKey,
activeRepoSettings,
activeWorktreeId,
allocateCommitMessageGenerationRequestId,
resolvedCommitMessageAi,
setCommitMessageGenerationRecord,
updateCommitDrafts,
updateCommitMessageGenerationRecord,
worktreePath
]
)
const handleGenerateCommitMessageClick = useCallback((): void => {
if (!sourceControlAiActionsVisible) {
return
}
if (
hasConfiguredCommitMessageGenerationDefaults({ settings, repo: activeRepo ?? null }) &&
resolvedCommitMessageAi?.ok
) {
void handleGenerate({ sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params })
return
}
openCommitGenerationDialog()
}, [
activeRepo,
handleGenerate,
openCommitGenerationDialog,
resolvedCommitMessageAi,
settings,
sourceControlAiActionsVisible
])
const generateCommitMessageForCreatePrIntent = useCallback(
async (
token: CreatePrIntentRunToken
): Promise<{
ok: boolean
message?: string
reason?: 'settings' | 'failed' | 'canceled'
}> => {
if (
!hasConfiguredCommitMessageGenerationDefaults({ settings, repo: activeRepo ?? null }) ||
resolvedCommitMessageAi?.ok !== true
) {
return { ok: false, reason: 'settings' }
}
if (isCustomAgentId(resolvedCommitMessageAi.value.params.agentId)) {
const command = resolvedCommitMessageAi.value.params.customAgentCommand?.trim() ?? ''
if (!command) {
return { ok: false, reason: 'settings' }
}
}
const target = getCreatePrIntentOperationTarget(token)
if (generateInFlightRef.current[target.worktreeId]) {
return { ok: false, reason: 'failed' }
}
generateInFlightRef.current[target.worktreeId] = true
setGenerateInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: true }))
setGenerateErrors((prev) => ({ ...prev, [target.worktreeId]: null }))
try {
const result = await generateRuntimeCommitMessage(target, {
sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params
})
if (!result.success) {
if (!result.canceled) {
setGenerateErrors((prev) => ({ ...prev, [target.worktreeId]: result.error }))
}
return { ok: false, reason: result.canceled ? 'canceled' : 'failed' }
}
useAppStore.getState().recordFeatureInteraction('ai-commit-generation')
setGenerateErrors((prev) => ({ ...prev, [target.worktreeId]: null }))
return { ok: true, message: result.message }
} catch (error) {
setGenerateErrors((prev) => ({
...prev,
[target.worktreeId]:
error instanceof Error ? error.message : 'Failed to generate commit message'
}))
return { ok: false, reason: 'failed' }
} finally {
setGenerateInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: false }))
generateInFlightRef.current[target.worktreeId] = false
}
},
[activeRepo, getCreatePrIntentOperationTarget, resolvedCommitMessageAi, settings]
)
const handleCancelGenerate = useCallback((): void => {
if (!activeWorktreeId || !worktreePath || !activeCommitMessageGenerationKey) {
return
}
if (!generateInFlightRef.current[activeWorktreeId]) {
return
}
updateCommitMessageGenerationRecord(activeCommitMessageGenerationKey, (record) =>
resolveCommitMessageGenerationCancel(record)
)
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
// Why: fire-and-forget — the in-flight generateCommitMessage promise
// resolves with `{canceled: true}` once the kill propagates, which is
// where the spinner is cleared. Awaiting here would just delay UI feedback.
void cancelRuntimeGenerateCommitMessage({
// Why: route the cancel by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
})
}, [
activeCommitMessageGenerationKey,
activeRepoSettings,
activeWorktreeId,
updateCommitMessageGenerationRecord,
worktreePath
])
// Why: a single dispatcher for every remote-only action the split button or
// chevron dropdown can trigger. Keeps the error-swallow pattern in one
// place — store slices already surface actionable toasts, so additional
// try/catch here would duplicate the notification.
const runRemoteAction = useCallback(
async (
kind:
| 'push'
| 'force_push'
| 'pull'
| 'fast_forward'
| 'sync'
| 'fetch'
| 'publish'
| 'rebase',
options?: {
target?: SourceControlOperationTarget
remoteStatus?: GitUpstreamStatus
baseRef?: string | null
}
): Promise<boolean> => {
const target =
options?.target ??
(activeWorktreeId && worktreePath
? {
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId: getConnectionId(activeWorktreeId) ?? undefined,
pushTarget: activeWorktree?.pushTarget
}
: null)
if (!target) {
return false
}
setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: null }))
try {
if (kind === 'publish') {
await pushBranch(
target.worktreeId,
target.worktreePath,
true,
target.connectionId,
target.pushTarget,
{ runtimeTargetSettings: target.settings }
)
return true
}
if (kind === 'push') {
const forceWithLease = shouldForcePushWithLeaseForUpstream(
options?.remoteStatus ?? remoteStatus
)
await pushBranch(
target.worktreeId,
target.worktreePath,
false,
target.connectionId,
target.pushTarget,
forceWithLease
? { forceWithLease: true, runtimeTargetSettings: target.settings }
: { runtimeTargetSettings: target.settings }
)
return true
}
if (kind === 'force_push') {
await pushBranch(
target.worktreeId,
target.worktreePath,
false,
target.connectionId,
target.pushTarget,
{ forceWithLease: true, runtimeTargetSettings: target.settings }
)
return true
}
if (kind === 'pull') {
await pullBranch(
target.worktreeId,
target.worktreePath,
target.connectionId,
target.pushTarget,
{
runtimeTargetSettings: target.settings
}
)
return true
}
if (kind === 'fast_forward') {
await fastForwardBranch(
target.worktreeId,
target.worktreePath,
target.connectionId,
target.pushTarget,
{ runtimeTargetSettings: target.settings }
)
return true
}
if (kind === 'fetch') {
await fetchBranch(
target.worktreeId,
target.worktreePath,
target.connectionId,
target.pushTarget,
{
runtimeTargetSettings: target.settings
}
)
return true
}
if (kind === 'rebase') {
const baseRef = options?.baseRef ?? effectiveBaseRef
if (!baseRef) {
return false
}
await rebaseFromBase(
target.worktreeId,
target.worktreePath,
baseRef,
target.connectionId,
target.pushTarget,
{ runtimeTargetSettings: target.settings }
)
return true
}
await syncBranch(
target.worktreeId,
target.worktreePath,
target.connectionId,
target.pushTarget,
{
runtimeTargetSettings: target.settings
}
)
setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: null }))
return true
} catch (error) {
// Why: remote action failures are surfaced by editor-slice actions to keep
// one consistent toast path and avoid duplicate notifications in the UI.
// Keep the latest failure inline too: dropdown-only actions like Fetch can
// otherwise look like nothing happened once the menu closes.
setRemoteActionErrors((prev) => ({
...prev,
[target.worktreeId]: {
kind,
message: resolveRemoteActionError(kind, error)
}
}))
return false
} finally {
if (!options?.target) {
refreshSourceControlAfterRemoteAction({
refreshGitStatus: refreshActiveGitStatusAfterMutation,
refreshBranchCompare: refreshBranchCompareRef.current,
refreshGitHistory: refreshGitHistoryRef.current
})
}
}
},
[
activeRepoSettings,
activeWorktree?.pushTarget,
activeWorktreeId,
fetchBranch,
fastForwardBranch,
effectiveBaseRef,
pullBranch,
pushBranch,
rebaseFromBase,
refreshActiveGitStatusAfterMutation,
remoteStatus,
syncBranch,
worktreePath
]
)
const handleAbortOperation = useCallback(
async (requestedOperation: AbortConflictOperation): Promise<void> => {
if (
!activeWorktreeId ||
!worktreePath ||
conflictOperation !== requestedOperation ||
isAbortingOperation
) {
return
}
const isRebase = requestedOperation === 'rebase'
const label = isRebase ? 'rebase' : 'merge'
const title = isRebase ? 'Abort rebase?' : 'Abort merge?'
const description = isRebase
? 'This cancels the rebase in progress and can discard conflict resolutions made during this rebase.'
: 'This cancels the merge in progress and can discard conflict resolutions made during this merge.'
const confirmed = await confirmAction({
title,
description,
confirmLabel: `Abort ${label}`,
confirmVariant: 'destructive'
})
if (!confirmed) {
return
}
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
setAbortOperationInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
try {
const context = {
// Why: route the abort by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
}
const abortGitOperation = isRebase ? abortRuntimeGitRebase : abortRuntimeGitMerge
await abortGitOperation(context)
} catch (error) {
const message = error instanceof Error ? error.message : `Failed to abort ${label}`
toast.error(
translate(
'auto.components.right.sidebar.SourceControl.f99560ab29',
'Abort {{value0}} failed',
{ value0: label }
),
{ description: message }
)
setRemoteActionErrors((prev) => ({
...prev,
[activeWorktreeId]: { kind: isRebase ? 'abort_rebase' : 'abort_merge', message }
}))
} finally {
setAbortOperationInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
refreshSourceControlAfterRemoteAction({
refreshGitStatus: refreshActiveGitStatusAfterMutation,
refreshBranchCompare: refreshBranchCompareRef.current,
refreshGitHistory: refreshGitHistoryRef.current
})
}
},
[
activeRepoSettings,
activeWorktreeId,
confirmAction,
conflictOperation,
isAbortingOperation,
refreshActiveGitStatusAfterMutation,
worktreePath
]
)
const handleAbortMerge = useCallback(async (): Promise<void> => {
await handleAbortOperation('merge')
}, [handleAbortOperation])
const handleAbortRebase = useCallback(async (): Promise<void> => {
await handleAbortOperation('rebase')
}, [handleAbortOperation])
const handleAbortOperationForConflict = useCallback(
(operation: GitConflictOperation): void => {
if (operation === 'merge') {
void handleAbortMerge()
return
}
if (operation === 'rebase') {
void handleAbortRebase()
}
},
[handleAbortMerge, handleAbortRebase]
)
// Why: compound actions must commit first and only run the follow-up remote
// op when the commit succeeds. handleCommit's return value carries that
// signal — a failure leaves commitError populated and short-circuits here
// so we never push a commit the user didn't actually land. The primary
// button never takes this path (it always emits a single-action kind);
// compound flows are reached only from the dropdown, which offers
// 'commit_push' and 'commit_sync' (there is no 'Commit & Publish' row).
const runCompoundCommitAction = useCallback(
async (remoteKind: 'push' | 'sync'): Promise<void> => {
const ok = await handleCommit()
if (!ok) {
return
}
await runRemoteAction(remoteKind)
},
[handleCommit, runRemoteAction]
)
const handlePullRequestCreated = useCallback(
async (result: CreatedHostedReview, context?: HostedReviewCreatedContext): Promise<void> => {
const repoPath = context?.repoPath ?? activeRepo?.path
const repoId = context?.repoId ?? activeRepo?.id
const branch = context?.branch ?? branchName
const worktreeId = context?.worktreeId ?? activeWorktreeId ?? null
const openChecks = context?.openChecks ?? true
if (!repoPath || !repoId || !branch) {
return
}
const copy = localizedHostedReviewCopy(
resolveSupportedHostedReviewCopyProvider(result.provider)
)
if (openChecks) {
setRightSidebarOpen(true)
setRightSidebarTab('checks')
}
try {
if (worktreeId && result.provider === 'github') {
await updateWorktreeMeta(worktreeId, { linkedPR: result.number })
}
if (worktreeId && result.provider === 'gitlab') {
await updateWorktreeMeta(worktreeId, { linkedGitLabMR: result.number })
}
if (worktreeId && result.provider === 'azure-devops') {
await updateWorktreeMeta(worktreeId, { linkedAzureDevOpsPR: result.number })
}
if (worktreeId && result.provider === 'gitea') {
await updateWorktreeMeta(worktreeId, { linkedGiteaPR: result.number })
}
const linkedReviewNumbers = {
linkedGitHubPR: result.provider === 'github' ? result.number : linkedGitHubPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR: result.provider === 'gitlab' ? result.number : linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR:
result.provider === 'azure-devops' ? result.number : linkedAzureDevOpsPR,
linkedGiteaPR: result.provider === 'gitea' ? result.number : linkedGiteaPR
}
if (result.provider === 'gitlab') {
await fetchHostedReviewForBranch(repoPath, branch, {
force: true,
repoId,
...linkedReviewNumbers
})
return
}
if (result.provider !== 'github') {
await fetchHostedReviewForBranch(repoPath, branch, {
force: true,
repoId,
...linkedReviewNumbers
})
return
}
await Promise.all([
fetchHostedReviewForBranch(repoPath, branch, {
force: true,
repoId,
...linkedReviewNumbers
}),
fetchPRForBranch(repoPath, branch, {
force: true,
repoId,
worktreeId: worktreeId ?? undefined,
linkedPRNumber: result.number
})
])
} catch {
toast.warning(
translate(
'auto.components.right.sidebar.SourceControl.0453ca3a9a',
'{{value0}} created, but Orca could not refresh it yet.',
{ value0: copy.titleLabel }
),
{
action: {
label: translate(
'auto.components.right.sidebar.SourceControl.812cb992ee',
'Open on {{value0}}',
{ value0: copy.providerName }
),
onClick: () => window.api.shell.openUrl(result.url)
}
}
)
}
},
[
activeRepo,
activeWorktreeId,
branchName,
fallbackGitHubPRNumber,
fetchHostedReviewForBranch,
fetchPRForBranch,
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGiteaPR,
linkedGitHubPR,
linkedGitLabMR,
setRightSidebarOpen,
setRightSidebarTab,
updateWorktreeMeta
]
)
const openHostedReviewInChecks = useCallback(() => {
setRightSidebarOpen(true)
setRightSidebarTab('checks')
}, [setRightSidebarOpen, setRightSidebarTab])
const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise<void> => {
// Why: AI PR detail generation may rebase before summarizing; if HEAD moved,
// refresh status before letting the user submit the generated draft.
await refreshActiveGitStatusAfterMutation()
}, [refreshActiveGitStatusAfterMutation])
const handleGeneratePullRequestFieldsForActive = useCallback(
async (
fields: PullRequestGenerationFields,
fieldRevisions: PullRequestFieldRevisions,
overrides?: RuntimeGeneratePullRequestFieldsOverrides
): Promise<void> => {
if (!activeRepo || !activePullRequestGenerationKey || !worktreePath || !branchName) {
return
}
const generationKey = activePullRequestGenerationKey
if (
useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running'
) {
return
}
const requestId = allocatePullRequestGenerationRequestId()
const context: PullRequestGenerationContext = {
worktreeId: activeWorktreeId,
worktreePath,
connectionId: getConnectionId(activeWorktreeId) ?? undefined,
requestId,
repoId: activeRepo.id,
branch: branchName,
runtimeTargetSettings: activeRepoSettings
}
const seed = { ...fields }
// Why: SourceControl can unmount on tab switches; persisting the running
// record lets the embedded PR composer resume when the user returns.
setPullRequestGenerationRecord(
generationKey,
createRunningPullRequestGenerationRecord(context, seed, fieldRevisions)
)
try {
const result = await generateRuntimePullRequestFields(
{
// Why: route generation by the repo OWNER host, not the focused runtime.
settings: context.runtimeTargetSettings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
},
{
base: stripBaseRef(seed.base.trim()),
title: seed.title,
body: seed.body,
draft: seed.draft,
provider: hostedReviewCreateProvider,
useTemplate: resolvedPrCreationDefaults.useTemplate
},
overrides
)
if (result.branchChangedByPreparation) {
await refreshGitStatusAfterPullRequestGeneration(context)
}
if (result.success) {
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
}
updatePullRequestGenerationRecord(generationKey, (record) => {
if (!result.success) {
return resolvePullRequestGenerationFailure({
record,
requestId,
canceled: result.canceled,
error: result.canceled ? null : result.error
})
}
if (!record) {
return null
}
return resolvePullRequestGenerationSuccess({
record,
requestId,
result: {
base: stripBaseRef(result.fields.base),
title: result.fields.title,
body: result.fields.body,
draft: result.fields.draft
}
})
})
} catch (error) {
updatePullRequestGenerationRecord(generationKey, (record) =>
resolvePullRequestGenerationFailure({
record,
requestId,
error:
error instanceof Error ? error.message : 'Failed to generate pull request details'
})
)
}
},
[
activePullRequestGenerationKey,
activeRepo,
activeRepoSettings,
activeWorktreeId,
allocatePullRequestGenerationRequestId,
branchName,
hostedReviewCreateProvider,
refreshGitStatusAfterPullRequestGeneration,
resolvedPrCreationDefaults.useTemplate,
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord,
worktreePath
]
)
const handleCancelGeneratePullRequestFieldsForActive = useCallback((): void => {
if (!activePullRequestGenerationKey) {
return
}
const record = prGenerationRecords[activePullRequestGenerationKey]
if (!record || record.status !== 'running') {
return
}
const generationKey = activePullRequestGenerationKey
updatePullRequestGenerationRecord(generationKey, (current) => {
if (!current || current.context.requestId !== record.context.requestId) {
return null
}
return resolvePullRequestGenerationCancel(current)
})
void cancelRuntimeGeneratePullRequestFields({
// Why: the user can switch hosts while generation runs; cancel the
// original request owner instead of the current focused host.
settings: record.context.runtimeTargetSettings,
worktreeId: record.context.worktreeId,
worktreePath: record.context.worktreePath,
connectionId: record.context.connectionId
}).catch((error) => {
updatePullRequestGenerationRecord(generationKey, (current) => {
if (!current || current.context.requestId !== record.context.requestId) {
return null
}
return {
...current,
status: 'failed',
error: error instanceof Error ? error.message : 'Failed to stop pull request generation',
hydrated: false
}
})
})
}, [activePullRequestGenerationKey, prGenerationRecords, updatePullRequestGenerationRecord])
const handlePullRequestGenerationSeedRestored = useCallback((): void => {
if (!activePullRequestGenerationKey || !activePullRequestGenerationRecord) {
return
}
const requestId = activePullRequestGenerationRecord.context.requestId
updatePullRequestGenerationRecord(activePullRequestGenerationKey, (record) =>
markPullRequestGenerationTerminalSeedRestored({
record,
requestId
})
)
}, [
activePullRequestGenerationKey,
activePullRequestGenerationRecord,
updatePullRequestGenerationRecord
])
const {
aiGenerationEnabled: prAiGenerationEnabled,
base: prBase,
setBase: setPrBase,
title: prTitle,
setTitle: setPrTitle,
body: prBody,
setBody: setPrBody,
draft: prDraft,
setDraft: setPrDraft,
baseQuery: prBaseQuery,
setBaseQuery: setPrBaseQuery,
baseResults: prBaseResults,
setBaseResults: setPrBaseResults,
baseSearchError: prBaseSearchError,
generating: prGenerating,
generateError: prGenerateError,
generateDisabled: prGenerateDisabled,
generateDisabledReason: prGenerateDisabledReason,
handleGenerate: handleGeneratePullRequestFields,
handleCancelGenerate: handleCancelGeneratePullRequestFields,
applyGeneratedFields: applyGeneratedPullRequestFields,
initializedFromEligibility: pullRequestFieldsInitialized
} = useCreatePullRequestDialogFields({
open: hostedReviewCreation?.canCreate === true,
repoId: activeRepo?.id ?? '',
worktreeId: activeWorktreeId,
worktreePath: worktreePath ?? '',
branch: branchName,
eligibility: hostedReviewCreation,
currentBaseRef: effectiveBaseRef,
repo: activeRepo ?? null,
settings: activeRepoSettings,
submitting: isCreatingPr,
prCreationDefaults: resolvedPrCreationDefaults,
sourceControlAiActionsVisible,
onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration,
generation: {
generating: activePullRequestGenerationRecord?.status === 'running',
generateError: activePullRequestGenerationRecord?.error ?? null,
seedRestoreKey: activePullRequestGenerationSeedRestoreKey,
seed: activePullRequestGenerationRecord?.seed ?? null,
seedFieldRevisions: activePullRequestGenerationRecord?.seedFieldRevisions ?? null,
onSeedRestored: handlePullRequestGenerationSeedRestored,
onGenerate: (fields, fieldRevisions, overrides) => {
void handleGeneratePullRequestFieldsForActive(fields, fieldRevisions, overrides)
},
onCancelGenerate: handleCancelGeneratePullRequestFieldsForActive
}
})
const handleGeneratePullRequestFieldsClick = useCallback((): void => {
if (!sourceControlAiActionsVisible) {
return
}
if (
hasConfiguredSourceControlTextGenerationDefaults({
actionId: 'pullRequest',
settings,
repo: activeRepo ?? null
})
) {
void handleGeneratePullRequestFields()
return
}
openPullRequestGenerationDialog()
}, [
activeRepo,
handleGeneratePullRequestFields,
openPullRequestGenerationDialog,
settings,
sourceControlAiActionsVisible
])
useEffect(() => {
// Why: on Source Control remount, the PR fields hook seeds eligibility
// defaults in an effect; hydrating before that effect runs gets overwritten.
if (
!activePullRequestGenerationKey ||
!activePullRequestGenerationRecord ||
activePullRequestGenerationRecord.status !== 'succeeded' ||
!activePullRequestGenerationRecord.result ||
activePullRequestGenerationRecord.hydrated ||
!pullRequestFieldsInitialized
) {
return
}
if (
!shouldHydratePullRequestGenerationResult({
record: activePullRequestGenerationRecord
})
) {
return
}
const result = activePullRequestGenerationRecord.result
applyGeneratedPullRequestFields(result, activePullRequestGenerationRecord.seedFieldRevisions)
updatePullRequestGenerationRecord(activePullRequestGenerationKey, (record) => {
if (
!record ||
record.context.requestId !== activePullRequestGenerationRecord.context.requestId
) {
return null
}
return {
...record,
hydrated: true
}
})
}, [
activePullRequestGenerationKey,
activePullRequestGenerationRecord,
applyGeneratedPullRequestFields,
pullRequestFieldsInitialized,
updatePullRequestGenerationRecord
])
useEffect(() => {
// Why: direct commit-message generation can finish after Source Control
// unmounts; the store record lets the remounted textarea consume it once.
if (
!activeCommitMessageGenerationKey ||
!activeWorktreeId ||
!activeCommitMessageGenerationRecord ||
activeCommitMessageGenerationRecord.status !== 'succeeded' ||
!activeCommitMessageGenerationRecord.message ||
activeCommitMessageGenerationRecord.hydrated
) {
return
}
updateCommitDrafts((prev) => {
const current = prev[activeWorktreeId]
return current && current.length > 0
? prev
: writeCommitDraftForWorktree(
prev,
activeWorktreeId,
activeCommitMessageGenerationRecord.message ?? ''
)
})
updateCommitMessageGenerationRecord(activeCommitMessageGenerationKey, (record) =>
markCommitMessageGenerationHydrated(record)
)
}, [
activeCommitMessageGenerationKey,
activeCommitMessageGenerationRecord,
activeWorktreeId,
updateCommitDrafts,
updateCommitMessageGenerationRecord
])
useEffect(() => {
if (!isBranchVisible || !activeRepo || isFolder || !branchName || !activeWorktreeId) {
setHostedReviewCreationState(null)
setHostedReviewCreationRequestState(null)
return
}
// Why: skip refetches while the user's PR flow is mid-flight. AI generation,
// Create PR intent, and submission can all perturb ahead/behind or dirty
// state temporarily. Recomputing eligibility mid-flow can tear down the
// composer or rotate dropdown hints before the final refresh restores truth.
if (prGenerating || isCreatingPr || isCreatePrIntentInFlight) {
setHostedReviewCreationRequestState(null)
return
}
let stale = false
setHostedReviewCreationRequestState({
repoId: activeRepo.id,
worktreeId: activeWorktreeId,
branch: branchName,
status: 'loading'
})
void getHostedReviewCreationEligibility({
repoPath: activeRepo.path,
repoId: activeRepo.id,
...(worktreePath ? { worktreePath } : {}),
branch: branchName,
base: effectiveBaseRef ?? null,
hasUncommittedChanges: hasUncommittedEntries,
hasUpstream: remoteStatus?.hasUpstream,
ahead: remoteStatus?.ahead,
behind: remoteStatus?.behind,
linkedGitHubPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR
})
.then((result) => {
if (!stale) {
setHostedReviewCreationState({
repoId: activeRepo.id,
worktreeId: activeWorktreeId,
branch: branchName,
data: result
})
setHostedReviewCreationRequestState(null)
}
})
.catch((error) => {
console.warn('[SourceControl] hosted review creation eligibility failed', error)
if (!stale) {
setHostedReviewCreationState(null)
setHostedReviewCreationRequestState({
repoId: activeRepo.id,
worktreeId: activeWorktreeId,
branch: branchName,
status: 'failed'
})
}
})
return () => {
stale = true
}
}, [
activeRepo,
branchName,
effectiveBaseRef,
getHostedReviewCreationEligibility,
hasUncommittedEntries,
setHostedReviewCreationRequestState,
isBranchVisible,
isCreatingPr,
isCreatePrIntentInFlight,
isFolder,
linkedGitHubPR,
fallbackGitHubPRNumber,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR,
prGenerating,
remoteStatus?.ahead,
remoteStatus?.behind,
remoteStatus?.hasUpstream,
activeWorktreeId,
worktreePath
])
const handleCreatePullRequest = useCallback(async (): Promise<void> => {
if (
!activeRepo ||
!activeWorktreeId ||
!worktreePath ||
!hostedReviewCreation ||
prGenerating ||
createPrInFlightRef.current[activeWorktreeId]
) {
return
}
if (!hostedReviewCreation.canCreate) {
// Why: blocked Create Review clicks are intentional for actionable states;
// the inline notice tells users which prerequisite to clear next.
const message = resolveBlockedCreateReviewNoticeMessage(hostedReviewCreation)
if (message) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message
})
}
return
}
const base = stripBaseRef(prBase).trim()
const title = prTitle.trim()
if (!title) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message: translate(
'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5',
'Enter a {{value0}} title.',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
})
return
}
if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(branchName).toLowerCase()) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message: translate(
'auto.components.right.sidebar.SourceControl.ae743199cd',
'Choose a different base branch before creating a {{value0}}.',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
})
return
}
createPrInFlightRef.current[activeWorktreeId] = true
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
try {
const result = await createHostedReview(activeRepo.path, {
repoId: activeRepo.id,
provider: hostedReviewCreateProvider,
base,
head: normalizeHostedReviewHeadRef(branchName),
title,
body: prBody,
draft: prDraft,
worktreePath,
useTemplate: resolvedPrCreationDefaults.useTemplate
})
if (result.ok) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number: result.number,
url: result.url
})
if (resolvedPrCreationDefaults.openAfterCreate) {
window.api.shell.openUrl(result.url)
}
return
}
if (result.existingReview?.url) {
const number = result.existingReview.number
toast.success(
number
? translate(
'auto.components.right.sidebar.SourceControl.eef5446523',
'{{value0}} #{{value1}} is already open',
{ value0: hostedReviewCreateCopy.titleLabel, value1: number }
)
: translate(
'auto.components.right.sidebar.SourceControl.d6fb1df5fe',
'{{value0}} is already open',
{ value0: hostedReviewCreateCopy.titleLabel }
),
{
action: {
label: translate(
'auto.components.right.sidebar.SourceControl.812cb992ee',
'Open on {{value0}}',
{ value0: hostedReviewCreateCopy.providerName }
),
onClick: () => window.api.shell.openUrl(result.existingReview!.url)
}
}
)
if (number) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number,
url: result.existingReview.url
})
return
}
}
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message: result.error
})
} catch (error) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message:
error instanceof Error
? error.message
: translate(
'auto.components.right.sidebar.SourceControl.e2b7a1c0d9f4',
'Failed to create {{value0}}',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
})
} finally {
createPrInFlightRef.current[activeWorktreeId] = false
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
}
}, [
activeRepo,
activeWorktreeId,
branchName,
createHostedReview,
handlePullRequestCreated,
hostedReviewCreation,
hostedReviewCreateCopy.providerName,
hostedReviewCreateCopy.reviewLabel,
hostedReviewCreateCopy.titleLabel,
hostedReviewCreateProvider,
prBase,
prBody,
prDraft,
prGenerating,
prTitle,
resolvedPrCreationDefaults.openAfterCreate,
resolvedPrCreationDefaults.useTemplate,
setCreatePrIntentNoticeForWorktree,
worktreePath
])
const createHostedReviewForCreatePrIntent = useCallback(
async (
token: CreatePrIntentRunToken,
eligibility: HostedReviewCreationEligibility
): Promise<boolean> => {
if (!activeRepo || !token.branch || !eligibility.canCreate) {
return false
}
const base = resolveCreatePrIntentReviewBase({
currentBaseRef: token.baseRef,
eligibilityDefaultBaseRef: eligibility.defaultBaseRef,
composerBaseRef: prBase
}).trim()
if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(token.branch).toLowerCase()) {
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'destructive',
message: translate(
'auto.components.right.sidebar.SourceControl.ae743199cd',
'Choose a different base branch before creating a {{value0}}.',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
})
return false
}
let fields = {
base,
title: resolveCreateReviewDraftTitle({
branch: token.branch,
eligibilityTitle: eligibility.title
}),
body: eligibility.body ?? prBody,
draft: resolvedPrCreationDefaults.draft
}
if (
hasConfiguredSourceControlTextGenerationDefaults({
actionId: 'pullRequest',
settings,
repo: activeRepo
})
) {
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'muted',
message: translate(
'auto.components.right.sidebar.SourceControl.createPrIntentGeneratingDetails',
'Generating review details…'
)
})
const target = getCreatePrIntentOperationTarget(token)
try {
const generated = await generateRuntimePullRequestFields(target, {
...fields,
provider: eligibility.provider,
useTemplate: resolvedPrCreationDefaults.useTemplate
})
if (generated.branchChangedByPreparation) {
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'muted',
message: translate(
'auto.components.right.sidebar.SourceControl.createPrIntentBranchChangedDuringDetails',
'Branch changed while generating review details. Retry Create PR.'
)
})
return false
}
if (generated.success) {
fields = {
// Why: Create PR intent auto-submits; generated details should
// not retarget the review without user confirmation.
base: fields.base,
title: generated.fields.title.trim() || fields.title,
body: generated.fields.body,
draft: generated.fields.draft
}
}
} catch (error) {
console.warn('[SourceControl] Create PR intent detail generation failed', error)
}
}
if (
!createPrIntentRunStillOwnsWorktree(token) ||
createPrIntentActiveTargetConflicts(token)
) {
return false
}
const createPrIntentIsForeground = (): boolean =>
createPrIntentRunTokenMatches(token, createPrIntentCurrentTargetRef.current)
const title = fields.title.trim()
if (!title) {
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'destructive',
message: translate(
'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5',
'Enter a {{value0}} title.',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
})
return false
}
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'muted',
message: translate(
'auto.components.right.sidebar.SourceControl.createPrIntentCreatingReview',
'Creating review…'
)
})
createPrInFlightRef.current[token.worktreeId] = true
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [token.worktreeId]: true }))
try {
const result = await createHostedReview(activeRepo.path, {
repoId: activeRepo.id,
provider: eligibility.provider,
base: fields.base,
head: normalizeHostedReviewHeadRef(token.branch),
title,
body: fields.body,
draft: fields.draft,
worktreePath: token.worktreePath,
useTemplate: resolvedPrCreationDefaults.useTemplate
})
if (result.ok) {
const openChecks = createPrIntentIsForeground()
await handlePullRequestCreated(
{
provider: eligibility.provider,
number: result.number,
url: result.url
},
{
repoPath: activeRepo.path,
repoId: activeRepo.id,
branch: token.branch,
worktreeId: token.worktreeId,
openChecks
}
)
if (openChecks && resolvedPrCreationDefaults.openAfterCreate) {
window.api.shell.openUrl(result.url)
}
setCreatePrIntentNoticeForWorktree(token.worktreeId, null)
return true
}
if (result.existingReview?.number && result.existingReview.url) {
const openChecks = createPrIntentIsForeground()
await handlePullRequestCreated(
{
provider: eligibility.provider,
number: result.existingReview.number,
url: result.existingReview.url
},
{
repoPath: activeRepo.path,
repoId: activeRepo.id,
branch: token.branch,
worktreeId: token.worktreeId,
openChecks
}
)
setCreatePrIntentNoticeForWorktree(token.worktreeId, null)
return true
}
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'destructive',
message: result.error
})
return false
} catch (error) {
const message =
error instanceof Error
? error.message
: translate(
'auto.components.right.sidebar.SourceControl.e2b7a1c0d9f4',
'Failed to create {{value0}}',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'destructive',
message
})
return false
} finally {
createPrInFlightRef.current[token.worktreeId] = false
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [token.worktreeId]: false }))
}
},
[
activeRepo,
createHostedReview,
createPrIntentActiveTargetConflicts,
createPrIntentRunStillOwnsWorktree,
getCreatePrIntentOperationTarget,
handlePullRequestCreated,
hostedReviewCreateCopy.reviewLabel,
prBase,
prBody,
resolvedPrCreationDefaults.draft,
resolvedPrCreationDefaults.openAfterCreate,
resolvedPrCreationDefaults.useTemplate,
setCreatePrIntentNoticeForWorktree,
settings
]
)
const refreshBranchCompareForCreatePrIntent = useCallback(
async (token: CreatePrIntentRunToken): Promise<number | undefined> => {
const baseRef = token.baseRef?.trim()
if (!baseRef) {
return undefined
}
const requestKey = `${token.worktreeId}:${baseRef}:${Date.now()}:create-pr-intent`
beginGitBranchCompareRequest(token.worktreeId, requestKey, baseRef)
const result = await getRuntimeGitBranchCompare(
{
// Why: the intent flow may continue after a worktree switch; use the
// token's original host target, not whatever branch is focused later.
settings: activeRepoSettings,
worktreeId: token.worktreeId,
worktreePath: token.worktreePath,
connectionId: getConnectionId(token.worktreeId) ?? undefined
},
baseRef
)
setGitBranchCompareResult(token.worktreeId, requestKey, result)
return result.summary.status === 'ready' ? (result.summary.commitsAhead ?? 0) : undefined
},
[activeRepoSettings, beginGitBranchCompareRequest, setGitBranchCompareResult]
)
const readHostedReviewCreationEligibilityForIntent = useCallback(
async ({
token,
hasUncommittedChanges,
upstreamStatus
}: {
token: CreatePrIntentRunToken
hasUncommittedChanges: boolean
upstreamStatus?: NonNullable<typeof remoteStatus>
}): Promise<HostedReviewCreationEligibility | null> => {
if (!activeRepo || !token.branch) {
return null
}
const result = await getHostedReviewCreationEligibility({
repoPath: activeRepo.path,
repoId: activeRepo.id,
worktreePath: token.worktreePath,
branch: token.branch,
base: token.baseRef ?? null,
hasUncommittedChanges,
hasUpstream: upstreamStatus?.hasUpstream,
ahead: upstreamStatus?.ahead,
behind: upstreamStatus?.behind,
linkedGitHubPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR
})
setHostedReviewCreationState({
repoId: activeRepo.id,
worktreeId: token.worktreeId,
branch: token.branch,
data: result
})
return result
},
[
activeRepo,
fallbackGitHubPRNumber,
getHostedReviewCreationEligibility,
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGiteaPR,
linkedGitHubPR,
linkedGitLabMR
]
)
const refreshGitStatusForCreatePrIntent = useCallback(
async (token: CreatePrIntentRunToken) => {
if (isFolder) {
return null
}
const target = getCreatePrIntentOperationTarget(token)
return await refreshGitStatusForWorktreeStrict({
// Why: Create PR intent can finish in the background after navigation,
// but branch-safety checks must inspect the worktree that started it.
settings: target.settings,
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
connectionId: target.connectionId,
pushTarget: target.pushTarget,
deps: {
setGitStatus,
updateWorktreeGitIdentity,
setUpstreamStatus
}
})
},
[
getCreatePrIntentOperationTarget,
isFolder,
setGitStatus,
setUpstreamStatus,
updateWorktreeGitIdentity
]
)
const runCreatePrIntent = useCallback(async (): Promise<void> => {
if (
!activeRepo ||
!activeWorktreeId ||
!worktreePath ||
!branchName ||
isExecutingBulk ||
isCommitting ||
isGenerating ||
isRemoteOperationActive ||
prGenerating ||
isCreatingPr ||
createPrIntentInFlightRef.current[activeWorktreeId]
) {
return
}
const token = createCreatePrIntentRunToken({
repoId: activeRepo.id,
worktreeId: activeWorktreeId,
worktreePath,
branch: branchName,
// Why: Create PR intent crosses async commit/push steps; the review
// target must stay tied to the base selected when the run started.
baseRef: effectiveBaseRef ?? null
})
const operationTarget = getCreatePrIntentOperationTarget(token)
const runIsCurrent = (): boolean =>
createPrIntentRunStillOwnsWorktree(token) && !createPrIntentActiveTargetConflicts(token)
let abortedByStaleTarget = false
const abortIfStale = (): boolean => {
if (runIsCurrent()) {
return false
}
abortedByStaleTarget = true
return true
}
createPrIntentRunTokenRef.current[token.worktreeId] = token
createPrIntentInFlightRef.current[token.worktreeId] = true
setCreatePrIntentInFlightByWorktree((prev) => ({ ...prev, [token.worktreeId]: true }))
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'muted',
message: translate(
'auto.components.right.sidebar.SourceControl.d37e68f61d',
'Preparing branch for review…'
)
})
try {
let latestStatusEntries = entries
let latestUpstreamStatus = remoteStatus
const refreshIntentSnapshot = async (): Promise<boolean> => {
const refreshed = await refreshGitStatusForCreatePrIntent(token)
if (!refreshed) {
return false
}
// Why: terminal checkouts are observed by this strict status snapshot
// before React updates createPrIntentCurrentTargetRef. Stop before the
// intent flow stages, commits, or pushes on a different branch.
if (!createPrIntentGitStatusMatchesToken(token, refreshed.status)) {
abortedByStaleTarget = true
return false
}
if (abortIfStale()) {
return false
}
latestStatusEntries = refreshed.status.entries
latestUpstreamStatus = refreshed.upstreamStatus
return true
}
const stageLatestIntentPaths = async (): Promise<boolean> => {
const stagePaths = getCreatePrIntentStagePaths({
unstaged: latestStatusEntries.filter((entry) => entry.area === 'unstaged'),
untracked: latestStatusEntries.filter((entry) => entry.area === 'untracked')
})
if (stagePaths.length === 0) {
return true
}
setIsExecutingBulk(true)
try {
await bulkStageRuntimeGitPaths(operationTarget, stagePaths)
} finally {
setIsExecutingBulk(false)
}
if (abortIfStale()) {
return false
}
return refreshIntentSnapshot()
}
if (!(await refreshIntentSnapshot())) {
return
}
if (!(await stageLatestIntentPaths())) {
return
}
const stagedEntries = latestStatusEntries.filter((entry) => entry.area === 'staged')
if (stagedEntries.length > 0) {
let message = readCommitDraftForWorktree(commitDraftsRef.current, token.worktreeId).trim()
if (!message) {
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'muted',
message: translate(
'auto.components.right.sidebar.SourceControl.8d8f5c6c94',
'Generating commit message…'
)
})
const generated = await generateCommitMessageForCreatePrIntent(token)
if (abortIfStale()) {
return
}
if (!generated.ok || !generated.message) {
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: generated.reason === 'settings' ? 'muted' : 'destructive',
message: translate(
generated.reason === 'settings'
? 'auto.components.right.sidebar.SourceControl.createPrIntentConfigureAi'
: 'auto.components.right.sidebar.SourceControl.createPrIntentGenerateFailed',
generated.reason === 'settings'
? 'Add a commit message or configure Source Control AI settings.'
: 'Could not generate a commit message. Add one and retry.'
),
action: generated.reason === 'settings' ? 'settings' : undefined
})
return
}
const draftAfterGeneration = readCommitDraftForWorktree(
commitDraftsRef.current,
token.worktreeId
).trim()
if (draftAfterGeneration) {
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'muted',
message: translate(
'auto.components.right.sidebar.SourceControl.fda060d6ce',
'Review the commit message, then retry Create PR.'
)
})
return
}
message = generated.message
updateCommitDrafts((prev) => writeCommitDraftForWorktree(prev, token.worktreeId, message))
}
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'muted',
message: translate(
'auto.components.right.sidebar.SourceControl.b75cb1fd0c',
'Committing changes…'
)
})
const committed = await handleCommit(message, {
skipStagedSnapshotCheck: true,
skipActiveConflictCheck: true,
target: operationTarget
})
if (abortIfStale()) {
return
}
if (!committed) {
// Why: pre-commit/lint hooks may rewrite tracked files before
// failing. Re-stage those safe hook outputs so retrying Create PR
// does not strand changes outside the intended all-in commit.
if (await refreshIntentSnapshot()) {
await stageLatestIntentPaths()
}
if (abortIfStale()) {
return
}
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'destructive',
message: translate(
'auto.components.right.sidebar.SourceControl.createPrIntentCommitFailed',
'Could not commit changes. Fix the issue, then retry Create PR.'
)
})
return
}
if (!(await refreshIntentSnapshot())) {
return
}
}
const branchAhead = await refreshBranchCompareForCreatePrIntent(token)
if (abortIfStale()) {
return
}
let eligibility = await readHostedReviewCreationEligibilityForIntent({
token,
hasUncommittedChanges: latestStatusEntries.length > 0,
upstreamStatus: latestUpstreamStatus
})
if (abortIfStale() || !eligibility) {
return
}
if (eligibility.canCreate) {
await createHostedReviewForCreatePrIntent(token, eligibility)
if (abortIfStale()) {
return
}
return
}
if (eligibility.blockedReason === 'existing_review') {
setCreatePrIntentNoticeForWorktree(token.worktreeId, null)
return
}
const remoteStep = resolveCreatePrIntentRemoteStep({
upstreamStatus: latestUpstreamStatus,
hostedReviewCreation: eligibility,
branchCommitsAhead: branchAhead,
hasCurrentBranch: Boolean(token.branch)
})
if (remoteStep === 'blocked' || remoteStep === 'none') {
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'muted',
message: translate(
eligibility.blockedReason === 'needs_sync'
? 'auto.components.right.sidebar.SourceControl.createPrIntentNeedsSync'
: 'auto.components.right.sidebar.SourceControl.createPrIntentBranchNotReady',
eligibility.blockedReason === 'needs_sync'
? 'Sync this branch before creating a review.'
: 'Branch is not ready to create a review yet.'
)
})
return
}
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'muted',
message: translate(
remoteStep === 'publish'
? 'auto.components.right.sidebar.SourceControl.createPrIntentPublishing'
: remoteStep === 'force_push'
? 'auto.components.right.sidebar.SourceControl.createPrIntentForcePushing'
: 'auto.components.right.sidebar.SourceControl.createPrIntentPushing',
remoteStep === 'publish'
? 'Publishing branch…'
: remoteStep === 'force_push'
? 'Force pushing with lease…'
: 'Pushing commits…'
)
})
const remoteOk = await runRemoteAction(remoteStep, {
target: operationTarget,
remoteStatus: latestUpstreamStatus,
baseRef: token.baseRef
})
if (abortIfStale()) {
return
}
if (!remoteOk) {
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'destructive',
message: translate(
'auto.components.right.sidebar.SourceControl.createPrIntentRemoteFailed',
'Could not update the remote branch. Retry Create PR.'
)
})
return
}
if (!(await refreshIntentSnapshot())) {
return
}
await refreshBranchCompareForCreatePrIntent(token)
if (abortIfStale()) {
return
}
eligibility = await readHostedReviewCreationEligibilityForIntent({
token,
hasUncommittedChanges: latestStatusEntries.length > 0,
upstreamStatus: latestUpstreamStatus
})
if (abortIfStale()) {
return
}
if (eligibility?.canCreate) {
await createHostedReviewForCreatePrIntent(token, eligibility)
if (abortIfStale()) {
return
}
return
}
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'muted',
message: translate(
'auto.components.right.sidebar.SourceControl.995c5e67ec',
'Review setup needs attention.'
)
})
} catch (error) {
console.warn('[SourceControl] Create PR intent failed', error)
if (!abortIfStale()) {
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'destructive',
message: translate(
'auto.components.right.sidebar.SourceControl.d7492cafce',
'Could not refresh Source Control. Retry Create PR.'
)
})
}
} finally {
if (createPrIntentRunTokenRef.current[token.worktreeId] === token) {
createPrIntentInFlightRef.current[token.worktreeId] = false
createPrIntentRunTokenRef.current[token.worktreeId] = null
if (abortedByStaleTarget) {
setCreatePrIntentNoticeForWorktree(token.worktreeId, null)
}
setCreatePrIntentInFlightByWorktree((prev) => ({
...prev,
[token.worktreeId]: false
}))
}
}
}, [
activeRepo,
activeWorktreeId,
branchName,
createPrIntentActiveTargetConflicts,
createPrIntentRunStillOwnsWorktree,
createHostedReviewForCreatePrIntent,
effectiveBaseRef,
entries,
generateCommitMessageForCreatePrIntent,
getCreatePrIntentOperationTarget,
handleCommit,
isCommitting,
isCreatingPr,
isExecutingBulk,
isGenerating,
isRemoteOperationActive,
prGenerating,
readHostedReviewCreationEligibilityForIntent,
refreshGitStatusForCreatePrIntent,
refreshBranchCompareForCreatePrIntent,
remoteStatus,
runRemoteAction,
setCreatePrIntentNoticeForWorktree,
updateCommitDrafts,
worktreePath
])
const hasUnstagedChanges = grouped.unstaged.length > 0 || grouped.untracked.length > 0
const hasStageableChanges = hasUnstagedChanges
const hasPartiallyStagedChanges = useMemo(() => {
if (grouped.staged.length === 0 || grouped.unstaged.length === 0) {
return false
}
const unstagedPaths = new Set(grouped.unstaged.map((entry) => entry.path))
return grouped.staged.some((entry) => unstagedPaths.has(entry.path))
}, [grouped.staged, grouped.unstaged])
const primaryAction: PrimaryAction = useMemo(() => {
return resolveCommitAreaPrimaryAction({
stagedCount: grouped.staged.length,
hasUnstagedChanges,
hasStageableChanges,
hasPartiallyStagedChanges,
hasMessage: commitMessage.trim().length > 0,
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
isCommitting,
isRemoteOperationActive: isRemoteOperationActive || isAbortingOperation,
upstreamStatus: remoteStatusForActions,
prState: hostedReviewStateForActions,
isPRStateLoading: isHostedReviewStateLoading,
inFlightRemoteOpKind,
hostedReviewCreation,
branchCommitsAhead:
branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined,
hasCurrentBranch: Boolean(branchName),
canPushLinkedReviewWithoutUpstream: canUseHostedReviewPushTarget,
isPrIntentInFlight: isCreatePrIntentInFlight
})
}, [
commitMessage,
grouped.staged.length,
hasStageableChanges,
hasUnstagedChanges,
hasPartiallyStagedChanges,
isCommitting,
isAbortingOperation,
isRemoteOperationActive,
inFlightRemoteOpKind,
hostedReviewCreation,
isHostedReviewStateLoading,
hostedReviewStateForActions,
canUseHostedReviewPushTarget,
isCreatePrIntentInFlight,
branchSummary?.commitsAhead,
branchSummary?.status,
branchName,
remoteStatusForActions,
unresolvedConflicts.length
])
const createPrHeaderAction: PrimaryAction | null = useMemo(() => {
const action = resolveCreatePrHeaderAction({
stagedCount: grouped.staged.length,
hasUnstagedChanges,
hasStageableChanges,
hasPartiallyStagedChanges,
hasMessage: commitMessage.trim().length > 0,
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
isCommitting,
isRemoteOperationActive: isRemoteOperationActive || isAbortingOperation,
upstreamStatus: remoteStatus,
prState: hostedReview?.state ?? null,
isPRStateLoading: isHostedReviewStateLoading,
inFlightRemoteOpKind,
hostedReviewCreation: hostedReviewCreationForHeader,
isHostedReviewCreationLoading:
isHostedReviewCreationLoading && hostedReviewCreationForHeader !== null,
branchCommitsAhead:
branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined,
hasCurrentBranch: Boolean(branchName),
isPrIntentInFlight: isCreatePrIntentInFlight
})
if ((prGenerating || isCreatingPr) && action?.kind === 'create_pr') {
return {
...action,
title: prGenerating
? translate(
'auto.components.right.sidebar.SourceControl.createPrIntentGeneratingDetails',
'Generating review details…'
)
: translate(
'auto.components.right.sidebar.SourceControl.fe5bd1a610',
'Creating {{value0}}...',
{ value0: hostedReviewCreateCopy.reviewLabel }
),
disabled: true
}
}
return action
}, [
branchName,
branchSummary?.commitsAhead,
branchSummary?.status,
commitMessage,
grouped.staged.length,
hasPartiallyStagedChanges,
hasStageableChanges,
hasUnstagedChanges,
hostedReview?.state,
hostedReviewCreationForHeader,
hostedReviewCreateCopy.reviewLabel,
inFlightRemoteOpKind,
isAbortingOperation,
isCommitting,
isCreatePrIntentInFlight,
isCreatingPr,
isHostedReviewCreationLoading,
isHostedReviewStateLoading,
isRemoteOperationActive,
prGenerating,
remoteStatus,
unresolvedConflicts.length
])
const directCreatePrAction =
createPrHeaderAction?.kind === 'create_pr' &&
hostedReviewCreation?.canCreate === true &&
(!createPrHeaderAction.disabled || isCreatingPr || prGenerating)
? createPrHeaderAction
: null
const visibleCreatePrHeaderAction = resolveVisibleCreatePrHeaderAction({
createPrHeaderAction
})
const dropdownItems: DropdownEntry[] = useMemo(
() =>
resolveDropdownItems({
stagedCount: grouped.staged.length,
hasUnstagedChanges,
hasStageableChanges,
hasPartiallyStagedChanges,
hasMessage: commitMessage.trim().length > 0,
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
isCommitting,
isRemoteOperationActive: isRemoteOperationActive || isAbortingOperation,
conflictOperation,
upstreamStatus: remoteStatusForActions,
prState: hostedReviewStateForActions,
isPRStateLoading: isHostedReviewStateLoading,
inFlightRemoteOpKind,
hostedReviewCreation,
isPullRequestOperationActive: prGenerating || isCreatingPr || isCreatePrIntentInFlight,
branchCommitsAhead:
branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined,
hasCurrentBranch: Boolean(branchName),
canPushLinkedReviewWithoutUpstream: canUseHostedReviewPushTarget,
rebaseBaseRef: effectiveBaseRef
}),
[
commitMessage,
grouped.staged.length,
hasStageableChanges,
hasUnstagedChanges,
hasPartiallyStagedChanges,
isCommitting,
conflictOperation,
isAbortingOperation,
isRemoteOperationActive,
inFlightRemoteOpKind,
hostedReviewCreation,
isCreatingPr,
isCreatePrIntentInFlight,
isHostedReviewStateLoading,
hostedReviewStateForActions,
prGenerating,
canUseHostedReviewPushTarget,
branchSummary?.commitsAhead,
branchSummary?.status,
branchName,
effectiveBaseRef,
remoteStatusForActions,
unresolvedConflicts.length
]
)
// Why: maps both the primary button click and any chevron dropdown item
// click to the right handler. Commit-ish kinds flow through handleCommit
// (which returns a boolean); compound actions use runCompoundCommitAction;
// pure remote actions go through runRemoteAction.
const handleActionInvoke = useCallback(
(kind: DropdownActionKind): void => {
if (prGenerating || isCreatingPr || isCreatePrIntentInFlight) {
return
}
switch (kind) {
case 'commit':
void handleCommit()
return
case 'commit_push':
void runCompoundCommitAction('push')
return
case 'commit_sync':
void runCompoundCommitAction('sync')
return
case 'abort_merge':
void handleAbortMerge()
return
case 'abort_rebase':
void handleAbortRebase()
return
case 'create_pr':
void handleCreatePullRequest()
return
case 'push_create_pr':
void runCreatePrIntent()
return
case 'push':
case 'force_push':
case 'pull':
case 'fast_forward':
case 'sync':
case 'fetch':
case 'publish':
case 'rebase_base':
void runRemoteAction(kind === 'rebase_base' ? 'rebase' : kind)
}
},
[
handleCommit,
handleCreatePullRequest,
handleAbortMerge,
handleAbortRebase,
isCreatingPr,
isCreatePrIntentInFlight,
prGenerating,
runCreatePrIntent,
runCompoundCommitAction,
runRemoteAction
]
)
// Why: modifier-click should keep the current pane intact by opening the
// selected Source Control file in a fresh split to the right.
const resolveSplitTargetGroupId = useCallback(
(event?: SourceControlRowOpenEvent): string | undefined => {
if (!event || !activeWorktreeId || !isSourceControlSplitOpenModifier(event, isMac)) {
return undefined
}
const sourceGroupId =
activeGroupIdByWorktree[activeWorktreeId] ?? groupsByWorktree[activeWorktreeId]?.[0]?.id
if (!sourceGroupId) {
return undefined
}
return createEmptySplitGroup(activeWorktreeId, sourceGroupId, 'right') ?? undefined
},
[activeGroupIdByWorktree, activeWorktreeId, createEmptySplitGroup, groupsByWorktree, isMac]
)
// Why: a stable string signature keeps this selector referentially stable so
// the panel only re-renders when the active editor file (or its diff source)
// actually changes. Gated on the visible tab being an editor so the highlight
// clears when the user switches to a terminal or browser surface.
const activeOpenFileSignature = useAppStore((s) => {
if (!activeWorktreeId) {
return null
}
if (s.activeTabTypeByWorktree?.[activeWorktreeId] !== 'editor') {
return null
}
const activeFileId = s.activeFileIdByWorktree?.[activeWorktreeId]
if (!activeFileId) {
return null
}
const activeFile = s.openFiles?.find(
(file) => file.id === activeFileId && file.worktreeId === activeWorktreeId
)
if (!activeFile) {
return null
}
return buildActiveOpenFileSignature(activeFile.diffSource, activeFile.relativePath)
})
const activeOpenAvailableRowKeys = useMemo(() => {
const keys = new Set<string>()
for (const entry of visibleSelectionEntries) {
keys.add(entry.key)
}
return keys
}, [visibleSelectionEntries])
const activeOpenRowKeys = useMemo(
() => buildActiveOpenRowKeys(activeOpenFileSignature, activeOpenAvailableRowKeys),
[activeOpenAvailableRowKeys, activeOpenFileSignature]
)
const handleOpenDiff = useCallback(
(entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => {
if (!activeWorktreeId || !worktreePath) {
return
}
const targetGroupId = resolveSplitTargetGroupId(event)
const openAsPreview = shouldOpenSourceControlRowAsPreview(event, targetGroupId)
if (entry.conflictKind && entry.conflictStatus) {
if (entry.conflictStatus === 'unresolved') {
trackConflictPath(activeWorktreeId, entry.path, entry.conflictKind)
}
openConflictFile(activeWorktreeId, worktreePath, entry, detectLanguage(entry.path), {
targetGroupId,
preview: openAsPreview
})
return
}
const language = detectLanguage(entry.path)
const filePath = joinPath(worktreePath, entry.path)
// Why: unstaged markdown diffs open as a normal edit tab in Changes
// view mode rather than a dedicated diff tab. This unifies sidebar
// clicks with the header's Edit|Changes toggle: there is exactly one
// tab per markdown file, and the sidebar click flips that tab's view
// mode. Staged diffs still open as a separate diff tab because the
// staged content is not what the editor would be editing. Non-markdown
// files keep the existing diff-tab flow until the diff-tab type is
// eventually collapsed (see reviews/changes-view-mode-plan.md §"Follow-up").
if (language === 'markdown' && entry.area === 'unstaged') {
openFile(
{
filePath,
relativePath: entry.path,
worktreeId: activeWorktreeId,
language,
mode: 'edit'
},
{ targetGroupId, preview: openAsPreview }
)
setEditorViewMode(filePath, 'changes')
return
}
openDiff(activeWorktreeId, filePath, entry.path, language, entry.area === 'staged', {
targetGroupId,
preview: openAsPreview
})
},
[
activeWorktreeId,
worktreePath,
resolveSplitTargetGroupId,
trackConflictPath,
openConflictFile,
openDiff,
openFile,
setEditorViewMode
]
)
const { selectedKeys, handleSelect, handleContextMenu, clearSelection } =
useSourceControlSelection({
flatEntries: visibleSelectionEntries,
onOpenDiff: handleOpenDiff,
shouldOpenAsSplit: (event) => isSourceControlSplitOpenModifier(event, isMac),
containerRef: sourceControlRef
})
// clear selection on list/tree presentation change
useEffect(() => {
clearSelection()
}, [sourceControlViewMode, clearSelection])
const handleToggleSourceControlViewMode = useCallback(() => {
if (!settings) {
return
}
updateSettings({
sourceControlViewMode: getNextSourceControlViewMode(sourceControlViewMode)
})
}, [settings, sourceControlViewMode, updateSettings])
// Clear selection on worktree or tab change
useEffect(() => {
clearSelection()
}, [activeWorktreeId, rightSidebarTab, clearSelection])
const flatEntriesByKey = useMemo(
() => new Map(visibleSelectionEntries.map((entry) => [entry.key, entry])),
[visibleSelectionEntries]
)
const selectedEntries = useMemo(
() =>
Array.from(selectedKeys)
.map((key) => flatEntriesByKey.get(key))
.filter((entry): entry is FlatEntry => Boolean(entry)),
[selectedKeys, flatEntriesByKey]
)
const bulkStagePaths = useMemo(
() =>
selectedEntries
.filter((entry) => isStageableStatusEntry(entry.entry))
.map((entry) => entry.entry.path),
[selectedEntries]
)
const bulkUnstagePaths = useMemo(
() =>
selectedEntries.filter((entry) => entry.area === 'staged').map((entry) => entry.entry.path),
[selectedEntries]
)
const selectedKeySet = selectedKeys
const handleBulkStage = useCallback(async () => {
if (!worktreePath || bulkStagePaths.length === 0) {
return
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await bulkStageRuntimeGitPaths(
{
// Why: route staging by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
bulkStagePaths
)
await refreshActiveGitStatusAfterMutation()
clearSelection()
} finally {
setIsExecutingBulk(false)
}
}, [
activeRepoSettings,
worktreePath,
bulkStagePaths,
clearSelection,
activeWorktreeId,
refreshActiveGitStatusAfterMutation
])
const handleBulkUnstage = useCallback(async () => {
if (!worktreePath || bulkUnstagePaths.length === 0) {
return
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await bulkUnstageRuntimeGitPaths(
{
// Why: route unstaging by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
bulkUnstagePaths
)
await refreshActiveGitStatusAfterMutation()
clearSelection()
} finally {
setIsExecutingBulk(false)
}
}, [
activeRepoSettings,
worktreePath,
bulkUnstagePaths,
clearSelection,
activeWorktreeId,
refreshActiveGitStatusAfterMutation
])
const handleStageAllPaths = useCallback(
async (paths: readonly string[]) => {
if (!worktreePath || isExecutingBulk || paths.length === 0) {
return
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await bulkStageRuntimeGitPaths(
{
// Why: route staging by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
[...paths]
)
await refreshActiveGitStatusAfterMutation()
clearSelection()
} finally {
setIsExecutingBulk(false)
}
},
[
activeRepoSettings,
activeWorktreeId,
clearSelection,
isExecutingBulk,
refreshActiveGitStatusAfterMutation,
worktreePath
]
)
const handleUnstagePaths = useCallback(
async (paths: readonly string[]) => {
if (!worktreePath || isExecutingBulk || paths.length === 0) {
return
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await bulkUnstageRuntimeGitPaths(
{
// Why: route unstaging by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
[...paths]
)
await refreshActiveGitStatusAfterMutation()
clearSelection()
} finally {
setIsExecutingBulk(false)
}
},
[
activeRepoSettings,
activeWorktreeId,
clearSelection,
isExecutingBulk,
refreshActiveGitStatusAfterMutation,
worktreePath
]
)
// Why: 'stage' primary stages every unstaged + untracked path in one
// bulkStage call. It bypasses handleActionInvoke because that handler is
// typed to DropdownActionKind and 'stage' is intentionally not in the
// dropdown union — the dropdown surface is unchanged.
const handleStageAllPrimary = useCallback(async (): Promise<void> => {
if (!worktreePath || isExecutingBulk) {
return
}
const filePaths = [
...getStageAllPaths(grouped.unstaged, 'unstaged'),
...getStageAllPaths(grouped.untracked, 'untracked')
]
if (filePaths.length === 0) {
return
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await bulkStageRuntimeGitPaths(
{
// Why: route staging by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
filePaths
)
await refreshActiveGitStatusAfterMutation()
clearSelection()
} finally {
setIsExecutingBulk(false)
}
}, [
activeRepoSettings,
worktreePath,
isExecutingBulk,
grouped,
activeWorktreeId,
clearSelection,
refreshActiveGitStatusAfterMutation
])
// Why: PrimaryActionKind is narrowed to the single-action kinds the
// primary can emit ('commit' | 'stage' | 'push' | 'pull' | 'sync' |
// 'publish' | 'create_pr') — compound commit_* kinds are dropdown-only. An exhaustive
// switch keeps the mapping honest: if a new PrimaryActionKind is added,
// TypeScript lights up the missing case instead of silently falling
// through. 'stage' routes to a dedicated primary-only handler because
// handleActionInvoke is typed to DropdownActionKind.
const handlePrimaryClick = useCallback((): void => {
switch (primaryAction.kind) {
case 'stage':
void handleStageAllPrimary()
return
case 'commit':
case 'push':
case 'pull':
case 'sync':
case 'publish':
case 'create_pr':
handleActionInvoke(primaryAction.kind)
return
case 'create_pr_intent':
void runCreatePrIntent()
}
}, [handleActionInvoke, handleStageAllPrimary, primaryAction.kind, runCreatePrIntent])
const handleCreatePrHeaderClick = useCallback((): void => {
if (!createPrHeaderAction || createPrHeaderAction.disabled) {
return
}
if (createPrHeaderAction.kind === 'create_pr') {
void handleCreatePullRequest()
return
}
if (createPrHeaderAction.kind === 'create_pr_intent') {
void runCreatePrIntent()
}
}, [createPrHeaderAction, handleCreatePullRequest, runCreatePrIntent])
const branchCompareInFlightRef = useRef(false)
const branchCompareRerunRef = useRef(false)
const branchCompareRunPromiseRef = useRef<Promise<void> | null>(null)
const refreshBranchCompareRef = useRef<() => Promise<void>>(async () => {})
const branchCompareStatusHeadRef = useRef<BranchCompareStatusHeadSnapshot | null>(null)
const runBranchCompare = useCallback(async () => {
if (!activeWorktreeId || !worktreePath || !effectiveBaseRef || isFolder) {
return
}
const requestKey = `${activeWorktreeId}:${effectiveBaseRef}:${Date.now()}`
const existingSummary =
useAppStore.getState().gitBranchCompareSummaryByWorktree[activeWorktreeId]
// Why: only show the loading spinner for the very first branch compare
// request, or when the base ref has changed (user picked a new one, or
// getBaseRefDefault corrected a stale cross-repo value). Polling retries
// — whether the previous result was 'ready' *or* an error — keep the
// current UI visible until the new IPC result arrives. Resetting to
// 'loading' on every poll when the compare is in an error state caused a
// visible loading→error→loading→error flicker.
const baseRefChanged = existingSummary && existingSummary.baseRef !== effectiveBaseRef
const shouldResetToLoading = !existingSummary || baseRefChanged
if (shouldResetToLoading) {
beginGitBranchCompareRequest(activeWorktreeId, requestKey, effectiveBaseRef)
} else {
beginGitBranchCompareRequest(activeWorktreeId, requestKey, effectiveBaseRef, {
preserveExistingSummary: true
})
}
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
const result = await getRuntimeGitBranchCompare(
{
// Why: route the branch compare by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
effectiveBaseRef
)
setGitBranchCompareResult(activeWorktreeId, requestKey, result)
} catch (error) {
setGitBranchCompareResult(activeWorktreeId, requestKey, {
summary: {
baseRef: effectiveBaseRef,
baseOid: null,
compareRef: branchName,
headOid: null,
mergeBase: null,
changedFiles: 0,
status: 'error',
errorMessage: error instanceof Error ? error.message : 'Branch compare failed'
},
entries: []
})
}
}, [
activeRepoSettings,
activeWorktreeId,
beginGitBranchCompareRequest,
branchName,
effectiveBaseRef,
isFolder,
setGitBranchCompareResult,
worktreePath
])
const refreshBranchCompare = useCallback(async () => {
if (branchCompareInFlightRef.current) {
branchCompareRerunRef.current = true
return branchCompareRunPromiseRef.current ?? undefined
}
branchCompareInFlightRef.current = true
const runPromise = (async (): Promise<void> => {
// Why: branch compare shells out to git from both event-driven refreshes
// and the fallback timer. Keep one compare chain in flight and
// collapse skipped ticks into one trailing refresh instead of stacking
// subprocesses while preserving the await contract for direct callers.
try {
await runBranchCompare()
} finally {
branchCompareInFlightRef.current = false
if (branchCompareRerunRef.current) {
branchCompareRerunRef.current = false
await refreshBranchCompareRef.current()
}
}
})()
branchCompareRunPromiseRef.current = runPromise
try {
await runPromise
} finally {
if (branchCompareRunPromiseRef.current === runPromise) {
branchCompareRunPromiseRef.current = null
}
}
}, [runBranchCompare])
refreshBranchCompareRef.current = refreshBranchCompare
const refreshGitHistory = useCallback(async (): Promise<void> => {
if (
!activeWorktreeId ||
!worktreePath ||
isFolder ||
!isBranchVisible ||
!isGitHistoryExpanded ||
!isGitHistoryVisible
) {
return
}
const worktreeId = activeWorktreeId
const requestId = gitHistoryRequestSeqRef.current + 1
gitHistoryRequestSeqRef.current = requestId
gitHistoryRequestByWorktreeRef.current[worktreeId] = requestId
setGitHistoryByWorktree((prev) => {
const previous = prev[worktreeId]
return {
...prev,
[worktreeId]: previous?.result
? { status: 'refreshing', result: previous.result }
: { status: 'loading' }
}
})
try {
const connectionId = getConnectionId(worktreeId) ?? undefined
const result = await getRuntimeGitHistory(
{
// Why: route the history read by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId,
worktreePath,
connectionId
},
{ limit: 50, baseRef: effectiveBaseRef }
)
if (gitHistoryRequestByWorktreeRef.current[worktreeId] !== requestId) {
return
}
setGitHistoryByWorktree((prev) => ({ ...prev, [worktreeId]: { status: 'ready', result } }))
} catch (error) {
if (gitHistoryRequestByWorktreeRef.current[worktreeId] !== requestId) {
return
}
const message = error instanceof Error ? error.message : 'Failed to load commits'
setGitHistoryByWorktree((prev) => {
const previous = prev[worktreeId]
return {
...prev,
[worktreeId]: previous?.result
? { status: 'error', result: previous.result, error: message }
: { status: 'error', error: message }
}
})
}
}, [
activeRepoSettings,
activeWorktreeId,
effectiveBaseRef,
isBranchVisible,
isFolder,
isGitHistoryExpanded,
isGitHistoryVisible,
worktreePath
])
const refreshGitHistoryRef = useRef(refreshGitHistory)
refreshGitHistoryRef.current = refreshGitHistory
useEffect(() => {
if (!activeWorktreeId || !worktreePath || !isBranchVisible || !effectiveBaseRef || isFolder) {
branchCompareStatusHeadRef.current = null
return
}
const current = {
baseRef: effectiveBaseRef,
statusHead: activeGitStatusHead,
worktreeId: activeWorktreeId
}
const previous = branchCompareStatusHeadRef.current
branchCompareStatusHeadRef.current = current
if (shouldRefreshBranchCompareForStatusHead(previous, current)) {
void refreshBranchCompareRef.current()
}
}, [
activeGitStatusHead,
activeWorktreeId,
effectiveBaseRef,
isBranchVisible,
isFolder,
worktreePath
])
useEffect(() => {
if (!activeWorktreeId || !worktreePath || !isBranchVisible || !effectiveBaseRef || isFolder) {
return
}
// Why: git-status HEAD changes refresh branch compare immediately. Keep a
// visible-window fallback for base refs or remote updates that do not move HEAD.
return installWindowVisibilityInterval({
run: () => void refreshBranchCompareRef.current(),
intervalMs: BRANCH_REFRESH_INTERVAL_MS
})
}, [activeWorktreeId, effectiveBaseRef, isBranchVisible, isFolder, worktreePath])
useEffect(() => {
// Why: history shells out to git. Defer the first load until the user
// expands Commits so source control stays cheap for large/remote repos.
if (!isBranchVisible || !isGitHistoryExpanded || !isGitHistoryVisible) {
return
}
void refreshGitHistoryRef.current()
}, [
activeWorktreeId,
effectiveBaseRef,
isBranchVisible,
isFolder,
isGitHistoryExpanded,
isGitHistoryVisible,
worktreePath
])
useEffect(() => {
// Why: gate on isBranchVisible so we don't spawn git processes while the
// sidebar is closed. Store-slice remote operations refresh upstream-status
// on success anyway, so the user's first sidebar open will show accurate
// state.
if (!activeWorktreeId || !worktreePath || isFolder || !isBranchVisible) {
return
}
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
void fetchUpstreamStatus(
activeWorktreeId,
worktreePath,
connectionId,
activeWorktree?.pushTarget,
{ runtimeTargetSettings: activeRepoSettings }
)
}, [
activeRepoSettings,
activeWorktree?.pushTarget,
activeWorktreeId,
fetchUpstreamStatus,
isBranchVisible,
isFolder,
worktreePath
])
const toggleSection = useCallback((section: string) => {
setCollapsedSections((prev) => {
const next = new Set(prev)
if (next.has(section)) {
next.delete(section)
} else {
next.add(section)
}
return next
})
}, [])
const toggleTreeDir = useCallback((key: string) => {
setCollapsedTreeDirs((prev) => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}, [])
const openCommittedDiff = useCallback(
(entry: GitBranchChangeEntry, event?: SourceControlRowOpenEvent) => {
if (
!activeWorktreeId ||
!worktreePath ||
!branchSummary ||
branchSummary.status !== 'ready'
) {
return
}
const targetGroupId = resolveSplitTargetGroupId(event)
openBranchDiff(
activeWorktreeId,
worktreePath,
entry,
branchSummary,
detectLanguage(entry.path),
{ targetGroupId, preview: shouldOpenSourceControlRowAsPreview(event, targetGroupId) }
)
},
[activeWorktreeId, branchSummary, openBranchDiff, resolveSplitTargetGroupId, worktreePath]
)
const { loadCommitFiles, openHistoryCommitDiff, openCommitFile, handleCommitAction } =
useGitHistoryCommitActions({
activeWorktreeId,
worktreePath,
activeRepoSettings,
resolveSplitTargetGroupId
})
// Why: a note's filePath is the same relative path used by GitStatusEntry /
// GitBranchChangeEntry, so we can route the click to whichever diff surface
// currently owns that file. Prefer the `unstaged` entry when a path is also
// staged — diff comments are authored against the working-tree (unstaged)
// diff card. Fall back to the branch compare, and finally just open the
// file as a normal editor tab so the user still gets navigation when
// neither side has the path anymore. When `commentId` is supplied and the
// route lands on a diff surface, also stamp scrollToDiffCommentId so the
// diff decorator scrolls that note into view; we clear any prior request
// first, so the editor-tab fallback then leaves the global null and a
// future DiffViewer mount can't accidentally consume a stale id.
const handleOpenComment = useCallback(
(comment: DiffComment) => {
if (!activeWorktreeId || !worktreePath) {
return
}
const filePath = comment.filePath
const commentId = comment.id
// Defensively clear any dangling prior scroll request before routing
// this click; only the diff branches below will re-stamp it.
cancelSourceControlEditorRevealFrames(pendingCommentEditorRevealFrameIdsRef)
setScrollToDiffCommentId(null)
if (getDiffCommentSource(comment) === 'markdown') {
const absPath = joinPath(worktreePath, filePath)
const language = detectLanguage(filePath)
setEditorViewMode(absPath, 'edit')
setMarkdownViewMode(absPath, 'source')
openFile({
filePath: absPath,
relativePath: filePath,
worktreeId: activeWorktreeId,
language,
mode: 'edit'
})
setPendingEditorReveal(null)
requestSourceControlEditorRevealFrame(pendingCommentEditorRevealFrameIdsRef, () => {
requestSourceControlEditorRevealFrame(pendingCommentEditorRevealFrameIdsRef, () => {
setPendingEditorReveal({
filePath: absPath,
line: comment.lineNumber,
column: 1,
matchLength: 0
})
setScrollToDiffCommentId(commentId)
})
})
return
}
const matches = entries.filter((e) => e.path === filePath)
const uncommitted =
matches.find((e) => e.area === 'unstaged') ??
matches.find((e) => e.area === 'untracked') ??
matches[0]
if (uncommitted) {
handleOpenDiff(uncommitted)
if (commentId) {
setScrollToDiffCommentId(commentId)
}
return
}
const branchEntry = branchEntries.find((e) => e.path === filePath)
if (branchEntry && branchSummary?.status === 'ready') {
openCommittedDiff(branchEntry)
if (commentId) {
setScrollToDiffCommentId(commentId)
}
return
}
// Why: fall through to a normal editor tab when neither the working-tree
// nor branch-compare diff has the file (e.g. the change has since been
// committed and merged, but the note still references the file). Force
// the editor tab into 'changes' mode and stamp scrollToDiffCommentId so
// the DiffViewer that EditorContent renders in changes mode picks up
// the scroll request — same surface the user can flip into manually
// via the editor's Edit/Changes toggle.
const absPath = joinPath(worktreePath, filePath)
const language = detectLanguage(filePath)
openFile({
filePath: absPath,
relativePath: filePath,
worktreeId: activeWorktreeId,
language,
mode: 'edit'
})
if (commentId) {
setEditorViewMode(absPath, 'changes')
setScrollToDiffCommentId(commentId)
}
},
[
activeWorktreeId,
branchEntries,
branchSummary,
entries,
handleOpenDiff,
openCommittedDiff,
openFile,
setEditorViewMode,
setScrollToDiffCommentId,
setMarkdownViewMode,
setPendingEditorReveal,
worktreePath
]
)
const handleStage = useCallback(
async (filePath: string) => {
if (!worktreePath) {
return
}
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await stageRuntimeGitPath(
{
// Why: route staging by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
filePath
)
await refreshActiveGitStatusAfterMutation()
} catch {
// git operation failed silently
}
},
[activeRepoSettings, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation]
)
const handleUnstage = useCallback(
async (filePath: string) => {
if (!worktreePath) {
return
}
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await unstageRuntimeGitPath(
{
// Why: route unstaging by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
filePath
)
await refreshActiveGitStatusAfterMutation()
} catch {
// git operation failed silently
}
},
[activeRepoSettings, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation]
)
// Why: split into two variants — `discardSingle` throws so bulk callers can
// aggregate failures into a single toast via `runDiscardAllForArea`'s
// onError, while `handleDiscard` swallows for the per-row fire-and-forget UI
// contract (no individual failure toast).
const discardSingle = useCallback(
async (filePath: string) => {
if (!worktreePath || !activeWorktreeId) {
return
}
const runtimeEnvironmentId =
useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() || null
// Why: git discard replaces the working tree version of this file. Any
// pending editor autosave must be quiesced first so it cannot recreate
// the discarded edits after git restores the file.
await requestEditorSaveQuiesce({
worktreeId: activeWorktreeId,
worktreePath,
relativePath: filePath,
runtimeEnvironmentId
})
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await discardRuntimeGitPath(
{
// Why: route the discard by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
filePath
)
notifyEditorExternalFileChange({
worktreeId: activeWorktreeId,
worktreePath,
relativePath: filePath,
runtimeEnvironmentId
})
},
[activeRepoSettings, activeWorktreeId, worktreePath]
)
const discardMany = useCallback(
async (filePaths: string[]) => {
if (!worktreePath || !activeWorktreeId) {
return
}
const runtimeEnvironmentId =
useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() || null
// Why: bulk discard replaces many working-tree files at once. Quiesce
// any matching editor autosaves before git mutates the files so a delayed
// save cannot recreate edits after the restore.
await Promise.all(
filePaths.map((relativePath) =>
requestEditorSaveQuiesce({
worktreeId: activeWorktreeId,
worktreePath,
relativePath,
runtimeEnvironmentId
})
)
)
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
await bulkDiscardRuntimeGitPaths(
{
// Why: route the discard by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
filePaths
)
for (const relativePath of filePaths) {
notifyEditorExternalFileChange({
worktreeId: activeWorktreeId,
worktreePath,
relativePath,
runtimeEnvironmentId
})
}
},
[activeRepoSettings, activeWorktreeId, worktreePath]
)
const handleDiscard = useCallback(
async (filePath: string) => {
try {
await discardSingle(filePath)
await refreshActiveGitStatusAfterMutation()
} catch {
// Why: per-row discard is fire-and-forget for the UI; failures are not
// surfaced individually. Bulk callers use `discardSingle` directly so
// they can aggregate failures into a single toast.
}
},
[discardSingle, refreshActiveGitStatusAfterMutation]
)
// Why: "Discard all" mirrors the per-row discard rules — it skips unresolved
// and resolved_locally rows because discarding those can silently re-create
// the conflict or lose the resolution (no v1 UX to explain this clearly).
// The happy path uses bulk discard IPC; the sequencing helper falls back to
// per-file discard when an older SSH relay does not support that method yet.
// The sequencing + filter rules live in discard-all-sequence.ts so they can
// be unit-tested independently of the full component (staged area needs a
// bulk-unstage first, and a failed unstage must skip the discard loop).
const handleRevertAllInArea = useCallback(
async (area: DiscardAllArea, confirmedPaths?: readonly string[]) => {
if (!worktreePath || !activeWorktreeId || isExecutingBulk) {
return
}
const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area)
if (paths.length === 0) {
return
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
// Why: `onError` fires once per failure — both for the bulk-unstage
// pre-step and for each per-file discard failure. Aggregate into one
// toast after the sequence completes so a partial failure across N
// files doesn't spam N error toasts.
const errors: unknown[] = []
const result = await runDiscardAllForArea(area, paths, {
bulkUnstage: (filePaths) =>
bulkUnstageRuntimeGitPaths(
{
// Why: route unstaging by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
filePaths
),
discardMany,
discardOne: discardSingle,
onError: (error) => {
errors.push(error)
console.error('[SourceControl] discard-all failure', error)
}
})
if (result.aborted) {
toast.error(
translate(
'auto.components.right.sidebar.SourceControl.a5e5a11090',
'Discard all failed — unable to unstage files before discard'
),
{
description: errors[0] instanceof Error ? errors[0].message : undefined
}
)
} else if (result.failed.length > 0) {
// Why: only include the first error message to avoid a huge toast
// body on bulk failures; a short sample of failed paths gives users
// enough context to retry or investigate.
const firstMsg = errors[0] instanceof Error ? errors[0].message : undefined
const sample = result.failed.slice(0, 3).join(', ')
const more = result.failed.length > 3 ? `, +${result.failed.length - 3} more` : ''
toast.error(
translate(
'auto.components.right.sidebar.SourceControl.8eb3782a0c',
'Failed to discard {{value0}} file{{value1}}',
{ value0: result.failed.length, value1: result.failed.length === 1 ? '' : 's' }
),
{
description: firstMsg
? translate(
'auto.components.right.sidebar.SourceControl.dc5a6465fc',
'{{value0}} (e.g. {{value1}}{{value2}})',
{ value0: firstMsg, value1: sample, value2: more }
)
: `${sample}${more}`
}
)
}
if (!result.aborted) {
await refreshActiveGitStatusAfterMutation()
clearSelection()
}
} finally {
setIsExecutingBulk(false)
}
},
[
activeRepoSettings,
worktreePath,
activeWorktreeId,
grouped,
isExecutingBulk,
clearSelection,
discardMany,
discardSingle,
refreshActiveGitStatusAfterMutation
]
)
const requestDiscardAllInArea = useCallback(
(area: DiscardAllArea, confirmedPaths?: readonly string[]): void => {
if (!worktreePath || !activeWorktreeId || isExecutingBulk) {
return
}
const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area)
if (paths.length === 0) {
return
}
setPendingDiscard({ kind: 'area', area, paths })
},
[activeWorktreeId, grouped, isExecutingBulk, worktreePath]
)
const requestDiscardEntry = useCallback(
(entry: GitStatusEntry): void => {
if (!worktreePath || !activeWorktreeId || isExecutingBulk) {
return
}
setPendingDiscard({ kind: 'entry', entry })
},
[activeWorktreeId, isExecutingBulk, worktreePath]
)
const confirmPendingDiscard = useCallback((): void => {
const pending = pendingDiscard
if (!pending) {
return
}
setPendingDiscard(null)
if (pending.kind === 'entry') {
void handleDiscard(pending.entry.path)
return
}
void handleRevertAllInArea(pending.area, pending.paths)
}, [handleDiscard, handleRevertAllInArea, pendingDiscard])
if (!activeWorktree || !activeRepo || !worktreePath) {
return (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground px-4 text-center">
{translate(
'auto.components.right.sidebar.SourceControl.c07b236287',
'Select a workspace to view changes'
)}
</div>
)
}
if (isFolder) {
return (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground px-4 text-center">
{translate(
'auto.components.right.sidebar.SourceControl.e131cd7128',
'Source Control is only available for Git repositories'
)}
</div>
)
}
const hasFilteredUncommittedEntries =
filteredGrouped.staged.length > 0 ||
filteredGrouped.unstaged.length > 0 ||
filteredGrouped.untracked.length > 0
const hasFilteredBranchEntries = filteredBranchEntries.length > 0
const showGenericEmptyState =
!hasUncommittedEntries && branchSummary?.status === 'ready' && branchEntries.length === 0
const currentWorktreeId = activeWorktree.id
return (
<>
<div ref={setSourceControlRoot} className="relative flex h-full flex-col overflow-hidden">
<SourceControlHeaderToolbar
filterQuery={filterQuery}
filterExpanded={filterExpanded}
onFilterQueryChange={setFilterQuery}
onFilterExpandedChange={setFilterExpanded}
visibleCreatePrHeaderAction={visibleCreatePrHeaderAction}
hostedReview={hostedReview}
isCreatePrIntentInFlight={isCreatePrIntentInFlight}
isCreatingPr={isCreatingPr || prGenerating}
onCreatePrHeaderClick={handleCreatePrHeaderClick}
onOpenHostedReviewInChecks={openHostedReviewInChecks}
sourceControlViewMode={sourceControlViewMode}
viewModeToggleDisabled={settings === null}
onToggleViewMode={handleToggleSourceControlViewMode}
onChangeBaseRef={() => setBaseRefDialogOpen(true)}
onRefreshBranchCompare={() => void refreshBranchCompare()}
branchCompareRefreshDisabled={!branchSummary || branchSummary.status === 'loading'}
diffCommentCount={diffCommentCount}
onExpandNotes={() => setDiffCommentsExpanded(true)}
branchSummary={branchSummary}
compareBaseRef={effectiveBaseRef}
upstreamStatus={remoteStatus}
/>
{detachedHeadDisplay && (
<div className="border-b border-border px-3 py-2">
<DetachedHeadBadge display={detachedHeadDisplay} side="bottom" />
</div>
)}
{/* Why: Diff-comments live on the worktree and apply across every diff
view the user opens. The header row expands inline to show per-file
comment previews plus a Copy-all action so the user can hand the
set off to whichever tool they want without leaving the sidebar.
Hidden when count is 0: notes are created from the diff view, so
an empty Notes shelf in the sidebar is pure chrome — it adds a
border, a row of space, and an expand control that only reveals
a redirect hint. */}
{activeWorktreeId && worktreePath && diffCommentCount > 0 && (
<div className="border-b border-border">
<div className="flex items-center gap-1 pl-3 pr-2 py-1.5">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1.5 text-left text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setDiffCommentsExpanded((prev) => !prev)}
aria-expanded={diffCommentsExpanded}
title={
diffCommentsExpanded
? translate(
'auto.components.right.sidebar.SourceControl.d13edef890',
'Collapse notes'
)
: translate(
'auto.components.right.sidebar.SourceControl.72f2bea3f4',
'Expand notes'
)
}
>
<ChevronDown
className={cn(
'size-3 shrink-0 transition-transform',
!diffCommentsExpanded && '-rotate-90'
)}
/>
<MessageSquare className="size-3.5 shrink-0" />
<span>
{translate('auto.components.right.sidebar.SourceControl.cc474e0b8c', 'Notes')}
</span>
{diffCommentCount > 0 && (
<span className="text-[11px] leading-none text-muted-foreground tabular-nums">
{diffCommentCount}
</span>
)}
</button>
<div className="ml-1 flex shrink-0 items-center gap-1.5">
<DiffNotesSendMenu
worktreeId={activeWorktreeId}
groupId={activeGroupId ?? activeWorktreeId}
comments={diffCommentsForActive}
triggerClassName="size-6"
/>
{diffCommentCount > 0 && (
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => void handleCopyDiffComments()}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.3baf6c77b4',
'Copy all notes to clipboard'
)}
>
{diffCommentsCopied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.right.sidebar.SourceControl.eae2d051af',
'Copy all notes'
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<DropdownMenu>
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label={translate(
'auto.components.right.sidebar.SourceControl.2fe2a67580',
'More note actions'
)}
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.right.sidebar.SourceControl.2fe2a67580',
'More note actions'
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={diffCommentCount === 0}
onSelect={() => {
if (!activeWorktreeId || diffCommentCount === 0) {
return
}
setPendingDiffCommentsClear({ kind: 'all', worktreeId: activeWorktreeId })
}}
>
<Trash2 className="size-3.5" />
{translate(
'auto.components.right.sidebar.SourceControl.1406954883',
'Clear all notes...'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{diffCommentsExpanded && (
<DiffCommentsInlineList
comments={diffCommentsForActive}
onDelete={(id) => void deleteDiffComment(activeWorktreeId, id)}
onOpen={(comment) => handleOpenComment(comment)}
onClearFile={(filePath) =>
setPendingDiffCommentsClear({
kind: 'file',
worktreeId: activeWorktreeId,
filePath
})
}
/>
)}
</div>
)}
<div
className="relative flex flex-1 flex-col overflow-auto scrollbar-sleek pt-1"
style={{ paddingBottom: selectedKeys.size > 0 ? 50 : undefined }}
>
{unresolvedConflictReviewEntries.length > 0 && (
<div className="px-3 pb-2">
<ConflictSummaryCard
conflictOperation={conflictOperation}
unresolvedCount={unresolvedConflictReviewEntries.length}
sourceControlAiActionsVisible={sourceControlAiActionsVisible}
isResolvingWithAI={false}
isAbortingOperation={isAbortingOperation}
onAbortOperation={handleAbortOperationForConflict}
onResolveWithAI={() => {
void handleResolveConflictsWithAI()
}}
onReview={() => {
if (!activeWorktreeId || !worktreePath) {
return
}
openConflictReview(
activeWorktreeId,
worktreePath,
unresolvedConflictReviewEntries,
'live-summary'
)
}}
/>
</div>
)}
{/* Why: show operation banner when rebase/merge/cherry-pick is in progress
but there are no unresolved conflicts (e.g. between rebase steps, or
after resolving all conflicts before running --continue). The
ConflictSummaryCard handles the "has conflicts" case above. */}
{unresolvedConflictReviewEntries.length === 0 && conflictOperation !== 'unknown' && (
<div className="px-3 pb-2">
<OperationBanner
conflictOperation={conflictOperation}
isAbortingOperation={isAbortingOperation}
onAbortOperation={handleAbortOperationForConflict}
/>
</div>
)}
{repositoryHuge && (
<div className="px-3 pb-2">
<TooManyChangesBanner limit={repositoryHuge.limit} />
</div>
)}
{showGenericEmptyState && !normalizedFilter ? (
<EmptyState
heading="No changes on this branch"
supportingText={`This workspace is clean and this branch has no changes ahead of ${branchSummary?.baseRef ?? 'base'}`}
/>
) : null}
{fileFilterState.tooLarge && (
<EmptyState
heading="Search text is too large"
supportingText="Use a shorter file filter."
/>
)}
{normalizedFilter && !hasFilteredUncommittedEntries && !hasFilteredBranchEntries && (
<EmptyState
heading="No matching files"
supportingText={`No changed files match "${filterQuery}"`}
/>
)}
{/* Why: keep CommitArea mounted across normal source-control states.
The split-button primary rotates through Push / Pull / Sync /
Publish on a clean tree and disables Commit with a "Nothing to
commit" tooltip when nothing is staged — gating on
hasUncommittedEntries (added by #1448 for the older Commit-only
design) would unmount the whole action surface on clean
worktrees and tear it down mid-commit when the staged list
clears. Active merge/rebase/cherry-pick operations are the
exception: commits would be misleading before the user continues
or aborts the operation. */}
{activeWorktree?.pushTarget && activeWorktree.pushTarget.remoteName !== 'origin' ? (
<div
className="flex items-center gap-1.5 px-1 text-[11px] text-muted-foreground"
title={translate(
'auto.components.right.sidebar.SourceControl.c05fe04839',
'Pushes to the fork at {{value0}} (not origin)',
{ value0: activeWorktree.pushTarget.remoteName }
)}
>
<GitFork className="size-3 shrink-0" aria-hidden="true" />
<span className="truncate">
{translate(
'auto.components.right.sidebar.SourceControl.78ce2d37ac',
'Pushes to fork'
)}
{describeForkPushTarget(activeWorktree.pushTarget)}
</span>
</div>
) : null}
{shouldRenderCommitArea(unresolvedConflicts.length, conflictOperation) &&
(directCreatePrAction ? (
<CreateHostedReviewComposer
provider={hostedReviewCreateProvider}
branch={branchName}
base={prBase}
setBase={setPrBase}
title={prTitle}
setTitle={setPrTitle}
body={prBody}
setBody={setPrBody}
draft={prDraft}
setDraft={setPrDraft}
baseQuery={prBaseQuery}
setBaseQuery={setPrBaseQuery}
baseResults={prBaseResults}
setBaseResults={setPrBaseResults}
baseSearchError={prBaseSearchError}
aiGenerationEnabled={sourceControlAiActionsVisible && prAiGenerationEnabled}
generating={prGenerating}
generateDisabled={prGenerateDisabled}
generateDisabledReason={prGenerateDisabledReason}
generateError={prGenerateError}
createError={
createPrIntentNotice?.tone === 'destructive' ? createPrIntentNotice.message : null
}
isCreating={isCreatingPr}
primaryAction={directCreatePrAction}
dropdownItems={dropdownItems}
onGenerate={handleGeneratePullRequestFieldsClick}
onCancelGenerate={handleCancelGeneratePullRequestFields}
onPrimaryAction={() => {
void handleCreatePullRequest()
}}
onDropdownAction={handleActionInvoke}
/>
) : (
<CommitArea
worktreeId={activeWorktreeId}
connectionId={activeConnectionId}
repoId={activeRepo?.id ?? null}
launchPlatform={activeSourceControlLaunchPlatform}
commitMessage={commitMessage}
commitError={commitError}
commitFailureRecoveryPrompt={commitFailureRecoveryPrompt}
remoteActionError={remoteActionError?.message ?? null}
createPrIntentNotice={createPrIntentNotice}
isCommitting={isCommitting}
isFixingCommitFailureWithAI={isLaunchingCommitFailureAgent}
isCreatingPr={isCreatingPr || isCreatePrIntentInFlight}
isCreatePrIntentInFlight={isCreatePrIntentInFlight}
groupId={activeGroupId ?? activeWorktreeId}
showComposer={!showGenericEmptyState}
sourceControlAiActionsVisible={sourceControlAiActionsVisible}
aiEnabled={sourceControlAiActionsVisible && resolvedCommitMessageAi?.ok === true}
aiAgentConfigured={resolvedCommitMessageAi?.ok === true}
isGenerating={isGenerating}
generateError={generateError}
stagedCount={grouped.staged.length}
hasPartiallyStagedChanges={hasPartiallyStagedChanges}
hasUnresolvedConflicts={unresolvedConflicts.length > 0}
isRemoteOperationActive={isRemoteOperationActive || isAbortingOperation}
inFlightRemoteOpKind={inFlightRemoteOpKind}
primaryAction={primaryAction}
dropdownItems={dropdownItems}
fixCommitFailureRecipe={getLaunchActionRecipe('fixCommitFailure')}
onCommitMessageChange={(value) => {
if (!activeWorktreeId) {
return
}
updateCommitDrafts((prev) =>
writeCommitDraftForWorktree(prev, activeWorktreeId, value)
)
}}
onGenerate={handleGenerateCommitMessageClick}
onCancelGenerate={handleCancelGenerate}
onSaveLaunchActionDefault={saveLaunchActionDefault}
onOpenSourceControlAiSettings={openSourceControlAiSettings}
onFixCommitFailureWithAI={handleFixCommitFailureWithAI}
onPrimaryAction={handlePrimaryClick}
onDropdownAction={handleActionInvoke}
/>
))}
{hasFilteredUncommittedEntries && (
<>
{displaySections.map((section) => {
const { area, id, items } = section
const isCollapsed = collapsedSections.has(id)
// Why: "Stage all"/"Unstage all" operate on the *unfiltered*
// group for the area — acting on just the filter-visible subset
// would surprise users who don't realize a filter is active.
// The +/- is hidden when the filter is active to avoid that
// mismatch between what's shown and what would be staged.
// Why: visibility and execution both resolve paths through the
// same eligibility rules as the handlers so the button can
// never show for a set the handler would then filter to empty.
const actionSection = unfilteredDisplaySectionsById.get(id) ?? section
const actionItems = actionSection.items
const stageAllPaths = actionItems
.filter(isStageableStatusEntry)
.map((entry) => entry.path)
const unstageAllPaths = getUnstageAllPaths(actionItems)
const discardAllPaths = getDiscardAllPaths(actionItems, area)
const canStageAll = !normalizedFilter && stageAllPaths.length > 0
const canUnstageAll = !normalizedFilter && unstageAllPaths.length > 0
const canRevertAll = !normalizedFilter && discardAllPaths.length > 0
const sectionLabel =
id === 'conflicts' ? CONFLICTS_SECTION_LABEL : SECTION_LABELS[area]
const sectionViewAction = getSourceControlSectionViewAction(actionSection)
return (
<div key={id}>
<SectionHeader
label={translate(sectionLabel.key, sectionLabel.fallback)}
count={items.length}
conflictCount={
items.filter((entry) => entry.conflictStatus === 'unresolved').length
}
isCollapsed={isCollapsed}
onToggle={() => toggleSection(id)}
actions={
<>
{/* Why: bulk action buttons are hover-only on
pointer devices to avoid cluttering the section
header with persistent icons. On no-hover
pointers (touch, and SSH sessions where hover
state is unreliable — see AGENTS.md "SSH Use
Case"), force them visible so they're reachable
without tabbing. One outer wrapper so that
focusing any action reveals all three siblings —
otherwise keyboard users tab into an invisible
next stop. */}
<div className="flex items-center can-hover:opacity-0 transition-opacity group-hover/section:opacity-100 focus-within:opacity-100">
{canRevertAll && (
<ActionButton
icon={area === 'untracked' ? Trash : Undo2}
// Why: for untracked files, discard deletes the file
// outright (rm -rf via git.discard's untracked branch).
// A generic "Discard all" label hides that severity —
// label explicitly for the destructive variant.
title={
area === 'untracked'
? translate(
'auto.components.right.sidebar.SourceControl.2f609a2e7c',
'Delete all untracked'
)
: translate(
'auto.components.right.sidebar.SourceControl.ce41708855',
'Discard all'
)
}
onClick={(event) => {
event.stopPropagation()
requestDiscardAllInArea(area, discardAllPaths)
}}
disabled={isExecutingBulk}
/>
)}
{canStageAll && (
<ActionButton
icon={Plus}
title={translate(
'auto.components.right.sidebar.SourceControl.24d2598eff',
'Stage all'
)}
onClick={(event) => {
event.stopPropagation()
void handleStageAllPaths(stageAllPaths)
}}
disabled={isExecutingBulk}
/>
)}
{canUnstageAll && (
<ActionButton
icon={Minus}
title={translate(
'auto.components.right.sidebar.SourceControl.9339382454',
'Unstage all'
)}
onClick={(event) => {
event.stopPropagation()
void handleUnstagePaths(unstageAllPaths)
}}
disabled={isExecutingBulk}
/>
)}
</div>
{sectionViewAction ? (
<Button
type="button"
variant="ghost"
size="sm"
className={
items.some((entry) => entry.conflictStatus === 'unresolved')
? 'h-6 px-1.5 text-[10px] text-muted-foreground hover:text-foreground'
: 'h-auto px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground'
}
onClick={(e) => {
e.stopPropagation()
if (!activeWorktreeId || !worktreePath) {
return
}
if (sectionViewAction.kind === 'conflict-review') {
openConflictReview(
activeWorktreeId,
worktreePath,
sectionViewAction.entries,
'live-summary'
)
} else {
openAllDiffs(
activeWorktreeId,
worktreePath,
undefined,
sectionViewAction.area,
sectionViewAction.entries
)
}
}}
>
{translate(
'auto.components.right.sidebar.SourceControl.48db37cca9',
'View all'
)}
</Button>
) : null}
</>
}
/>
{!isCollapsed &&
(sourceControlViewMode === 'tree'
? (visibleTreeRowsBySection[id] ?? []).map((node) => {
if (node.type === 'directory') {
return (
<SourceControlTreeDirectoryRow
key={node.key}
node={node}
actionPaths={getSourceControlDirectoryActionPaths(node)}
hideBulkActions={Boolean(normalizedFilter)}
isExecutingBulk={isExecutingBulk}
isCollapsed={collapsedTreeDirs.has(node.key)}
onToggle={() => toggleTreeDir(node.key)}
onRequestDiscardPaths={(discardArea, paths) =>
setPendingDiscard({
kind: 'area',
area: discardArea,
paths
})
}
onStagePaths={handleStageAllPaths}
onUnstagePaths={handleUnstagePaths}
/>
)
}
return (
<UncommittedEntryRow
key={node.key}
entryKey={node.key}
entry={node.entry}
currentWorktreeId={currentWorktreeId}
worktreePath={worktreePath}
depth={node.depth}
selected={selectedKeySet.has(node.key)}
isOpenFile={activeOpenRowKeys.has(node.key)}
onSelect={handleSelect}
onContextMenu={handleContextMenu}
onRevealInExplorer={revealInExplorer}
connectionId={activeConnectionId}
onOpen={handleOpenDiff}
onStage={handleStage}
onUnstage={handleUnstage}
onDiscard={requestDiscardEntry}
commentCount={diffCommentCountByPath.get(node.entry.path) ?? 0}
showPathHint={false}
/>
)
})
: items.map((entry) => {
const key = `${entry.area}::${entry.path}`
return (
<UncommittedEntryRow
key={key}
entryKey={key}
entry={entry}
currentWorktreeId={currentWorktreeId}
worktreePath={worktreePath}
selected={selectedKeySet.has(key)}
isOpenFile={activeOpenRowKeys.has(key)}
onSelect={handleSelect}
onContextMenu={handleContextMenu}
onRevealInExplorer={revealInExplorer}
connectionId={activeConnectionId}
onOpen={handleOpenDiff}
onStage={handleStage}
onUnstage={handleUnstage}
onDiscard={requestDiscardEntry}
commentCount={diffCommentCountByPath.get(entry.path) ?? 0}
/>
)
}))}
</div>
)
})}
</>
)}
{shouldShowSourceControlCompareUnavailableCard(
branchSummary,
hasUncommittedEntries,
branchEntries.length > 0,
Boolean(normalizedFilter)
) && branchSummary ? (
<CompareUnavailable
summary={branchSummary}
onChangeBaseRef={() => setBaseRefDialogOpen(true)}
onRetry={() => void refreshBranchCompare()}
/>
) : null}
{branchSummary?.status === 'ready' && hasFilteredBranchEntries && (
<div>
<SectionHeader
label={translate(
'auto.components.right.sidebar.SourceControl.d7ae61269b',
'Committed on Branch'
)}
count={filteredBranchEntries.length}
isCollapsed={collapsedSections.has('branch')}
onToggle={() => toggleSection('branch')}
actions={
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
if (activeWorktreeId && worktreePath && branchSummary) {
openBranchAllDiffs(activeWorktreeId, worktreePath, branchSummary)
}
}}
>
{translate(
'auto.components.right.sidebar.SourceControl.48db37cca9',
'View all'
)}
</Button>
}
/>
{!collapsedSections.has('branch') &&
(sourceControlViewMode === 'tree'
? visibleBranchTreeRows.map((node) => {
if (node.type === 'directory') {
return (
<SourceControlBranchTreeDirectoryRow
key={node.key}
node={node}
isCollapsed={collapsedTreeDirs.has(node.key)}
onToggle={() => toggleTreeDir(node.key)}
/>
)
}
return (
<BranchEntryRow
key={node.key}
entry={node.entry}
currentWorktreeId={currentWorktreeId}
worktreePath={worktreePath}
depth={node.depth}
onRevealInExplorer={revealInExplorer}
connectionId={activeConnectionId}
onOpen={(event) => openCommittedDiff(node.entry, event)}
commentCount={diffCommentCountByPath.get(node.entry.path) ?? 0}
showPathHint={false}
/>
)
})
: filteredBranchEntries.map((entry) => (
<BranchEntryRow
key={`branch:${entry.path}`}
entry={entry}
currentWorktreeId={currentWorktreeId}
worktreePath={worktreePath}
onRevealInExplorer={revealInExplorer}
connectionId={activeConnectionId}
onOpen={(event) => openCommittedDiff(entry, event)}
commentCount={diffCommentCountByPath.get(entry.path) ?? 0}
/>
)))}
</div>
)}
{isGitHistoryVisible && (
// Why: the graph is reference context for the whole panel, so when
// file sections are short it should occupy the bottom, and when the
// pane scrolls it should remain docked as branch context.
<div className="sticky bottom-0 z-10 mt-auto shrink-0 border-t border-border bg-sidebar/95 backdrop-blur-sm">
<GitHistoryPanel
state={gitHistoryState}
collapsed={collapsedSections.has('history')}
onToggle={() => toggleSection('history')}
onRefresh={() => void refreshGitHistory()}
onOpenCommit={(item) => void openHistoryCommitDiff(item)}
onLoadCommitFiles={loadCommitFiles}
onOpenCommitFile={openCommitFile}
onCommitAction={handleCommitAction}
/>
</div>
)}
</div>
{selectedKeys.size > 0 && (
<BulkActionBar
selectedCount={selectedKeys.size}
stageableCount={bulkStagePaths.length}
unstageableCount={bulkUnstagePaths.length}
onStage={handleBulkStage}
onUnstage={handleBulkUnstage}
onClear={clearSelection}
isExecuting={isExecutingBulk}
/>
)}
</div>
<Dialog
open={resolvedPendingDiffCommentsClear !== null}
onOpenChange={(open) => {
if (!open && !isClearingDiffComments) {
setPendingDiffCommentsClear(null)
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">
{translate('auto.components.right.sidebar.SourceControl.574d2f4413', 'Clear Notes')}
</DialogTitle>
<DialogDescription className="text-xs">
{pendingDiffCommentsClearDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setPendingDiffCommentsClear(null)}
disabled={isClearingDiffComments}
>
{translate('auto.components.right.sidebar.SourceControl.05bb8f4a48', 'Cancel')}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void handleConfirmDiffCommentsClear()}
disabled={isClearingDiffComments || pendingDiffCommentsClearCount === 0}
>
<Trash2 className="size-4" />
{translate('auto.components.right.sidebar.SourceControl.574d2f4413', 'Clear Notes')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SourceControlDiscardDialog
pendingDiscard={pendingDiscard}
onCancel={() => setPendingDiscard(null)}
onConfirm={confirmPendingDiscard}
/>
<Dialog open={baseRefDialogOpen} onOpenChange={setBaseRefDialogOpen}>
<DialogContent className="flex max-h-[min(85vh,36rem)] max-w-xl flex-col overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle className="text-sm">
{translate(
'auto.components.right.sidebar.SourceControl.476b77745b',
'Change Base Ref'
)}
</DialogTitle>
<DialogDescription className="text-xs">
{translate(
'auto.components.right.sidebar.SourceControl.c9ad22888e',
'Pick the branch compare target for this repository.'
)}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 overflow-y-auto scrollbar-sleek">
<BaseRefPicker
repoId={activeRepo.id}
currentBaseRef={pickerBaseRef}
onSelect={(ref) => {
if (baseRefOwnedByWorktree && activeWorktreeId) {
void updateWorktreeMeta(activeWorktreeId, { baseRef: ref })
} else {
void updateRepo(activeRepo.id, { worktreeBaseRef: ref })
}
setBaseRefDialogOpen(false)
window.setTimeout(() => void refreshBranchCompare(), 0)
}}
onUsePrimary={() => {
if (baseRefOwnedByWorktree && activeWorktreeId) {
void updateWorktreeMeta(activeWorktreeId, { baseRef: undefined })
} else {
void updateRepo(activeRepo.id, { worktreeBaseRef: undefined })
}
setBaseRefDialogOpen(false)
window.setTimeout(() => void refreshBranchCompare(), 0)
}}
/>
</div>
</DialogContent>
</Dialog>
<SourceControlAgentActionDialog
open={sourceControlAiActionsVisible && resolveConflictsComposerOpen}
onOpenChange={setResolveConflictsComposerOpen}
actionId="resolveConflicts"
title={translate(
'auto.components.right.sidebar.SourceControl.19652ddd76',
'Resolve Conflicts With AI'
)}
description={translate(
'auto.components.right.sidebar.SourceControl.901140f47d',
'Review and edit the full command input before starting an agent.'
)}
baseCommandInput={resolveConflictsPrompt}
worktreeId={activeWorktreeId}
groupId={activeGroupId ?? activeWorktreeId}
connectionId={activeConnectionId}
repoId={activeRepo?.id ?? null}
promptDelivery="submit-after-ready"
launchPlatform={activeSourceControlLaunchPlatform}
launchSource="conflict_resolution"
savedAgentId={readSourceControlLaunchRecipeAgentId(
getLaunchActionRecipe('resolveConflicts')
)}
savedCommandInputTemplate={
getLaunchActionRecipe('resolveConflicts').commandInputTemplate ?? null
}
savedAgentArgs={getLaunchActionRecipe('resolveConflicts').agentArgs ?? null}
onSaveAgentDefault={saveLaunchActionDefault}
onOpenSettings={openSourceControlAiSettings}
onLaunched={() =>
toast.success(
translate(
'auto.components.right.sidebar.SourceControl.e48caaf0dd',
'Started an AI agent for the conflicts.'
)
)
}
/>
<SourceControlTextGenerationDialog
open={sourceControlAiActionsVisible && commitGenerationDialogOpen}
onOpenChange={setCommitGenerationDialogOpen}
actionId="commitMessage"
title={translate(
'auto.components.right.sidebar.SourceControl.6b122529d4',
'Generate Commit Message'
)}
description={translate(
'auto.components.right.sidebar.SourceControl.f4c766f1ca',
'Choose the agent and command template for this run.'
)}
generateLabel="Generate"
settings={settings}
repo={activeRepo ?? null}
discoveryHostKey={sourceControlAiDiscoveryHostKey}
onGenerate={(params) => {
void handleGenerate({ sourceControlAiResolvedParams: params })
}}
onSaveDefaults={handleSaveCommitMessageGenerationDefaults}
/>
<SourceControlTextGenerationDialog
open={sourceControlAiActionsVisible && pullRequestGenerationDialogOpen}
onOpenChange={setPullRequestGenerationDialogOpen}
actionId="pullRequest"
title={translate(
'auto.components.right.sidebar.SourceControl.1a6a6e0bc5',
'Generate Hosted Review Details'
)}
description={translate(
'auto.components.right.sidebar.SourceControl.f4c766f1ca',
'Choose the agent and command template for this run.'
)}
generateLabel="Generate"
settings={settings}
repo={activeRepo ?? null}
discoveryHostKey={sourceControlAiDiscoveryHostKey}
onGenerate={(params) => {
void handleGeneratePullRequestFields({ sourceControlAiResolvedParams: params })
}}
onSaveDefaults={handleSavePullRequestGenerationDefaults}
/>
</>
)
}
const SourceControl = React.memo(SourceControlInner)
export default SourceControl
type CommitFailureFixSplitButtonProps = {
label: string
worktreeId: string | null
groupId: string | null
connectionId?: string | null
repoId?: string | null
launchPlatform?: NodeJS.Platform
prompt: string | null
isLaunching: boolean
variant: React.ComponentProps<typeof Button>['variant']
size: React.ComponentProps<typeof Button>['size']
iconClassName: string
primaryClassName?: string
chevronClassName?: string
savedAgentId?: TuiAgent | null
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
onSaveAgentDefault?: (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => void | Promise<void>
onOpenSettings?: () => void
onFixWithDefaultAgent: (promptOverride?: string) => Promise<boolean> | boolean
onPromptDelivered: () => void
}
function CommitFailureFixSplitButton({
label,
worktreeId,
groupId,
connectionId,
repoId,
launchPlatform,
prompt,
isLaunching,
variant,
size,
iconClassName,
primaryClassName,
chevronClassName,
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
onSaveAgentDefault,
onOpenSettings,
onFixWithDefaultAgent,
onPromptDelivered
}: CommitFailureFixSplitButtonProps): React.JSX.Element {
const [composerOpen, setComposerOpen] = useState(false)
const canLaunch = Boolean(worktreeId && groupId && prompt)
const dividerClass = variant === 'default' ? 'border-primary-foreground/20' : 'border-border'
return (
<>
<DropdownMenu>
<div className="flex shrink-0 items-stretch">
<Button
type="button"
variant={variant}
size={size}
className={cn('rounded-r-none', primaryClassName)}
disabled={isLaunching || !canLaunch}
onClick={() => void onFixWithDefaultAgent()}
title={translate(
'auto.components.right.sidebar.SourceControl.4b37ae99b0',
'Start the default AI agent to fix this commit failure'
)}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.30b8d4f181',
'Fix commit failure with AI'
)}
>
{isLaunching ? (
<RefreshCw className={cn(iconClassName, 'animate-spin')} />
) : (
<Sparkle className={iconClassName} />
)}
{label}
</Button>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant={variant}
size={size}
className={cn('rounded-l-none border-l', dividerClass, chevronClassName)}
disabled={isLaunching || !canLaunch}
title={translate(
'auto.components.right.sidebar.SourceControl.dd43c47089',
'Choose an agent for this commit failure'
)}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.ec7bfced55',
'Choose agent to fix commit failure'
)}
>
<ChevronDown className={iconClassName} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent align="end" className="min-w-[210px] p-1">
{worktreeId && groupId && prompt ? (
<DropdownMenuItem
onSelect={() => setComposerOpen(true)}
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<SlidersHorizontal className="size-4 text-muted-foreground" />
{translate(
'auto.components.right.sidebar.SourceControl.f0a2dc9e46',
'Customize launch...'
)}
</DropdownMenuItem>
) : (
<DropdownMenuItem disabled>
{translate(
'auto.components.right.sidebar.SourceControl.9e5ccd00aa',
'Commit failure context unavailable'
)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{worktreeId && groupId && prompt ? (
<SourceControlAgentActionDialog
open={composerOpen}
onOpenChange={setComposerOpen}
actionId="fixCommitFailure"
title={translate(
'auto.components.right.sidebar.SourceControl.054ead86b1',
'Fix Commit Failure With AI'
)}
description={translate(
'auto.components.right.sidebar.SourceControl.15b7f210d7',
'Choose the agent and edit the full command input before launch.'
)}
baseCommandInput={prompt}
worktreeId={worktreeId}
groupId={groupId}
connectionId={connectionId}
repoId={repoId}
promptDelivery="submit-after-ready"
launchPlatform={launchPlatform}
launchSource="source_control_recovery"
savedAgentId={savedAgentId}
savedCommandInputTemplate={savedCommandInputTemplate}
savedAgentArgs={savedAgentArgs}
onSaveAgentDefault={onSaveAgentDefault}
onOpenSettings={onOpenSettings}
onLaunched={onPromptDelivered}
/>
) : null}
</>
)
}
function getCommitFailureKindLabel(summary: string): string | null {
if (/\blint\b/i.test(summary)) {
return 'Lint'
}
if (/\bhook\b|\bpre-commit\b/i.test(summary)) {
return 'Hook'
}
return null
}
type CommitAreaProps = {
worktreeId: string | null
groupId: string | null
connectionId?: string | null
repoId?: string | null
launchPlatform?: NodeJS.Platform
commitMessage: string
commitError: string | null
commitFailureRecoveryPrompt: string | null
remoteActionError: string | null
createPrIntentNotice?: CreatePrIntentNotice | null
isCommitting: boolean
isFixingCommitFailureWithAI: boolean
isCreatingPr?: boolean
isCreatePrIntentInFlight?: boolean
showComposer?: boolean
sourceControlAiActionsVisible: boolean
aiEnabled: boolean
aiAgentConfigured: boolean
isGenerating: boolean
generateError: string | null
stagedCount: number
hasPartiallyStagedChanges: boolean
hasUnresolvedConflicts: boolean
isRemoteOperationActive: boolean
inFlightRemoteOpKind: RemoteOpKind | null
primaryAction: PrimaryAction
dropdownItems: DropdownEntry[]
fixCommitFailureRecipe?: SourceControlActionRecipe
onCommitMessageChange: (message: string) => void
onGenerate: () => void
onCancelGenerate: () => void
onSaveLaunchActionDefault?: (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => void | Promise<void>
onOpenSourceControlAiSettings?: () => void
onFixCommitFailureWithAI: (promptOverride?: string) => Promise<boolean> | boolean
onPrimaryAction: () => void
onDropdownAction: (kind: DropdownActionKind) => void
}
export function CommitArea({
worktreeId,
groupId,
connectionId,
repoId,
launchPlatform,
commitMessage,
commitError,
commitFailureRecoveryPrompt,
remoteActionError,
createPrIntentNotice,
isCommitting,
isFixingCommitFailureWithAI,
isCreatingPr = false,
isCreatePrIntentInFlight = false,
showComposer = true,
sourceControlAiActionsVisible,
aiEnabled,
aiAgentConfigured,
isGenerating,
generateError,
stagedCount,
hasPartiallyStagedChanges,
hasUnresolvedConflicts,
isRemoteOperationActive,
inFlightRemoteOpKind,
primaryAction,
dropdownItems,
fixCommitFailureRecipe,
onCommitMessageChange,
onGenerate,
onCancelGenerate,
onSaveLaunchActionDefault,
onOpenSourceControlAiSettings,
onFixCommitFailureWithAI,
onPrimaryAction,
onDropdownAction
}: CommitAreaProps): React.JSX.Element {
// Why: cap at 12 rows so a pasted multi-page commit message doesn't push
// the Commit button off-screen. The textarea keeps `resize-none` (matching
// the existing style) — the browser scrolls internally past 12 rows.
const rows = getCommitMessageTextareaRows(commitMessage)
// Why: only spin the primary when its label matches what's actually
// running. The commit-area resolver overrides the primary kind to mirror
// the in-flight op (e.g. user picks Sync from the dropdown → primary
// becomes "Sync"), so the equality check spins the button for any primary-
// eligible remote op the user triggered. Background ops the primary
// doesn't show (Fetch) leave primaryAction.kind unchanged and the
// mismatch keeps the spinner off — the disabled state alone is enough
// signal there. Commit still spins on isCommitting because that path
// doesn't go through inFlightRemoteOpKind.
const primaryHostsRemoteOperation =
primaryAction.kind === inFlightRemoteOpKind ||
(primaryAction.kind === 'push' && inFlightRemoteOpKind === 'force_push')
const showSpinner =
primaryAction.kind === 'create_pr' || primaryAction.kind === 'create_pr_intent'
? isCreatingPr
: primaryAction.kind === 'commit'
? isCommitting
: isRemoteOperationActive && primaryHostsRemoteOperation
// Why: when the primary doesn't host the in-flight op (e.g. Fetch, or any
// dropdown action that mismatches the primary's natural label) the click
// would otherwise be silent — the toast only fires on failure and a
// no-op fetch leaves status counts unchanged. Spinning the chevron gives
// the user immediate feedback that the action they picked is running,
// while still leaving the menu reachable to read the disabled-row
// tooltips.
const showChevronSpinner =
(isCommitting || isCreatingPr || isRemoteOperationActive) && !showSpinner
const commitFailureSummary = useMemo(
() => (commitError ? summarizeCommitFailure(commitError) : null),
[commitError]
)
const commitFailureKindLabel = useMemo(
() => (commitFailureSummary ? getCommitFailureKindLabel(commitFailureSummary) : null),
[commitFailureSummary]
)
const hasCommitFailureDetails = useMemo(
() =>
commitError && commitFailureSummary
? hasExpandedCommitFailureDetails(commitError, commitFailureSummary)
: false,
[commitError, commitFailureSummary]
)
// Why: the details dialog is scoped to the worktree, not the exact stderr
// text, so a retried commit can refresh an open dialog with newer output.
const commitFailureWorktreeKey = getCommitFailureDialogWorktreeKey(worktreeId)
const [commitFailureDialogState, setCommitFailureDialogState] =
useState<CommitFailureDialogState>({
worktreeKey: commitFailureWorktreeKey,
open: false
})
const isCommitFailureDialogOpen = shouldShowCommitFailureDialog(
commitFailureDialogState,
commitFailureWorktreeKey,
hasCommitFailureDetails
)
const setCommitFailureDialogOpen = useCallback(
(open: boolean) => {
setCommitFailureDialogState({ worktreeKey: commitFailureWorktreeKey, open })
},
[commitFailureWorktreeKey]
)
const handleFixCommitFailureWithAI = useCallback(
async (promptOverride?: string): Promise<boolean> => {
const launched = await onFixCommitFailureWithAI(promptOverride)
if (launched) {
setCommitFailureDialogOpen(false)
}
return launched
},
[onFixCommitFailureWithAI, setCommitFailureDialogOpen]
)
const handleCommitFailureAgentPromptDelivered = useCallback(() => {
setCommitFailureDialogOpen(false)
}, [setCommitFailureDialogOpen])
useEffect(() => {
setCommitFailureDialogState((current) =>
syncCommitFailureDialogState(current, commitFailureWorktreeKey, hasCommitFailureDetails)
)
}, [commitFailureWorktreeKey, hasCommitFailureDetails])
// Why: most primary-kind labels are anchored by a directional icon so
// the affirmative Commit (✓) reads distinctly from the remote-state
// labels sharing this slot — Push (↑), Sync (↕), Publish (☁︎↑). Pull is
// intentionally icon-less because the down-arrow read as a
// download/save affordance. The icon is decorative; the label and
// title attribute carry the meaning for assistive tech.
const PrimaryIcon = PRIMARY_ICONS[primaryAction.kind]
const hasMessage = commitMessage.trim().length > 0
const isCommitMessageDisabled = isCommitMessageFieldDisabled({
stagedCount,
hasPartiallyStagedChanges,
hasMessage,
hasUnresolvedConflicts,
isCommitting,
isRemoteOperationActive,
isPullRequestOperationActive: isCreatingPr
})
const describedBy = [
commitError ? 'commit-area-error' : null,
remoteActionError ? 'commit-area-remote-error' : null,
createPrIntentNotice ? 'commit-area-create-pr-intent' : null,
generateError ? 'commit-area-generate-error' : null
]
.filter(Boolean)
.join(' ')
// Why: only render Generate when it has a runnable path; otherwise the
// composer should stay focused on the normal Commit action.
// Why: Create PR intent owns message generation and surfaces status via the
// inline notice; a second composer spinner stacks on the primary spinner.
const showGenerate =
showComposer && aiEnabled && !isCreatePrIntentInFlight && (aiAgentConfigured || isGenerating)
let generateDisabledReason: string | undefined
if (isGenerating) {
generateDisabledReason = 'Generating commit message…'
} else if (isCommitting) {
generateDisabledReason = 'Commit in progress…'
} else if (!aiAgentConfigured) {
generateDisabledReason = 'Pick an agent in Settings -> Git -> Source Control AI.'
} else if (stagedCount === 0) {
generateDisabledReason = 'Stage at least one file to generate a message.'
} else if (hasMessage) {
generateDisabledReason = 'Clear the message to regenerate.'
}
const isGenerateDisabled =
!aiAgentConfigured ||
isGenerating ||
isCommitting ||
stagedCount === 0 ||
hasMessage ||
hasUnresolvedConflicts
const moreCommitAndRemoteActionsLabel = translate(
'auto.components.right.sidebar.SourceControl.cc199ccc5f',
'More commit and remote actions'
)
const moreActionsLabel = translate(
'auto.components.right.sidebar.SourceControl.4d6e1fd7f3',
'More actions'
)
const dropdownMenuContent = (
<DropdownMenuContent align="end" className="min-w-[14rem]">
{dropdownItems.map((entry, index) =>
entry.kind === 'separator' ? (
<DropdownMenuSeparator key={`sep-${index}`} />
) : (
<Tooltip key={entry.kind}>
<TooltipTrigger asChild>
<div className="block">
<DropdownMenuItem
disabled={entry.disabled}
title={entry.title}
variant={entry.variant}
className="w-full"
onSelect={(event) => {
if (entry.disabled) {
event.preventDefault()
return
}
onDropdownAction(entry.kind)
}}
>
<span className="flex min-w-0 flex-col">
<span>{entry.label}</span>
{entry.hint ? (
<span className="truncate text-[10px] text-muted-foreground">
{entry.hint}
</span>
) : null}
</span>
</DropdownMenuItem>
</div>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8} className="max-w-72">
{entry.title}
</TooltipContent>
</Tooltip>
)
)}
</DropdownMenuContent>
)
return (
<div className="px-3 pb-2">
{showComposer ? (
<div className="relative">
<textarea
rows={rows}
value={commitMessage}
disabled={isCommitMessageDisabled}
onChange={(e) => onCommitMessageChange(e.target.value)}
placeholder={translate(
'auto.components.right.sidebar.SourceControl.0d0a8359d3',
'Message'
)}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.b94112eb9e',
'Commit message'
)}
aria-describedby={describedBy || undefined}
// Why: reserve right padding so typed text does not slide under the
// absolute-positioned Generate icon in the top-right corner.
// Why: match Input surface tokens and pin disabled:border-input so
// Chromium's UA disabled styles don't wash out the field outline.
className={`mt-0.5 min-h-14 w-full resize-none appearance-none rounded-md border border-input bg-background shadow-xs px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:border-input disabled:bg-background disabled:text-foreground disabled:shadow-xs dark:bg-input/30 dark:disabled:bg-input/30 ${
showGenerate ? 'pr-8' : ''
}`}
/>
{showGenerate &&
(isGenerating ? (
// Why: while generating the icon doubles as the cancel affordance.
// Default state shows the spinning RefreshCw; on hover/focus we
// swap to a Square ("stop") with a destructive tint so the user
// sees that clicking will abort the run. Group/group-hover toggles
// keep this stateless on the React side.
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onCancelGenerate()}
title={translate(
'auto.components.right.sidebar.SourceControl.527e130b6f',
'Stop generating'
)}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.ddc1fbd690',
'Stop generating commit message'
)}
className="group absolute right-1.5 top-1.5 inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-destructive/40"
>
<RefreshCw className="size-3.5 animate-spin group-hover:hidden group-focus-visible:hidden" />
<Square className="hidden size-3.5 fill-current group-hover:block group-focus-visible:block" />
</button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{translate(
'auto.components.right.sidebar.SourceControl.37a81f29ad',
'Generating commit message. Click to stop.'
)}
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-disabled={isGenerateDisabled}
onClick={(event) => {
if (isGenerateDisabled) {
event.preventDefault()
return
}
onGenerate()
}}
title={
generateDisabledReason ??
translate(
'auto.components.right.sidebar.SourceControl.b16b8f0e4b',
'ai commit msg'
)
}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.461575b9bc',
'Generate commit message with AI'
)}
className={cn(
'absolute right-1.5 top-1.5 inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
isGenerateDisabled &&
'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground'
)}
>
<Sparkles className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{generateDisabledReason ??
translate(
'auto.components.right.sidebar.SourceControl.b16b8f0e4b',
'ai commit msg'
)}
</TooltipContent>
</Tooltip>
))}
</div>
) : null}
{/* Why: the current manual action + chevron sit together as a visual
split button so the edit → commit → push loop stays in a single
vertical band. The chevron exposes the full action surface without
forcing morphing labels to carry every possible intent. */}
<div
className={cn(showComposer ? 'mt-1 flex items-stretch gap-1' : 'flex items-stretch gap-1')}
>
<div className="flex flex-1 items-stretch">
{/* Why: match the hosted-review action buttons in Checks
(size="xs", px-3 text-[11px]) so the sidebar has a consistent
action-button shape across Source Control and Checks. */}
<Tooltip>
<TooltipTrigger asChild>
<span className="flex flex-1">
<Button
type="button"
variant="outline"
size="xs"
disabled={primaryAction.disabled}
onClick={() => onPrimaryAction()}
className="w-full rounded-r-none px-3 text-[11px]"
title={primaryAction.title}
>
{showSpinner ? (
<Loader2 className="size-3.5 animate-spin" />
) : PrimaryIcon ? (
<PrimaryIcon className="size-3.5" aria-hidden="true" />
) : null}
{primaryAction.label}
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-72">
{primaryAction.title}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="xs"
className={cn(
'rounded-l-none border-l border-border px-1.5 shrink-0',
// Why: mirror the primary's disabled dimming so the split
// button reads as one unit when Commit is unavailable. The
// chevron itself stays clickable — its dropdown exposes
// independently-gated remote actions (push / fetch / pull)
// that are still valid when the primary is disabled.
primaryAction.disabled && 'opacity-50'
)}
aria-label={moreCommitAndRemoteActionsLabel}
title={moreActionsLabel}
>
{showChevronSpinner ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<ChevronDown className="size-3.5" />
)}
</Button>
</DropdownMenuTrigger>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{moreCommitAndRemoteActionsLabel}
</TooltipContent>
</Tooltip>
{dropdownMenuContent}
</DropdownMenu>
</div>
</div>
{commitError && (
// Why: role="alert" + aria-live="polite" lets screen readers announce
// commit failures; the id ties the message to the textarea via
// aria-describedby so assistive tech associates the two.
<div
id="commit-area-error"
role="alert"
aria-live="polite"
className="mt-2 min-w-0 overflow-hidden rounded-lg border border-destructive/20 bg-card text-card-foreground shadow-xs"
>
<div className="h-0.5 bg-destructive/70" aria-hidden="true" />
<div className="grid min-w-0 gap-2 px-2.5 py-2.5">
<div className="grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-1.5">
<span className="mt-px inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<TriangleAlert className="size-3" aria-hidden="true" />
</span>
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-xs font-semibold text-foreground">
{translate(
'auto.components.right.sidebar.SourceControl.011f9713fc',
'Commit blocked'
)}
</span>
{commitFailureKindLabel ? (
<span className="shrink-0 rounded-full bg-destructive/10 px-1.5 py-px text-[10px] leading-4 font-semibold text-destructive">
{commitFailureKindLabel}
</span>
) : null}
</div>
<p className="col-start-2 mt-0.5 line-clamp-3 min-w-0 font-mono text-[11px] leading-4 break-words text-muted-foreground [overflow-wrap:anywhere]">
{commitFailureSummary}
</p>
</div>
<div className="ml-[1.375rem] flex min-w-0 items-center gap-1.5">
{sourceControlAiActionsVisible ? (
<CommitFailureFixSplitButton
label={translate(
'auto.components.right.sidebar.SourceControl.60bd988f0b',
'AI Fix'
)}
worktreeId={worktreeId}
groupId={groupId}
connectionId={connectionId}
repoId={repoId}
launchPlatform={launchPlatform}
prompt={commitFailureRecoveryPrompt}
isLaunching={isFixingCommitFailureWithAI}
variant="secondary"
size="xs"
iconClassName="size-3"
primaryClassName="h-6 px-2 text-[11px]"
chevronClassName="h-6 px-1.5"
savedAgentId={readSourceControlLaunchRecipeAgentId(fixCommitFailureRecipe)}
savedCommandInputTemplate={fixCommitFailureRecipe?.commandInputTemplate ?? null}
savedAgentArgs={fixCommitFailureRecipe?.agentArgs ?? null}
onSaveAgentDefault={onSaveLaunchActionDefault}
onOpenSettings={onOpenSourceControlAiSettings}
onFixWithDefaultAgent={handleFixCommitFailureWithAI}
onPromptDelivered={handleCommitFailureAgentPromptDelivered}
/>
) : null}
{hasCommitFailureDetails && (
<Button
type="button"
variant="outline"
size="xs"
className="h-6 shrink-0 border-foreground/25 px-2 text-[11px] font-semibold"
onClick={() => setCommitFailureDialogOpen(true)}
>
{translate('auto.components.right.sidebar.SourceControl.03d238218c', 'Details')}
</Button>
)}
</div>
</div>
</div>
)}
{commitError && commitFailureSummary && hasCommitFailureDetails && (
<Dialog
key={commitFailureWorktreeKey}
open={isCommitFailureDialogOpen}
onOpenChange={setCommitFailureDialogOpen}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.right.sidebar.SourceControl.a9bf7c171a',
'Commit Failed'
)}
</DialogTitle>
<DialogDescription>{commitFailureSummary}</DialogDescription>
</DialogHeader>
<pre className="max-h-[60vh] overflow-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs whitespace-pre-wrap text-foreground scrollbar-sleek">
{commitError}
</pre>
<DialogFooter>
{sourceControlAiActionsVisible ? (
<CommitFailureFixSplitButton
label={translate(
'auto.components.right.sidebar.SourceControl.834cb3f23d',
'Fix with AI'
)}
worktreeId={worktreeId}
groupId={groupId}
connectionId={connectionId}
repoId={repoId}
launchPlatform={launchPlatform}
prompt={commitFailureRecoveryPrompt}
isLaunching={isFixingCommitFailureWithAI}
variant="default"
size="sm"
iconClassName="size-4"
primaryClassName="rounded-r-none"
chevronClassName="rounded-l-none border-l border-primary-foreground/20 px-2"
savedAgentId={readSourceControlLaunchRecipeAgentId(fixCommitFailureRecipe)}
savedCommandInputTemplate={fixCommitFailureRecipe?.commandInputTemplate ?? null}
savedAgentArgs={fixCommitFailureRecipe?.agentArgs ?? null}
onSaveAgentDefault={onSaveLaunchActionDefault}
onOpenSettings={onOpenSourceControlAiSettings}
onFixWithDefaultAgent={handleFixCommitFailureWithAI}
onPromptDelivered={handleCommitFailureAgentPromptDelivered}
/>
) : null}
<DialogClose asChild>
<Button type="button" variant="outline" size="sm">
{translate('auto.components.right.sidebar.SourceControl.783a808870', 'Close')}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{remoteActionError && isPullPolicyRemoteActionError(remoteActionError) ? (
<PullPolicyRemoteActionNotice id="commit-area-remote-error" />
) : remoteActionError ? (
<p
id="commit-area-remote-error"
role="alert"
aria-live="polite"
className="mt-1 text-[11px] text-destructive"
>
{remoteActionError}
</p>
) : null}
{createPrIntentNotice && (
<div
id="commit-area-create-pr-intent"
role={createPrIntentNotice.tone === 'destructive' ? 'alert' : 'status'}
aria-live="polite"
className={cn(
'mt-1 flex min-w-0 items-center gap-1.5 text-[11px]',
createPrIntentNotice.tone === 'destructive'
? 'text-destructive'
: 'text-muted-foreground'
)}
>
{/* Why: Create Review blockers carry recovery steps; truncating them hides
the action the user needs in the default narrow sidebar. */}
<span className="min-w-0 flex-1 break-words leading-4 [overflow-wrap:anywhere]">
{createPrIntentNotice.message}
</span>
{createPrIntentNotice.action === 'settings' && onOpenSourceControlAiSettings ? (
<button
type="button"
className="shrink-0 font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
onClick={() => onOpenSourceControlAiSettings()}
>
{translate(
'auto.components.right.sidebar.SourceControl.473f18758e',
'Source Control AI settings'
)}
</button>
) : null}
</div>
)}
{generateError && (
<p
id="commit-area-generate-error"
role="alert"
aria-live="polite"
className="mt-1 text-[11px] text-destructive"
>
{generateError}
</p>
)}
</div>
)
}
type BranchCompareStatusHeadSnapshot = {
baseRef: string
statusHead: string | null
worktreeId: string
}
export function shouldRefreshBranchCompareForStatusHead(
previous: BranchCompareStatusHeadSnapshot | null,
current: BranchCompareStatusHeadSnapshot
): boolean {
return (
current.statusHead !== null &&
previous !== null &&
previous.worktreeId === current.worktreeId &&
previous.baseRef === current.baseRef &&
previous.statusHead !== current.statusHead
)
}
export function shouldShowCompareSummary(summary: GitBranchCompareSummary | null): boolean {
if (!summary || summary.status === 'loading') {
return true
}
if (summary.status !== 'ready') {
return true
}
return typeof summary.commitsAhead === 'number' && summary.commitsAhead > 0
}
export function CompareSummary({
summary,
onChangeBaseRef,
onRetry
}: {
summary: GitBranchCompareSummary | null
onChangeBaseRef: () => void
onRetry: () => void
}): React.JSX.Element | null {
if (!summary || summary.status === 'loading') {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<RefreshCw className="size-3.5 animate-spin" />
<span>
{translate('auto.components.right.sidebar.SourceControl.11b5dd8e41', 'Comparing against')}
{summary?.baseRef ?? '…'}
</span>
</div>
)
}
if (summary.status !== 'ready') {
return (
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className="min-w-0 flex-1 truncate">
{summary.errorMessage ??
translate(
'auto.components.right.sidebar.SourceControl.715d229c86',
'Branch compare unavailable'
)}
</span>
<div className="flex shrink-0 items-center gap-2">
<CompareSummaryToolbarButton
icon={Settings2}
label={translate(
'auto.components.right.sidebar.SourceControl.493f963029',
'Change base ref'
)}
onClick={onChangeBaseRef}
/>
<CompareSummaryToolbarButton
icon={RefreshCw}
label={translate('auto.components.right.sidebar.SourceControl.286dbda4d6', 'Retry')}
onClick={onRetry}
/>
</div>
</div>
)
}
const commitsAhead = summary.commitsAhead
const showCommitsAhead = typeof commitsAhead === 'number' && commitsAhead > 0
const commitsAheadTitle = showCommitsAhead
? `${commitsAhead} ${commitsAhead === 1 ? 'commit' : 'commits'} ahead of ${summary.baseRef}`
: undefined
if (!showCommitsAhead) {
return null
}
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="flex min-w-0 items-center gap-1" title={commitsAheadTitle}>
<ArrowUp className="size-3" />
<span>
{commitsAhead}{' '}
{translate('auto.components.right.sidebar.SourceControl.3278b2767b', 'ahead')}
</span>
</span>
<div className="ml-auto flex shrink-0 items-center gap-2">
<CompareSummaryToolbarButton
icon={Settings2}
label={translate(
'auto.components.right.sidebar.SourceControl.493f963029',
'Change base ref'
)}
onClick={onChangeBaseRef}
/>
<CompareSummaryToolbarButton
icon={RefreshCw}
label={translate(
'auto.components.right.sidebar.SourceControl.ed34038d0d',
'Refresh branch compare'
)}
onClick={onRetry}
/>
</div>
</div>
)
}
export function CompareSummaryToolbarButton({
icon: Icon,
label,
onClick
}: {
icon: LucideIcon
label: string
onClick: () => void
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
aria-label={label}
onClick={onClick}
>
<Icon className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
)
}
function CompareUnavailable({
summary,
onChangeBaseRef,
onRetry
}: {
summary: GitBranchCompareSummary
onChangeBaseRef: () => void
onRetry: () => void
}): React.JSX.Element {
const changeBaseRefAllowed =
summary.status === 'invalid-base' ||
summary.status === 'no-merge-base' ||
summary.status === 'error'
return (
<div className="m-3 rounded-md border border-border/60 bg-muted/20 px-3 py-3 text-xs">
<div className="font-medium text-foreground">
{summary.status === 'error'
? translate(
'auto.components.right.sidebar.SourceControl.97d8b03cdf',
'Branch compare failed'
)
: translate(
'auto.components.right.sidebar.SourceControl.715d229c86',
'Branch compare unavailable'
)}
</div>
<div className="mt-1 text-muted-foreground">
{summary.errorMessage ??
translate(
'auto.components.right.sidebar.SourceControl.b6922abb13',
'Unable to load branch compare.'
)}
</div>
<div className="mt-3 flex items-center gap-2">
{changeBaseRefAllowed && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={onChangeBaseRef}
>
<Settings2 className="size-3.5" />
{translate('auto.components.right.sidebar.SourceControl.476b77745b', 'Change Base Ref')}
</Button>
)}
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={onRetry}>
<RefreshCw className="size-3.5" />
{translate('auto.components.right.sidebar.SourceControl.286dbda4d6', 'Retry')}
</Button>
</div>
</div>
)
}
function SectionHeader({
label,
count,
conflictCount = 0,
isCollapsed,
onToggle,
actions
}: {
label: string
count: number
conflictCount?: number
isCollapsed: boolean
onToggle: () => void
actions?: React.ReactNode
}): React.JSX.Element {
// Why: wrap the toggle button and actions in a shared rounded container
// so the hover background spans the entire row instead of clipping around
// the label. The outer div keeps the vertical spacing that separates
// sections; the inner wrapper owns the hover rectangle.
return (
<div className="pl-1 pr-3 pt-3 pb-1">
<div className="group/section flex items-center rounded-md pr-1 hover:bg-accent hover:text-accent-foreground">
<button
type="button"
className="flex flex-1 items-center gap-1 px-0.5 py-0.5 text-left text-xs font-semibold uppercase tracking-wider text-foreground/70 group-hover/section:text-accent-foreground"
onClick={onToggle}
>
<ChevronDown
className={cn('size-3.5 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
/>
<span>{label}</span>
<span className="text-[11px] font-medium tabular-nums">{count}</span>
{conflictCount > 0 && (
<span className="text-[11px] font-medium text-destructive/80">
· {conflictCount}{' '}
{translate('auto.components.right.sidebar.SourceControl.413a3ba113', 'conflict')}
{conflictCount === 1 ? '' : 's'}
</span>
)}
</button>
<div className="shrink-0 flex items-center">{actions}</div>
</div>
</div>
)
}
function getLocalizedDiffCommentLineLabel(
comment: Pick<DiffComment, 'lineNumber' | 'startLine'>
): string {
if (comment.startLine !== undefined && comment.startLine !== comment.lineNumber) {
return translate(
'auto.components.right.sidebar.SourceControl.d97ef8f221',
'lines {{value0}}-{{value1}}',
{
value0: comment.startLine,
value1: comment.lineNumber
}
)
}
return translate('auto.components.right.sidebar.SourceControl.6f8bfa0eb9', 'line {{value0}}', {
value0: comment.lineNumber
})
}
function getLocalizedConflictKindLabel(kind: NonNullable<GitStatusEntry['conflictKind']>): string {
switch (kind) {
case 'both_modified':
return translate('auto.components.right.sidebar.SourceControl.c569d29a02', 'both modified')
case 'both_added':
return translate('auto.components.right.sidebar.SourceControl.ea7287d84f', 'both added')
case 'deleted_by_us':
return translate('auto.components.right.sidebar.SourceControl.bd0151ef7b', 'deleted by us')
case 'deleted_by_them':
return translate('auto.components.right.sidebar.SourceControl.44594e8c61', 'deleted by them')
case 'added_by_us':
return translate('auto.components.right.sidebar.SourceControl.24773ee581', 'added by us')
case 'added_by_them':
return translate('auto.components.right.sidebar.SourceControl.c03d7c952f', 'added by them')
case 'both_deleted':
return translate('auto.components.right.sidebar.SourceControl.5b176fa431', 'both deleted')
}
}
function DiffCommentsInlineList({
comments,
onDelete,
onClearFile,
onOpen
}: {
comments: DiffComment[]
onDelete: (commentId: string) => void
onClearFile: (filePath: string) => void
// Why: clicking the note row navigates the user to that file's diff (or
// editor as a fallback) and, when a `commentId` is supplied, scrolls the
// diff to that specific note via the scrollToDiffCommentId UI slice.
onOpen: (comment: DiffComment) => void
}): React.JSX.Element {
// Why: group by filePath so the inline list mirrors the structure in the
// Notes tab — a compact section per file with line-number prefixes.
const groups = useMemo(() => {
const map = new Map<string, DiffComment[]>()
for (const c of comments) {
const list = map.get(c.filePath) ?? []
list.push(c)
map.set(c.filePath, list)
}
for (const list of map.values()) {
list.sort((a, b) => a.lineNumber - b.lineNumber)
}
return Array.from(map.entries())
}, [comments])
const [copiedId, showCopiedId] = useCopyFeedbackState<string | null>(null)
const handleCopyOne = useCallback(
async (c: DiffComment): Promise<void> => {
try {
await window.api.ui.writeClipboardText(formatDiffComment(c))
showCopiedId(c.id)
} catch {
// Why: swallow — clipboard write can fail when the window isn't focused.
}
},
[showCopiedId]
)
if (comments.length === 0) {
return (
<div className="px-6 py-2 text-[11px] text-muted-foreground">
{translate(
'auto.components.right.sidebar.SourceControl.ac8cbe3bf5',
'Hover over a line in the diff view and click the + to add a note.'
)}
</div>
)
}
return (
<div className="bg-muted/20">
{groups.map(([filePath, list]) => (
<div key={filePath} className="px-3 py-1.5">
<div className="group/file flex items-center gap-1">
<button
type="button"
className="block min-w-0 flex-1 truncate text-left text-[10px] font-medium text-muted-foreground hover:text-foreground"
onClick={() => {
const first = list[0]
if (first) {
onOpen(first)
}
}}
title={translate(
'auto.components.right.sidebar.SourceControl.0d963bf982',
'Open {{value0}}',
{ value0: filePath }
)}
>
{filePath}
</button>
<button
type="button"
className="shrink-0 rounded p-0.5 text-muted-foreground can-hover:opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover/file:opacity-100"
onClick={() => onClearFile(filePath)}
title={translate(
'auto.components.right.sidebar.SourceControl.59654650d3',
'Clear notes for {{value0}}',
{ value0: filePath }
)}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.59654650d3',
'Clear notes for {{value0}}',
{ value0: filePath }
)}
>
<Trash2 className="size-3" />
</button>
</div>
<ul className="mt-1 space-y-1">
{list.map((c) => (
<li
key={c.id}
className="group flex items-center gap-1.5 rounded px-1 py-0.5 hover:bg-accent/40"
>
<button
type="button"
// Why: a single inner button is the click/keyboard target so
// the row's action buttons (copy/delete) can stay as
// siblings without nesting interactive elements — that
// pattern violates ARIA's no-interactive-descendants rule
// for buttons and lets bubbled key events from the children
// fire the row's open handler.
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded text-left"
onClick={() => onOpen(c)}
title={translate(
'auto.components.right.sidebar.SourceControl.0b5b8c234c',
'Open {{value0}} ({{value1}})',
{ value0: c.filePath, value1: getLocalizedDiffCommentLineLabel(c) }
)}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.3eb9b2805e',
'Open note on {{value0}}',
{ value0: getLocalizedDiffCommentLineLabel(c) }
)}
>
<span className="shrink-0 rounded bg-muted px-1 py-0.5 text-[10px] leading-none tabular-nums text-muted-foreground">
{getDiffCommentLineLabel(c, true)}
</span>
<span className="shrink-0 rounded bg-muted/70 px-1 py-0.5 text-[10px] leading-none text-muted-foreground">
{getDiffCommentSource(c) === 'markdown'
? translate('auto.components.right.sidebar.SourceControl.94c42b252e', 'MD')
: translate('auto.components.right.sidebar.SourceControl.c56ba7fa06', 'Diff')}
</span>
{c.sentAt ? (
<span className="shrink-0 rounded bg-muted/70 px-1 py-0.5 text-[10px] leading-none text-muted-foreground">
{translate('auto.components.right.sidebar.SourceControl.655633c08a', 'Sent')}
</span>
) : null}
<span className="block min-w-0 flex-1 whitespace-pre-wrap break-words text-[11px] leading-snug text-foreground">
{c.body}
</span>
</button>
<button
type="button"
className="shrink-0 rounded p-0.5 text-muted-foreground can-hover:opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => void handleCopyOne(c)}
title={translate(
'auto.components.right.sidebar.SourceControl.1623bf4e19',
'Copy note'
)}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.c085946bda',
'Copy note on line {{value0}}',
{ value0: c.lineNumber }
)}
>
{copiedId === c.id ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
<button
type="button"
className="shrink-0 rounded p-0.5 text-muted-foreground can-hover:opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => onDelete(c.id)}
title={translate(
'auto.components.right.sidebar.SourceControl.b656381c18',
'Delete note'
)}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.c321542ee2',
'Delete note on line {{value0}}',
{ value0: c.lineNumber }
)}
>
<Trash className="size-3" />
</button>
</li>
))}
</ul>
</div>
))}
</div>
)
}
export function ConflictSummaryCard({
conflictOperation,
unresolvedCount,
sourceControlAiActionsVisible,
isResolvingWithAI,
isAbortingOperation = false,
onAbortOperation,
onResolveWithAI,
onReview
}: {
conflictOperation: GitConflictOperation
unresolvedCount: number
sourceControlAiActionsVisible: boolean
isResolvingWithAI: boolean
isAbortingOperation?: boolean
onAbortOperation?: (operation: GitConflictOperation) => void
onResolveWithAI: () => void
onReview: () => void
}): React.JSX.Element {
const operationLabel =
conflictOperation === 'merge'
? 'Merge conflicts'
: conflictOperation === 'rebase'
? 'Rebase conflicts'
: conflictOperation === 'cherry-pick'
? 'Cherry-pick conflicts'
: 'Conflicts'
return (
<div className="rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2">
<div className="flex items-start gap-2">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-foreground" aria-live="polite">
{translate(
'auto.components.right.sidebar.SourceControl.d7a5942e41',
'{{value0}}: {{value1}} unresolved',
{ value0: operationLabel, value1: unresolvedCount }
)}
</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{translate(
'auto.components.right.sidebar.SourceControl.3eeccbb221',
'Resolved files move back to normal changes after they leave the live conflict state.'
)}
</div>
</div>
</div>
<div className="mt-2">
{sourceControlAiActionsVisible ? (
<Button
type="button"
variant="default"
size="sm"
className="h-7 w-full text-xs"
disabled={isResolvingWithAI}
onClick={onResolveWithAI}
>
{isResolvingWithAI ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
<Sparkles className="size-3.5" />
)}
{translate('auto.components.right.sidebar.SourceControl.f6cb48b6fe', 'Resolve with AI')}
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
className={cn(sourceControlAiActionsVisible && 'mt-1.5', 'h-7 w-full text-xs')}
onClick={onReview}
>
<GitMerge className="size-3.5" />
{translate('auto.components.right.sidebar.SourceControl.27a50fe970', 'Review conflicts')}
</Button>
{(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? (
<Button
type="button"
// Why: abort is the escape hatch for this state, so match the quiet
// outline conflict-review action instead of reading as destructive.
variant="outline"
size="sm"
className="mt-1.5 h-7 w-full text-xs"
disabled={isResolvingWithAI || isAbortingOperation}
onClick={() => onAbortOperation(conflictOperation)}
>
{isAbortingOperation ? <RefreshCw className="size-3.5 animate-spin" /> : null}
{conflictOperation === 'rebase'
? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase')
: translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')}
</Button>
) : null}
</div>
</div>
)
}
// Why: this banner is separate from ConflictSummaryCard because a rebase (or
// merge/cherry-pick) can be in progress without any conflicts — e.g. between
// rebase steps, or after resolving all conflicts but before --continue. The
// user needs to see the operation state so they know the worktree is mid-rebase
// and that they should run `git rebase --continue` or `--abort`.
export function OperationBanner({
conflictOperation,
isAbortingOperation = false,
onAbortOperation
}: {
conflictOperation: GitConflictOperation
isAbortingOperation?: boolean
onAbortOperation?: (operation: GitConflictOperation) => void
}): React.JSX.Element {
const label =
conflictOperation === 'merge'
? 'Merge in progress'
: conflictOperation === 'rebase'
? 'Rebase in progress'
: conflictOperation === 'cherry-pick'
? 'Cherry-pick in progress'
: 'Operation in progress'
const Icon = conflictOperation === 'rebase' ? GitPullRequestArrow : GitMerge
return (
<div className="rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2">
<div className="flex items-center justify-center gap-2">
<Icon className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<span className="text-xs font-medium text-foreground">{label}</span>
</div>
{(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? (
<Button
type="button"
// Why: abort is the escape hatch for this state, so match the quiet
// outline conflict-review action instead of reading as destructive.
variant="outline"
size="sm"
className="mt-2 h-7 w-full text-xs"
disabled={isAbortingOperation}
onClick={() => onAbortOperation(conflictOperation)}
>
{isAbortingOperation ? <RefreshCw className="size-3.5 animate-spin" /> : null}
{conflictOperation === 'rebase'
? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase')
: translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')}
</Button>
) : null}
</div>
)
}
export function TooManyChangesBanner({ limit }: { limit: number }): React.JSX.Element {
return (
<div className="rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2">
<div className="flex items-center gap-2">
<AlertTriangle className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<span className="text-xs text-foreground">
{translate(
'auto.components.right.sidebar.SourceControl.tooManyChanges',
'Too many changes detected. Only the first {{value0}} are shown.',
{ value0: limit.toLocaleString() }
)}
</span>
</div>
</div>
)
}
function SourceControlTreeDirectoryRow({
node,
actionPaths,
hideBulkActions,
isExecutingBulk,
isCollapsed,
onToggle,
onRequestDiscardPaths,
onStagePaths,
onUnstagePaths
}: {
node: SourceControlTreeDirectoryNode
actionPaths: SourceControlDirectoryActionPaths
hideBulkActions: boolean
isExecutingBulk: boolean
isCollapsed: boolean
onToggle: () => void
onRequestDiscardPaths: (area: DiscardAllArea, paths: readonly string[]) => void
onStagePaths: (paths: readonly string[]) => Promise<void>
onUnstagePaths: (paths: readonly string[]) => Promise<void>
}): React.JSX.Element {
// Why: filtered tree nodes only contain visible descendants. Folder-wide
// bulk labels would overpromise if they acted on that filtered subset.
const canStage = !hideBulkActions && actionPaths.stagePaths.length > 0
const canUnstage = !hideBulkActions && actionPaths.unstagePaths.length > 0
const canDiscard = !hideBulkActions && actionPaths.discardPaths.length > 0
return (
<div
className="group relative flex w-full items-center gap-1 pr-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
style={{
paddingLeft: `${node.depth * SOURCE_CONTROL_TREE_INDENT_PX + SOURCE_CONTROL_TREE_DIRECTORY_PADDING_PX}px`
}}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1 text-left"
onClick={onToggle}
aria-expanded={!isCollapsed}
>
<ChevronDown
className={cn('size-3 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
/>
{isCollapsed ? (
<Folder className="size-3 shrink-0" />
) : (
<FolderOpen className="size-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate">{node.name}</span>
</button>
<span className="w-4 shrink-0 text-center text-[10px] font-bold tabular-nums text-muted-foreground/80">
{node.fileCount}
</span>
{(canDiscard || canStage || canUnstage) && (
<div className={SOURCE_CONTROL_ROW_ACTION_OVERLAY_CLASS}>
{canDiscard && (
<ActionButton
icon={node.area === 'untracked' ? Trash : Undo2}
title={
node.area === 'untracked'
? translate(
'auto.components.right.sidebar.SourceControl.9b367363b6',
'Delete untracked in folder'
)
: translate(
'auto.components.right.sidebar.SourceControl.6d7f2a47e5',
'Discard folder'
)
}
onClick={(event) => {
event.stopPropagation()
onRequestDiscardPaths(node.area, actionPaths.discardPaths)
}}
disabled={isExecutingBulk}
/>
)}
{canStage && (
<ActionButton
icon={Plus}
title={translate(
'auto.components.right.sidebar.SourceControl.bfe9011a0e',
'Stage folder'
)}
onClick={(event) => {
event.stopPropagation()
void onStagePaths(actionPaths.stagePaths)
}}
disabled={isExecutingBulk}
/>
)}
{canUnstage && (
<ActionButton
icon={Minus}
title={translate(
'auto.components.right.sidebar.SourceControl.ab31221779',
'Unstage folder'
)}
onClick={(event) => {
event.stopPropagation()
void onUnstagePaths(actionPaths.unstagePaths)
}}
disabled={isExecutingBulk}
/>
)}
</div>
)}
</div>
)
}
function SourceControlBranchTreeDirectoryRow({
node,
isCollapsed,
onToggle
}: {
node: BranchSourceControlTreeDirectoryNode
isCollapsed: boolean
onToggle: () => void
}): React.JSX.Element {
return (
<div
className="group relative flex w-full items-center gap-1 pr-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
style={{
paddingLeft: `${node.depth * SOURCE_CONTROL_TREE_INDENT_PX + SOURCE_CONTROL_TREE_DIRECTORY_PADDING_PX}px`
}}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1 text-left"
onClick={onToggle}
aria-expanded={!isCollapsed}
>
<ChevronDown
className={cn('size-3 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
/>
{isCollapsed ? (
<Folder className="size-3 shrink-0" />
) : (
<FolderOpen className="size-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate">{node.name}</span>
</button>
<span className="w-4 shrink-0 text-center text-[10px] font-bold tabular-nums text-muted-foreground/80">
{node.fileCount}
</span>
</div>
)
}
// Why: a compact +added/-removed magnitude lets users gauge change size at a
// glance. Use git decoration tokens so the source-control sidebar follows the
// documented light/dark status palette.
function DiffLineCounts({
added,
removed
}: {
added?: number
removed?: number
}): React.JSX.Element | null {
const hasAdded = typeof added === 'number' && added > 0
const hasRemoved = typeof removed === 'number' && removed > 0
if (!hasAdded && !hasRemoved) {
return null
}
return (
<span className="shrink-0 tabular-nums text-[10px]">
{hasAdded && <span style={{ color: 'var(--git-decoration-added)' }}>+{added}</span>}
{hasAdded && hasRemoved && <span> </span>}
{hasRemoved && <span style={{ color: 'var(--git-decoration-deleted)' }}>-{removed}</span>}
</span>
)
}
const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
entryKey,
entry,
currentWorktreeId,
worktreePath,
depth = 0,
selected,
isOpenFile = false,
onSelect,
onContextMenu,
onRevealInExplorer,
connectionId,
onOpen,
onStage,
onUnstage,
onDiscard,
commentCount,
showPathHint = true
}: {
entryKey: string
entry: GitStatusEntry
currentWorktreeId: string
worktreePath: string
depth?: number
selected?: boolean
isOpenFile?: boolean
onSelect?: (e: React.MouseEvent, key: string, entry: GitStatusEntry) => void
onContextMenu?: (key: string) => void
onRevealInExplorer: (worktreeId: string, absolutePath: string) => void
connectionId?: string | null
onOpen: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void
onStage: (filePath: string) => Promise<void>
onUnstage: (filePath: string) => Promise<void>
onDiscard: (entry: GitStatusEntry) => void
commentCount: number
showPathHint?: boolean
}): React.JSX.Element {
const FileIcon = getFileTypeIcon(entry.path)
const fileName = basename(entry.path)
const parentDir = dirname(entry.path)
const dirPath = parentDir === '.' ? '' : parentDir
const isUnresolvedConflict = entry.conflictStatus === 'unresolved'
const isResolvedLocally = entry.conflictStatus === 'resolved_locally'
const isSubmoduleWorktreeOnly = isSubmoduleWorktreeOnlyChange(entry)
const conflictLabel = entry.conflictKind
? getLocalizedConflictKindLabel(entry.conflictKind)
: null
// Why: the hint text ("Open and edit…", "Decide whether to…") was removed
// from the sidebar because it's not actionable here — the user can only
// click the row, and the conflict-kind label alone is sufficient context.
// Why: Stage is suppressed for unresolved conflicts because `git add` would
// immediately erase the `u` record — the only live conflict signal in the
// sidebar — before the user has actually reviewed the file. The user should
// resolve in the editor first, then stage from the post-resolution state.
//
// Discard is hidden for both unresolved AND resolved_locally rows in v1.
// For unresolved: discarding is too easy to misfire on a high-risk file.
// For resolved_locally: discarding can silently re-create the conflict or
// lose the resolution, and v1 does not have UX to explain this clearly.
const canDiscard =
!isUnresolvedConflict &&
!isResolvedLocally &&
(entry.area === 'unstaged' || entry.area === 'untracked')
const canStage = isStageableStatusEntry(entry)
const canUnstage = entry.area === 'staged'
return (
<SourceControlEntryContextMenu
currentWorktreeId={currentWorktreeId}
absolutePath={joinPath(worktreePath, entry.path)}
connectionId={connectionId}
onView={() => onOpen(entry)}
onRevealInExplorer={onRevealInExplorer}
onOpenChange={(open) => {
if (open && onContextMenu) {
onContextMenu(entryKey)
}
}}
>
<div
data-testid="source-control-entry"
data-source-control-path={entry.path}
data-source-control-area={entry.area}
// Why: the currently open file gets the strongest "current row" accent
// (full `bg-accent` + `data-current`) per the styleguide, outranking the
// lighter bulk-selection tint so the open file always reads as active.
data-current={isOpenFile ? 'true' : undefined}
className={cn(
'group relative flex cursor-pointer items-center gap-1 pr-3 py-1 transition-colors',
isOpenFile ? 'bg-accent hover:bg-accent' : 'hover:bg-accent/40',
!isOpenFile && selected && 'bg-accent/60'
)}
style={{
paddingLeft: `${depth * SOURCE_CONTROL_TREE_INDENT_PX + SOURCE_CONTROL_TREE_FILE_PADDING_PX}px`
}}
draggable
onDragStart={(e) => {
if (isUnresolvedConflict && entry.status === 'deleted') {
e.preventDefault()
return
}
const absolutePath = joinPath(worktreePath, entry.path)
e.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, absolutePath)
e.dataTransfer.effectAllowed = 'copy'
}}
onClick={(e) => {
if (onSelect) {
onSelect(e, entryKey, entry)
} else {
onOpen(entry, e)
}
}}
onDoubleClick={(e) => {
onOpen(entry, toPermanentSourceControlRowOpenEvent(e))
}}
>
<FileIcon className="size-3.5 shrink-0" style={{ color: STATUS_COLORS[entry.status] }} />
<div className="min-w-0 flex-1 text-xs">
<span className="min-w-0 block truncate">
<span className="text-foreground">{fileName}</span>
{showPathHint && dirPath && (
<span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>
)}
</span>
{(conflictLabel || isSubmoduleWorktreeOnly) && (
<div className="truncate text-[11px] text-muted-foreground">
{conflictLabel ?? SUBMODULE_WORKTREE_ONLY_LABEL}
</div>
)}
</div>
{commentCount > 0 && (
// Why: show a small note marker on any row that has diff notes
// so the user can tell at a glance which files have review notes
// attached, without opening the Notes tab.
<span
className="flex shrink-0 items-center gap-0.5 text-[10px] text-muted-foreground"
title={translate(
'auto.components.right.sidebar.SourceControl.657e0c90ad',
'{{value0}} note{{value1}}',
{ value0: commentCount, value1: commentCount === 1 ? '' : 's' }
)}
>
<MessageSquare className="size-3" />
<span className="tabular-nums">{commentCount}</span>
</span>
)}
{entry.conflictStatus ? (
<ConflictBadge entry={entry} />
) : (
<>
<DiffLineCounts added={entry.added} removed={entry.removed} />
<span
className="w-4 shrink-0 text-center text-[10px] font-bold"
style={{ color: STATUS_COLORS[entry.status] }}
>
{STATUS_LABELS[entry.status]}
</span>
</>
)}
<div className={SOURCE_CONTROL_ROW_ACTION_OVERLAY_CLASS}>
{canDiscard && (
<ActionButton
icon={entry.area === 'untracked' ? Trash : Undo2}
title={
entry.area === 'untracked'
? translate(
'auto.components.right.sidebar.SourceControl.11463f7a98',
'Delete untracked file'
)
: entry.status === 'deleted'
? translate(
'auto.components.right.sidebar.SourceControl.989f3d5e34',
'Restore file'
)
: translate(
'auto.components.right.sidebar.SourceControl.d54dd48b0b',
'Discard changes'
)
}
onClick={(event) => {
event.stopPropagation()
onDiscard(entry)
}}
/>
)}
{(canStage || isSubmoduleWorktreeOnly) && (
<ActionButton
icon={Plus}
title={
isSubmoduleWorktreeOnly
? SUBMODULE_WORKTREE_ONLY_STAGE_TOOLTIP
: translate('auto.components.right.sidebar.SourceControl.8cde1a2fb0', 'Stage')
}
onClick={(event) => {
event.stopPropagation()
void onStage(entry.path)
}}
disabled={isSubmoduleWorktreeOnly}
/>
)}
{canUnstage && (
<ActionButton
icon={Minus}
title={translate('auto.components.right.sidebar.SourceControl.df5040e3c3', 'Unstage')}
onClick={(event) => {
event.stopPropagation()
void onUnstage(entry.path)
}}
/>
)}
</div>
</div>
</SourceControlEntryContextMenu>
)
})
function ConflictBadge({ entry }: { entry: GitStatusEntry }): React.JSX.Element {
const isUnresolvedConflict = entry.conflictStatus === 'unresolved'
const label = isUnresolvedConflict
? translate('auto.components.right.sidebar.SourceControl.31f6d46278', 'Unresolved')
: translate('auto.components.right.sidebar.SourceControl.2c417432b7', 'Resolved locally')
const conflictKindLabel = entry.conflictKind
? getLocalizedConflictKindLabel(entry.conflictKind)
: null
const Icon = isUnresolvedConflict ? TriangleAlert : CircleCheck
const badge = (
<span
role="status"
aria-label={
conflictKindLabel
? translate(
'auto.components.right.sidebar.SourceControl.d206117f90',
'{{value0}} conflict ({{value1}})',
{ value0: label, value1: conflictKindLabel }
)
: translate(
'auto.components.right.sidebar.SourceControl.05838cfdeb',
'{{value0}} conflict',
{ value0: label }
)
}
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold',
isUnresolvedConflict
? 'bg-destructive/12 text-destructive'
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400'
)}
>
<Icon className="size-3" />
<span>{label}</span>
</span>
)
if (isUnresolvedConflict) {
return badge
}
return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{translate(
'auto.components.right.sidebar.SourceControl.03194cfff4',
'Local session state derived from a conflict you opened here.'
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
function BranchEntryRow({
entry,
currentWorktreeId,
worktreePath,
depth = 0,
onRevealInExplorer,
connectionId,
onOpen,
commentCount,
showPathHint = true
}: {
entry: GitBranchChangeEntry
currentWorktreeId: string
worktreePath: string
depth?: number
onRevealInExplorer: (worktreeId: string, absolutePath: string) => void
connectionId?: string | null
onOpen: (event?: SourceControlRowOpenEvent) => void
commentCount: number
showPathHint?: boolean
}): React.JSX.Element {
const FileIcon = getFileTypeIcon(entry.path)
const fileName = basename(entry.path)
const parentDir = dirname(entry.path)
const dirPath = parentDir === '.' ? '' : parentDir
return (
<SourceControlEntryContextMenu
currentWorktreeId={currentWorktreeId}
absolutePath={joinPath(worktreePath, entry.path)}
connectionId={connectionId}
onView={() => onOpen()}
onRevealInExplorer={onRevealInExplorer}
>
<div
className="group flex cursor-pointer items-center gap-1 pr-3 py-1 transition-colors hover:bg-accent/40"
style={{
paddingLeft: `${depth * SOURCE_CONTROL_TREE_INDENT_PX + SOURCE_CONTROL_TREE_FILE_PADDING_PX}px`
}}
draggable
onDragStart={(e) => {
const absolutePath = joinPath(worktreePath, entry.path)
e.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, absolutePath)
e.dataTransfer.effectAllowed = 'copy'
}}
onClick={(e) => onOpen(e)}
onDoubleClick={(e) => onOpen(toPermanentSourceControlRowOpenEvent(e))}
>
<FileIcon className="size-3.5 shrink-0" style={{ color: STATUS_COLORS[entry.status] }} />
<span className="min-w-0 flex-1 truncate text-xs">
<span className="text-foreground">{fileName}</span>
{showPathHint && dirPath && (
<span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>
)}
</span>
{commentCount > 0 && (
<span
className="flex shrink-0 items-center gap-0.5 text-[10px] text-muted-foreground"
title={translate(
'auto.components.right.sidebar.SourceControl.657e0c90ad',
'{{value0}} note{{value1}}',
{ value0: commentCount, value1: commentCount === 1 ? '' : 's' }
)}
>
<MessageSquare className="size-3" />
<span className="tabular-nums">{commentCount}</span>
</span>
)}
<DiffLineCounts added={entry.added} removed={entry.removed} />
<span
className="w-4 shrink-0 text-center text-[10px] font-bold"
style={{ color: STATUS_COLORS[entry.status] }}
>
{STATUS_LABELS[entry.status]}
</span>
</div>
</SourceControlEntryContextMenu>
)
}
function EmptyState({
heading,
supportingText
}: {
heading: string
supportingText: string
}): React.JSX.Element {
return (
<div className="px-4 py-6">
<div className="text-sm font-medium text-foreground">{heading}</div>
<div className="mt-1 text-xs text-muted-foreground">{supportingText}</div>
</div>
)
}
export function ActionButton({
icon: Icon,
title,
onClick,
disabled
}: {
icon: React.ComponentType<{ className?: string }>
title: string
onClick: (event: React.MouseEvent) => void
disabled?: boolean
}): React.JSX.Element {
// Why: use the Radix Tooltip instead of the native `title` attribute so the
// label matches the rest of the sidebar chrome (consistent styling, no OS
// delay quirks, dismissible on pointer leave).
//
// Why (no local TooltipProvider): the app root mounts a single
// TooltipProvider (see App.tsx); nesting another one here gives this subtree
// its own delay-timing state and breaks Radix's "skip the open delay when
// moving between adjacent tooltip triggers" handoff between sibling action
// buttons in the section header.
//
// Why (disabled handling): Radix's TooltipTrigger asChild on a disabled
// <button> gets pointer-events blocked in Chromium, which suppresses the
// tooltip entirely — a regression vs. the native `title` attribute it
// replaced. We keep the button interactive and rely on the caller's
// `isExecutingBulk` early-return to no-op the click during bulk ops;
// `aria-disabled` + visual dimming preserves the disabled affordance.
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className={cn(
'text-muted-foreground hover:bg-background/70 hover:text-foreground',
disabled && 'opacity-50 cursor-not-allowed'
)}
aria-label={title}
aria-disabled={disabled}
onClick={(event) => {
if (disabled) {
event.preventDefault()
return
}
onClick(event)
}}
>
<Icon className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{title}
</TooltipContent>
</Tooltip>
)
}
function compareGitStatusEntries(a: GitStatusEntry, b: GitStatusEntry): number {
return (
getConflictSortRank(a) - getConflictSortRank(b) ||
a.path.localeCompare(b.path, undefined, { numeric: true })
)
}
function getConflictSortRank(entry: GitStatusEntry): number {
if (entry.conflictStatus === 'unresolved') {
return 0
}
if (entry.conflictStatus === 'resolved_locally') {
return 1
}
return 2
}