orca/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogF...

564 lines
17 KiB
TypeScript

/* eslint-disable max-lines -- Why: field state, base search, AI generation,
and cancellation share request guards that need to stay in one hook. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { getConnectionId } from '@/lib/connection-context'
import { useAppStore, type AppState } from '@/store'
import {
cancelRuntimeGeneratePullRequestFields,
generateRuntimePullRequestFields,
type RuntimeGeneratePullRequestFieldsOverrides,
type RuntimeGitContext
} from '@/runtime/runtime-git-client'
import {
getRuntimeRepoBaseRefDefault,
searchRuntimeRepoBaseRefDetails
} from '@/runtime/runtime-repo-client'
import type { Repo } from '../../../../shared/types'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs'
import type { BaseRefSearchResult } from '../../../../shared/types'
import {
DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
resolveSourceControlAiForOperation
} from '../../../../shared/source-control-ai'
import type { SourceControlAiPrCreationDefaults } from '../../../../shared/source-control-ai-types'
import type {
PullRequestFieldName,
PullRequestFieldRevisions
} from '@/store/slices/pull-request-generation'
type PullRequestDraftFields = {
base: string
title: string
body: string
draft: boolean
}
type UseCreatePullRequestDialogFieldsOptions = {
open: boolean
repoId: string
worktreeId: string | null
worktreePath: string
branch: string
eligibility: HostedReviewCreationEligibility | null
currentBaseRef?: string | null
repo?: Pick<Repo, 'sourceControlAi'> | null
settings: AppState['settings']
submitting: boolean
prCreationDefaults?: SourceControlAiPrCreationDefaults
sourceControlAiActionsVisible?: boolean
onBranchChangedByGeneration?: () => Promise<void>
generation?: {
generating: boolean
generateError: string | null
onGenerate: (
fields: PullRequestDraftFields,
fieldRevisions: PullRequestFieldRevisions,
overrides?: RuntimeGeneratePullRequestFieldsOverrides
) => void
onCancelGenerate: () => void
}
}
type GenerationSeed = {
requestId: number
fieldRevisions: PullRequestFieldRevisions
context: RuntimeGitContext
}
function createInitialPullRequestFieldRevisions(): PullRequestFieldRevisions {
return {
base: 0,
title: 0,
body: 0,
draft: 0
}
}
export function stripBaseRef(ref: string): string {
return normalizeHostedReviewBaseRef(ref)
}
function resolveCreateReviewDefaultBaseRef({
currentBaseRef,
eligibilityDefaultBaseRef
}: {
currentBaseRef?: string | null
eligibilityDefaultBaseRef?: string | null
}): string {
return stripBaseRef(currentBaseRef?.trim() || eligibilityDefaultBaseRef?.trim() || '')
}
export function normalizeCreateReviewBaseSearchResults(
results: readonly BaseRefSearchResult[]
): string[] {
const seen = new Set<string>()
const branches: string[] = []
for (const result of results) {
// Why: hosted review APIs take branch names, while base search displays
// remote-qualified refs. Detailed search already resolves slashy remotes.
const branch = stripBaseRef((result.localBranchName || result.refName).trim())
if (!branch || seen.has(branch)) {
continue
}
seen.add(branch)
branches.push(branch)
}
return branches
}
export function useCreatePullRequestDialogFields({
open,
repoId,
worktreeId,
worktreePath,
branch,
eligibility,
currentBaseRef,
repo,
settings,
submitting,
prCreationDefaults,
sourceControlAiActionsVisible = true,
onBranchChangedByGeneration,
generation
}: UseCreatePullRequestDialogFieldsOptions) {
const resolvedPullRequestAi = settings
? resolveSourceControlAiForOperation({
settings,
repo,
operation: 'pullRequest'
})
: null
const resolvedPrDefaults = {
...DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
...prCreationDefaults
}
const initializedFromEligibilityRef = useRef<string | null>(null)
const [initializedEligibilityKey, setInitializedEligibilityKey] = useState<string | null>(null)
const syncedDefaultBaseRef = useRef<string | null>(null)
const baseEditedByUserRef = useRef(false)
const autoGeneratedForKeyRef = useRef<string | null>(null)
const generateInFlightRef = useRef(false)
const generationRequestIdRef = useRef(0)
const generationSeedRef = useRef<GenerationSeed | null>(null)
const fieldRevisionsRef = useRef<PullRequestFieldRevisions>(
createInitialPullRequestFieldRevisions()
)
const [base, setBase] = useState('')
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [draft, setDraft] = useState(false)
const [baseQuery, setBaseQuery] = useState('')
const [baseResults, setBaseResults] = useState<string[]>([])
const [baseSearchError, setBaseSearchError] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const [generateError, setGenerateError] = useState<string | null>(null)
const hasExternalGeneration = Boolean(generation)
const currentEligibilityKey =
open && eligibility ? `${repoId}:${worktreeId ?? worktreePath}:${branch}` : null
const resolvedDefaultBaseRef = resolveCreateReviewDefaultBaseRef({
currentBaseRef,
eligibilityDefaultBaseRef: eligibility?.defaultBaseRef
})
const markFieldDirty = useCallback((field: PullRequestFieldName): void => {
fieldRevisionsRef.current = {
...fieldRevisionsRef.current,
[field]: fieldRevisionsRef.current[field] + 1
}
}, [])
const setUserBase = useCallback(
(value: string): void => {
baseEditedByUserRef.current = true
markFieldDirty('base')
setBase(value)
},
[markFieldDirty]
)
const setUserTitle = useCallback(
(value: string): void => {
markFieldDirty('title')
setTitle(value)
},
[markFieldDirty]
)
const setUserBody = useCallback(
(value: string): void => {
markFieldDirty('body')
setBody(value)
},
[markFieldDirty]
)
const setUserDraft = useCallback(
(value: boolean): void => {
markFieldDirty('draft')
setDraft(value)
},
[markFieldDirty]
)
const applyGeneratedFields = useCallback(
(
fields: PullRequestDraftFields,
seedRevisions: PullRequestFieldRevisions
): PullRequestDraftFields => {
const currentRevisions = fieldRevisionsRef.current
const nextFields = { base, title, body, draft }
// Why: AI generation runs asynchronously; only fields untouched since
// the request started are safe to replace with generated output.
if (currentRevisions.base === seedRevisions.base) {
nextFields.base = stripBaseRef(fields.base)
setBase(nextFields.base)
setBaseQuery('')
setBaseResults([])
}
if (currentRevisions.title === seedRevisions.title) {
nextFields.title = fields.title
setTitle(fields.title)
}
if (currentRevisions.body === seedRevisions.body) {
nextFields.body = fields.body
setBody(fields.body)
}
if (currentRevisions.draft === seedRevisions.draft) {
nextFields.draft = fields.draft
setDraft(fields.draft)
}
return nextFields
},
[base, body, draft, title]
)
useEffect(() => {
if (!open) {
if (!hasExternalGeneration) {
generationRequestIdRef.current += 1
if (generateInFlightRef.current) {
const requestContext = generationSeedRef.current?.context
if (requestContext?.worktreePath) {
void cancelRuntimeGeneratePullRequestFields(requestContext)
}
}
generateInFlightRef.current = false
generationSeedRef.current = null
initializedFromEligibilityRef.current = null
syncedDefaultBaseRef.current = null
baseEditedByUserRef.current = false
setInitializedEligibilityKey(null)
autoGeneratedForKeyRef.current = null
setGenerating(false)
setGenerateError(null)
}
return
}
if (!eligibility) {
return
}
const initializationKey = currentEligibilityKey
if (!initializationKey) {
return
}
if (initializedFromEligibilityRef.current === initializationKey) {
setInitializedEligibilityKey((current) =>
current === initializationKey ? current : initializationKey
)
return
}
if (!hasExternalGeneration) {
// Why: a branch/context switch invalidates any local AI request; cancel
// it before reseeding fields so stale generated text cannot land later.
generationRequestIdRef.current += 1
const requestContext = generationSeedRef.current?.context
if (generateInFlightRef.current && requestContext?.worktreePath) {
void cancelRuntimeGeneratePullRequestFields(requestContext)
}
generateInFlightRef.current = false
generationSeedRef.current = null
setGenerating(false)
}
// Why: eligibility refreshes while the dialog is open; only seed fields
// once per branch so late refreshes do not overwrite user edits.
initializedFromEligibilityRef.current = initializationKey
setInitializedEligibilityKey(initializationKey)
autoGeneratedForKeyRef.current = null
fieldRevisionsRef.current = createInitialPullRequestFieldRevisions()
baseEditedByUserRef.current = false
syncedDefaultBaseRef.current = resolvedDefaultBaseRef || null
setBase(resolvedDefaultBaseRef)
setTitle(eligibility.title ?? '')
setBody(eligibility.body ?? '')
setDraft(resolvedPrDefaults.draft)
setBaseQuery('')
setBaseResults([])
setBaseSearchError(null)
setGenerateError(null)
}, [
branch,
currentEligibilityKey,
eligibility,
hasExternalGeneration,
open,
repoId,
resolvedDefaultBaseRef,
resolvedPrDefaults.draft,
worktreeId,
worktreePath
])
useEffect(() => {
if (
!open ||
!eligibility ||
!initializedFromEligibilityRef.current ||
!resolvedDefaultBaseRef
) {
return
}
if (syncedDefaultBaseRef.current === resolvedDefaultBaseRef) {
return
}
syncedDefaultBaseRef.current = resolvedDefaultBaseRef
if (baseEditedByUserRef.current) {
return
}
// Why: the Source Control compare-base picker can change the intended
// review target while generation is in flight; bump the revision so stale
// generated details cannot retarget an untouched base back to the old ref.
markFieldDirty('base')
setBase(resolvedDefaultBaseRef)
setBaseQuery('')
setBaseResults([])
setBaseSearchError(null)
}, [eligibility, markFieldDirty, open, resolvedDefaultBaseRef])
const effectiveGenerating = generation?.generating ?? generating
const effectiveGenerateError = generation?.generateError ?? generateError
useEffect(() => {
if (!open || base) {
return
}
let stale = false
void getRuntimeRepoBaseRefDefault(settings, repoId)
.then((result) => {
if (!stale && result.defaultBaseRef) {
setBase(stripBaseRef(result.defaultBaseRef))
}
})
.catch(() => undefined)
return () => {
stale = true
}
}, [base, open, repoId, settings])
useEffect(() => {
if (!open || baseQuery.trim().length < 2) {
setBaseResults([])
setBaseSearchError(null)
return
}
let stale = false
const timer = window.setTimeout(() => {
void searchRuntimeRepoBaseRefDetails(settings, repoId, baseQuery.trim(), 20)
.then((results) => {
if (!stale) {
setBaseResults(normalizeCreateReviewBaseSearchResults(results))
setBaseSearchError(null)
}
})
.catch(() => {
if (!stale) {
setBaseResults([])
setBaseSearchError('Branch discovery failed.')
}
})
}, 200)
return () => {
stale = true
window.clearTimeout(timer)
}
}, [baseQuery, open, repoId, settings])
let generateDisabledReason: string | undefined
if (submitting) {
generateDisabledReason = 'Create PR in progress...'
} else if (!resolvedPullRequestAi?.ok) {
generateDisabledReason =
resolvedPullRequestAi?.error ?? 'Enable Source Control AI in Settings -> Git.'
} else if (!base.trim()) {
generateDisabledReason = 'Choose a base branch before generating.'
}
const generateDisabled = !effectiveGenerating && Boolean(generateDisabledReason)
const handleGenerate = useCallback(
async (overrides?: RuntimeGeneratePullRequestFieldsOverrides): Promise<void> => {
if (!worktreePath || !base.trim() || effectiveGenerating || generateDisabled) {
return
}
if (generation) {
generation.onGenerate(
{ base, title, body, draft },
{ ...fieldRevisionsRef.current },
overrides
)
return
}
const requestId = generationRequestIdRef.current + 1
generationRequestIdRef.current = requestId
const connectionId = getConnectionId(worktreeId) ?? undefined
const requestContext = {
// Why: PR generation belongs to the visible worktree owner. Global
// focused-host changes must not retarget an in-flight generation.
settings,
worktreeId,
worktreePath,
connectionId
}
const seed = {
requestId,
fieldRevisions: { ...fieldRevisionsRef.current },
context: requestContext
}
generationSeedRef.current = seed
generateInFlightRef.current = true
setGenerating(true)
setGenerateError(null)
try {
const result = await generateRuntimePullRequestFields(
requestContext,
{
base: stripBaseRef(base.trim()),
title,
body,
draft,
provider: eligibility?.provider,
useTemplate: resolvedPrDefaults.useTemplate
},
overrides
)
if (result.branchChangedByPreparation) {
await onBranchChangedByGeneration?.()
}
const isCurrentRequest = generationRequestIdRef.current === requestId
if (!isCurrentRequest) {
return
}
if (!result.success) {
if (result.canceled) {
setGenerateError(null)
return
}
setGenerateError(result.error)
return
}
const currentSeed = generationSeedRef.current
if (!currentSeed || currentSeed.requestId !== requestId) {
return
}
applyGeneratedFields(result.fields, currentSeed.fieldRevisions)
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
setGenerateError(null)
} catch (error) {
if (generationRequestIdRef.current !== requestId) {
return
}
setGenerateError(
error instanceof Error ? error.message : 'Failed to generate pull request details'
)
} finally {
if (generationRequestIdRef.current === requestId) {
generateInFlightRef.current = false
generationSeedRef.current = null
setGenerating(false)
}
}
},
[
base,
body,
draft,
effectiveGenerating,
applyGeneratedFields,
eligibility?.provider,
generation,
generateDisabled,
onBranchChangedByGeneration,
resolvedPrDefaults.useTemplate,
settings,
title,
worktreeId,
worktreePath
]
)
const handleCancelGenerate = useCallback((): void => {
if (generation) {
generation.onCancelGenerate()
return
}
const requestContext = generationSeedRef.current?.context
if (!requestContext?.worktreePath || !generateInFlightRef.current) {
return
}
generationRequestIdRef.current += 1
generateInFlightRef.current = false
generationSeedRef.current = null
setGenerating(false)
setGenerateError(null)
void cancelRuntimeGeneratePullRequestFields(requestContext)
}, [generation])
useEffect(() => {
if (
!open ||
!resolvedPrDefaults.generateDetailsOnOpen ||
!initializedFromEligibilityRef.current ||
autoGeneratedForKeyRef.current === initializedFromEligibilityRef.current ||
generateDisabled ||
effectiveGenerating ||
!base.trim()
) {
return
}
autoGeneratedForKeyRef.current = initializedFromEligibilityRef.current
void handleGenerate()
}, [
base,
effectiveGenerating,
generateDisabled,
handleGenerate,
open,
resolvedPrDefaults.generateDetailsOnOpen
])
return {
aiGenerationEnabled: sourceControlAiActionsVisible && resolvedPullRequestAi?.ok === true,
initializedFromEligibility:
currentEligibilityKey !== null && initializedEligibilityKey === currentEligibilityKey,
base,
setBase: setUserBase,
title,
setTitle: setUserTitle,
body,
setBody: setUserBody,
draft,
setDraft: setUserDraft,
fieldRevisions: fieldRevisionsRef.current,
applyGeneratedFields,
baseQuery,
setBaseQuery,
baseResults,
setBaseResults,
baseSearchError,
generating: effectiveGenerating,
generateError: effectiveGenerateError,
generateDisabled,
generateDisabledReason,
handleGenerate,
handleCancelGenerate
}
}