564 lines
17 KiB
TypeScript
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
|
|
}
|
|
}
|