Embed hosted review creation composer directly in Checks panel (#5140)
* Embed hosted review creation composer directly in the Checks panel - Replaces the modal pull request/merge request creation dialog with an inline composer embedded in the empty state of the Checks sidebar. - Extracts and moves pull request generation state to a dedicated store slice so AI-generated details are persisted across sidebar unmounts. * Fix hosted review composer feedback
This commit is contained in:
parent
903adcc2e0
commit
5621f7686b
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable max-lines -- Why: the checks panel co-locates PR header, checks, comments,
|
||||
merge actions, and conflict state in one component to keep the data flow straightforward. */
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
|
|
@ -66,11 +66,11 @@ import {
|
|||
isResolvablePRCommentGroup
|
||||
} from '../pr-comments-resolution-prompt'
|
||||
import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch'
|
||||
import { CreatePullRequestDialog } from './CreatePullRequestDialog'
|
||||
import type {
|
||||
HostedReviewCreationEligibility,
|
||||
HostedReviewProvider
|
||||
} from '../../../../shared/hosted-review'
|
||||
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
|
||||
import { getHostedReviewCacheKey, refreshHostedReviewCard } from '@/store/slices/hosted-review'
|
||||
import { toast } from 'sonner'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog'
|
||||
|
|
@ -89,7 +89,11 @@ import {
|
|||
getChecksPanelEmptyStateCopy,
|
||||
shouldShowChecksPanelPublishBranchAction
|
||||
} from './checks-panel-empty-state'
|
||||
import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client'
|
||||
import {
|
||||
getRuntimeGitScope,
|
||||
getRuntimeGitStatus,
|
||||
getRuntimeGitUpstreamStatus
|
||||
} from '@/runtime/runtime-git-client'
|
||||
import {
|
||||
buildChecksPanelGitStatusContextKey,
|
||||
readChecksPanelPublishActionGitStatus,
|
||||
|
|
@ -107,7 +111,13 @@ import { gitLabPipelineJobsToPRChecks } from '../../../../shared/gitlab-pipeline
|
|||
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
|
||||
import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog'
|
||||
import { readSourceControlLaunchRecipeAgentId } from '@/lib/source-control-launch-agent-selection'
|
||||
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai'
|
||||
import {
|
||||
DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
|
||||
resolveSourceControlActionRecipe,
|
||||
resolveSourceControlAiForOperation,
|
||||
resolveSourceControlAiPrCreationDefaults
|
||||
} from '../../../../shared/source-control-ai'
|
||||
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key'
|
||||
import {
|
||||
type SourceControlActionRecipe,
|
||||
type SourceControlLaunchActionId
|
||||
|
|
@ -117,6 +127,10 @@ import {
|
|||
type SourceControlAiWriteTarget
|
||||
} from '../../../../shared/source-control-ai-recipe-save'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
import { CreateHostedReviewComposer } from './CreateHostedReviewComposer'
|
||||
import { formatCreateError } from './create-pull-request-review-copy'
|
||||
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
|
||||
import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups'
|
||||
|
||||
|
|
@ -325,6 +339,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
const getHostedReviewCreationEligibility = useAppStore(
|
||||
(s) => s.getHostedReviewCreationEligibility
|
||||
)
|
||||
const createHostedReview = useAppStore((s) => s.createHostedReview)
|
||||
const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh)
|
||||
const conflictOperation = useAppStore((s) =>
|
||||
activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown'
|
||||
|
|
@ -371,8 +386,10 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
const [emptyRefreshing, setEmptyRefreshing] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [conflictDetailsRefreshing, setConflictDetailsRefreshing] = useState(false)
|
||||
const [createPrDialogOpen, setCreatePrDialogOpen] = useState(false)
|
||||
const [createPrPushFirst, setCreatePrPushFirst] = useState(false)
|
||||
const createPrInFlightRef = useRef<string | null>(null)
|
||||
const [isCreatingPr, setIsCreatingPr] = useState(false)
|
||||
const [createPrError, setCreatePrError] = useState<string | null>(null)
|
||||
const [isPublishingBranch, setIsPublishingBranch] = useState(false)
|
||||
const isResolvingConflictsWithAI = false
|
||||
const [isFixingChecksWithAI, setIsFixingChecksWithAI] = useState(false)
|
||||
|
|
@ -497,8 +514,10 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
setIsRefreshing(false)
|
||||
setEmptyRefreshing(false)
|
||||
setConflictDetailsRefreshing(false)
|
||||
setCreatePrDialogOpen(false)
|
||||
setCreatePrPushFirst(false)
|
||||
createPrInFlightRef.current = null
|
||||
setIsCreatingPr(false)
|
||||
setCreatePrError(null)
|
||||
setIsPublishingBranch(false)
|
||||
setAgentComposerState(null)
|
||||
setHostedReviewCreationSnapshot(null)
|
||||
|
|
@ -636,6 +655,95 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
hostedReviewCreationSnapshot?.requestKey === hostedReviewCreationRequestKey
|
||||
? hostedReviewCreationSnapshot.data
|
||||
: null
|
||||
const hostedReviewCreateProvider: HostedReviewProvider =
|
||||
hostedReviewCreation?.provider === 'gitlab' ? 'gitlab' : 'github'
|
||||
const hostedReviewCreateCopy = localizedHostedReviewCopy(hostedReviewCreateProvider)
|
||||
const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise<void> => {
|
||||
if (!activeWorktreeId || !activeWorktree?.path) {
|
||||
return
|
||||
}
|
||||
// Why: AI PR detail generation can rebase before summarizing. If HEAD
|
||||
// moved, the embedded composer should push before creating the review.
|
||||
setCreatePrPushFirst(true)
|
||||
const connectionId = activeConnectionId ?? undefined
|
||||
await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId)
|
||||
}, [activeConnectionId, activeWorktree?.path, activeWorktreeId, fetchUpstreamStatus])
|
||||
const prCreationDefaults = useMemo(() => {
|
||||
if (!settings) {
|
||||
return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
|
||||
}
|
||||
const hostKey = getCommitMessageModelDiscoveryHostKeyForScope(
|
||||
getRuntimeGitScope(settings, repo?.connectionId)
|
||||
)
|
||||
const resolved = resolveSourceControlAiForOperation({
|
||||
settings,
|
||||
repo,
|
||||
operation: 'pullRequest',
|
||||
discoveryHostKey: hostKey,
|
||||
prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
|
||||
})
|
||||
return resolved.ok
|
||||
? resolved.value.prCreationDefaults
|
||||
: resolveSourceControlAiPrCreationDefaults({
|
||||
settings,
|
||||
repo,
|
||||
prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
|
||||
})
|
||||
}, [repo, settings])
|
||||
const createComposerOpen =
|
||||
!activeReview &&
|
||||
!isFolder &&
|
||||
Boolean(branch) &&
|
||||
(hostedReviewCreation?.canCreate === true ||
|
||||
hostedReviewCreation?.blockedReason === 'needs_push')
|
||||
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
|
||||
} = useCreatePullRequestDialogFields({
|
||||
open: createComposerOpen,
|
||||
repoId: repo?.id ?? '',
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath: activeWorktreePath ?? '',
|
||||
branch,
|
||||
eligibility: hostedReviewCreation,
|
||||
repo,
|
||||
settings,
|
||||
submitting: isCreatingPr,
|
||||
prCreationDefaults,
|
||||
onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration
|
||||
})
|
||||
const handlePrBaseChange = useCallback(
|
||||
(value: string): void => {
|
||||
setCreatePrError(null)
|
||||
setPrBase(value)
|
||||
},
|
||||
[setPrBase]
|
||||
)
|
||||
const handlePrTitleChange = useCallback(
|
||||
(value: string): void => {
|
||||
setCreatePrError(null)
|
||||
setPrTitle(value)
|
||||
},
|
||||
[setPrTitle]
|
||||
)
|
||||
const stateRequestKey =
|
||||
repo && branch
|
||||
? activeGitLabReview
|
||||
|
|
@ -2441,17 +2549,6 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
pushBranch
|
||||
])
|
||||
|
||||
const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise<void> => {
|
||||
if (!activeWorktreeId || !activeWorktree?.path) {
|
||||
return
|
||||
}
|
||||
// Why: AI PR detail generation rebases before summarizing; if HEAD moved,
|
||||
// the dialog must push before creating from the refreshed branch state.
|
||||
setCreatePrPushFirst(true)
|
||||
const connectionId = activeConnectionId ?? undefined
|
||||
await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId)
|
||||
}, [activeConnectionId, activeWorktree?.path, activeWorktreeId, fetchUpstreamStatus])
|
||||
|
||||
const handlePullRequestCreated = useCallback(
|
||||
async (result: {
|
||||
provider: HostedReviewProvider
|
||||
|
|
@ -2508,6 +2605,166 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
]
|
||||
)
|
||||
|
||||
const handleCreatePullRequest = useCallback(async (): Promise<void> => {
|
||||
if (!repo || !branch || !createComposerOpen || prGenerating || createPrInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestContextKey = panelContextKey
|
||||
const isCurrentCreateRequest = (): boolean =>
|
||||
panelContextKeyRef.current === requestContextKey &&
|
||||
createPrInFlightRef.current === requestContextKey
|
||||
const base = stripBaseRef(prBase).trim()
|
||||
const title = prTitle.trim()
|
||||
const worktreePath = activeWorktreePath ?? repo.path
|
||||
if (!title) {
|
||||
setCreatePrError(
|
||||
translate(
|
||||
'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5',
|
||||
'Enter a {{value0}} title.',
|
||||
{
|
||||
value0: hostedReviewCreateCopy.reviewLabel
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase()) {
|
||||
setCreatePrError(
|
||||
translate(
|
||||
'auto.components.right.sidebar.SourceControl.ae743199cd',
|
||||
'Choose a different base branch before creating a {{value0}}.',
|
||||
{ value0: hostedReviewCreateCopy.reviewLabel }
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
createPrInFlightRef.current = requestContextKey
|
||||
setIsCreatingPr(true)
|
||||
setCreatePrError(null)
|
||||
let pushed = false
|
||||
try {
|
||||
const shouldPushBeforeCreate =
|
||||
createPrPushFirst || hostedReviewCreation?.blockedReason === 'needs_push'
|
||||
if (shouldPushBeforeCreate) {
|
||||
const ok = await pushBeforeCreatePullRequest()
|
||||
if (!isCurrentCreateRequest()) {
|
||||
return
|
||||
}
|
||||
if (!ok) {
|
||||
setCreatePrError('Push failed. Resolve the push error, then try again.')
|
||||
return
|
||||
}
|
||||
pushed = true
|
||||
}
|
||||
const result = await createHostedReview(repo.path, {
|
||||
provider: hostedReviewCreateProvider,
|
||||
base,
|
||||
head: normalizeHostedReviewHeadRef(branch),
|
||||
title,
|
||||
body: prBody,
|
||||
draft: prDraft,
|
||||
worktreePath,
|
||||
useTemplate: prCreationDefaults.useTemplate
|
||||
})
|
||||
if (!isCurrentCreateRequest()) {
|
||||
return
|
||||
}
|
||||
if (result.ok) {
|
||||
await handlePullRequestCreated({
|
||||
provider: hostedReviewCreateProvider,
|
||||
number: result.number,
|
||||
url: result.url
|
||||
})
|
||||
if (prCreationDefaults.openAfterCreate) {
|
||||
openHttpLink(result.url, { worktreeId: activeWorktreeId })
|
||||
}
|
||||
setCreatePrPushFirst(false)
|
||||
return
|
||||
}
|
||||
if (result.existingReview?.url) {
|
||||
const number = result.existingReview.number
|
||||
toast.success(
|
||||
number
|
||||
? translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.b6ce28da5b',
|
||||
'{{value0}} #{{value1}} is already open',
|
||||
{ value0: hostedReviewCreateCopy.titleLabel, value1: number }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.cf9e69f3be',
|
||||
'{{value0}} is already open',
|
||||
{ value0: hostedReviewCreateCopy.titleLabel }
|
||||
),
|
||||
{
|
||||
action: {
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.192e686e57',
|
||||
'Open on {{value0}}',
|
||||
{ value0: hostedReviewCreateCopy.providerName }
|
||||
),
|
||||
onClick: () => window.api.shell.openUrl(result.existingReview!.url)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (number) {
|
||||
await handlePullRequestCreated({
|
||||
provider: hostedReviewCreateProvider,
|
||||
number,
|
||||
url: result.existingReview.url
|
||||
})
|
||||
setCreatePrPushFirst(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
setCreatePrError(formatCreateError(result, pushed, hostedReviewCreateCopy.shortLabel))
|
||||
} catch (error) {
|
||||
if (!isCurrentCreateRequest()) {
|
||||
return
|
||||
}
|
||||
setCreatePrError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.right.sidebar.SourceControl.e2b7a1c0d9f4',
|
||||
'Failed to create {{value0}}',
|
||||
{ value0: hostedReviewCreateCopy.reviewLabel }
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
if (createPrInFlightRef.current === requestContextKey) {
|
||||
createPrInFlightRef.current = null
|
||||
setIsCreatingPr(false)
|
||||
setGitStatusRefreshNonce((value) => value + 1)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeWorktreePath,
|
||||
activeWorktreeId,
|
||||
branch,
|
||||
createComposerOpen,
|
||||
createHostedReview,
|
||||
createPrPushFirst,
|
||||
handlePullRequestCreated,
|
||||
hostedReviewCreateCopy.providerName,
|
||||
hostedReviewCreateCopy.reviewLabel,
|
||||
hostedReviewCreateCopy.shortLabel,
|
||||
hostedReviewCreateCopy.titleLabel,
|
||||
hostedReviewCreateProvider,
|
||||
hostedReviewCreation?.blockedReason,
|
||||
panelContextKey,
|
||||
prBase,
|
||||
prBody,
|
||||
prCreationDefaults.openAfterCreate,
|
||||
prCreationDefaults.useTemplate,
|
||||
prDraft,
|
||||
prGenerating,
|
||||
prTitle,
|
||||
pushBeforeCreatePullRequest,
|
||||
repo
|
||||
])
|
||||
|
||||
// ── Empty state ──
|
||||
if (!activeWorktree) {
|
||||
return (
|
||||
|
|
@ -2561,7 +2818,6 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
linkedGitLabMR !== null || hostedReviewCreation?.provider === 'gitlab'
|
||||
const emptyReviewLabel = emptyReviewIsGitLab ? 'merge request' : 'pull request'
|
||||
const emptyReviewShortLabel = emptyReviewIsGitLab ? 'MR' : 'PR'
|
||||
const canCreate = hostedReviewCreation?.canCreate
|
||||
const canPushCreate = hostedReviewCreation?.blockedReason === 'needs_push'
|
||||
const canPublishBranch =
|
||||
isPublishingBranch ||
|
||||
|
|
@ -2581,74 +2837,78 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
reviewShortLabel: emptyReviewShortLabel
|
||||
})
|
||||
return (
|
||||
<>
|
||||
{repo && (
|
||||
/* Keyed to the same branch/worktree context as the panel's render-time
|
||||
reset so dialog-local submission state cannot leak across contexts. */
|
||||
<CreatePullRequestDialog
|
||||
key={panelContextKey}
|
||||
open={createPrDialogOpen}
|
||||
repoId={repo.id}
|
||||
repoPath={repo.path}
|
||||
worktreeId={activeWorktreeId}
|
||||
worktreePath={activeWorktreePath ?? repo.path}
|
||||
branch={branch}
|
||||
eligibility={hostedReviewCreation}
|
||||
pushBeforeCreate={createPrPushFirst}
|
||||
onOpenChange={setCreatePrDialogOpen}
|
||||
onPushBeforeCreate={pushBeforeCreatePullRequest}
|
||||
onBranchChangedByGeneration={handleBranchChangedByPullRequestGeneration}
|
||||
onCreated={handlePullRequestCreated}
|
||||
/>
|
||||
<div className="px-4 py-6">
|
||||
{detachedHeadDisplay && (
|
||||
<div className="mb-3">
|
||||
<DetachedHeadBadge display={detachedHeadDisplay} side="bottom" />
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 py-6">
|
||||
{detachedHeadDisplay && (
|
||||
<div className="mb-3">
|
||||
<DetachedHeadBadge display={detachedHeadDisplay} side="bottom" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-foreground">{emptyStateCopy.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{emptyStateCopy.description}</div>
|
||||
{!operationInProgress && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{canPublishBranch && (
|
||||
<Button
|
||||
size="xs"
|
||||
disabled={isPublishingBranch || isRemoteOperationActive}
|
||||
onClick={handlePublishBranch}
|
||||
>
|
||||
{isPublishingBranch
|
||||
? translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.fdb27637f2',
|
||||
'Publishing…'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.6633c7a1fb',
|
||||
'Publish Branch'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{(canCreate || canPushCreate) && (
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setCreatePrPushFirst(canPushCreate)
|
||||
setCreatePrDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
{canPushCreate
|
||||
? translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.98f4c37b33',
|
||||
'Push & Create {{value0}}',
|
||||
{ value0: emptyReviewShortLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.889cdfba04',
|
||||
'Create {{value0}}',
|
||||
{ value0: emptyReviewShortLabel }
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-sm font-medium text-foreground">{emptyStateCopy.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{emptyStateCopy.description}</div>
|
||||
{!operationInProgress && createComposerOpen ? (
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<CreateHostedReviewComposer
|
||||
className="p-0"
|
||||
provider={hostedReviewCreateProvider}
|
||||
branch={branch}
|
||||
base={prBase}
|
||||
setBase={handlePrBaseChange}
|
||||
title={prTitle}
|
||||
setTitle={handlePrTitleChange}
|
||||
body={prBody}
|
||||
setBody={setPrBody}
|
||||
draft={prDraft}
|
||||
setDraft={setPrDraft}
|
||||
baseQuery={prBaseQuery}
|
||||
setBaseQuery={setPrBaseQuery}
|
||||
baseResults={prBaseResults}
|
||||
setBaseResults={setPrBaseResults}
|
||||
baseSearchError={prBaseSearchError}
|
||||
aiGenerationEnabled={prAiGenerationEnabled}
|
||||
generating={prGenerating}
|
||||
generateDisabled={prGenerateDisabled}
|
||||
generateDisabledReason={prGenerateDisabledReason}
|
||||
generateError={prGenerateError}
|
||||
createError={createPrError}
|
||||
isCreating={isCreatingPr}
|
||||
pushBeforeCreate={createPrPushFirst || canPushCreate}
|
||||
primaryAction={{
|
||||
disabled: isCreatingPr || isPublishingBranch || isRemoteOperationActive,
|
||||
title: canPushCreate
|
||||
? translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.98f4c37b33',
|
||||
'Push & Create {{value0}}',
|
||||
{ value0: emptyReviewShortLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.889cdfba04',
|
||||
'Create {{value0}}',
|
||||
{ value0: emptyReviewShortLabel }
|
||||
)
|
||||
}}
|
||||
onGenerate={() => void handleGeneratePullRequestFields()}
|
||||
onCancelGenerate={handleCancelGeneratePullRequestFields}
|
||||
onPrimaryAction={() => void handleCreatePullRequest()}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{!operationInProgress && (!createComposerOpen || canPublishBranch) && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{canPublishBranch && (
|
||||
<Button
|
||||
size="xs"
|
||||
disabled={isPublishingBranch || isRemoteOperationActive}
|
||||
onClick={handlePublishBranch}
|
||||
>
|
||||
{isPublishingBranch
|
||||
? translate('auto.components.right.sidebar.ChecksPanel.fdb27637f2', 'Publishing…')
|
||||
: translate(
|
||||
'auto.components.right.sidebar.ChecksPanel.6633c7a1fb',
|
||||
'Publish Branch'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!createComposerOpen ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
|
|
@ -2667,10 +2927,10 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
? translate('auto.components.right.sidebar.ChecksPanel.71026ca2cb', 'Refreshing…')
|
||||
: translate('auto.components.right.sidebar.ChecksPanel.7f4489f370', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,361 @@
|
|||
import {
|
||||
ChevronDown,
|
||||
GitMerge,
|
||||
GitPullRequestArrow,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Square
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
localizedHostedReviewCopy,
|
||||
resolveSupportedHostedReviewCopyProvider
|
||||
} from '@/i18n/hosted-review-localized-copy'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { HostedReviewProvider } from '../../../../shared/hosted-review'
|
||||
import { stripBaseRef } from './useCreatePullRequestDialogFields'
|
||||
import type { DropdownActionKind, DropdownEntry } from './source-control-dropdown-items'
|
||||
import { CreateHostedReviewComposerFields } from './CreateHostedReviewComposerFields'
|
||||
|
||||
const EMPTY_DROPDOWN_ITEMS: DropdownEntry[] = []
|
||||
|
||||
export type CreateHostedReviewFields = {
|
||||
base: string
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
}
|
||||
|
||||
export type CreateHostedReviewComposerPrimaryAction = {
|
||||
disabled: boolean
|
||||
title: string
|
||||
}
|
||||
|
||||
export type CreateHostedReviewComposerProps = {
|
||||
className?: string
|
||||
provider: HostedReviewProvider
|
||||
branch: string
|
||||
base: string
|
||||
setBase: (value: string) => void
|
||||
title: string
|
||||
setTitle: (value: string) => void
|
||||
body: string
|
||||
setBody: (value: string) => void
|
||||
draft: boolean
|
||||
setDraft: (value: boolean) => void
|
||||
baseQuery: string
|
||||
setBaseQuery: (value: string) => void
|
||||
baseResults: string[]
|
||||
setBaseResults: (value: string[]) => void
|
||||
baseSearchError: string | null
|
||||
aiGenerationEnabled: boolean
|
||||
generating: boolean
|
||||
generateDisabled: boolean
|
||||
generateDisabledReason?: string
|
||||
generateError: string | null
|
||||
createError: string | null
|
||||
isCreating: boolean
|
||||
pushBeforeCreate?: boolean
|
||||
primaryAction: CreateHostedReviewComposerPrimaryAction
|
||||
dropdownItems?: DropdownEntry[]
|
||||
onGenerate: () => void
|
||||
onCancelGenerate: () => void
|
||||
onPrimaryAction: () => void
|
||||
onDropdownAction?: (kind: DropdownActionKind) => void
|
||||
}
|
||||
|
||||
export function CreateHostedReviewComposer({
|
||||
className,
|
||||
provider,
|
||||
branch,
|
||||
base,
|
||||
setBase,
|
||||
title,
|
||||
setTitle,
|
||||
body,
|
||||
setBody,
|
||||
draft,
|
||||
setDraft,
|
||||
baseQuery,
|
||||
setBaseQuery,
|
||||
baseResults,
|
||||
setBaseResults,
|
||||
baseSearchError,
|
||||
aiGenerationEnabled,
|
||||
generating,
|
||||
generateDisabled,
|
||||
generateDisabledReason,
|
||||
generateError,
|
||||
createError,
|
||||
isCreating,
|
||||
pushBeforeCreate = false,
|
||||
primaryAction,
|
||||
dropdownItems,
|
||||
onGenerate,
|
||||
onCancelGenerate,
|
||||
onPrimaryAction,
|
||||
onDropdownAction
|
||||
}: CreateHostedReviewComposerProps): React.JSX.Element {
|
||||
const copy = localizedHostedReviewCopy(resolveSupportedHostedReviewCopyProvider(provider))
|
||||
const ReviewIcon = provider === 'gitlab' ? GitMerge : GitPullRequestArrow
|
||||
const normalizedBase = stripBaseRef(base)
|
||||
const strippedBranch = stripBaseRef(branch)
|
||||
const baseSameAsBranch = normalizedBase.toLowerCase() === strippedBranch.toLowerCase()
|
||||
const createDisabled =
|
||||
primaryAction.disabled ||
|
||||
generating ||
|
||||
title.trim().length === 0 ||
|
||||
normalizedBase.trim().length === 0 ||
|
||||
baseSameAsBranch
|
||||
// Why: surface a concrete reason on the disabled Create PR button so the
|
||||
// user knows what's blocking submission instead of a silent gray state.
|
||||
let createDisabledReason: string | undefined
|
||||
if (generating) {
|
||||
createDisabledReason = translate(
|
||||
'auto.components.right.sidebar.SourceControl.318e2a7f88',
|
||||
'Wait for AI generation to finish.'
|
||||
)
|
||||
} else if (title.trim().length === 0) {
|
||||
createDisabledReason = translate(
|
||||
'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5',
|
||||
'Enter a {{value0}} title.',
|
||||
{ value0: copy.reviewLabel }
|
||||
)
|
||||
} else if (normalizedBase.trim().length === 0) {
|
||||
createDisabledReason = translate(
|
||||
'auto.components.right.sidebar.SourceControl.f76307c1f7',
|
||||
'Choose a base branch.'
|
||||
)
|
||||
} else if (baseSameAsBranch) {
|
||||
createDisabledReason = translate(
|
||||
'auto.components.right.sidebar.SourceControl.4f76c0a9de',
|
||||
'Base branch must differ from the head branch.'
|
||||
)
|
||||
}
|
||||
|
||||
// Why: lock the title/body/base inputs while AI generation is running so
|
||||
// the user can't race the request; generated fields only hydrate safely if
|
||||
// the hook still sees untouched field revisions.
|
||||
const fieldsLocked = generating
|
||||
const generateDetailsLabel = translate(
|
||||
'auto.components.right.sidebar.SourceControl.02d8c04339',
|
||||
'Generate {{value0}} details with AI',
|
||||
{ value0: copy.reviewLabel }
|
||||
)
|
||||
const stopGeneratingDetailsLabel = translate(
|
||||
'auto.components.right.sidebar.SourceControl.b355e740b2',
|
||||
'Stop generating {{value0}} details',
|
||||
{ value0: copy.reviewLabel }
|
||||
)
|
||||
const generateTooltipLabel = generating
|
||||
? stopGeneratingDetailsLabel
|
||||
: (generateDisabledReason ?? generateDetailsLabel)
|
||||
const generateButton = generating ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => onCancelGenerate()}
|
||||
className="text-[11px] text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={stopGeneratingDetailsLabel}
|
||||
>
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
<span>
|
||||
{translate('auto.components.right.sidebar.SourceControl.e868cec4e1', 'Generating…')}
|
||||
</span>
|
||||
<Square className="size-2.5 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={generateDisabled}
|
||||
onClick={() => onGenerate()}
|
||||
className="text-[11px] disabled:hover:bg-background"
|
||||
aria-label={generateDetailsLabel}
|
||||
>
|
||||
<Sparkles className="size-3" />
|
||||
{translate('auto.components.right.sidebar.SourceControl.aee92f8684', 'Generate')}
|
||||
</Button>
|
||||
)
|
||||
const effectiveDropdownItems = dropdownItems ?? EMPTY_DROPDOWN_ITEMS
|
||||
const showDropdown = effectiveDropdownItems.length > 0 && onDropdownAction
|
||||
|
||||
return (
|
||||
<div className={cn('px-3 pb-2', className)}>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<ReviewIcon className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="font-medium text-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.e1970d327d',
|
||||
'New {{value0}}',
|
||||
{ value0: copy.reviewLabel }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{aiGenerationEnabled ? (
|
||||
<Tooltip>
|
||||
{!generating && generateDisabled ? (
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0 cursor-not-allowed">{generateButton}</span>
|
||||
</TooltipTrigger>
|
||||
) : (
|
||||
<TooltipTrigger asChild>{generateButton}</TooltipTrigger>
|
||||
)}
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
{generateTooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CreateHostedReviewComposerFields
|
||||
copy={copy}
|
||||
base={base}
|
||||
setBase={setBase}
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
body={body}
|
||||
setBody={setBody}
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
baseQuery={baseQuery}
|
||||
setBaseQuery={setBaseQuery}
|
||||
baseResults={baseResults}
|
||||
setBaseResults={setBaseResults}
|
||||
baseSearchError={baseSearchError}
|
||||
generateError={generateError}
|
||||
createError={createError}
|
||||
fieldsLocked={fieldsLocked}
|
||||
generating={generating}
|
||||
normalizedBase={normalizedBase}
|
||||
strippedBranch={strippedBranch}
|
||||
baseSameAsBranch={baseSameAsBranch}
|
||||
/>
|
||||
|
||||
<div className="flex items-stretch pt-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
disabled={createDisabled}
|
||||
onClick={() => onPrimaryAction()}
|
||||
className={cn('h-7 flex-1 px-3 text-xs', showDropdown && 'rounded-r-none')}
|
||||
title={createDisabledReason ?? primaryAction.title}
|
||||
>
|
||||
{isCreating ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<ReviewIcon className="size-3.5" />
|
||||
)}
|
||||
{getCreateButtonLabel({
|
||||
isCreating,
|
||||
pushBeforeCreate,
|
||||
draft,
|
||||
shortLabel: copy.shortLabel
|
||||
})}
|
||||
</Button>
|
||||
{showDropdown ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className={cn(
|
||||
'h-7 rounded-l-none border-l border-primary-foreground/20 px-1.5 shrink-0',
|
||||
createDisabled && 'opacity-50'
|
||||
)}
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.SourceControl.c5e4175139',
|
||||
'More {{value0}} and remote actions',
|
||||
{ value0: copy.reviewLabel }
|
||||
)}
|
||||
title={translate(
|
||||
'auto.components.right.sidebar.SourceControl.4d6e1fd7f3',
|
||||
'More actions'
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[14rem]">
|
||||
{effectiveDropdownItems.map((entry, index) =>
|
||||
entry.kind === 'separator' ? (
|
||||
<DropdownMenuSeparator key={`sep-${index}`} />
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={entry.kind}
|
||||
disabled={entry.disabled}
|
||||
title={entry.title}
|
||||
variant={entry.variant}
|
||||
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>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getCreateButtonLabel({
|
||||
isCreating,
|
||||
pushBeforeCreate,
|
||||
draft,
|
||||
shortLabel
|
||||
}: {
|
||||
isCreating: boolean
|
||||
pushBeforeCreate: boolean
|
||||
draft: boolean
|
||||
shortLabel: string
|
||||
}): string {
|
||||
if (isCreating) {
|
||||
return translate('auto.components.right.sidebar.SourceControl.26511c22b4', 'Creating...')
|
||||
}
|
||||
if (pushBeforeCreate) {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.CreateHostedReviewComposer.741ff8a0d2',
|
||||
'Push & Create {{value0}}',
|
||||
{ value0: shortLabel }
|
||||
)
|
||||
}
|
||||
if (draft) {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.SourceControl.aaf1451654',
|
||||
'Create draft {{value0}}',
|
||||
{ value0: shortLabel }
|
||||
)
|
||||
}
|
||||
return translate('auto.components.right.sidebar.SourceControl.5acbcedc1a', 'Create {{value0}}', {
|
||||
value0: shortLabel
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
import { ArrowDownUp, Check, ChevronDown, Sparkles, TriangleAlert } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { LocalizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy'
|
||||
import { stripBaseRef } from './useCreatePullRequestDialogFields'
|
||||
|
||||
type CreateHostedReviewComposerFieldsProps = {
|
||||
copy: LocalizedHostedReviewCopy
|
||||
base: string
|
||||
setBase: (value: string) => void
|
||||
title: string
|
||||
setTitle: (value: string) => void
|
||||
body: string
|
||||
setBody: (value: string) => void
|
||||
draft: boolean
|
||||
setDraft: (value: boolean) => void
|
||||
baseQuery: string
|
||||
setBaseQuery: (value: string) => void
|
||||
baseResults: string[]
|
||||
setBaseResults: (value: string[]) => void
|
||||
baseSearchError: string | null
|
||||
generateError: string | null
|
||||
createError: string | null
|
||||
fieldsLocked: boolean
|
||||
generating: boolean
|
||||
normalizedBase: string
|
||||
strippedBranch: string
|
||||
baseSameAsBranch: boolean
|
||||
}
|
||||
|
||||
export function CreateHostedReviewComposerFields({
|
||||
copy,
|
||||
base,
|
||||
setBase,
|
||||
title,
|
||||
setTitle,
|
||||
body,
|
||||
setBody,
|
||||
draft,
|
||||
setDraft,
|
||||
baseQuery,
|
||||
setBaseQuery,
|
||||
baseResults,
|
||||
setBaseResults,
|
||||
baseSearchError,
|
||||
generateError,
|
||||
createError,
|
||||
fieldsLocked,
|
||||
generating,
|
||||
normalizedBase,
|
||||
strippedBranch,
|
||||
baseSameAsBranch
|
||||
}: CreateHostedReviewComposerFieldsProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{/* Why: a single line that shows the head->base flow plain-language so
|
||||
the user can sanity-check the merge direction at a glance. */}
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="truncate font-mono text-foreground" title={strippedBranch}>
|
||||
{strippedBranch}
|
||||
</span>
|
||||
<ArrowDownUp className="size-3 rotate-90 shrink-0 opacity-60" aria-hidden="true" />
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-mono',
|
||||
baseSameAsBranch ? 'text-destructive' : 'text-foreground'
|
||||
)}
|
||||
title={
|
||||
normalizedBase ||
|
||||
translate('auto.components.right.sidebar.SourceControl.7a09d7f9d2', 'base')
|
||||
}
|
||||
>
|
||||
{normalizedBase ||
|
||||
translate('auto.components.right.sidebar.SourceControl.7a09d7f9d2', 'base')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-2">
|
||||
<input
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.SourceControl.a6eda33521',
|
||||
'{{value0}} title',
|
||||
{ value0: copy.titleLabel }
|
||||
)}
|
||||
value={title}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder={translate('auto.components.right.sidebar.SourceControl.7d6a8f0082', 'Title')}
|
||||
className="h-8 w-full min-w-0 rounded-md border border-border bg-background px-2 text-xs font-medium text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.SourceControl.a8873e1d62',
|
||||
'{{value0}} description',
|
||||
{ value0: copy.titleLabel }
|
||||
)}
|
||||
rows={6}
|
||||
value={body}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.right.sidebar.SourceControl.a0dc20fc93',
|
||||
'Description (optional)'
|
||||
)}
|
||||
className="min-h-[7.5rem] w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60 scrollbar-sleek"
|
||||
/>
|
||||
|
||||
{generating ? (
|
||||
// Why: visible scrim + status row so the user understands the title
|
||||
// and description fields will be replaced while inputs are locked.
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-background/40"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="pointer-events-auto flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-[11px] text-muted-foreground shadow-sm">
|
||||
<Sparkles className="size-3 animate-pulse text-foreground" />
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.9484270f45',
|
||||
'Generating title & description…'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Why: base picker as its own labeled row so the title input can use
|
||||
the full width. The dropdown chevron makes the picker affordance
|
||||
obvious; the inline label clarifies that this is the merge target. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{translate('auto.components.right.sidebar.SourceControl.1f7119f604', 'Base')}
|
||||
</span>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<input
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.SourceControl.6055949c50',
|
||||
'{{value0}} base branch',
|
||||
{ value0: copy.titleLabel }
|
||||
)}
|
||||
value={baseQuery || base}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) => {
|
||||
setBaseQuery(event.target.value)
|
||||
setBase(event.target.value)
|
||||
}}
|
||||
placeholder={translate(
|
||||
'auto.components.right.sidebar.SourceControl.e64a632456',
|
||||
'main'
|
||||
)}
|
||||
className="h-7 w-full min-w-0 rounded-md border border-border bg-background px-2 pr-6 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-1.5 top-1.5 size-3.5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className={cn(
|
||||
'flex h-7 items-center gap-2 rounded-md border border-border bg-background px-2 text-xs text-foreground transition-colors',
|
||||
fieldsLocked
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-pointer hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) => setDraft(event.target.checked)}
|
||||
className="size-3.5 shrink-0 rounded border-border accent-primary"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{translate('auto.components.right.sidebar.SourceControl.78ddfd0bb4', 'Create as draft')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{baseResults.length > 0 ? (
|
||||
<div className="max-h-28 overflow-auto rounded-md border border-border p-1 scrollbar-sleek">
|
||||
{baseResults.map((ref) => (
|
||||
<button
|
||||
key={ref}
|
||||
type="button"
|
||||
disabled={fieldsLocked}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left font-mono text-xs hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent',
|
||||
stripBaseRef(base) === ref && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (fieldsLocked) {
|
||||
return
|
||||
}
|
||||
setBase(ref)
|
||||
setBaseQuery('')
|
||||
setBaseResults([])
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{ref}</span>
|
||||
{stripBaseRef(base) === ref ? <Check className="size-3" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CreateHostedReviewComposerMessages
|
||||
copy={copy}
|
||||
baseSameAsBranch={baseSameAsBranch}
|
||||
baseSearchError={baseSearchError}
|
||||
generateError={generateError}
|
||||
createError={createError}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateHostedReviewComposerMessages({
|
||||
copy,
|
||||
baseSameAsBranch,
|
||||
baseSearchError,
|
||||
generateError,
|
||||
createError
|
||||
}: {
|
||||
copy: LocalizedHostedReviewCopy
|
||||
baseSameAsBranch: boolean
|
||||
baseSearchError: string | null
|
||||
generateError: string | null
|
||||
createError: string | null
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{baseSameAsBranch ? (
|
||||
<CreateHostedReviewComposerMessage>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.ae743199cd',
|
||||
'Choose a different base branch before creating a {{value0}}.',
|
||||
{ value0: copy.reviewLabel }
|
||||
)}
|
||||
</CreateHostedReviewComposerMessage>
|
||||
) : null}
|
||||
{baseSearchError ? (
|
||||
<CreateHostedReviewComposerMessage>{baseSearchError}</CreateHostedReviewComposerMessage>
|
||||
) : null}
|
||||
{generateError ? (
|
||||
<CreateHostedReviewComposerMessage>{generateError}</CreateHostedReviewComposerMessage>
|
||||
) : null}
|
||||
{createError ? (
|
||||
<CreateHostedReviewComposerMessage>{createError}</CreateHostedReviewComposerMessage>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateHostedReviewComposerMessage({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<p className="flex items-start gap-1 text-[11px] text-destructive">
|
||||
<TriangleAlert className="mt-px size-3 shrink-0" aria-hidden="true" />
|
||||
<span>{children}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { PullRequestComposer } from './SourceControl'
|
||||
import { CreateHostedReviewComposer } from './CreateHostedReviewComposer'
|
||||
import { resolveDropdownItems } from './source-control-dropdown-items'
|
||||
import { resolvePrimaryAction } from './source-control-primary-action'
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ function renderPullRequestComposer({
|
|||
|
||||
return renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<PullRequestComposer
|
||||
<CreateHostedReviewComposer
|
||||
provider="github"
|
||||
branch="branch-login-issue"
|
||||
base="master"
|
||||
|
|
@ -77,7 +77,7 @@ function elementByLabel(markup: string, tagName: string, label: string): string
|
|||
return element
|
||||
}
|
||||
|
||||
describe('PullRequestComposer generate tooltip', () => {
|
||||
describe('CreateHostedReviewComposer generate tooltip', () => {
|
||||
it('renders hosted review labels without leaking interpolation placeholders', () => {
|
||||
const markup = renderPullRequestComposer()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
arePullRequestGenerationFieldsEqual,
|
||||
createPullRequestGenerationSlice,
|
||||
createRunningPullRequestGenerationRecord,
|
||||
getPullRequestGenerationRecordKey,
|
||||
getPullRequestGenerationWorktreeKey,
|
||||
|
|
@ -8,8 +10,9 @@ import {
|
|||
resolvePullRequestGenerationSuccess,
|
||||
shouldApplyPullRequestGenerationResult,
|
||||
shouldHydratePullRequestGenerationResult,
|
||||
type PullRequestGenerationSlice,
|
||||
type PullRequestGenerationRecord
|
||||
} from './SourceControl'
|
||||
} from '@/store/slices/pull-request-generation'
|
||||
|
||||
const seed = {
|
||||
base: 'main',
|
||||
|
|
@ -45,6 +48,14 @@ function runningRecord(overrides: Partial<PullRequestGenerationRecord> = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
function createPullRequestGenerationTestStore() {
|
||||
return create<PullRequestGenerationSlice>()((...args) =>
|
||||
createPullRequestGenerationSlice(
|
||||
...(args as unknown as Parameters<typeof createPullRequestGenerationSlice>)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
describe('SourceControl pull request generation records', () => {
|
||||
it('keys PR generation by worktree id and falls back to path', () => {
|
||||
expect(getPullRequestGenerationWorktreeKey('wt-a', '/repo/a')).toBe('wt-a')
|
||||
|
|
@ -158,4 +169,113 @@ describe('SourceControl pull request generation records', () => {
|
|||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps PR generation results in the store after the composer unmounts', () => {
|
||||
const store = createPullRequestGenerationTestStore()
|
||||
const key = getPullRequestGenerationRecordKey({
|
||||
worktreeId: 'wt-a',
|
||||
worktreePath: '/repo/a',
|
||||
repoId: 'repo-1',
|
||||
branch: 'feature-a'
|
||||
})
|
||||
expect(key).not.toBeNull()
|
||||
const record = createRunningPullRequestGenerationRecord(
|
||||
{
|
||||
worktreeId: 'wt-a',
|
||||
worktreePath: '/repo/a',
|
||||
connectionId: 'conn-a',
|
||||
requestId: 1,
|
||||
repoId: 'repo-1',
|
||||
branch: 'feature-a'
|
||||
},
|
||||
seed,
|
||||
fieldRevisions
|
||||
)
|
||||
store.getState().setPullRequestGenerationRecord(key!, record)
|
||||
|
||||
const generated = {
|
||||
base: 'main',
|
||||
title: 'Generated after tab switch',
|
||||
body: 'Generated body',
|
||||
draft: false
|
||||
}
|
||||
store.getState().updatePullRequestGenerationRecord(key!, (current) =>
|
||||
resolvePullRequestGenerationSuccess({
|
||||
record: current,
|
||||
requestId: 1,
|
||||
result: generated
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getState().pullRequestGenerationRecords[key!]).toMatchObject({
|
||||
status: 'succeeded',
|
||||
result: generated,
|
||||
hydrated: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not reuse PR generation request ids across composer remounts', () => {
|
||||
const store = createPullRequestGenerationTestStore()
|
||||
const key = getPullRequestGenerationRecordKey({
|
||||
worktreeId: 'wt-a',
|
||||
worktreePath: '/repo/a',
|
||||
repoId: 'repo-1',
|
||||
branch: 'feature-a'
|
||||
})
|
||||
expect(key).not.toBeNull()
|
||||
const firstRequestId = store.getState().allocatePullRequestGenerationRequestId()
|
||||
store.getState().setPullRequestGenerationRecord(
|
||||
key!,
|
||||
createRunningPullRequestGenerationRecord(
|
||||
{
|
||||
worktreeId: 'wt-a',
|
||||
worktreePath: '/repo/a',
|
||||
connectionId: 'conn-a',
|
||||
requestId: firstRequestId,
|
||||
repoId: 'repo-1',
|
||||
branch: 'feature-a'
|
||||
},
|
||||
seed,
|
||||
fieldRevisions
|
||||
)
|
||||
)
|
||||
store.getState().updatePullRequestGenerationRecord(key!, resolvePullRequestGenerationCancel)
|
||||
|
||||
const secondRequestId = store.getState().allocatePullRequestGenerationRequestId()
|
||||
expect(secondRequestId).toBeGreaterThan(firstRequestId)
|
||||
store.getState().setPullRequestGenerationRecord(
|
||||
key!,
|
||||
createRunningPullRequestGenerationRecord(
|
||||
{
|
||||
worktreeId: 'wt-a',
|
||||
worktreePath: '/repo/a',
|
||||
connectionId: 'conn-a',
|
||||
requestId: secondRequestId,
|
||||
repoId: 'repo-1',
|
||||
branch: 'feature-a'
|
||||
},
|
||||
seed,
|
||||
fieldRevisions
|
||||
)
|
||||
)
|
||||
|
||||
const staleResult = {
|
||||
base: 'main',
|
||||
title: 'Stale generated title',
|
||||
body: 'Stale body',
|
||||
draft: false
|
||||
}
|
||||
store.getState().updatePullRequestGenerationRecord(key!, (current) =>
|
||||
resolvePullRequestGenerationSuccess({
|
||||
record: current,
|
||||
requestId: firstRequestId,
|
||||
result: staleResult
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getState().pullRequestGenerationRecords[key!]).toMatchObject({
|
||||
status: 'running',
|
||||
result: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -144,11 +144,7 @@ import {
|
|||
} from '@/runtime/runtime-git-client'
|
||||
import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client'
|
||||
import { PullRequestIcon } from './checks-panel-content'
|
||||
import {
|
||||
stripBaseRef,
|
||||
useCreatePullRequestDialogFields,
|
||||
type PullRequestFieldRevisions
|
||||
} from './useCreatePullRequestDialogFields'
|
||||
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
|
||||
import { GitHistoryPanel, type GitHistoryPanelState } from './GitHistoryPanel'
|
||||
import type { GitHistoryItem } from '../../../../shared/git-history'
|
||||
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
|
||||
|
|
@ -199,6 +195,18 @@ import {
|
|||
localizedHostedReviewCopy,
|
||||
resolveSupportedHostedReviewCopyProvider
|
||||
} from '@/i18n/hosted-review-localized-copy'
|
||||
import { CreateHostedReviewComposer } from './CreateHostedReviewComposer'
|
||||
import {
|
||||
createRunningPullRequestGenerationRecord,
|
||||
getPullRequestGenerationRecordKey,
|
||||
resolvePullRequestGenerationCancel,
|
||||
resolvePullRequestGenerationFailure,
|
||||
resolvePullRequestGenerationSuccess,
|
||||
shouldHydratePullRequestGenerationResult,
|
||||
type PullRequestFieldRevisions,
|
||||
type PullRequestGenerationContext,
|
||||
type PullRequestGenerationFields
|
||||
} from '@/store/slices/pull-request-generation'
|
||||
|
||||
export {
|
||||
appendCommitFailureCustomInstruction,
|
||||
|
|
@ -366,36 +374,6 @@ function requestSourceControlEditorRevealFrame(
|
|||
|
||||
type CommitDraftsByWorktree = Record<string, string>
|
||||
|
||||
export type PullRequestGenerationFields = {
|
||||
base: string
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
}
|
||||
|
||||
export type PullRequestGenerationContext = {
|
||||
worktreeId: string | null
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
requestId: number
|
||||
repoId: string
|
||||
branch: string
|
||||
}
|
||||
|
||||
export type PullRequestGenerationStatus = 'idle' | 'running' | 'canceled' | 'failed' | 'succeeded'
|
||||
|
||||
export type PullRequestGenerationRecord = {
|
||||
context: PullRequestGenerationContext
|
||||
seed: PullRequestGenerationFields
|
||||
seedFieldRevisions: PullRequestFieldRevisions
|
||||
status: PullRequestGenerationStatus
|
||||
result: PullRequestGenerationFields | null
|
||||
error: string | null
|
||||
hydrated: boolean
|
||||
}
|
||||
|
||||
type PullRequestGenerationRecords = Record<string, PullRequestGenerationRecord>
|
||||
|
||||
export function normalizeSourceControlViewMode(value: unknown): SourceControlViewMode {
|
||||
return value === 'tree' || value === 'list' ? value : 'list'
|
||||
}
|
||||
|
|
@ -462,138 +440,6 @@ export function writeCommitDraftForWorktree(
|
|||
return { ...drafts, [worktreeId]: value }
|
||||
}
|
||||
|
||||
export function getPullRequestGenerationWorktreeKey(
|
||||
worktreeId: string | null | undefined,
|
||||
worktreePath: string | null | undefined
|
||||
): string | null {
|
||||
if (worktreeId) {
|
||||
return worktreeId
|
||||
}
|
||||
return worktreePath?.trim() ? worktreePath : null
|
||||
}
|
||||
|
||||
export function getPullRequestGenerationRecordKey({
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
repoId,
|
||||
branch
|
||||
}: {
|
||||
worktreeId: string | null | undefined
|
||||
worktreePath: string | null | undefined
|
||||
repoId: string | null | undefined
|
||||
branch: string | null | undefined
|
||||
}): string | null {
|
||||
const worktreeKey = getPullRequestGenerationWorktreeKey(worktreeId, worktreePath)
|
||||
if (!worktreeKey || !repoId || !branch) {
|
||||
return null
|
||||
}
|
||||
return JSON.stringify([repoId, worktreeKey, branch])
|
||||
}
|
||||
|
||||
export function arePullRequestGenerationFieldsEqual(
|
||||
left: PullRequestGenerationFields,
|
||||
right: PullRequestGenerationFields
|
||||
): boolean {
|
||||
return (
|
||||
left.base === right.base &&
|
||||
left.title === right.title &&
|
||||
left.body === right.body &&
|
||||
left.draft === right.draft
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldApplyPullRequestGenerationResult({
|
||||
record,
|
||||
requestId
|
||||
}: {
|
||||
record: PullRequestGenerationRecord | null | undefined
|
||||
requestId: number
|
||||
}): boolean {
|
||||
return record?.context.requestId === requestId && record.status === 'running'
|
||||
}
|
||||
|
||||
export function shouldHydratePullRequestGenerationResult({
|
||||
record
|
||||
}: {
|
||||
record: PullRequestGenerationRecord | null | undefined
|
||||
}): boolean {
|
||||
return record?.status === 'succeeded' && record.result !== null && !record.hydrated
|
||||
}
|
||||
|
||||
export function createRunningPullRequestGenerationRecord(
|
||||
context: PullRequestGenerationContext,
|
||||
seed: PullRequestGenerationFields,
|
||||
seedFieldRevisions: PullRequestFieldRevisions
|
||||
): PullRequestGenerationRecord {
|
||||
return {
|
||||
context,
|
||||
seed,
|
||||
seedFieldRevisions,
|
||||
status: 'running',
|
||||
result: null,
|
||||
error: null,
|
||||
hydrated: false
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePullRequestGenerationSuccess({
|
||||
record,
|
||||
requestId,
|
||||
result
|
||||
}: {
|
||||
record: PullRequestGenerationRecord | null | undefined
|
||||
requestId: number
|
||||
result: PullRequestGenerationFields
|
||||
}): PullRequestGenerationRecord | null {
|
||||
if (!record || record.context.requestId !== requestId || record.status !== 'running') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
status: 'succeeded',
|
||||
result,
|
||||
error: null,
|
||||
hydrated: false
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePullRequestGenerationFailure({
|
||||
record,
|
||||
requestId,
|
||||
error,
|
||||
canceled = false
|
||||
}: {
|
||||
record: PullRequestGenerationRecord | null | undefined
|
||||
requestId: number
|
||||
error: string | null
|
||||
canceled?: boolean
|
||||
}): PullRequestGenerationRecord | null {
|
||||
if (!record || record.context.requestId !== requestId || record.status !== 'running') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
status: canceled ? 'canceled' : 'failed',
|
||||
result: null,
|
||||
error: canceled ? null : error,
|
||||
hydrated: false
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePullRequestGenerationCancel(
|
||||
record: PullRequestGenerationRecord | null | undefined
|
||||
): PullRequestGenerationRecord | null {
|
||||
if (!record || record.status !== 'running') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
status: 'canceled',
|
||||
error: null,
|
||||
hydrated: false
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRenderCommitArea(
|
||||
scope: SourceControlScope,
|
||||
unresolvedConflictCount: number,
|
||||
|
|
@ -1016,9 +862,12 @@ function SourceControlInner(): React.JSX.Element {
|
|||
const [createPrErrors, setCreatePrErrors] = useState<Record<string, string | null>>({})
|
||||
const isCreatingPr = createPrInFlightByWorktree[activeWorktreeId ?? ''] ?? false
|
||||
const createPrError = createPrErrors[activeWorktreeId ?? ''] ?? null
|
||||
const prGenerationRequestSeqRef = useRef(0)
|
||||
const prGenerationInFlightRef = useRef<Record<string, boolean>>({})
|
||||
const [prGenerationRecords, setPrGenerationRecords] = useState<PullRequestGenerationRecords>({})
|
||||
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 filterInputRef = useRef<HTMLInputElement>(null)
|
||||
const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId)
|
||||
const commitError = commitErrors[activeWorktreeId ?? ''] ?? null
|
||||
|
|
@ -1057,7 +906,6 @@ function SourceControlInner(): React.JSX.Element {
|
|||
const activePullRequestGenerationRecord =
|
||||
activePullRequestGenerationRecordCandidate &&
|
||||
activePullRequestGenerationRecordCandidate.context.repoId === activeRepo?.id &&
|
||||
activePullRequestGenerationRecordCandidate.context.worktreeId === activeWorktreeId &&
|
||||
activePullRequestGenerationRecordCandidate.context.branch === branchName
|
||||
? activePullRequestGenerationRecordCandidate
|
||||
: null
|
||||
|
|
@ -2073,12 +1921,13 @@ function SourceControlInner(): React.JSX.Element {
|
|||
if (!activeRepo || !activePullRequestGenerationKey || !worktreePath || !branchName) {
|
||||
return
|
||||
}
|
||||
if (prGenerationInFlightRef.current[activePullRequestGenerationKey]) {
|
||||
const generationKey = activePullRequestGenerationKey
|
||||
if (
|
||||
useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const requestId = prGenerationRequestSeqRef.current + 1
|
||||
prGenerationRequestSeqRef.current = requestId
|
||||
const generationKey = activePullRequestGenerationKey
|
||||
const requestId = allocatePullRequestGenerationRequestId()
|
||||
const context: PullRequestGenerationContext = {
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
|
|
@ -2088,11 +1937,12 @@ function SourceControlInner(): React.JSX.Element {
|
|||
branch: branchName
|
||||
}
|
||||
const seed = { ...fields }
|
||||
prGenerationInFlightRef.current[generationKey] = true
|
||||
setPrGenerationRecords((prev) => ({
|
||||
...prev,
|
||||
[generationKey]: createRunningPullRequestGenerationRecord(context, seed, fieldRevisions)
|
||||
}))
|
||||
// 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(
|
||||
|
|
@ -2116,27 +1966,19 @@ function SourceControlInner(): React.JSX.Element {
|
|||
if (result.success) {
|
||||
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
|
||||
}
|
||||
setPrGenerationRecords((prev) => {
|
||||
const record = prev[generationKey]
|
||||
updatePullRequestGenerationRecord(generationKey, (record) => {
|
||||
if (!result.success) {
|
||||
const nextRecord = resolvePullRequestGenerationFailure({
|
||||
return resolvePullRequestGenerationFailure({
|
||||
record,
|
||||
requestId,
|
||||
canceled: result.canceled,
|
||||
error: result.canceled ? null : result.error
|
||||
})
|
||||
if (!nextRecord) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[generationKey]: nextRecord
|
||||
}
|
||||
}
|
||||
if (!record) {
|
||||
return prev
|
||||
return null
|
||||
}
|
||||
const nextRecord = resolvePullRequestGenerationSuccess({
|
||||
return resolvePullRequestGenerationSuccess({
|
||||
record,
|
||||
requestId,
|
||||
result: {
|
||||
|
|
@ -2146,41 +1988,27 @@ function SourceControlInner(): React.JSX.Element {
|
|||
draft: result.fields.draft
|
||||
}
|
||||
})
|
||||
if (!nextRecord) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[generationKey]: nextRecord
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
setPrGenerationRecords((prev) => {
|
||||
const record = prev[generationKey]
|
||||
const nextRecord = resolvePullRequestGenerationFailure({
|
||||
updatePullRequestGenerationRecord(generationKey, (record) =>
|
||||
resolvePullRequestGenerationFailure({
|
||||
record,
|
||||
requestId,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to generate pull request details'
|
||||
})
|
||||
if (!nextRecord) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[generationKey]: nextRecord
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
prGenerationInFlightRef.current[generationKey] = false
|
||||
)
|
||||
}
|
||||
},
|
||||
[
|
||||
activePullRequestGenerationKey,
|
||||
activeRepo,
|
||||
activeWorktreeId,
|
||||
allocatePullRequestGenerationRequestId,
|
||||
branchName,
|
||||
refreshGitStatusAfterPullRequestGeneration,
|
||||
setPullRequestGenerationRecord,
|
||||
updatePullRequestGenerationRecord,
|
||||
worktreePath
|
||||
]
|
||||
)
|
||||
|
|
@ -2194,19 +2022,11 @@ function SourceControlInner(): React.JSX.Element {
|
|||
return
|
||||
}
|
||||
const generationKey = activePullRequestGenerationKey
|
||||
setPrGenerationRecords((prev) => {
|
||||
const current = prev[generationKey]
|
||||
updatePullRequestGenerationRecord(generationKey, (current) => {
|
||||
if (!current || current.context.requestId !== record.context.requestId) {
|
||||
return prev
|
||||
}
|
||||
const nextRecord = resolvePullRequestGenerationCancel(current)
|
||||
if (!nextRecord) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[generationKey]: nextRecord
|
||||
return null
|
||||
}
|
||||
return resolvePullRequestGenerationCancel(current)
|
||||
})
|
||||
void cancelRuntimeGeneratePullRequestFields({
|
||||
settings: useAppStore.getState().settings,
|
||||
|
|
@ -2214,24 +2034,19 @@ function SourceControlInner(): React.JSX.Element {
|
|||
worktreePath: record.context.worktreePath,
|
||||
connectionId: record.context.connectionId
|
||||
}).catch((error) => {
|
||||
setPrGenerationRecords((prev) => {
|
||||
const current = prev[generationKey]
|
||||
updatePullRequestGenerationRecord(generationKey, (current) => {
|
||||
if (!current || current.context.requestId !== record.context.requestId) {
|
||||
return prev
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[generationKey]: {
|
||||
...current,
|
||||
status: 'failed',
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to stop pull request generation',
|
||||
hydrated: false
|
||||
}
|
||||
...current,
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Failed to stop pull request generation',
|
||||
hydrated: false
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [activePullRequestGenerationKey, prGenerationRecords])
|
||||
}, [activePullRequestGenerationKey, prGenerationRecords, updatePullRequestGenerationRecord])
|
||||
|
||||
const {
|
||||
aiGenerationEnabled: prAiGenerationEnabled,
|
||||
|
|
@ -2310,17 +2125,23 @@ function SourceControlInner(): React.JSX.Element {
|
|||
}
|
||||
const result = activePullRequestGenerationRecord.result
|
||||
applyGeneratedPullRequestFields(result, activePullRequestGenerationRecord.seedFieldRevisions)
|
||||
setPrGenerationRecords((prev) => ({
|
||||
...prev,
|
||||
[activePullRequestGenerationKey]: {
|
||||
...activePullRequestGenerationRecord,
|
||||
updatePullRequestGenerationRecord(activePullRequestGenerationKey, (record) => {
|
||||
if (
|
||||
!record ||
|
||||
record.context.requestId !== activePullRequestGenerationRecord.context.requestId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
hydrated: true
|
||||
}
|
||||
}))
|
||||
})
|
||||
}, [
|
||||
activePullRequestGenerationKey,
|
||||
activePullRequestGenerationRecord,
|
||||
applyGeneratedPullRequestFields
|
||||
applyGeneratedPullRequestFields,
|
||||
updatePullRequestGenerationRecord
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -4125,7 +3946,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
|
||||
{shouldRenderCommitArea(scope, unresolvedConflicts.length, conflictOperation) &&
|
||||
(primaryAction.kind === 'create_pr' ? (
|
||||
<PullRequestComposer
|
||||
<CreateHostedReviewComposer
|
||||
provider={hostedReviewCreateProvider}
|
||||
branch={branchName}
|
||||
base={prBase}
|
||||
|
|
@ -4702,444 +4523,6 @@ function SourceControlInner(): React.JSX.Element {
|
|||
const SourceControl = React.memo(SourceControlInner)
|
||||
export default SourceControl
|
||||
|
||||
type PullRequestComposerProps = {
|
||||
provider: HostedReviewProvider
|
||||
branch: string
|
||||
base: string
|
||||
setBase: (value: string) => void
|
||||
title: string
|
||||
setTitle: (value: string) => void
|
||||
body: string
|
||||
setBody: (value: string) => void
|
||||
draft: boolean
|
||||
setDraft: (value: boolean) => void
|
||||
baseQuery: string
|
||||
setBaseQuery: (value: string) => void
|
||||
baseResults: string[]
|
||||
setBaseResults: (value: string[]) => void
|
||||
baseSearchError: string | null
|
||||
aiGenerationEnabled: boolean
|
||||
generating: boolean
|
||||
generateDisabled: boolean
|
||||
generateDisabledReason?: string
|
||||
generateError: string | null
|
||||
createError: string | null
|
||||
isCreating: boolean
|
||||
primaryAction: PrimaryAction
|
||||
dropdownItems: DropdownEntry[]
|
||||
onGenerate: () => void
|
||||
onCancelGenerate: () => void
|
||||
onPrimaryAction: () => void
|
||||
onDropdownAction: (kind: DropdownActionKind) => void
|
||||
}
|
||||
|
||||
export function PullRequestComposer({
|
||||
provider,
|
||||
branch,
|
||||
base,
|
||||
setBase,
|
||||
title,
|
||||
setTitle,
|
||||
body,
|
||||
setBody,
|
||||
draft,
|
||||
setDraft,
|
||||
baseQuery,
|
||||
setBaseQuery,
|
||||
baseResults,
|
||||
setBaseResults,
|
||||
baseSearchError,
|
||||
aiGenerationEnabled,
|
||||
generating,
|
||||
generateDisabled,
|
||||
generateDisabledReason,
|
||||
generateError,
|
||||
createError,
|
||||
isCreating,
|
||||
primaryAction,
|
||||
dropdownItems,
|
||||
onGenerate,
|
||||
onCancelGenerate,
|
||||
onPrimaryAction,
|
||||
onDropdownAction
|
||||
}: PullRequestComposerProps): React.JSX.Element {
|
||||
const copy = localizedHostedReviewCopy(resolveSupportedHostedReviewCopyProvider(provider))
|
||||
const ReviewIcon = provider === 'gitlab' ? GitMerge : GitPullRequestArrow
|
||||
const normalizedBase = stripBaseRef(base)
|
||||
const strippedBranch = stripBaseRef(branch)
|
||||
const baseSameAsBranch = normalizedBase.toLowerCase() === strippedBranch.toLowerCase()
|
||||
const createDisabled =
|
||||
primaryAction.disabled ||
|
||||
generating ||
|
||||
title.trim().length === 0 ||
|
||||
normalizedBase.trim().length === 0 ||
|
||||
baseSameAsBranch
|
||||
// Why: surface a concrete reason on the disabled Create PR button so the
|
||||
// user knows what's blocking submission instead of a silent gray state.
|
||||
let createDisabledReason: string | undefined
|
||||
if (generating) {
|
||||
createDisabledReason = 'Wait for AI generation to finish.'
|
||||
} else if (title.trim().length === 0) {
|
||||
createDisabledReason = translate(
|
||||
'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5',
|
||||
'Enter a {{value0}} title.',
|
||||
{ value0: copy.reviewLabel }
|
||||
)
|
||||
} else if (normalizedBase.trim().length === 0) {
|
||||
createDisabledReason = 'Choose a base branch.'
|
||||
} else if (baseSameAsBranch) {
|
||||
createDisabledReason = 'Base branch must differ from the head branch.'
|
||||
}
|
||||
|
||||
// Why: lock the title/body/base inputs while AI generation is running so
|
||||
// the user can't race the request — the hook otherwise rejects the result
|
||||
// with "Fields changed while generating" and silently drops the draft.
|
||||
const fieldsLocked = generating
|
||||
const generateDetailsLabel = translate(
|
||||
'auto.components.right.sidebar.SourceControl.02d8c04339',
|
||||
'Generate {{value0}} details with AI',
|
||||
{ value0: copy.reviewLabel }
|
||||
)
|
||||
const stopGeneratingDetailsLabel = translate(
|
||||
'auto.components.right.sidebar.SourceControl.b355e740b2',
|
||||
'Stop generating {{value0}} details',
|
||||
{ value0: copy.reviewLabel }
|
||||
)
|
||||
const generateTooltipLabel = generating
|
||||
? stopGeneratingDetailsLabel
|
||||
: (generateDisabledReason ?? generateDetailsLabel)
|
||||
const generateButton = generating ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => onCancelGenerate()}
|
||||
className="text-[11px] text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={stopGeneratingDetailsLabel}
|
||||
>
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
<span>
|
||||
{translate('auto.components.right.sidebar.SourceControl.e868cec4e1', 'Generating…')}
|
||||
</span>
|
||||
<Square className="size-2.5 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={generateDisabled}
|
||||
onClick={() => onGenerate()}
|
||||
className="text-[11px] disabled:hover:bg-background"
|
||||
aria-label={generateDetailsLabel}
|
||||
>
|
||||
<Sparkles className="size-3" />
|
||||
{translate('auto.components.right.sidebar.SourceControl.aee92f8684', 'Generate')}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-2">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<ReviewIcon className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="font-medium text-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.e1970d327d',
|
||||
'New {{value0}}',
|
||||
{ value0: copy.reviewLabel }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{aiGenerationEnabled ? (
|
||||
<Tooltip>
|
||||
{!generating && generateDisabled ? (
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0 cursor-not-allowed">{generateButton}</span>
|
||||
</TooltipTrigger>
|
||||
) : (
|
||||
<TooltipTrigger asChild>{generateButton}</TooltipTrigger>
|
||||
)}
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
{generateTooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Why: a single line that shows the head→base flow plain-language so
|
||||
the user can sanity-check the merge direction at a glance. */}
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="truncate font-mono text-foreground" title={strippedBranch}>
|
||||
{strippedBranch}
|
||||
</span>
|
||||
<ArrowDownUp className="size-3 rotate-90 shrink-0 opacity-60" aria-hidden="true" />
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-mono',
|
||||
baseSameAsBranch ? 'text-destructive' : 'text-foreground'
|
||||
)}
|
||||
title={
|
||||
normalizedBase ||
|
||||
translate('auto.components.right.sidebar.SourceControl.7a09d7f9d2', 'base')
|
||||
}
|
||||
>
|
||||
{normalizedBase ||
|
||||
translate('auto.components.right.sidebar.SourceControl.7a09d7f9d2', 'base')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-2">
|
||||
<input
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.SourceControl.a6eda33521',
|
||||
'{{value0}} title',
|
||||
{ value0: copy.titleLabel }
|
||||
)}
|
||||
value={title}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.right.sidebar.SourceControl.7d6a8f0082',
|
||||
'Title'
|
||||
)}
|
||||
className="h-8 w-full min-w-0 rounded-md border border-border bg-background px-2 text-xs font-medium text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.SourceControl.a8873e1d62',
|
||||
'{{value0}} description',
|
||||
{ value0: copy.titleLabel }
|
||||
)}
|
||||
rows={6}
|
||||
value={body}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.right.sidebar.SourceControl.a0dc20fc93',
|
||||
'Description (optional)'
|
||||
)}
|
||||
className="min-h-[7.5rem] w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60 scrollbar-sleek"
|
||||
/>
|
||||
|
||||
{generating ? (
|
||||
// Why: visible scrim + status row so the user understands the
|
||||
// title and description fields will be replaced when generation
|
||||
// finishes; locking the inputs above also prevents the
|
||||
// "Fields changed while generating" race in the hook.
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-background/40"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="pointer-events-auto flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-[11px] text-muted-foreground shadow-sm">
|
||||
<Sparkles className="size-3 animate-pulse text-foreground" />
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.9484270f45',
|
||||
'Generating title & description…'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Why: base picker as its own labeled row so the title input can use
|
||||
the full width. The dropdown chevron makes the picker affordance
|
||||
obvious; the inline label clarifies that this is the merge target. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{translate('auto.components.right.sidebar.SourceControl.1f7119f604', 'Base')}
|
||||
</span>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<input
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.SourceControl.6055949c50',
|
||||
'{{value0}} base branch',
|
||||
{ value0: copy.titleLabel }
|
||||
)}
|
||||
value={baseQuery || base}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) => {
|
||||
setBaseQuery(event.target.value)
|
||||
setBase(event.target.value)
|
||||
}}
|
||||
placeholder={translate(
|
||||
'auto.components.right.sidebar.SourceControl.e64a632456',
|
||||
'main'
|
||||
)}
|
||||
className="h-7 w-full min-w-0 rounded-md border border-border bg-background px-2 pr-6 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-1.5 top-1.5 size-3.5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className={cn(
|
||||
'flex h-7 items-center gap-2 rounded-md border border-border bg-background px-2 text-xs text-foreground transition-colors',
|
||||
fieldsLocked
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-pointer hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) => setDraft(event.target.checked)}
|
||||
className="size-3.5 shrink-0 rounded border-border accent-primary"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{translate('auto.components.right.sidebar.SourceControl.78ddfd0bb4', 'Create as draft')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{baseResults.length > 0 ? (
|
||||
<div className="max-h-28 overflow-auto rounded-md border border-border p-1 scrollbar-sleek">
|
||||
{baseResults.map((ref) => (
|
||||
<button
|
||||
key={ref}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left font-mono text-xs hover:bg-accent',
|
||||
stripBaseRef(base) === ref && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
onClick={() => {
|
||||
setBase(ref)
|
||||
setBaseQuery('')
|
||||
setBaseResults([])
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{ref}</span>
|
||||
{stripBaseRef(base) === ref ? <Check className="size-3" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-stretch pt-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
disabled={createDisabled}
|
||||
onClick={() => onPrimaryAction()}
|
||||
className="h-7 flex-1 rounded-r-none px-3 text-xs"
|
||||
title={createDisabledReason ?? primaryAction.title}
|
||||
>
|
||||
{isCreating ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<ReviewIcon className="size-3.5" />
|
||||
)}
|
||||
{isCreating
|
||||
? translate('auto.components.right.sidebar.SourceControl.26511c22b4', 'Creating...')
|
||||
: draft
|
||||
? translate(
|
||||
'auto.components.right.sidebar.SourceControl.aaf1451654',
|
||||
'Create draft {{value0}}',
|
||||
{ value0: copy.shortLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.SourceControl.5acbcedc1a',
|
||||
'Create {{value0}}',
|
||||
{ value0: copy.shortLabel }
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className={cn(
|
||||
'h-7 rounded-l-none border-l border-primary-foreground/20 px-1.5 shrink-0',
|
||||
createDisabled && 'opacity-50'
|
||||
)}
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.SourceControl.c5e4175139',
|
||||
'More {{value0}} and remote actions',
|
||||
{ value0: copy.reviewLabel }
|
||||
)}
|
||||
title={translate(
|
||||
'auto.components.right.sidebar.SourceControl.4d6e1fd7f3',
|
||||
'More actions'
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[14rem]">
|
||||
{dropdownItems.map((entry, index) =>
|
||||
entry.kind === 'separator' ? (
|
||||
<DropdownMenuSeparator key={`sep-${index}`} />
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={entry.kind}
|
||||
disabled={entry.disabled}
|
||||
title={entry.title}
|
||||
variant={entry.variant}
|
||||
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>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{baseSameAsBranch ? (
|
||||
<p className="flex items-start gap-1 text-[11px] text-destructive">
|
||||
<TriangleAlert className="mt-px size-3 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.ae743199cd',
|
||||
'Choose a different base branch before creating a {{value0}}.',
|
||||
{ value0: copy.reviewLabel }
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{baseSearchError ? (
|
||||
<p className="flex items-start gap-1 text-[11px] text-destructive">
|
||||
<TriangleAlert className="mt-px size-3 shrink-0" aria-hidden="true" />
|
||||
<span>{baseSearchError}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{generateError ? (
|
||||
<p className="flex items-start gap-1 text-[11px] text-destructive">
|
||||
<TriangleAlert className="mt-px size-3 shrink-0" aria-hidden="true" />
|
||||
<span>{generateError}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{createError ? (
|
||||
<p className="flex items-start gap-1 text-[11px] text-destructive">
|
||||
<TriangleAlert className="mt-px size-3 shrink-0" aria-hidden="true" />
|
||||
<span>{createError}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CommitFailureFixSplitButtonProps = {
|
||||
label: string
|
||||
worktreeId: string | null
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import {
|
|||
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 PullRequestFieldName = 'base' | 'title' | 'body' | 'draft'
|
||||
export type PullRequestFieldRevisions = Record<PullRequestFieldName, number>
|
||||
type PullRequestDraftFields = {
|
||||
base: string
|
||||
title: string
|
||||
|
|
@ -231,6 +233,18 @@ export function useCreatePullRequestDialogFields({
|
|||
if (initializedFromEligibilityRef.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
|
||||
|
|
|
|||
|
|
@ -7460,6 +7460,9 @@
|
|||
"71026ca2cb": "Refreshing…",
|
||||
"889cdfba04": "Create {{value0}}",
|
||||
"98f4c37b33": "Push & Create {{value0}}",
|
||||
"b6ce28da5b": "{{value0}} #{{value1}} is already open",
|
||||
"cf9e69f3be": "{{value0}} is already open",
|
||||
"192e686e57": "Open on {{value0}}",
|
||||
"6633c7a1fb": "Publish Branch",
|
||||
"fdb27637f2": "Publishing…",
|
||||
"e56c42122e": "destructive",
|
||||
|
|
@ -7494,6 +7497,9 @@
|
|||
"21c7a1daa0": "{{value0}} is already open",
|
||||
"db9cee18f7": "Create {{value0}}"
|
||||
},
|
||||
"CreateHostedReviewComposer": {
|
||||
"741ff8a0d2": "Push & Create {{value0}}"
|
||||
},
|
||||
"CreatePullRequestGenerateButton": {
|
||||
"4012459f8a": "Generate with AI",
|
||||
"a0501572c1": "Generate {{value0}} details with AI",
|
||||
|
|
@ -7712,6 +7718,9 @@
|
|||
"30b8d4f181": "Fix commit failure with AI",
|
||||
"4b37ae99b0": "Start the default AI agent to fix this commit failure",
|
||||
"ae743199cd": "Choose a different base branch before creating a {{value0}}.",
|
||||
"318e2a7f88": "Wait for AI generation to finish.",
|
||||
"f76307c1f7": "Choose a base branch.",
|
||||
"4f76c0a9de": "Base branch must differ from the head branch.",
|
||||
"c5e4175139": "More {{value0}} and remote actions",
|
||||
"78ddfd0bb4": "Create as draft",
|
||||
"e64a632456": "main",
|
||||
|
|
|
|||
|
|
@ -7460,6 +7460,9 @@
|
|||
"71026ca2cb": "Refrescante…",
|
||||
"889cdfba04": "Crear {{value0}}",
|
||||
"98f4c37b33": "Empujar y crear {{value0}}",
|
||||
"b6ce28da5b": "{{value0}} #{{value1}} ya está abierto",
|
||||
"cf9e69f3be": "{{value0}} ya está abierto",
|
||||
"192e686e57": "Abrir en {{value0}}",
|
||||
"6633c7a1fb": "Rama de publicación",
|
||||
"fdb27637f2": "Publicación…",
|
||||
"e56c42122e": "destructivo",
|
||||
|
|
@ -7494,6 +7497,9 @@
|
|||
"21c7a1daa0": "{{value0}} ya está abierto",
|
||||
"db9cee18f7": "Crear {{value0}}"
|
||||
},
|
||||
"CreateHostedReviewComposer": {
|
||||
"741ff8a0d2": "Empujar y crear {{value0}}"
|
||||
},
|
||||
"CreatePullRequestGenerateButton": {
|
||||
"4012459f8a": "Generar con IA",
|
||||
"a0501572c1": "Genera detalles {{value0}} con IA",
|
||||
|
|
@ -7712,6 +7718,9 @@
|
|||
"30b8d4f181": "Soluciona el error de commit con IA",
|
||||
"4b37ae99b0": "Inicie el agente de IA predeterminado para solucionar este error de commit",
|
||||
"ae743199cd": "Elija una rama base diferente antes de crear un {{value0}}.",
|
||||
"318e2a7f88": "Espere a que termine la generación con IA.",
|
||||
"f76307c1f7": "Elija una rama base.",
|
||||
"4f76c0a9de": "La rama base debe ser distinta de la rama HEAD.",
|
||||
"c5e4175139": "Más {{value0}} y acciones remotas",
|
||||
"78ddfd0bb4": "Crear como borrador",
|
||||
"e64a632456": "principal",
|
||||
|
|
|
|||
|
|
@ -7460,6 +7460,9 @@
|
|||
"71026ca2cb": "更新中…",
|
||||
"889cdfba04": "{{value0}} を作成する",
|
||||
"98f4c37b33": "プッシュして{{value0}}を作成",
|
||||
"b6ce28da5b": "{{value0}} #{{value1}} はすでに開いています",
|
||||
"cf9e69f3be": "{{value0}} はすでに開いています",
|
||||
"192e686e57": "{{value0}} で開く",
|
||||
"6633c7a1fb": "ブランチを公開",
|
||||
"fdb27637f2": "公開中…",
|
||||
"e56c42122e": "破壊的な",
|
||||
|
|
@ -7494,6 +7497,9 @@
|
|||
"21c7a1daa0": "{{value0}} はすでに開いています",
|
||||
"db9cee18f7": "{{value0}} を作成"
|
||||
},
|
||||
"CreateHostedReviewComposer": {
|
||||
"741ff8a0d2": "プッシュして{{value0}}を作成"
|
||||
},
|
||||
"CreatePullRequestGenerateButton": {
|
||||
"4012459f8a": "AIで生成",
|
||||
"a0501572c1": "AI を使用して {{value0}} の詳細を生成する",
|
||||
|
|
@ -7712,6 +7718,9 @@
|
|||
"30b8d4f181": "AI による commit 失敗の修正",
|
||||
"4b37ae99b0": "デフォルトの AI agent を開始して、この commit の失敗を修正します",
|
||||
"ae743199cd": "{{value0}} を作成する前に別のベースブランチを選択してください。",
|
||||
"318e2a7f88": "AI 生成が完了するまでお待ちください。",
|
||||
"f76307c1f7": "ベースブランチを選択してください。",
|
||||
"4f76c0a9de": "ベースブランチは head ブランチとは異なる必要があります。",
|
||||
"c5e4175139": "その他の {{value0}} とリモート操作",
|
||||
"78ddfd0bb4": "下書きとして作成",
|
||||
"e64a632456": "主要",
|
||||
|
|
|
|||
|
|
@ -7460,6 +7460,9 @@
|
|||
"71026ca2cb": "새로고침 중…",
|
||||
"889cdfba04": "{{value0}} 만들기",
|
||||
"98f4c37b33": "푸시 및 생성 {{value0}}",
|
||||
"b6ce28da5b": "{{value0}} #{{value1}}이(가) 이미 열려 있습니다",
|
||||
"cf9e69f3be": "{{value0}}이(가) 이미 열려 있습니다",
|
||||
"192e686e57": "{{value0}}에서 열기",
|
||||
"6633c7a1fb": "게시 브랜치",
|
||||
"fdb27637f2": "출판…",
|
||||
"e56c42122e": "파괴적인",
|
||||
|
|
@ -7494,6 +7497,9 @@
|
|||
"21c7a1daa0": "{{value0}}이(가) 이미 열려 있습니다",
|
||||
"db9cee18f7": "{{value0}} 만들기"
|
||||
},
|
||||
"CreateHostedReviewComposer": {
|
||||
"741ff8a0d2": "푸시 및 생성 {{value0}}"
|
||||
},
|
||||
"CreatePullRequestGenerateButton": {
|
||||
"4012459f8a": "AI로 생성",
|
||||
"a0501572c1": "AI로 {{value0}} 세부정보 생성",
|
||||
|
|
@ -7712,6 +7718,9 @@
|
|||
"30b8d4f181": "AI로 commit 실패 수정",
|
||||
"4b37ae99b0": "이 commit 실패를 수정하려면 기본 AI agent를 시작하세요.",
|
||||
"ae743199cd": "{{value0}}을(를) 만들기 전에 다른 베이스 브랜치를 선택하세요.",
|
||||
"318e2a7f88": "AI 생성이 완료될 때까지 기다리세요.",
|
||||
"f76307c1f7": "베이스 브랜치를 선택하세요.",
|
||||
"4f76c0a9de": "베이스 브랜치는 head 브랜치와 달라야 합니다.",
|
||||
"c5e4175139": "더 많은 {{value0}} 및 원격 작업",
|
||||
"78ddfd0bb4": "초안으로 만들기",
|
||||
"e64a632456": "기본",
|
||||
|
|
|
|||
|
|
@ -7460,6 +7460,9 @@
|
|||
"71026ca2cb": "刷新中…",
|
||||
"889cdfba04": "创建 {{value0}}",
|
||||
"98f4c37b33": "推送并创建 {{value0}}",
|
||||
"b6ce28da5b": "{{value0}} #{{value1}} 已打开",
|
||||
"cf9e69f3be": "{{value0}} 已打开",
|
||||
"192e686e57": "打开于 {{value0}}",
|
||||
"6633c7a1fb": "发布分支",
|
||||
"fdb27637f2": "出版…",
|
||||
"e56c42122e": "destructive",
|
||||
|
|
@ -7494,6 +7497,9 @@
|
|||
"21c7a1daa0": "{{value0}} 已打开",
|
||||
"db9cee18f7": "创建 {{value0}}"
|
||||
},
|
||||
"CreateHostedReviewComposer": {
|
||||
"741ff8a0d2": "推送并创建 {{value0}}"
|
||||
},
|
||||
"CreatePullRequestGenerateButton": {
|
||||
"4012459f8a": "用 AI 生成",
|
||||
"a0501572c1": "使用 AI 生成 {{value0}} 详细信息",
|
||||
|
|
@ -7712,6 +7718,9 @@
|
|||
"30b8d4f181": "使用 AI 修复 commit 失败",
|
||||
"4b37ae99b0": "启动默认的AIAgent 来修复此 commit 失败",
|
||||
"ae743199cd": "在创建 {{value0}} 之前,请选择其他基础分支。",
|
||||
"318e2a7f88": "请等待 AI 生成完成。",
|
||||
"f76307c1f7": "请选择基础分支。",
|
||||
"4f76c0a9de": "基础分支必须与 head 分支不同。",
|
||||
"c5e4175139": "更多 {{value0}} 和远程操作",
|
||||
"78ddfd0bb4": "创建为草稿",
|
||||
"e64a632456": "主要的",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { createDetectedAgentsSlice } from './slices/detected-agents'
|
|||
import { createWorktreeNavHistorySlice } from './slices/worktree-nav-history'
|
||||
import { createDictationSlice } from './slices/dictation'
|
||||
import { createWorkspaceCleanupSlice } from './slices/workspace-cleanup'
|
||||
import { createPullRequestGenerationSlice } from './slices/pull-request-generation'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
|
||||
|
||||
|
|
@ -61,7 +62,8 @@ export const useAppStore = create<AppState>()((...a) => ({
|
|||
...createDetectedAgentsSlice(...a),
|
||||
...createWorktreeNavHistorySlice(...a),
|
||||
...createDictationSlice(...a),
|
||||
...createWorkspaceCleanupSlice(...a)
|
||||
...createWorkspaceCleanupSlice(...a),
|
||||
...createPullRequestGenerationSlice(...a)
|
||||
}))
|
||||
|
||||
registerHttpLinkStoreAccessor(() => useAppStore.getState())
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ import { createDetectedAgentsSlice } from './detected-agents'
|
|||
import { createWorktreeNavHistorySlice } from './worktree-nav-history'
|
||||
import { createDictationSlice } from './dictation'
|
||||
import { createWorkspaceCleanupSlice } from './workspace-cleanup'
|
||||
import { createPullRequestGenerationSlice } from './pull-request-generation'
|
||||
|
||||
function createTestStore() {
|
||||
return create<AppState>()((...a) => ({
|
||||
|
|
@ -167,7 +168,8 @@ function createTestStore() {
|
|||
...createDetectedAgentsSlice(...a),
|
||||
...createWorktreeNavHistorySlice(...a),
|
||||
...createDictationSlice(...a),
|
||||
...createWorkspaceCleanupSlice(...a)
|
||||
...createWorkspaceCleanupSlice(...a),
|
||||
...createPullRequestGenerationSlice(...a)
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
|
||||
export type PullRequestFieldName = 'base' | 'title' | 'body' | 'draft'
|
||||
export type PullRequestFieldRevisions = Record<PullRequestFieldName, number>
|
||||
|
||||
export type PullRequestGenerationFields = {
|
||||
base: string
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
}
|
||||
|
||||
export type PullRequestGenerationContext = {
|
||||
worktreeId: string | null
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
requestId: number
|
||||
repoId: string
|
||||
branch: string
|
||||
}
|
||||
|
||||
export type PullRequestGenerationStatus = 'idle' | 'running' | 'canceled' | 'failed' | 'succeeded'
|
||||
|
||||
export type PullRequestGenerationRecord = {
|
||||
context: PullRequestGenerationContext
|
||||
seed: PullRequestGenerationFields
|
||||
seedFieldRevisions: PullRequestFieldRevisions
|
||||
status: PullRequestGenerationStatus
|
||||
result: PullRequestGenerationFields | null
|
||||
error: string | null
|
||||
hydrated: boolean
|
||||
}
|
||||
|
||||
export type PullRequestGenerationRecords = Record<string, PullRequestGenerationRecord>
|
||||
|
||||
export type PullRequestGenerationSlice = {
|
||||
pullRequestGenerationRequestSeq: number
|
||||
pullRequestGenerationRecords: PullRequestGenerationRecords
|
||||
allocatePullRequestGenerationRequestId: () => number
|
||||
setPullRequestGenerationRecord: (key: string, record: PullRequestGenerationRecord) => void
|
||||
updatePullRequestGenerationRecord: (
|
||||
key: string,
|
||||
updater: (record: PullRequestGenerationRecord | null) => PullRequestGenerationRecord | null
|
||||
) => void
|
||||
}
|
||||
|
||||
export function getPullRequestGenerationWorktreeKey(
|
||||
worktreeId: string | null | undefined,
|
||||
worktreePath: string | null | undefined
|
||||
): string | null {
|
||||
if (worktreeId) {
|
||||
return worktreeId
|
||||
}
|
||||
return worktreePath?.trim() ? worktreePath : null
|
||||
}
|
||||
|
||||
export function getPullRequestGenerationRecordKey({
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
repoId,
|
||||
branch
|
||||
}: {
|
||||
worktreeId: string | null | undefined
|
||||
worktreePath: string | null | undefined
|
||||
repoId: string | null | undefined
|
||||
branch: string | null | undefined
|
||||
}): string | null {
|
||||
const worktreeKey = getPullRequestGenerationWorktreeKey(worktreeId, worktreePath)
|
||||
if (!worktreeKey || !repoId || !branch) {
|
||||
return null
|
||||
}
|
||||
return JSON.stringify([repoId, worktreeKey, branch])
|
||||
}
|
||||
|
||||
export function arePullRequestGenerationFieldsEqual(
|
||||
left: PullRequestGenerationFields,
|
||||
right: PullRequestGenerationFields
|
||||
): boolean {
|
||||
return (
|
||||
left.base === right.base &&
|
||||
left.title === right.title &&
|
||||
left.body === right.body &&
|
||||
left.draft === right.draft
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldApplyPullRequestGenerationResult({
|
||||
record,
|
||||
requestId
|
||||
}: {
|
||||
record: PullRequestGenerationRecord | null | undefined
|
||||
requestId: number
|
||||
}): boolean {
|
||||
return record?.context.requestId === requestId && record.status === 'running'
|
||||
}
|
||||
|
||||
export function shouldHydratePullRequestGenerationResult({
|
||||
record
|
||||
}: {
|
||||
record: PullRequestGenerationRecord | null | undefined
|
||||
}): boolean {
|
||||
return record?.status === 'succeeded' && record.result !== null && !record.hydrated
|
||||
}
|
||||
|
||||
export function createRunningPullRequestGenerationRecord(
|
||||
context: PullRequestGenerationContext,
|
||||
seed: PullRequestGenerationFields,
|
||||
seedFieldRevisions: PullRequestFieldRevisions
|
||||
): PullRequestGenerationRecord {
|
||||
return {
|
||||
context,
|
||||
seed,
|
||||
seedFieldRevisions,
|
||||
status: 'running',
|
||||
result: null,
|
||||
error: null,
|
||||
hydrated: false
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePullRequestGenerationSuccess({
|
||||
record,
|
||||
requestId,
|
||||
result
|
||||
}: {
|
||||
record: PullRequestGenerationRecord | null | undefined
|
||||
requestId: number
|
||||
result: PullRequestGenerationFields
|
||||
}): PullRequestGenerationRecord | null {
|
||||
if (!record || record.context.requestId !== requestId || record.status !== 'running') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
status: 'succeeded',
|
||||
result,
|
||||
error: null,
|
||||
hydrated: false
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePullRequestGenerationFailure({
|
||||
record,
|
||||
requestId,
|
||||
error,
|
||||
canceled = false
|
||||
}: {
|
||||
record: PullRequestGenerationRecord | null | undefined
|
||||
requestId: number
|
||||
error: string | null
|
||||
canceled?: boolean
|
||||
}): PullRequestGenerationRecord | null {
|
||||
if (!record || record.context.requestId !== requestId || record.status !== 'running') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
status: canceled ? 'canceled' : 'failed',
|
||||
result: null,
|
||||
error: canceled ? null : error,
|
||||
hydrated: false
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePullRequestGenerationCancel(
|
||||
record: PullRequestGenerationRecord | null | undefined
|
||||
): PullRequestGenerationRecord | null {
|
||||
if (!record || record.status !== 'running') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
status: 'canceled',
|
||||
error: null,
|
||||
hydrated: false
|
||||
}
|
||||
}
|
||||
|
||||
export const createPullRequestGenerationSlice: StateCreator<
|
||||
AppState,
|
||||
[],
|
||||
[],
|
||||
PullRequestGenerationSlice
|
||||
> = (set) => ({
|
||||
pullRequestGenerationRequestSeq: 0,
|
||||
pullRequestGenerationRecords: {},
|
||||
allocatePullRequestGenerationRequestId: () => {
|
||||
let nextRequestId = 0
|
||||
set((state) => {
|
||||
nextRequestId = state.pullRequestGenerationRequestSeq + 1
|
||||
return {
|
||||
pullRequestGenerationRequestSeq: nextRequestId
|
||||
}
|
||||
})
|
||||
return nextRequestId
|
||||
},
|
||||
setPullRequestGenerationRecord: (key, record) =>
|
||||
set((state) => ({
|
||||
pullRequestGenerationRecords: {
|
||||
...state.pullRequestGenerationRecords,
|
||||
[key]: record
|
||||
}
|
||||
})),
|
||||
updatePullRequestGenerationRecord: (key, updater) =>
|
||||
set((state) => {
|
||||
const nextRecord = updater(state.pullRequestGenerationRecords[key] ?? null)
|
||||
if (!nextRecord) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
pullRequestGenerationRecords: {
|
||||
...state.pullRequestGenerationRecords,
|
||||
[key]: nextRecord
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -37,6 +37,7 @@ import { createDetectedAgentsSlice } from './detected-agents'
|
|||
import { createWorktreeNavHistorySlice } from './worktree-nav-history'
|
||||
import { createDictationSlice } from './dictation'
|
||||
import { createWorkspaceCleanupSlice } from './workspace-cleanup'
|
||||
import { createPullRequestGenerationSlice } from './pull-request-generation'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export const TEST_REPO = {
|
||||
|
|
@ -77,7 +78,8 @@ export function createTestStore() {
|
|||
...createDetectedAgentsSlice(...a),
|
||||
...createWorktreeNavHistorySlice(...a),
|
||||
...createDictationSlice(...a),
|
||||
...createWorkspaceCleanupSlice(...a)
|
||||
...createWorkspaceCleanupSlice(...a),
|
||||
...createPullRequestGenerationSlice(...a)
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type { DetectedAgentsSlice } from './slices/detected-agents'
|
|||
import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history'
|
||||
import type { DictationSlice } from './slices/dictation'
|
||||
import type { WorkspaceCleanupSlice } from './slices/workspace-cleanup'
|
||||
import type { PullRequestGenerationSlice } from './slices/pull-request-generation'
|
||||
|
||||
export type AppState = RepoSlice &
|
||||
SparsePresetsSlice &
|
||||
|
|
@ -56,4 +57,5 @@ export type AppState = RepoSlice &
|
|||
DetectedAgentsSlice &
|
||||
WorktreeNavHistorySlice &
|
||||
DictationSlice &
|
||||
WorkspaceCleanupSlice
|
||||
WorkspaceCleanupSlice &
|
||||
PullRequestGenerationSlice
|
||||
|
|
|
|||
Loading…
Reference in New Issue