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:
Jinjing 2026-06-11 02:11:43 -07:00 committed by GitHub
parent 903adcc2e0
commit 5621f7686b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1456 additions and 779 deletions

View File

@ -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>
)
}

View File

@ -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
})
}

View File

@ -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>
)
}

View File

@ -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()

View File

@ -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
})
})
})

View File

@ -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 headbase 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

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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": "主要",

View File

@ -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": "기본",

View File

@ -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": "主要的",

View File

@ -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())

View File

@ -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)
}))
}

View File

@ -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
}
}
})
})

View File

@ -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)
}))
}

View File

@ -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