diff --git a/src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts index cd165fa77c5..0781faa3033 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts +++ b/src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts @@ -214,6 +214,108 @@ describe('SourceControl pull request generation records', () => { ).toBe(true) }) + it.each([ + { + name: 'an untouched unchecked Draft takes the agent draft', + seedDraft: false, + revision: 0, + agentDraft: true, + draft: true + }, + { + name: 'a checked Draft stays checked', + seedDraft: true, + revision: 0, + agentDraft: false, + draft: true + }, + { + name: 'a Draft the user unchecked stays unchecked', + seedDraft: false, + revision: 1, + agentDraft: true, + draft: false + } + ])( + 'a Create PR run never takes the generated base, and $name', + ({ seedDraft, revision, agentDraft, draft }) => { + const record = runningRecord({ + autoSubmit: true, + seed: { ...seed, draft: seedDraft }, + seedFieldRevisions: { ...fieldRevisions, draft: revision } + }) + + const completed = resolvePullRequestGenerationSuccess({ + record, + requestId: 3, + result: { + base: 'develop', + title: 'Generated title', + body: 'Generated body', + draft: agentDraft + } + }) + + expect(completed?.result).toEqual({ + base: 'main', + title: 'Generated title', + body: 'Generated body', + draft + }) + } + ) + + it('a reviewed run keeps the agent base and title for the form', () => { + const generated = { + base: 'develop', + title: 'Generated title', + body: 'Generated body', + draft: true + } + + const completed = resolvePullRequestGenerationSuccess({ + record: runningRecord(), + requestId: 3, + result: generated + }) + + expect(completed?.result).toEqual(generated) + }) + + it.each([ + { + name: 'a Draft checked before the run stays checked', + seedDraft: true, + revision: 0, + draft: true + }, + { + name: 'a Draft the user unchecked before the run stays unchecked', + seedDraft: false, + revision: 1, + draft: false + } + ])( + 'a reviewed run never reverts the user Draft choice: $name', + ({ seedDraft, revision, draft }) => { + const completed = resolvePullRequestGenerationSuccess({ + record: runningRecord({ + seed: { ...seed, draft: seedDraft }, + seedFieldRevisions: { ...fieldRevisions, draft: revision } + }), + requestId: 3, + result: { + base: 'develop', + title: 'Generated title', + body: 'Generated body', + draft: !seedDraft + } + }) + + expect(completed?.result?.draft).toBe(draft) + } + ) + it('keeps PR generation results in the store after the composer unmounts', () => { const store = createPullRequestGenerationTestStore() const key = getPullRequestGenerationRecordKey({ diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-composer-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-composer-state.tsx index ea0f1a6564b..2da3a2158ec 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-composer-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-composer-state.tsx @@ -100,6 +100,7 @@ export function useChecksPanelComposerState(model: ChecksPanelComposerStateInput setBody: setPrBody, draft: prDraft, setDraft: setPrDraft, + fieldsAreSeedPlaceholders: prFieldsAreSeedPlaceholders, stackedCreationSupported: prStackedCreationSupported, repoDefaultBaseRef: prRepoDefaultBaseRef, baseQuery: prBaseQuery, @@ -137,9 +138,7 @@ export function useChecksPanelComposerState(model: ChecksPanelComposerStateInput seed: activePullRequestGenerationRecord?.seed ?? null, seedFieldRevisions: activePullRequestGenerationRecord?.seedFieldRevisions ?? null, onSeedRestored: handlePullRequestGenerationSeedRestored, - onGenerate: (fields, fieldRevisions, overrides) => { - void handleGeneratePullRequestFieldsForActive(fields, fieldRevisions, overrides) - }, + onGenerate: handleGeneratePullRequestFieldsForActive, onCancelGenerate: handleCancelGeneratePullRequestFieldsForActive } }) @@ -261,6 +260,7 @@ export function useChecksPanelComposerState(model: ChecksPanelComposerStateInput setPrBody, prDraft, setPrDraft, + prFieldsAreSeedPlaceholders, prStackedCreationSupported, prRepoDefaultBaseRef, prBaseQuery, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.test.tsx index 218040c0b14..7bbd1f32aaf 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.test.tsx @@ -2,74 +2,100 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { getDefaultSettings } from '../../../../../shared/constants' import type { CreateHostedReviewResult } from '../../../../../shared/hosted-review' +import { getDefaultSourceControlAiSettings } from '../../../../../shared/source-control-ai-settings' +import type * as HttpLinkRouting from '@/lib/http-link-routing' import { useChecksPanelCreateReview } from './use-checks-panel-create-review' +const { openHttpLink } = vi.hoisted(() => ({ openHttpLink: vi.fn() })) +vi.mock('@/lib/http-link-routing', async (importOriginal) => ({ + ...(await importOriginal()), + openHttpLink +})) + type CreateInput = Parameters[0] -afterEach(cleanup) +afterEach(() => { + cleanup() + openHttpLink.mockClear() + useAppStore.setState({ activeWorktreeId: null }) +}) + +function makeInput(overrides: Partial = {}): CreateInput { + const createdReview: CreateHostedReviewResult = { + ok: true, + number: 42, + url: 'https://github.com/orca/app/pull/42' + } + return { + activePullRequestGenerationKey: null, + activeWorktreeId: null, + activeWorktreePath: '/workspace/repo', + branch: 'refs/heads/feature/create', + createComposerOpen: true, + createHostedReview: vi.fn(async () => createdReview), + createPrInFlightRef: { current: null }, + createPrPushFirst: false, + createStackedHostedReview: vi.fn(), + fallbackGitHubPRNumber: null, + fetchGitLabDetails: vi.fn(), + fetchHostedReviewForBranch: vi.fn(), + handleGeneratePullRequestFields: vi.fn(async () => {}), + hostedReviewCreateCopy: { + providerName: 'GitHub', + reviewLabel: 'pull request', + shortLabel: 'PR', + titleLabel: 'Pull request' + } as CreateInput['hostedReviewCreateCopy'], + hostedReviewCreateProvider: 'github', + hostedReviewCreation: null, + linkedAzureDevOpsPR: null, + linkedBitbucketPR: null, + linkedGiteaPR: null, + linkedGitLabMR: null, + linkedPR: null, + mountedRef: { current: true }, + ownerSettings: null, + panelContextKey: 'repo-1::worktree-1::feature/create', + panelContextKeyRef: { current: 'repo-1::worktree-1::feature/create' }, + prAiGenerationEnabled: false, + prBase: 'refs/remotes/origin/main', + prBody: 'Create body', + prCreationDefaults: { + draft: false, + generateDetailsOnOpen: false, + openAfterCreate: false, + useTemplate: true + }, + prDraft: true, + prFieldsAreSeedPlaceholders: false, + prGenerating: false, + prTitle: ' Create title ', + pushBeforeCreatePullRequest: vi.fn(async () => true), + refreshLinkedGitHubPullRequest: vi.fn(), + repo: { id: 'repo-1', path: '/workspace/repo' } as NonNullable, + setCreatePrError: vi.fn(), + setGitStatusRefreshNonce: vi.fn(), + setIsCreatingPr: vi.fn(), + setRightSidebarOpen: vi.fn(), + setRightSidebarTab: vi.fn(), + updatePullRequestGenerationRecord: vi.fn(), + updateWorktreeMeta: vi.fn(), + ...overrides + } +} describe('useChecksPanelCreateReview provider flow', () => { it('sends normalized GitHub create input and releases the in-flight gate after success', async () => { - const createdReview: CreateHostedReviewResult = { - ok: true, - number: 42, - url: 'https://github.com/orca/app/pull/42' - } - const createHostedReview: CreateInput['createHostedReview'] = vi.fn(async () => createdReview) - const refreshLinkedGitHubPullRequest: CreateInput['refreshLinkedGitHubPullRequest'] = vi.fn() - const setIsCreatingPr: CreateInput['setIsCreatingPr'] = vi.fn() - const createPrInFlightRef = { current: null as string | null } - const input: CreateInput = { - activePullRequestGenerationKey: null, - activeWorktreeId: null, - activeWorktreePath: '/workspace/repo', - branch: 'refs/heads/feature/create', - createComposerOpen: true, + const input = makeInput() + const { createHostedReview, createPrInFlightRef, - createPrPushFirst: false, - createStackedHostedReview: vi.fn(), - fallbackGitHubPRNumber: null, - fetchGitLabDetails: vi.fn(), - fetchHostedReviewForBranch: vi.fn(), - hostedReviewCreateCopy: { - providerName: 'GitHub', - reviewLabel: 'pull request', - shortLabel: 'PR', - titleLabel: 'Pull request' - } as CreateInput['hostedReviewCreateCopy'], - hostedReviewCreateProvider: 'github', - hostedReviewCreation: null, - linkedAzureDevOpsPR: null, - linkedBitbucketPR: null, - linkedGiteaPR: null, - linkedGitLabMR: null, - linkedPR: null, - panelContextKey: 'repo-1::worktree-1::feature/create', - panelContextKeyRef: { current: 'repo-1::worktree-1::feature/create' }, - prBase: 'refs/remotes/origin/main', - prBody: 'Create body', - prCreationDefaults: { - draft: false, - generateDetailsOnOpen: false, - openAfterCreate: false, - useTemplate: true - }, - prDraft: true, - prGenerating: false, - prTitle: ' Create title ', - pushBeforeCreatePullRequest: vi.fn(async () => true), refreshLinkedGitHubPullRequest, - repo: { id: 'repo-1', path: '/workspace/repo' } as NonNullable, - setCreatePrError: vi.fn(), - setGitStatusRefreshNonce: vi.fn(), - setIsCreatingPr, - setRightSidebarOpen: vi.fn(), - setRightSidebarTab: vi.fn(), - updatePullRequestGenerationRecord: vi.fn(), - updateWorktreeMeta: vi.fn() - } + setIsCreatingPr + } = input const { result } = renderHook(() => useChecksPanelCreateReview(input)) await act(async () => result.current.handleCreatePullRequest(false)) @@ -90,4 +116,224 @@ describe('useChecksPanelCreateReview provider flow', () => { expect(setIsCreatingPr).toHaveBeenLastCalledWith(false) expect(createPrInFlightRef.current).toBeNull() }) + + it('generates details, then creates with them, when the composer still holds placeholders', async () => { + const input = makeInput({ + activePullRequestGenerationKey: 'worktree-1::repo-1::feature/create', + handleGeneratePullRequestFields: vi.fn(async () => ({ + result: { base: 'main', title: 'Add create flow', body: 'Details.', draft: false } + })), + ownerSettings: { + ...getDefaultSettings('/home/test'), + sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' } + }, + prAiGenerationEnabled: true, + prFieldsAreSeedPlaceholders: true + }) + const { result } = renderHook(() => useChecksPanelCreateReview(input)) + + await act(async () => result.current.handleCreatePullRequest(false)) + + expect(input.handleGeneratePullRequestFields).toHaveBeenCalledTimes(1) + expect(input.createHostedReview).toHaveBeenCalledWith( + '/workspace/repo', + expect.objectContaining({ + base: 'main', + title: 'Add create flow', + body: 'Details.', + draft: false + }) + ) + }) + + it('creates with the finished run even while the panel still shows it generating', async () => { + const generated = { base: 'main', title: 'Add create flow', body: 'Details.', draft: false } + let finish: () => void = () => {} + const input = makeInput({ + activePullRequestGenerationKey: 'worktree-1::repo-1::feature/create', + handleGeneratePullRequestFields: vi.fn( + () => + new Promise<{ result: typeof generated }>((resolve) => { + finish = () => resolve({ result: generated }) + }) + ), + ownerSettings: { + ...getDefaultSettings('/home/test'), + sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' } + }, + prAiGenerationEnabled: true, + prFieldsAreSeedPlaceholders: true + }) + const { result, rerender } = renderHook( + (props: CreateInput) => useChecksPanelCreateReview(props), + { initialProps: input } + ) + + let click: Promise = Promise.resolve() + act(() => { + click = result.current.handleCreatePullRequest(false) + }) + rerender({ ...input, prGenerating: true }) + await act(async () => { + finish() + await click + }) + + expect(input.createHostedReview).toHaveBeenCalledWith( + '/workspace/repo', + expect.objectContaining({ title: 'Add create flow', body: 'Details.' }) + ) + }) + it.each([ + { name: 'still selected', selectedAtFinish: 'worktree-1', reveals: true }, + { name: 'no longer selected', selectedAtFinish: 'worktree-2', reveals: false } + ])( + 'reveals the created PR only when its worktree is $name after the panel closed mid-run', + async ({ selectedAtFinish, reveals }) => { + useAppStore.setState({ activeWorktreeId: 'worktree-1' }) + let finish: () => void = () => {} + const input = makeInput({ + activePullRequestGenerationKey: 'worktree-1::repo-1::feature/create', + activeWorktreeId: 'worktree-1', + handleGeneratePullRequestFields: vi.fn( + () => + new Promise<{ result: { base: string; title: string; body: string; draft: boolean } }>( + (resolve) => { + finish = () => + resolve({ + result: { base: 'main', title: 'Add create flow', body: '', draft: false } + }) + } + ) + ), + ownerSettings: { + ...getDefaultSettings('/home/test'), + sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' } + }, + prAiGenerationEnabled: true, + prCreationDefaults: { + draft: false, + generateDetailsOnOpen: false, + openAfterCreate: true, + useTemplate: true + }, + prFieldsAreSeedPlaceholders: true + }) + const { result, unmount } = renderHook(() => useChecksPanelCreateReview(input)) + + let click: Promise = Promise.resolve() + act(() => { + click = result.current.handleCreatePullRequest(false) + }) + unmount() + input.mountedRef.current = false + useAppStore.setState({ activeWorktreeId: selectedAtFinish }) + await act(async () => { + finish() + await click + }) + + expect(input.createHostedReview).toHaveBeenCalledTimes(1) + expect(input.updateWorktreeMeta).toHaveBeenCalledWith('worktree-1', expect.anything()) + expect(input.setRightSidebarTab).toHaveBeenCalledTimes(reveals ? 1 : 0) + expect(openHttpLink).toHaveBeenCalledTimes(reveals ? 1 : 0) + } + ) + + it.each(['github', 'gitlab'] as const)( + 'links the %s review to the clicked worktree, without touching the panel, when the panel moved on mid-run', + async (provider) => { + useAppStore.setState({ activeWorktreeId: 'worktree-1' }) + let finish: () => void = () => {} + const input = makeInput({ + activePullRequestGenerationKey: 'worktree-1::repo-1::feature/create', + activeWorktreeId: 'worktree-1', + handleGeneratePullRequestFields: vi.fn( + () => + new Promise<{ result: { base: string; title: string; body: string; draft: boolean } }>( + (resolve) => { + finish = () => + resolve({ + result: { base: 'main', title: 'Add create flow', body: '', draft: false } + }) + } + ) + ), + hostedReviewCreateProvider: provider, + ownerSettings: { + ...getDefaultSettings('/home/test'), + sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' } + }, + prAiGenerationEnabled: true, + prCreationDefaults: { + draft: false, + generateDetailsOnOpen: false, + openAfterCreate: true, + useTemplate: true + }, + prFieldsAreSeedPlaceholders: true + }) + const { result, rerender } = renderHook( + (props: CreateInput) => useChecksPanelCreateReview(props), + { initialProps: input } + ) + + let click: Promise = Promise.resolve() + act(() => { + click = result.current.handleCreatePullRequest(false) + }) + // The still-mounted panel switches to another worktree, as a worktree switch does. + useAppStore.setState({ activeWorktreeId: 'worktree-2' }) + input.panelContextKeyRef.current = 'repo-1::worktree-2::feature/other' + rerender({ + ...input, + activePullRequestGenerationKey: 'worktree-2::repo-1::feature/other', + activeWorktreeId: 'worktree-2', + branch: 'refs/heads/feature/other', + createComposerOpen: false, + panelContextKey: 'repo-1::worktree-2::feature/other' + }) + await act(async () => { + finish() + await click + }) + + expect(input.createHostedReview).toHaveBeenCalledWith( + '/workspace/repo', + expect.objectContaining({ head: 'feature/create', title: 'Add create flow' }) + ) + expect(input.updateWorktreeMeta).toHaveBeenCalledWith('worktree-1', expect.anything()) + expect(input.updatePullRequestGenerationRecord).toHaveBeenCalledWith( + 'worktree-1::repo-1::feature/create', + expect.any(Function) + ) + expect(input.setIsCreatingPr).not.toHaveBeenCalled() + expect(input.setCreatePrError).not.toHaveBeenCalled() + expect(input.createPrInFlightRef.current).toBeNull() + expect(input.fetchGitLabDetails).not.toHaveBeenCalled() + expect(input.setRightSidebarTab).not.toHaveBeenCalled() + expect(openHttpLink).not.toHaveBeenCalled() + } + ) + + it('opens the created PR while the panel is still showing it, even when another worktree is selected', async () => { + useAppStore.setState({ activeWorktreeId: 'worktree-2' }) + const input = makeInput({ + activeWorktreeId: 'worktree-1', + prCreationDefaults: { + draft: false, + generateDetailsOnOpen: false, + openAfterCreate: true, + useTemplate: true + } + }) + const { result } = renderHook(() => useChecksPanelCreateReview(input)) + + await act(async () => result.current.handleCreatePullRequest(false)) + + expect(input.setRightSidebarTab).toHaveBeenCalledWith('checks') + expect(openHttpLink).toHaveBeenCalledWith('https://github.com/orca/app/pull/42', { + worktreeId: 'worktree-1' + }) + }) }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx index 2ccb1188de7..4928d98263f 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx @@ -1,68 +1,54 @@ import { useCallback } from 'react' import { toast } from 'sonner' -import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh' import { openHttpLink } from '@/lib/http-link-routing' -import { resolveCreatedHostedReviewLink } from '../source-control-created-review-link' import { formatCreateError } from '../create-pull-request-review-copy' import { stripBaseRef } from '../create-pull-request-base-ref-normalization' import { normalizeHostedReviewHeadRef } from '../../../../../shared/hosted-review-refs' -import { - hostedReviewProviderSupportsDraft, - type HostedReviewProvider -} from '../../../../../shared/hosted-review' +import { hostedReviewProviderSupportsDraft } from '../../../../../shared/hosted-review' import type { ChecksPanelReviewState } from './use-checks-panel-review-state' import type { ChecksPanelControllerState } from './use-checks-panel-controller-state' -import type { ChecksPanelContextState } from './use-checks-panel-context-state' -import type { ChecksPanelPollingState } from './use-checks-panel-polling' import type { ChecksPanelComposerState } from './use-checks-panel-composer-state' import type { ChecksPanelBranchActionsState } from './use-checks-panel-branch-actions' -import type { ChecksPanelCheckAndReviewActionsState } from './use-checks-panel-check-and-review-actions' import { clearPullRequestGenerationRequiresPushBeforeCreate } from '@/store/slices/pull-request-generation' import { translate } from '@/i18n/i18n' +import type { PullRequestGenerationFields } from '@/store/slices/pull-request-generation' +import { useGenerateBeforeCreatePullRequest } from '../use-generate-before-create-pull-request' +import { createdReviewIsForeground } from '../created-review-foreground' +import { + useChecksPanelCreatedReview, + type ChecksPanelCreatedReviewInput +} from './use-checks-panel-created-review' -type ChecksPanelCreateReviewInput = Pick< - ChecksPanelReviewState, - | 'activePullRequestGenerationKey' - | 'createComposerOpen' - | 'createPrPushFirst' - | 'hostedReviewCreateCopy' - | 'hostedReviewCreateProvider' - | 'hostedReviewCreation' - | 'prCreationDefaults' -> & +type ChecksPanelCreateReviewInput = ChecksPanelCreatedReviewInput & + Pick< + ChecksPanelReviewState, + | 'activePullRequestGenerationKey' + | 'createComposerOpen' + | 'createPrPushFirst' + | 'hostedReviewCreateCopy' + | 'hostedReviewCreateProvider' + | 'hostedReviewCreation' + | 'prCreationDefaults' + > & Pick< ChecksPanelControllerState, - | 'activeWorktreeId' | 'activeWorktreePath' - | 'branch' | 'createHostedReview' | 'createPrInFlightRef' | 'createStackedHostedReview' - | 'fetchHostedReviewForBranch' + | 'mountedRef' + | 'ownerSettings' | 'panelContextKey' | 'panelContextKeyRef' - | 'repo' | 'setCreatePrError' | 'setGitStatusRefreshNonce' | 'setIsCreatingPr' - | 'setRightSidebarOpen' - | 'setRightSidebarTab' | 'updatePullRequestGenerationRecord' - | 'updateWorktreeMeta' > & - Pick< - ChecksPanelContextState, - | 'fallbackGitHubPRNumber' - | 'linkedAzureDevOpsPR' - | 'linkedBitbucketPR' - | 'linkedGiteaPR' - | 'linkedGitLabMR' - | 'linkedPR' - > & - Pick & Pick & - Pick & - Pick + Pick & + Pick & + Pick export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput) { const { @@ -75,160 +61,91 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput) createPrInFlightRef, createPrPushFirst, createStackedHostedReview, - fallbackGitHubPRNumber, - fetchGitLabDetails, - fetchHostedReviewForBranch, + handleGeneratePullRequestFields, hostedReviewCreateCopy, hostedReviewCreateProvider, hostedReviewCreation, - linkedAzureDevOpsPR, - linkedBitbucketPR, - linkedGiteaPR, - linkedGitLabMR, - linkedPR, + ownerSettings, panelContextKey, panelContextKeyRef, + prAiGenerationEnabled, prBase, prBody, prCreationDefaults, prDraft, + prFieldsAreSeedPlaceholders, prGenerating, prTitle, pushBeforeCreatePullRequest, - refreshLinkedGitHubPullRequest, repo, setCreatePrError, setGitStatusRefreshNonce, setIsCreatingPr, - setRightSidebarOpen, - setRightSidebarTab, - updatePullRequestGenerationRecord, - updateWorktreeMeta + updatePullRequestGenerationRecord } = model - const handlePullRequestCreated = useCallback( - async (result: { - provider: HostedReviewProvider - number: number - url: string - }): Promise => { - if (!repo || !branch) { - return - } - setRightSidebarOpen(true) - setRightSidebarTab('checks') - try { - const createdLink = resolveCreatedHostedReviewLink(result.provider, result.number) - if (activeWorktreeId && result.provider !== 'unsupported') { - await updateWorktreeMeta(activeWorktreeId, createdLink.worktree) - } - const linkedReviewNumbers = { - linkedGitHubPR: linkedPR, - fallbackGitHubPR: fallbackGitHubPRNumber, - linkedGitLabMR, - linkedBitbucketPR, - linkedAzureDevOpsPR, - linkedGiteaPR, - ...createdLink.lookup - } - if (result.provider === 'gitlab') { - const refreshedReview = await refreshHostedReviewCard(fetchHostedReviewForBranch, { - repoPath: repo.path, - repoId: repo.id, - branch, - ...linkedReviewNumbers - }) - const refreshedGitLabReview = - refreshedReview?.provider === 'gitlab' ? refreshedReview : null - await fetchGitLabDetails({ - mrNumberOverride: result.number, - headShaOverride: refreshedGitLabReview?.headSha, - commitAsCurrent: true - }) - return - } - if (result.provider !== 'github') { - await refreshHostedReviewCard(fetchHostedReviewForBranch, { - repoPath: repo.path, - repoId: repo.id, - branch, - ...linkedReviewNumbers - }) - return - } - await refreshLinkedGitHubPullRequest(result.number) - } catch { - // The success toast keeps the hosted URL available; Checks can be refreshed manually. - } - }, - [ - branch, - fallbackGitHubPRNumber, - fetchGitLabDetails, - fetchHostedReviewForBranch, - linkedAzureDevOpsPR, - linkedBitbucketPR, - linkedGiteaPR, - linkedGitLabMR, - linkedPR, - refreshLinkedGitHubPullRequest, - repo, - setRightSidebarOpen, - setRightSidebarTab, - activeWorktreeId, - updateWorktreeMeta - ] - ) - - const handleCreatePullRequest = useCallback( - async (stacked = false): Promise => { - if (!repo || !branch || !createComposerOpen || prGenerating || createPrInFlightRef.current) { - return - } + const handlePullRequestCreated = useChecksPanelCreatedReview(model) + const createPullRequest = useCallback( + async (stacked = false, generated?: PullRequestGenerationFields): Promise => { const requestContextKey = panelContextKey + // Why: a click-owned run can reach here after the panel moved on; it still creates and links the clicked branch's review, and drives only the panel that still shows it. + const panelShowsRequest = panelContextKeyRef.current === requestContextKey + if ( + !repo || + !branch || + !createComposerOpen || + prGenerating || + (panelShowsRequest && createPrInFlightRef.current) + ) { + return + } const isCurrentCreateRequest = (): boolean => panelContextKeyRef.current === requestContextKey && createPrInFlightRef.current === requestContextKey - const base = stripBaseRef(prBase).trim() - const title = prTitle.trim() + const showCreateError = (message: string): void => { + if (isCurrentCreateRequest()) { + setCreatePrError(message) + } + } + const fields = generated ?? { base: prBase, title: prTitle, body: prBody, draft: prDraft } + const base = stripBaseRef(fields.base).trim() + const title = fields.title.trim() const worktreePath = activeWorktreePath ?? repo.path - if (!title) { - setCreatePrError( - translate( + const invalidFieldsError = !title + ? 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 } - ) - ) + : !base || stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase() + ? translate( + 'auto.components.right.sidebar.SourceControl.ae743199cd', + 'Choose a different base branch before creating a {{value0}}.', + { value0: hostedReviewCreateCopy.reviewLabel } + ) + : null + if (invalidFieldsError) { + if (panelShowsRequest) { + setCreatePrError(invalidFieldsError) + } return } - createPrInFlightRef.current = requestContextKey - setIsCreatingPr(true) - setCreatePrError(null) + if (panelShowsRequest) { + 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.') + showCreateError('Push failed. Resolve the push error, then try again.') return } pushed = true @@ -239,24 +156,23 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput) base, head: normalizeHostedReviewHeadRef(branch), title, - body: prBody, - draft: prDraft && hostedReviewProviderSupportsDraft(hostedReviewCreateProvider), + body: fields.body, + draft: fields.draft && hostedReviewProviderSupportsDraft(hostedReviewCreateProvider), worktreePath, useTemplate: prCreationDefaults.useTemplate } const result = stacked ? await createStackedHostedReview(repo.path, createInput) : await createHostedReview(repo.path, createInput) - if (!isCurrentCreateRequest()) { - return - } + // Why: read before linking, which changes the panel's context key. + const panelShowsReview = model.mountedRef.current && isCurrentCreateRequest() if (result.ok) { - await handlePullRequestCreated({ - provider: hostedReviewCreateProvider, - number: result.number, - url: result.url - }) - if (prCreationDefaults.openAfterCreate) { + const foreground = createdReviewIsForeground(activeWorktreeId, panelShowsReview) + await handlePullRequestCreated( + { provider: hostedReviewCreateProvider, number: result.number, url: result.url }, + panelShowsReview + ) + if (prCreationDefaults.openAfterCreate && foreground) { openHttpLink(result.url, { worktreeId: activeWorktreeId }) } if (activePullRequestGenerationKey) { @@ -293,11 +209,10 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput) } ) if (number) { - await handlePullRequestCreated({ - provider: hostedReviewCreateProvider, - number, - url: result.existingReview.url - }) + await handlePullRequestCreated( + { provider: hostedReviewCreateProvider, number, url: result.existingReview.url }, + panelShowsReview + ) if (activePullRequestGenerationKey) { updatePullRequestGenerationRecord( activePullRequestGenerationKey, @@ -313,19 +228,15 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput) if ('createdReview' in result && result.createdReview?.url) { const { number, url } = result.createdReview if (number) { - await handlePullRequestCreated({ - provider: hostedReviewCreateProvider, - number, - url - }) + await handlePullRequestCreated( + { provider: hostedReviewCreateProvider, number, url }, + panelShowsReview + ) } } - setCreatePrError(formatCreateError(result, pushed, hostedReviewCreateCopy.shortLabel)) + showCreateError(formatCreateError(result, pushed, hostedReviewCreateCopy.shortLabel)) } catch (error) { - if (!isCurrentCreateRequest()) { - return - } - setCreatePrError( + showCreateError( error instanceof Error ? error.message : translate( @@ -358,6 +269,7 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput) hostedReviewCreateCopy.titleLabel, hostedReviewCreateProvider, hostedReviewCreation?.blockedReason, + model.mountedRef, panelContextKey, prBase, prBody, @@ -376,6 +288,16 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput) setCreatePrError ] ) + const { handleCreatePullRequest } = useGenerateBeforeCreatePullRequest({ + aiGenerationEnabled: prAiGenerationEnabled, + canCreate: createComposerOpen, + createPullRequest, + fieldsAreSeedPlaceholders: prFieldsAreSeedPlaceholders, + generatePullRequestFields: handleGeneratePullRequestFields, + generationKey: activePullRequestGenerationKey, + repo, + settings: ownerSettings + }) return { handlePullRequestCreated, handleCreatePullRequest } } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-created-review.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-created-review.tsx new file mode 100644 index 00000000000..a7b80296642 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-created-review.tsx @@ -0,0 +1,134 @@ +import { useCallback } from 'react' +import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh' +import { resolveCreatedHostedReviewLink } from '../source-control-created-review-link' +import type { HostedReviewProvider } from '../../../../../shared/hosted-review' +import type { ChecksPanelControllerState } from './use-checks-panel-controller-state' +import type { ChecksPanelContextState } from './use-checks-panel-context-state' +import type { ChecksPanelPollingState } from './use-checks-panel-polling' +import type { ChecksPanelCheckAndReviewActionsState } from './use-checks-panel-check-and-review-actions' +import { createdReviewIsForeground } from '../created-review-foreground' + +export type ChecksPanelCreatedReviewInput = Pick< + ChecksPanelControllerState, + | 'activeWorktreeId' + | 'branch' + | 'fetchHostedReviewForBranch' + | 'repo' + | 'setRightSidebarOpen' + | 'setRightSidebarTab' + | 'updateWorktreeMeta' +> & + Pick< + ChecksPanelContextState, + | 'fallbackGitHubPRNumber' + | 'linkedAzureDevOpsPR' + | 'linkedBitbucketPR' + | 'linkedGiteaPR' + | 'linkedGitLabMR' + | 'linkedPR' + > & + Pick & + Pick + +/** Links a review created from the Checks panel to its worktree and refreshes what shows it. */ +export function useChecksPanelCreatedReview(model: ChecksPanelCreatedReviewInput) { + const { + activeWorktreeId, + branch, + fallbackGitHubPRNumber, + fetchGitLabDetails, + fetchHostedReviewForBranch, + linkedAzureDevOpsPR, + linkedBitbucketPR, + linkedGiteaPR, + linkedGitLabMR, + linkedPR, + refreshLinkedGitHubPullRequest, + repo, + setRightSidebarOpen, + setRightSidebarTab, + updateWorktreeMeta + } = model + return useCallback( + async ( + result: { + provider: HostedReviewProvider + number: number + url: string + }, + // Why: a create that outlives its panel context still links its worktree, but must not paint into the panel's new context. + panelShowsReview: boolean + ): Promise => { + if (!repo || !branch) { + return + } + if (createdReviewIsForeground(activeWorktreeId, panelShowsReview)) { + setRightSidebarOpen(true) + setRightSidebarTab('checks') + } + try { + const createdLink = resolveCreatedHostedReviewLink(result.provider, result.number) + if (activeWorktreeId && result.provider !== 'unsupported') { + await updateWorktreeMeta(activeWorktreeId, createdLink.worktree) + } + const linkedReviewNumbers = { + linkedGitHubPR: linkedPR, + fallbackGitHubPR: fallbackGitHubPRNumber, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR, + ...createdLink.lookup + } + if (result.provider === 'gitlab') { + const refreshedReview = await refreshHostedReviewCard(fetchHostedReviewForBranch, { + repoPath: repo.path, + repoId: repo.id, + branch, + ...linkedReviewNumbers + }) + if (!panelShowsReview) { + return + } + const refreshedGitLabReview = + refreshedReview?.provider === 'gitlab' ? refreshedReview : null + await fetchGitLabDetails({ + mrNumberOverride: result.number, + headShaOverride: refreshedGitLabReview?.headSha, + commitAsCurrent: true + }) + return + } + if (result.provider !== 'github') { + await refreshHostedReviewCard(fetchHostedReviewForBranch, { + repoPath: repo.path, + repoId: repo.id, + branch, + ...linkedReviewNumbers + }) + return + } + await refreshLinkedGitHubPullRequest(result.number) + } catch { + // The success toast keeps the hosted URL available; Checks can be refreshed manually. + } + }, + [ + activeWorktreeId, + branch, + fallbackGitHubPRNumber, + fetchGitLabDetails, + fetchHostedReviewForBranch, + linkedAzureDevOpsPR, + linkedBitbucketPR, + linkedGiteaPR, + linkedGitLabMR, + linkedPR, + refreshLinkedGitHubPullRequest, + repo, + setRightSidebarOpen, + setRightSidebarTab, + updateWorktreeMeta + ] + ) +} diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx index 8b838bca334..5f537afafef 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx @@ -3,12 +3,22 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type * as RuntimeGitClient from '@/runtime/runtime-git-client' +import type { RuntimeGeneratePullRequestFieldsResult } from '@/runtime/runtime-git-client-context' +import { useAppStore } from '@/store' +import { resolvePullRequestGenerationCancel } from '@/store/slices/pull-request-generation' -const runtime = vi.hoisted(() => ({ cancel: vi.fn().mockResolvedValue(undefined) })) +const runtime = vi.hoisted(() => ({ + cancel: vi.fn().mockResolvedValue(undefined), + generate: vi.fn() +})) vi.mock('@/runtime/runtime-git-client', async (importOriginal) => { const original = await importOriginal() - return { ...original, cancelRuntimeGeneratePullRequestFields: runtime.cancel } + return { + ...original, + cancelRuntimeGeneratePullRequestFields: runtime.cancel, + generateRuntimePullRequestFields: runtime.generate + } }) import { useChecksPanelGeneration } from './use-checks-panel-generation' @@ -18,6 +28,8 @@ type GenerationInput = Parameters[0] afterEach(() => { cleanup() runtime.cancel.mockReset().mockResolvedValue(undefined) + runtime.generate.mockReset() + useAppStore.setState({ pullRequestGenerationRecords: {} }) }) describe('useChecksPanelGeneration cancellation ownership', () => { @@ -75,3 +87,200 @@ describe('useChecksPanelGeneration cancellation ownership', () => { expect(updateRecord).toHaveBeenCalledWith('repo-1::owner-worktree', expect.any(Function)) }) }) + +describe('useChecksPanelGeneration outcome', () => { + it.each([ + { name: 'keeps the base for a Create PR run', autoSubmit: true, stop: false, base: 'main' }, + { + name: 'keeps the agent base for a reviewed run', + autoSubmit: false, + stop: false, + base: 'develop' + }, + { name: 'returns no result when Stop lands first', autoSubmit: true, stop: true, base: null } + ])('$name', async ({ autoSubmit, stop, base }) => { + const generationKey = 'repo-1::worktree-1::feature' + let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {} + runtime.generate.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } = + useAppStore.getState() + const input: GenerationInput = { + activePullRequestGenerationKey: generationKey, + activePullRequestGenerationRecord: null, + activeWorktreeId: 'worktree-1', + activeWorktreePath: '/workspace/repo', + allocatePullRequestGenerationRequestId: vi.fn(() => 11), + branch: 'feature', + handleBranchChangedByPullRequestGeneration: vi.fn(), + hostedReviewCreateProvider: 'github', + ownerSettings: null, + prCreationDefaults: { + draft: false, + generateDetailsOnOpen: false, + openAfterCreate: false, + useTemplate: true + }, + prGenerationRecords: {}, + repo: { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + }, + setPullRequestGenerationRecord, + updatePullRequestGenerationRecord + } + const { result } = renderHook(() => useChecksPanelGeneration(input)) + const fields = { base: 'main', title: 'Feature', body: '', draft: false } + + const outcome = result.current.handleGeneratePullRequestFieldsForActive( + fields, + { base: 0, title: 0, body: 0, draft: 0 }, + undefined, + { autoSubmit } + ) + if (stop) { + updatePullRequestGenerationRecord(generationKey, resolvePullRequestGenerationCancel) + } + const generated = { base: 'develop', title: 'Add feature flag', body: 'Details.', draft: false } + answer({ success: true, fields: generated }) + + await expect(outcome).resolves.toEqual({ result: base ? { ...generated, base } : null }) + }) + + it("returns no result when a later run replaced this one, not the later run's details", async () => { + const generationKey = 'repo-1::worktree-1::feature' + let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {} + runtime.generate.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } = + useAppStore.getState() + const { result } = renderHook(() => + useChecksPanelGeneration({ + activePullRequestGenerationKey: generationKey, + activePullRequestGenerationRecord: null, + activeWorktreeId: 'worktree-1', + activeWorktreePath: '/workspace/repo', + allocatePullRequestGenerationRequestId: vi.fn(() => 11), + branch: 'feature', + handleBranchChangedByPullRequestGeneration: vi.fn(), + hostedReviewCreateProvider: 'github', + ownerSettings: null, + prCreationDefaults: { + draft: false, + generateDetailsOnOpen: false, + openAfterCreate: false, + useTemplate: true + }, + prGenerationRecords: {}, + repo: { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + }, + setPullRequestGenerationRecord, + updatePullRequestGenerationRecord + }) + ) + const fields = { base: 'main', title: 'Feature', body: '', draft: false } + + const outcome = result.current.handleGeneratePullRequestFieldsForActive( + fields, + { base: 0, title: 0, body: 0, draft: 0 }, + undefined, + { autoSubmit: true } + ) + // Stop, then a Generate click whose run finishes before the stopped one winds down. + const stopped = useAppStore.getState().pullRequestGenerationRecords[generationKey] + expect(stopped?.status).toBe('running') + setPullRequestGenerationRecord(generationKey, { + ...stopped!, + context: { ...stopped!.context, requestId: 12 }, + status: 'succeeded', + result: { ...fields, title: 'Later run' } + }) + answer({ success: false, error: 'canceled', canceled: true }) + + await expect(outcome).resolves.toEqual({ result: null }) + }) + + it('settles with no result as soon as Stop lands, without waiting for the stopped request, and drops its late result', async () => { + const generationKey = 'repo-1::worktree-1::feature' + let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {} + runtime.generate.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + // The cancel never reaches the host, so the stopped request stays pending. + runtime.cancel.mockReturnValue(new Promise(() => {})) + const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } = + useAppStore.getState() + const { result } = renderHook(() => + useChecksPanelGeneration({ + activePullRequestGenerationKey: generationKey, + activePullRequestGenerationRecord: null, + activeWorktreeId: 'worktree-1', + activeWorktreePath: '/workspace/repo', + allocatePullRequestGenerationRequestId: vi.fn(() => 11), + branch: 'feature', + handleBranchChangedByPullRequestGeneration: vi.fn(), + hostedReviewCreateProvider: 'github', + ownerSettings: null, + prCreationDefaults: { + draft: false, + generateDetailsOnOpen: false, + openAfterCreate: false, + useTemplate: true + }, + prGenerationRecords: useAppStore((s) => s.pullRequestGenerationRecords), + repo: { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + }, + setPullRequestGenerationRecord, + updatePullRequestGenerationRecord + }) + ) + let outcome: Promise = Promise.resolve() + act(() => { + outcome = result.current.handleGeneratePullRequestFieldsForActive( + { base: 'main', title: 'Feature', body: '', draft: false }, + { base: 0, title: 0, body: 0, draft: 0 }, + undefined, + { autoSubmit: true } + ) + }) + + act(() => result.current.handleCancelGeneratePullRequestFieldsForActive()) + expect(runtime.cancel).toHaveBeenCalledTimes(1) + const stillPending = new Promise((resolve) => setTimeout(() => resolve('still pending'), 50)) + await expect(Promise.race([outcome, stillPending])).resolves.toEqual({ result: null }) + + answer({ + success: true, + fields: { base: 'develop', title: 'Late run', body: 'Late.', draft: false } + }) + await act(async () => {}) + expect(useAppStore.getState().pullRequestGenerationRecords[generationKey]).toMatchObject({ + status: 'canceled', + result: null + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.tsx index 94df8aea1cc..48cd276016a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.tsx @@ -1,4 +1,6 @@ import { useCallback } from 'react' +import type { PullRequestGenerationOutcome } from '../create-pull-request-dialog-field-model' +import { settlePullRequestGenerationRequest } from '../pull-request-generation-request-outcome' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { @@ -19,6 +21,7 @@ import { type PullRequestGenerationContext, type PullRequestGenerationFields } from '@/store/slices/pull-request-generation' +import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit' type ChecksPanelGenerationInput = Pick< ChecksPanelReviewState, @@ -62,16 +65,17 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) { async ( fields: PullRequestGenerationFields, fieldRevisions: PullRequestFieldRevisions, - overrides?: RuntimeGeneratePullRequestFieldsOverrides - ): Promise => { + overrides?: RuntimeGeneratePullRequestFieldsOverrides, + options?: PullRequestGenerationOptions + ): Promise => { if (!repo || !activePullRequestGenerationKey || !activeWorktreePath || !branch) { - return + return undefined } const generationKey = activePullRequestGenerationKey if ( useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running' ) { - return + return undefined } const requestId = allocatePullRequestGenerationRequestId() const context: PullRequestGenerationContext = { @@ -88,7 +92,12 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) { useAppStore.getState().pullRequestGenerationRecords[generationKey] ?.requiresPushBeforeCreate === true // Why: ChecksPanel unsets the composer on navigate-away; persist the request so generation can finish in the background. - const runningRecord = createRunningPullRequestGenerationRecord(context, seed, fieldRevisions) + const runningRecord = createRunningPullRequestGenerationRecord( + context, + seed, + fieldRevisions, + options?.autoSubmit + ) setPullRequestGenerationRecord( generationKey, previousRequiresPushBeforeCreate @@ -96,61 +105,64 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) { : runningRecord ) - try { - const result = await generateRuntimePullRequestFields( - { - // Why: route generation by the worktree owner captured at click time. - settings: context.runtimeTargetSettings, - worktreeId: context.worktreeId, - worktreePath: context.worktreePath, - connectionId: context.connectionId - }, - { - base: stripBaseRef(seed.base.trim()), - title: seed.title, - body: seed.body, - draft: seed.draft, - provider: hostedReviewCreateProvider, - useTemplate: prCreationDefaults.useTemplate - }, - overrides - ) - if (result.branchChangedByPreparation) { - await handleBranchChangedByPullRequestGeneration(generationKey, context) - } - if (result.success) { - useAppStore.getState().recordFeatureInteraction('ai-pr-generation') - } - updatePullRequestGenerationRecord(generationKey, (record) => { - if (!result.success) { - return resolvePullRequestGenerationFailure({ + const request = (async (): Promise => { + try { + const result = await generateRuntimePullRequestFields( + { + // Why: route generation by the worktree owner captured at click time. + settings: context.runtimeTargetSettings, + worktreeId: context.worktreeId, + worktreePath: context.worktreePath, + connectionId: context.connectionId + }, + { + base: stripBaseRef(seed.base.trim()), + title: seed.title, + body: seed.body, + draft: seed.draft, + provider: hostedReviewCreateProvider, + useTemplate: prCreationDefaults.useTemplate + }, + overrides + ) + if (result.branchChangedByPreparation) { + await handleBranchChangedByPullRequestGeneration(generationKey, context) + } + if (result.success) { + useAppStore.getState().recordFeatureInteraction('ai-pr-generation') + } + updatePullRequestGenerationRecord(generationKey, (record) => { + if (!result.success) { + return resolvePullRequestGenerationFailure({ + record, + requestId, + canceled: result.canceled, + error: result.canceled ? null : result.error + }) + } + return resolvePullRequestGenerationSuccess({ record, requestId, - canceled: result.canceled, - error: result.canceled ? null : result.error + result: { + base: stripBaseRef(result.fields.base), + title: result.fields.title, + body: result.fields.body, + draft: result.fields.draft + } }) - } - return resolvePullRequestGenerationSuccess({ - record, - requestId, - result: { - base: stripBaseRef(result.fields.base), - title: result.fields.title, - body: result.fields.body, - draft: result.fields.draft - } }) - }) - } catch (error) { - updatePullRequestGenerationRecord(generationKey, (record) => - resolvePullRequestGenerationFailure({ - record, - requestId, - error: - error instanceof Error ? error.message : 'Failed to generate pull request details' - }) - ) - } + } catch (error) { + updatePullRequestGenerationRecord(generationKey, (record) => + resolvePullRequestGenerationFailure({ + record, + requestId, + error: + error instanceof Error ? error.message : 'Failed to generate pull request details' + }) + ) + } + })() + return await settlePullRequestGenerationRequest(generationKey, requestId, request) }, [ activePullRequestGenerationKey, diff --git a/src/renderer/src/components/right-sidebar/create-pull-request-dialog-field-model.ts b/src/renderer/src/components/right-sidebar/create-pull-request-dialog-field-model.ts index 927ef3c4a57..bb081a35c39 100644 --- a/src/renderer/src/components/right-sidebar/create-pull-request-dialog-field-model.ts +++ b/src/renderer/src/components/right-sidebar/create-pull-request-dialog-field-model.ts @@ -6,7 +6,13 @@ import type { import type { Repo } from '../../../../shared/repo-types' import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' import type { SourceControlAiPrCreationDefaults } from '../../../../shared/source-control-ai-types' -import type { PullRequestFieldRevisions } from '@/store/slices/pull-request-generation' +import type { + PullRequestFieldRevisions, + PullRequestGenerationFields +} from '@/store/slices/pull-request-generation' +import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit' + +export type PullRequestGenerationOutcome = { result: PullRequestGenerationFields | null } export type PullRequestDraftFields = { base: string @@ -44,8 +50,9 @@ export type UseCreatePullRequestDialogFieldsOptions = { onGenerate: ( fields: PullRequestDraftFields, fieldRevisions: PullRequestFieldRevisions, - overrides?: RuntimeGeneratePullRequestFieldsOverrides - ) => void + overrides?: RuntimeGeneratePullRequestFieldsOverrides, + options?: PullRequestGenerationOptions + ) => void | Promise onCancelGenerate: () => void } } diff --git a/src/renderer/src/components/right-sidebar/create-review-draft-title.ts b/src/renderer/src/components/right-sidebar/create-review-draft-title.ts index fae7496bfad..f5f4c84c90f 100644 --- a/src/renderer/src/components/right-sidebar/create-review-draft-title.ts +++ b/src/renderer/src/components/right-sidebar/create-review-draft-title.ts @@ -1,4 +1,5 @@ import { humanizeBranchSlug } from '../../../../shared/branch-name-from-work' +import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' export function resolveCreateReviewDraftTitle({ @@ -16,3 +17,17 @@ export function resolveCreateReviewDraftTitle({ const branchLeaf = normalizedBranch.split('/').pop()?.replace(/_/g, '-') ?? '' return humanizeBranchSlug(branchLeaf) || normalizedBranch } + +/** The title and body the composer seeds for a branch, before any edit or generation. */ +export function resolveCreateReviewSeedText({ + branch, + eligibility +}: { + branch: string + eligibility: Pick | null +}): { title: string; body: string } { + return { + title: resolveCreateReviewDraftTitle({ branch, eligibilityTitle: eligibility?.title }), + body: eligibility?.body ?? '' + } +} diff --git a/src/renderer/src/components/right-sidebar/created-review-foreground.ts b/src/renderer/src/components/right-sidebar/created-review-foreground.ts new file mode 100644 index 00000000000..5920f381f26 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/created-review-foreground.ts @@ -0,0 +1,10 @@ +import { useAppStore } from '@/store' + +// Why: a create that outlives its panel (e.g. after generation) must not reveal the review over a worktree the user has since switched to. +// A panel still showing the review counts too, since it can differ from the selected worktree (Checks follows the terminal cwd). +export function createdReviewIsForeground( + worktreeId: string | null, + panelShowsReview = false +): boolean { + return panelShowsReview || useAppStore.getState().activeWorktreeId === worktreeId +} diff --git a/src/renderer/src/components/right-sidebar/pull-request-generation-request-outcome.ts b/src/renderer/src/components/right-sidebar/pull-request-generation-request-outcome.ts new file mode 100644 index 00000000000..53f8dcc2ecb --- /dev/null +++ b/src/renderer/src/components/right-sidebar/pull-request-generation-request-outcome.ts @@ -0,0 +1,45 @@ +import { useAppStore } from '@/store' +import type { PullRequestGenerationRecords } from '@/store/slices/pull-request-generation' +import type { PullRequestGenerationOutcome } from './create-pull-request-dialog-field-model' + +function isRequestRunning( + records: PullRequestGenerationRecords, + generationKey: string, + requestId: number +): boolean { + const record = records[generationKey] + return record?.context.requestId === requestId && record.status === 'running' +} + +/** + * Settles a generation request's outcome once its record stops running rather than when the request + * returns: Stop cancels the record at once, and a cancel that cannot reach a slow host must not keep + * the run's caller (a Create PR click) waiting. + */ +export async function settlePullRequestGenerationRequest( + generationKey: string, + requestId: number, + request: Promise +): Promise { + let unsubscribe = (): void => {} + const stoppedRunning = new Promise((resolve) => { + const resolveIfStopped = (records: PullRequestGenerationRecords): void => { + if (!isRequestRunning(records, generationKey, requestId)) { + resolve() + } + } + unsubscribe = useAppStore.subscribe((state) => + resolveIfStopped(state.pullRequestGenerationRecords) + ) + resolveIfStopped(useAppStore.getState().pullRequestGenerationRecords) + }) + try { + // Why: the request keeps running after Stop; its late result is dropped by the record's requestId and status checks. + await Promise.race([request, stoppedRunning]) + } finally { + unsubscribe() + } + const record = useAppStore.getState().pullRequestGenerationRecords[generationKey] + // Why: failed, stopped, or superseded runs carry no result for this request. + return { result: record?.context.requestId === requestId ? record.result : null } +} diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts index f89236426cb..76e2b45add1 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts @@ -390,6 +390,21 @@ describe('source-control Create PR intent flow helpers', () => { }) }) + it('never downgrades a "Create as draft" choice to a ready review', () => { + expect( + resolveCreatePrIntentGeneratedReviewFields( + { base: 'main', title: 'Feature branch', body: '', draft: true }, + { + success: true, + fields: { base: 'main', title: 'Generated title', body: 'Details', draft: false } + } + ) + ).toEqual({ + ok: true, + fields: { base: 'main', title: 'Generated title', body: 'Details', draft: true } + }) + }) + it('surfaces the commit failure summary in the Create PR intent notice', () => { expect( getCreatePrIntentCommitFailureNoticeMessage( diff --git a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts index d72d6dcc86e..3f576bfdef1 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts @@ -216,7 +216,8 @@ export function resolveCreatePrIntentGeneratedReviewFields( title: generated.fields.title.trim() || current.title, // Why: a description is optional everywhere else (composer, GitHub/GitLab), so an intentionally empty generated body is a valid result, not a failure. body: generated.fields.body, - draft: generated.fields.draft + // Why: there is no form to review this one, so the agent may flag unfinished work but never downgrade a "Create as draft" choice. + draft: current.draft || generated.fields.draft } } } diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-review-composer.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-review-composer.ts index 6335b51fee9..3e25db500a5 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-review-composer.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-review-composer.ts @@ -70,6 +70,7 @@ export function useSourceControlCreateReviewComposer({ setBody: setPrBody, draft: prDraft, setDraft: setPrDraft, + fieldsAreSeedPlaceholders: prFieldsAreSeedPlaceholders, stackedCreationSupported: prStackedCreationSupported, repoDefaultBaseRef: prRepoDefaultBaseRef, baseQuery: prBaseQuery, @@ -107,9 +108,7 @@ export function useSourceControlCreateReviewComposer({ seed: activePullRequestGenerationRecord?.seed ?? null, seedFieldRevisions: activePullRequestGenerationRecord?.seedFieldRevisions ?? null, onSeedRestored: handlePullRequestGenerationSeedRestored, - onGenerate: (fields, fieldRevisions, overrides) => { - void handleGeneratePullRequestFieldsForActive(fields, fieldRevisions, overrides) - }, + onGenerate: handleGeneratePullRequestFieldsForActive, onCancelGenerate: handleCancelGeneratePullRequestFieldsForActive } }) @@ -202,6 +201,7 @@ export function useSourceControlCreateReviewComposer({ prBaseSearchPending, prBody, prDraft, + prFieldsAreSeedPlaceholders, prGenerateDisabled, prGenerateDisabledReason, prGenerateError, diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.test.tsx b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.test.tsx new file mode 100644 index 00000000000..e950caad5bb --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.test.tsx @@ -0,0 +1,623 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { useRef, useState } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeGitClient from '@/runtime/runtime-git-client' +import type { RuntimeGeneratePullRequestFieldsResult } from '@/runtime/runtime-git-client-context' +import { useAppStore } from '@/store' +import { + createRunningPullRequestGenerationRecord, + resolvePullRequestGenerationFailure, + resolvePullRequestGenerationSuccess, + type PullRequestGenerationFields +} from '@/store/slices/pull-request-generation' +import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit' +import type { PullRequestGenerationOutcome } from '../../create-pull-request-dialog-field-model' +import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy' +import { getDefaultSettings } from '../../../../../../shared/constants' +import type { + CreateHostedReviewResult, + HostedReviewCreationEligibility +} from '../../../../../../shared/hosted-review' +import { + DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, + getDefaultSourceControlAiSettings +} from '../../../../../../shared/source-control-ai-settings' +import { useSourceControlHostedReviewCreation } from './use-hosted-review-creation' +import { useSourceControlHostedReviewEligibility } from './use-hosted-review-eligibility' +import { useSourceControlHostedReviewState } from './use-hosted-review-state' +import { useSourceControlPullRequestGeneration } from './use-pull-request-generation' + +const runtime = vi.hoisted(() => ({ cancel: vi.fn(), generate: vi.fn() })) + +vi.mock('@/runtime/runtime-git-client', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + cancelRuntimeGeneratePullRequestFields: runtime.cancel, + generateRuntimePullRequestFields: runtime.generate + } +}) + +type Input = Parameters[0] + +const generatedFields = { + base: 'develop', + title: 'Correct README install steps', + body: 'Fixes the typo.', + draft: true +} + +const GENERATION_KEY = 'wt-1::repo-1::fix-readme-typo' +const runningRecordFor = (options: PullRequestGenerationOptions = {}) => + createRunningPullRequestGenerationRecord( + { + worktreeId: 'wt-1', + worktreePath: '/repo', + requestId: 7, + repoId: 'repo-1', + branch: 'fix-readme-typo' + }, + { base: 'main', title: 'Fix readme typo', body: '', draft: false }, + { base: 0, title: 0, body: 0, draft: 0 }, + options.autoSubmit + ) +const runningRecord = runningRecordFor() + +const readyEligibility: HostedReviewCreationEligibility = { + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'not_found' +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + runtime.cancel.mockReset() + runtime.generate.mockReset() + useAppStore.setState({ pullRequestGenerationRecords: {}, activeWorktreeId: null }) +}) + +function makeInput(overrides: Partial = {}): Input { + const createdReview: CreateHostedReviewResult = { + ok: true, + number: 42, + url: 'https://github.com/o/r/pull/42' + } + return { + activePullRequestGenerationKey: GENERATION_KEY, + activeRepo: { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + }, + activeWorktreeId: 'wt-1', + branchName: 'fix-readme-typo', + createHostedReview: vi.fn(async () => createdReview), + createPrInFlightRef: { current: {} }, + createStackedHostedReview: vi.fn(), + handleGeneratePullRequestFields: generationReturning(generatedFields), + handlePullRequestCreated: vi.fn(async () => {}), + hostedReviewCreateCopy: localizedHostedReviewCopy('github'), + hostedReviewCreateProvider: 'github', + hostedReviewCreation: readyEligibility, + prAiGenerationEnabled: true, + prBase: 'main', + prBody: '', + prDraft: false, + prFieldsAreSeedPlaceholders: true, + prGenerating: false, + prTitle: 'Fix readme typo', + resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, + setCreatePrInFlightByWorktree: vi.fn(), + setCreatePrIntentNoticeForWorktree: vi.fn(), + settings: { + ...getDefaultSettings('/home/test'), + sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' } + }, + worktreePath: '/repo', + ...overrides + } +} + +// Like the store-routed generation: the record runs before the first await and settles to the outcome. +function startGeneration(options?: PullRequestGenerationOptions): void { + useAppStore.getState().setPullRequestGenerationRecord(GENERATION_KEY, runningRecordFor(options)) +} +function settleGeneration( + result: PullRequestGenerationFields | null +): PullRequestGenerationOutcome { + useAppStore + .getState() + .updatePullRequestGenerationRecord(GENERATION_KEY, (record) => + result + ? resolvePullRequestGenerationSuccess({ record, requestId: 7, result }) + : resolvePullRequestGenerationFailure({ record, requestId: 7, error: 'Agent failed' }) + ) + return { + result: useAppStore.getState().pullRequestGenerationRecords[GENERATION_KEY]?.result ?? null + } +} +function generationReturning(result: PullRequestGenerationFields | null) { + return vi.fn(async (_overrides?: unknown, options?: PullRequestGenerationOptions) => { + startGeneration(options) + return settleGeneration(result) + }) +} +function deferredGeneration() { + let finish: (result: PullRequestGenerationFields | null) => void = () => {} + const generate = vi.fn((_overrides?: unknown, options?: PullRequestGenerationOptions) => { + startGeneration(options) + return new Promise((resolve) => { + finish = (result) => resolve(settleGeneration(result)) + }) + }) + return { generate, finish: (result: PullRequestGenerationFields | null) => finish(result) } +} + +describe('useSourceControlHostedReviewCreation', () => { + it('generates details for untouched placeholders, then creates with them', async () => { + const input = makeInput() + const { result } = renderHook(() => useSourceControlHostedReviewCreation(input)) + + await act(async () => result.current.handleCreatePullRequest()) + + expect(input.handleGeneratePullRequestFields).toHaveBeenCalledTimes(1) + expect(input.createHostedReview).toHaveBeenCalledTimes(1) + expect(input.createHostedReview).toHaveBeenCalledWith( + '/repo', + expect.objectContaining({ + base: 'main', + title: 'Correct README install steps', + body: 'Fixes the typo.', + draft: true + }) + ) + }) + + it('does not create when generation fails, and the next click submits as shown, even after the panel reopens', async () => { + const input = makeInput({ handleGeneratePullRequestFields: generationReturning(null) }) + const first = renderHook(() => useSourceControlHostedReviewCreation(input)) + + await act(async () => first.result.current.handleCreatePullRequest()) + expect(input.createHostedReview).not.toHaveBeenCalled() + first.unmount() + + const reopened = renderHook(() => useSourceControlHostedReviewCreation(input)) + await act(async () => reopened.result.current.handleCreatePullRequest()) + expect(input.handleGeneratePullRequestFields).toHaveBeenCalledTimes(1) + expect(input.createHostedReview).toHaveBeenCalledWith( + '/repo', + expect.objectContaining({ title: 'Fix readme typo', body: '' }) + ) + }) + + it('starts one run for repeated clicks and creates once', async () => { + const { generate, finish } = deferredGeneration() + const input = makeInput({ handleGeneratePullRequestFields: generate }) + const { result } = renderHook(() => useSourceControlHostedReviewCreation(input)) + + let firstClick: Promise = Promise.resolve() + await act(async () => { + firstClick = result.current.handleCreatePullRequest() + await result.current.handleCreatePullRequest() + }) + await act(async () => { + finish(generatedFields) + await firstClick + }) + + expect(generate).toHaveBeenCalledTimes(1) + expect(input.createHostedReview).toHaveBeenCalledTimes(1) + }) + + it('generates for another worktree while the first worktree is still generating', async () => { + const { generate: generateA, finish: finishA } = deferredGeneration() + const input = makeInput({ handleGeneratePullRequestFields: generateA }) + const { result, rerender } = renderHook( + (props: Input) => useSourceControlHostedReviewCreation(props), + { initialProps: input } + ) + + let clickA: Promise = Promise.resolve() + act(() => { + clickA = result.current.handleCreatePullRequest() + }) + const generateB = vi.fn(async () => ({ result: generatedFields })) + rerender({ + ...input, + activePullRequestGenerationKey: 'wt-2::repo-1::add-usage', + activeWorktreeId: 'wt-2', + branchName: 'add-usage', + handleGeneratePullRequestFields: generateB + }) + await act(async () => result.current.handleCreatePullRequest()) + await act(async () => { + finishA(null) + await clickA + }) + + expect(generateB).toHaveBeenCalledTimes(1) + expect(input.createHostedReview).toHaveBeenCalledTimes(1) + expect(input.createHostedReview).toHaveBeenCalledWith( + '/repo', + expect.objectContaining({ title: 'Correct README install steps', head: 'add-usage' }) + ) + }) + + it('submits as shown when clicked again after Stop', async () => { + const { generate, finish } = deferredGeneration() + const input = makeInput({ handleGeneratePullRequestFields: generate }) + const { result } = renderHook(() => useSourceControlHostedReviewCreation(input)) + + let firstClick: Promise = Promise.resolve() + act(() => { + firstClick = result.current.handleCreatePullRequest() + }) + useAppStore + .getState() + .setPullRequestGenerationRecord(GENERATION_KEY, { ...runningRecord, status: 'canceled' }) + await act(async () => { + finish(generatedFields) + await firstClick + }) + await act(async () => result.current.handleCreatePullRequest()) + + expect(generate).toHaveBeenCalledTimes(1) + expect(input.createHostedReview).toHaveBeenCalledTimes(1) + expect(input.createHostedReview).toHaveBeenCalledWith( + '/repo', + expect.objectContaining({ title: 'Fix readme typo', body: '' }) + ) + }) + + it('still creates the clicked branch PR when the panel closes mid-run', async () => { + const { generate, finish } = deferredGeneration() + const input = makeInput({ handleGeneratePullRequestFields: generate }) + const { result, rerender, unmount } = renderHook( + (props: Input) => useSourceControlHostedReviewCreation(props), + { initialProps: input } + ) + + let click: Promise = Promise.resolve() + act(() => { + click = result.current.handleCreatePullRequest() + }) + rerender({ ...input, prGenerating: true }) + unmount() + await act(async () => { + finish(generatedFields) + await click + }) + + expect(input.createHostedReview).toHaveBeenCalledWith( + '/repo', + expect.objectContaining({ head: 'fix-readme-typo', title: 'Correct README install steps' }) + ) + }) + + it.each([ + { name: 'still selected', selectedAtFinish: 'wt-1', reveals: true }, + { name: 'no longer selected', selectedAtFinish: 'wt-2', reveals: false } + ])( + 'reveals the created PR only when its worktree is $name after the panel closed mid-run', + async ({ selectedAtFinish, reveals }) => { + const openUrl = vi.fn() + vi.stubGlobal('api', { shell: { openUrl } }) + useAppStore.setState({ activeWorktreeId: 'wt-1' }) + const { generate, finish } = deferredGeneration() + const input = makeInput({ + handleGeneratePullRequestFields: generate, + resolvedPrCreationDefaults: { + ...DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, + openAfterCreate: true + } + }) + const { result, unmount } = renderHook(() => useSourceControlHostedReviewCreation(input)) + + let click: Promise = Promise.resolve() + act(() => { + click = result.current.handleCreatePullRequest() + }) + unmount() + useAppStore.setState({ activeWorktreeId: selectedAtFinish }) + await act(async () => { + finish(generatedFields) + await click + }) + + expect(input.createHostedReview).toHaveBeenCalledTimes(1) + expect(input.handlePullRequestCreated).toHaveBeenCalledWith( + expect.objectContaining({ number: 42 }), + expect.objectContaining({ worktreeId: 'wt-1', openChecks: reveals }) + ) + expect(openUrl).toHaveBeenCalledTimes(reveals ? 1 : 0) + } + ) + + it('creates after generation even though generation ending refreshes eligibility', async () => { + const { generate, finish } = deferredGeneration() + const base = makeInput({ handleGeneratePullRequestFields: generate }) + // Why: the first probe confirms the branch; any refetch stays pending, as a real one does for a while. + const getEligibility = vi + .fn<() => Promise>() + .mockResolvedValueOnce(readyEligibility) + .mockReturnValue(new Promise(() => {})) + // Wires the real eligibility probe to the real create, as the Source Control panel does. + const { result } = renderHook(() => { + const [inFlight, setCreatePrInFlightByWorktree] = useState>({}) + const createPrInFlightRef = useRef>({}) + const prGenerating = useAppStore( + (s) => s.pullRequestGenerationRecords[GENERATION_KEY]?.status === 'running' + ) + const state = useSourceControlHostedReviewState({ + activePrFromQueue: null, + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + branchName: 'fix-readme-typo', + hostedReviewCacheKey: null, + hostedReviewEntryData: null, + linkedPR: null, + suppressedGitHubPR: null + }) + useSourceControlHostedReviewEligibility({ + activeRepoConnectionId: null, + activeRepoExecutionHostId: null, + activeRepoId: 'repo-1', + activeRepoPath: '/repo', + activeWorktreeId: 'wt-1', + branchName: 'fix-readme-typo', + effectiveBaseRef: 'main', + fallbackGitHubPRNumber: null, + getHostedReviewCreationEligibility: getEligibility, + hasUncommittedEntries: false, + isBranchVisible: true, + isCreatePrIntentInFlight: false, + isCreatingPr: inFlight['wt-1'] === true, + isFolder: false, + linkedAzureDevOpsPR: null, + linkedBitbucketPR: null, + linkedGitHubPR: null, + linkedGitLabMR: null, + linkedGiteaPR: null, + prGenerating, + provisionalHostedReviewProvider: 'github', + remoteStatus: undefined, + hostedReviewCreationProviderHintRef: state.hostedReviewCreationProviderHintRef, + setHostedReviewCreationRequestState: state.setHostedReviewCreationRequestState, + setHostedReviewCreationState: state.setHostedReviewCreationState, + worktreePath: '/repo' + }) + return useSourceControlHostedReviewCreation({ + ...base, + createPrInFlightRef, + hostedReviewCreation: state.hostedReviewCreation, + prGenerating, + setCreatePrInFlightByWorktree + }) + }) + await act(async () => {}) + + let click: Promise = Promise.resolve() + act(() => { + click = result.current.handleCreatePullRequest() + }) + // A repeated click is refused while the first one is in flight. + await act(async () => result.current.handleCreatePullRequest()) + // The run's record settles and the panel re-renders before the click's continuation resumes. + await act(async () => { + useAppStore + .getState() + .updatePullRequestGenerationRecord(GENERATION_KEY, (record) => + resolvePullRequestGenerationSuccess({ record, requestId: 7, result: generatedFields }) + ) + }) + await act(async () => { + finish(generatedFields) + await click + }) + + expect(base.createHostedReview).toHaveBeenCalledWith( + '/repo', + expect.objectContaining({ title: 'Correct README install steps' }) + ) + }) + + it.each([ + { name: 'fails', result: null }, + { name: 'creates', result: generatedFields } + ])('releases the in-flight hold when the run $name', async ({ result }) => { + const { generate, finish } = deferredGeneration() + const input = makeInput({ handleGeneratePullRequestFields: generate }) + const { result: hook } = renderHook(() => useSourceControlHostedReviewCreation(input)) + const inFlight = (): boolean | undefined => + vi + .mocked(input.setCreatePrInFlightByWorktree) + .mock.calls.reduce>( + (state, [update]) => (typeof update === 'function' ? update(state) : update), + {} + )['wt-1'] + + let click: Promise = Promise.resolve() + act(() => { + click = hook.current.handleCreatePullRequest() + }) + expect(inFlight()).toBe(true) + await act(async () => { + finish(result) + await click + }) + + expect(inFlight()).toBe(false) + }) + + it('releases the hold as soon as Stop lands, so the next click submits as shown while the stopped request is still pending', async () => { + let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {} + runtime.generate.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + // The cancel never reaches the host, so the stopped request stays pending. + runtime.cancel.mockReturnValue(new Promise(() => {})) + const base = makeInput() + // Wires the real store-routed generation and Stop to the real create and its hold, as the Source Control panel does. + const { result } = renderHook(() => { + const [inFlight, setCreatePrInFlightByWorktree] = useState>({}) + const createPrInFlightRef = useRef>({}) + const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords) + const generation = useSourceControlPullRequestGeneration({ + activeRepo: base.activeRepo, + activeRepoSettings: null, + activeWorktreeId: 'wt-1', + allocatePullRequestGenerationRequestId: + useAppStore.getState().allocatePullRequestGenerationRequestId, + branchName: 'fix-readme-typo', + hostedReviewCreateProvider: 'github', + prGenerationRecords, + refreshGitStatusAfterPullRequestGeneration: vi.fn(), + resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, + setPullRequestGenerationRecord: useAppStore.getState().setPullRequestGenerationRecord, + updatePullRequestGenerationRecord: useAppStore.getState().updatePullRequestGenerationRecord, + worktreePath: '/repo' + }) + const creation = useSourceControlHostedReviewCreation({ + ...base, + activePullRequestGenerationKey: generation.activePullRequestGenerationKey, + createPrInFlightRef, + handleGeneratePullRequestFields: (overrides, options) => + generation.handleGeneratePullRequestFieldsForActive( + { base: base.prBase, title: base.prTitle, body: base.prBody, draft: base.prDraft }, + { base: 0, title: 0, body: 0, draft: 0 }, + overrides, + options + ), + prGenerating: generation.activePullRequestGenerationRecord?.status === 'running', + setCreatePrInFlightByWorktree + }) + return { creation, generation, inFlight: inFlight['wt-1'] === true } + }) + + let firstClick: Promise = Promise.resolve() + act(() => { + firstClick = result.current.creation.handleCreatePullRequest() + }) + expect(runtime.generate).toHaveBeenCalledTimes(1) + expect(result.current.inFlight).toBe(true) + + await act(async () => + result.current.generation.handleCancelGeneratePullRequestFieldsForActive() + ) + expect(result.current.inFlight).toBe(false) + await act(async () => result.current.creation.handleCreatePullRequest()) + + expect(runtime.generate).toHaveBeenCalledTimes(1) + expect(base.createHostedReview).toHaveBeenCalledTimes(1) + expect(base.createHostedReview).toHaveBeenCalledWith( + '/repo', + expect.objectContaining({ title: 'Fix readme typo', body: '' }) + ) + // The stopped request's late result creates nothing. + await act(async () => { + answer({ success: true, fields: generatedFields }) + await firstClick + }) + expect(base.createHostedReview).toHaveBeenCalledTimes(1) + }) + + it('creates the clicked branch PR without revealing Checks when the panel moved to another worktree mid-run', async () => { + const openUrl = vi.fn() + vi.stubGlobal('api', { shell: { openUrl } }) + useAppStore.setState({ activeWorktreeId: 'wt-1' }) + const { generate, finish } = deferredGeneration() + const input = makeInput({ + handleGeneratePullRequestFields: generate, + resolvedPrCreationDefaults: { + ...DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, + openAfterCreate: true + } + }) + const { result, rerender } = renderHook( + (props: Input) => useSourceControlHostedReviewCreation(props), + { initialProps: input } + ) + + let click: Promise = Promise.resolve() + act(() => { + click = result.current.handleCreatePullRequest() + }) + useAppStore.setState({ activeWorktreeId: 'wt-2' }) + rerender({ + ...input, + activePullRequestGenerationKey: 'wt-2::repo-1::other-branch', + activeWorktreeId: 'wt-2', + branchName: 'other-branch', + hostedReviewCreation: null + }) + await act(async () => { + finish(generatedFields) + await click + }) + + expect(input.createHostedReview).toHaveBeenCalledTimes(1) + expect(input.createHostedReview).toHaveBeenCalledWith( + '/repo', + expect.objectContaining({ head: 'fix-readme-typo', title: 'Correct README install steps' }) + ) + expect(input.handlePullRequestCreated).toHaveBeenCalledWith( + expect.objectContaining({ number: 42 }), + expect.objectContaining({ worktreeId: 'wt-1', branch: 'fix-readme-typo', openChecks: false }) + ) + expect(openUrl).not.toHaveBeenCalled() + const noticeTargets = vi + .mocked(input.setCreatePrIntentNoticeForWorktree) + .mock.calls.map(([worktreeId]) => worktreeId) + expect(new Set(noticeTargets)).toEqual(new Set(['wt-1'])) + }) + + it.each([ + { name: 'the fields were edited', overrides: { prFieldsAreSeedPlaceholders: false } }, + { name: 'AI actions are off', overrides: { prAiGenerationEnabled: false } }, + { + name: 'no PR agent is configured', + overrides: { settings: getDefaultSettings('/home/test') } + }, + { + name: 'generation cannot start', + overrides: { handleGeneratePullRequestFields: vi.fn(async () => undefined) } + } + ])('submits as shown when $name', async ({ overrides }) => { + const input = makeInput(overrides) + const { result } = renderHook(() => useSourceControlHostedReviewCreation(input)) + + await act(async () => result.current.handleCreatePullRequest()) + + expect(input.createHostedReview).toHaveBeenCalledWith( + '/repo', + expect.objectContaining({ title: input.prTitle, body: '' }) + ) + }) + + it('shows the blocked notice instead of generating when the branch is not ready', async () => { + const input = makeInput({ + hostedReviewCreation: { ...readyEligibility, canCreate: false, blockedReason: 'needs_push' } + }) + const { result } = renderHook(() => useSourceControlHostedReviewCreation(input)) + + await act(async () => result.current.handleCreatePullRequest()) + + expect(input.handleGeneratePullRequestFields).not.toHaveBeenCalled() + expect(input.createHostedReview).not.toHaveBeenCalled() + expect(input.setCreatePrIntentNoticeForWorktree).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ tone: 'destructive' }) + ) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.ts index 72d852186c3..87412e2641e 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.ts @@ -10,62 +10,76 @@ import type { SourceControlWorktreeContext } from '../listing/use-worktree-conte import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state' import type { SourceControlCreateReviewComposer } from './use-create-review-composer' import type { SourceControlHostedReviewCreated } from './use-hosted-review-created' +import type { HostedReviewCreatedContext } from './hosted-review-creation-state' import type { SourceControlHostedReviewState } from './use-hosted-review-state' +import type { SourceControlPullRequestGeneration } from './use-pull-request-generation' +import type { PullRequestGenerationFields } from '@/store/slices/pull-request-generation' +import { useGenerateBeforeCreatePullRequest } from '../../use-generate-before-create-pull-request' +import { createdReviewIsForeground } from '../../created-review-foreground' /** * Submits the composer as a hosted review (optionally stacked) and reconciles the "already open" and * partially-created-stack outcomes, both of which still leave a real review to link. */ export function useSourceControlHostedReviewCreation({ + activePullRequestGenerationKey, activeRepo, activeWorktreeId, branchName, createHostedReview, createPrInFlightRef, createStackedHostedReview, + handleGeneratePullRequestFields, handlePullRequestCreated, hostedReviewCreateCopy, hostedReviewCreateProvider, hostedReviewCreation, + prAiGenerationEnabled, prBase, prBody, prDraft, + prFieldsAreSeedPlaceholders, prGenerating, prTitle, resolvedPrCreationDefaults, setCreatePrInFlightByWorktree, setCreatePrIntentNoticeForWorktree, + settings, worktreePath }: { + activePullRequestGenerationKey: SourceControlPullRequestGeneration['activePullRequestGenerationKey'] activeRepo: SourceControlWorktreeContext['activeRepo'] activeWorktreeId: string | null branchName: string createHostedReview: SourceControlStoreActions['createHostedReview'] createPrInFlightRef: SourceControlWorktreeOperationState['createPrInFlightRef'] createStackedHostedReview: SourceControlStoreActions['createStackedHostedReview'] + handleGeneratePullRequestFields: SourceControlCreateReviewComposer['handleGeneratePullRequestFields'] handlePullRequestCreated: SourceControlHostedReviewCreated['handlePullRequestCreated'] hostedReviewCreateCopy: SourceControlHostedReviewState['hostedReviewCreateCopy'] hostedReviewCreateProvider: SourceControlHostedReviewState['hostedReviewCreateProvider'] hostedReviewCreation: SourceControlHostedReviewState['hostedReviewCreation'] + prAiGenerationEnabled: SourceControlCreateReviewComposer['prAiGenerationEnabled'] prBase: SourceControlCreateReviewComposer['prBase'] prBody: SourceControlCreateReviewComposer['prBody'] prDraft: SourceControlCreateReviewComposer['prDraft'] + prFieldsAreSeedPlaceholders: SourceControlCreateReviewComposer['prFieldsAreSeedPlaceholders'] prGenerating: boolean prTitle: SourceControlCreateReviewComposer['prTitle'] resolvedPrCreationDefaults: SourceControlAi['resolvedPrCreationDefaults'] setCreatePrInFlightByWorktree: SourceControlWorktreeOperationState['setCreatePrInFlightByWorktree'] setCreatePrIntentNoticeForWorktree: SourceControlWorktreeOperationState['setCreatePrIntentNoticeForWorktree'] + settings: SourceControlWorktreeContext['settings'] worktreePath: string | null }) { - const handleCreatePullRequest = useCallback( - async (stacked = false): Promise => { + const createPullRequest = useCallback( + async (stacked = false, generated?: PullRequestGenerationFields): Promise => { if ( !activeRepo || !activeWorktreeId || !worktreePath || !hostedReviewCreation || - prGenerating || - createPrInFlightRef.current[activeWorktreeId] + prGenerating ) { return } @@ -82,8 +96,9 @@ export function useSourceControlHostedReviewCreation({ return } - const base = stripBaseRef(prBase).trim() - const title = prTitle.trim() + const fields = generated ?? { base: prBase, title: prTitle, body: prBody, draft: prDraft } + const base = stripBaseRef(fields.base).trim() + const title = fields.title.trim() if (!title) { setCreatePrIntentNoticeForWorktree(activeWorktreeId, { @@ -109,9 +124,14 @@ export function useSourceControlHostedReviewCreation({ return } - createPrInFlightRef.current[activeWorktreeId] = true - setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) setCreatePrIntentNoticeForWorktree(activeWorktreeId, null) + const createdContext = (): HostedReviewCreatedContext => ({ + repoPath: activeRepo.path, + repoId: activeRepo.id, + branch: branchName, + worktreeId: activeWorktreeId, + openChecks: createdReviewIsForeground(activeWorktreeId) + }) try { const createInput = { repoId: activeRepo.id, @@ -119,8 +139,8 @@ export function useSourceControlHostedReviewCreation({ base, head: normalizeHostedReviewHeadRef(branchName), title, - body: prBody, - draft: prDraft, + body: fields.body, + draft: fields.draft, worktreePath, useTemplate: resolvedPrCreationDefaults.useTemplate } @@ -130,12 +150,16 @@ export function useSourceControlHostedReviewCreation({ if (result.ok) { setCreatePrIntentNoticeForWorktree(activeWorktreeId, null) - await handlePullRequestCreated({ - provider: hostedReviewCreateProvider, - number: result.number, - url: result.url - }) - if (resolvedPrCreationDefaults.openAfterCreate) { + const context = createdContext() + await handlePullRequestCreated( + { + provider: hostedReviewCreateProvider, + number: result.number, + url: result.url + }, + context + ) + if (context.openChecks && resolvedPrCreationDefaults.openAfterCreate) { window.api.shell.openUrl(result.url) } return @@ -168,11 +192,14 @@ export function useSourceControlHostedReviewCreation({ ) if (number) { setCreatePrIntentNoticeForWorktree(activeWorktreeId, null) - await handlePullRequestCreated({ - provider: hostedReviewCreateProvider, - number, - url: result.existingReview.url - }) + await handlePullRequestCreated( + { + provider: hostedReviewCreateProvider, + number, + url: result.existingReview.url + }, + createdContext() + ) return } } @@ -183,11 +210,14 @@ export function useSourceControlHostedReviewCreation({ if ('createdReview' in result && result.createdReview?.url) { const { number, url } = result.createdReview if (number) { - await handlePullRequestCreated({ - provider: hostedReviewCreateProvider, - number, - url - }) + await handlePullRequestCreated( + { + provider: hostedReviewCreateProvider, + number, + url + }, + createdContext() + ) } } @@ -207,9 +237,6 @@ export function useSourceControlHostedReviewCreation({ { value0: hostedReviewCreateCopy.reviewLabel } ) }) - } finally { - createPrInFlightRef.current[activeWorktreeId] = false - setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) } }, [ @@ -217,7 +244,6 @@ export function useSourceControlHostedReviewCreation({ activeWorktreeId, branchName, createHostedReview, - createPrInFlightRef, createStackedHostedReview, handlePullRequestCreated, hostedReviewCreation, @@ -232,12 +258,47 @@ export function useSourceControlHostedReviewCreation({ prTitle, resolvedPrCreationDefaults.openAfterCreate, resolvedPrCreationDefaults.useTemplate, - setCreatePrInFlightByWorktree, setCreatePrIntentNoticeForWorktree, worktreePath ] ) + const { handleCreatePullRequest: generateThenCreatePullRequest } = + useGenerateBeforeCreatePullRequest({ + aiGenerationEnabled: prAiGenerationEnabled, + canCreate: hostedReviewCreation?.canCreate === true, + createPullRequest, + fieldsAreSeedPlaceholders: prFieldsAreSeedPlaceholders, + generatePullRequestFields: handleGeneratePullRequestFields, + generationKey: activePullRequestGenerationKey, + repo: activeRepo, + settings + }) + + const handleCreatePullRequest = useCallback( + async (stacked = false): Promise => { + const worktreeId = activeWorktreeId + if (!worktreeId || createPrInFlightRef.current[worktreeId]) { + return + } + // Why: like the prepare-branch route, the click is in flight until its create settles, so the composer stays up through generation instead of closing while eligibility refreshes. + createPrInFlightRef.current[worktreeId] = true + setCreatePrInFlightByWorktree((prev) => ({ ...prev, [worktreeId]: true })) + try { + await generateThenCreatePullRequest(stacked) + } finally { + createPrInFlightRef.current[worktreeId] = false + setCreatePrInFlightByWorktree((prev) => ({ ...prev, [worktreeId]: false })) + } + }, + [ + activeWorktreeId, + createPrInFlightRef, + generateThenCreatePullRequest, + setCreatePrInFlightByWorktree + ] + ) + return { handleCreatePullRequest } } diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx b/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx new file mode 100644 index 00000000000..9d7f95d37d3 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeGitClient from '@/runtime/runtime-git-client' +import type { RuntimeGeneratePullRequestFieldsResult } from '@/runtime/runtime-git-client-context' +import { useAppStore } from '@/store' +import { DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS } from '../../../../../../shared/source-control-ai-settings' + +const runtime = vi.hoisted(() => ({ + cancel: vi.fn().mockResolvedValue(undefined), + generate: vi.fn() +})) + +vi.mock('@/runtime/runtime-git-client', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + cancelRuntimeGeneratePullRequestFields: runtime.cancel, + generateRuntimePullRequestFields: runtime.generate + } +}) + +import { useSourceControlPullRequestGeneration } from './use-pull-request-generation' + +afterEach(() => { + cleanup() + runtime.cancel.mockReset().mockResolvedValue(undefined) + runtime.generate.mockReset() + useAppStore.setState({ pullRequestGenerationRecords: {} }) +}) + +describe('useSourceControlPullRequestGeneration outcome', () => { + it.each([ + { name: 'keeps the base for a Create PR run', autoSubmit: true, base: 'main' }, + { name: 'keeps the agent base for a reviewed run', autoSubmit: false, base: 'develop' } + ])('$name', async ({ autoSubmit, base }) => { + const generated = { base: 'develop', title: 'Add feature flag', body: 'Details.', draft: false } + runtime.generate.mockResolvedValue({ success: true, fields: generated }) + const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } = + useAppStore.getState() + const { result } = renderHook(() => + useSourceControlPullRequestGeneration({ + activeRepo: { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + }, + activeRepoSettings: null, + activeWorktreeId: 'wt-1', + allocatePullRequestGenerationRequestId: vi.fn(() => 5), + branchName: 'feature', + hostedReviewCreateProvider: 'github', + prGenerationRecords: {}, + refreshGitStatusAfterPullRequestGeneration: vi.fn(), + resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, + setPullRequestGenerationRecord, + updatePullRequestGenerationRecord, + worktreePath: '/repo' + }) + ) + + const outcome = await result.current.handleGeneratePullRequestFieldsForActive( + { base: 'main', title: 'Feature', body: '', draft: false }, + { base: 0, title: 0, body: 0, draft: 0 }, + undefined, + { autoSubmit } + ) + + expect(outcome).toEqual({ result: { ...generated, base } }) + }) + + it("returns no result when a later run replaced this one, not the later run's details", async () => { + const generationKey = JSON.stringify(['repo-1', 'wt-1', 'feature']) + let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {} + runtime.generate.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } = + useAppStore.getState() + const { result } = renderHook(() => + useSourceControlPullRequestGeneration({ + activeRepo: { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + }, + activeRepoSettings: null, + activeWorktreeId: 'wt-1', + allocatePullRequestGenerationRequestId: vi.fn(() => 5), + branchName: 'feature', + hostedReviewCreateProvider: 'github', + prGenerationRecords: {}, + refreshGitStatusAfterPullRequestGeneration: vi.fn(), + resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, + setPullRequestGenerationRecord, + updatePullRequestGenerationRecord, + worktreePath: '/repo' + }) + ) + const fields = { base: 'main', title: 'Feature', body: '', draft: false } + + const outcome = result.current.handleGeneratePullRequestFieldsForActive( + fields, + { base: 0, title: 0, body: 0, draft: 0 }, + undefined, + { autoSubmit: true } + ) + // Stop, then a Generate click whose run finishes before the stopped one winds down. + const stopped = useAppStore.getState().pullRequestGenerationRecords[generationKey] + expect(stopped?.status).toBe('running') + setPullRequestGenerationRecord(generationKey, { + ...stopped!, + context: { ...stopped!.context, requestId: 6 }, + status: 'succeeded', + result: { ...fields, title: 'Later run' } + }) + answer({ success: false, error: 'canceled', canceled: true }) + + await expect(outcome).resolves.toEqual({ result: null }) + }) + + it('settles with no result as soon as Stop lands, without waiting for the stopped request, and drops its late result', async () => { + const generationKey = JSON.stringify(['repo-1', 'wt-1', 'feature']) + let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {} + runtime.generate.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + // The cancel never reaches the host, so the stopped request stays pending. + runtime.cancel.mockReturnValue(new Promise(() => {})) + const subscribe = useAppStore.subscribe + const released = vi.fn() + const subscribed = vi.spyOn(useAppStore, 'subscribe').mockImplementation((listener) => { + const unsubscribe = subscribe(listener) + return () => { + released() + unsubscribe() + } + }) + const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } = + useAppStore.getState() + const { result } = renderHook(() => + useSourceControlPullRequestGeneration({ + activeRepo: { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + }, + activeRepoSettings: null, + activeWorktreeId: 'wt-1', + allocatePullRequestGenerationRequestId: vi.fn(() => 5), + branchName: 'feature', + hostedReviewCreateProvider: 'github', + prGenerationRecords: useAppStore((s) => s.pullRequestGenerationRecords), + refreshGitStatusAfterPullRequestGeneration: vi.fn(), + resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, + setPullRequestGenerationRecord, + updatePullRequestGenerationRecord, + worktreePath: '/repo' + }) + ) + let outcome: Promise = Promise.resolve() + act(() => { + outcome = result.current.handleGeneratePullRequestFieldsForActive( + { base: 'main', title: 'Feature', body: '', draft: false }, + { base: 0, title: 0, body: 0, draft: 0 }, + undefined, + { autoSubmit: true } + ) + }) + + act(() => result.current.handleCancelGeneratePullRequestFieldsForActive()) + expect(runtime.cancel).toHaveBeenCalledTimes(1) + const stillPending = new Promise((resolve) => setTimeout(() => resolve('still pending'), 50)) + await expect(Promise.race([outcome, stillPending])).resolves.toEqual({ result: null }) + // The run's store listener is gone once its outcome settles. + expect(subscribed).toHaveBeenCalledTimes(1) + expect(released).toHaveBeenCalledTimes(1) + subscribed.mockRestore() + + answer({ + success: true, + fields: { base: 'develop', title: 'Late run', body: 'Late.', draft: false } + }) + await act(async () => {}) + expect(useAppStore.getState().pullRequestGenerationRecords[generationKey]).toMatchObject({ + status: 'canceled', + result: null + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts index e711999901b..e1c11cba073 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts @@ -5,6 +5,8 @@ import { generateRuntimePullRequestFields, type RuntimeGeneratePullRequestFieldsOverrides } from '@/runtime/runtime-git-client' +import type { PullRequestGenerationOutcome } from '../../create-pull-request-dialog-field-model' +import { settlePullRequestGenerationRequest } from '../../pull-request-generation-request-outcome' import { useAppStore } from '@/store' import { createRunningPullRequestGenerationRecord, @@ -18,6 +20,7 @@ import { type PullRequestGenerationContext, type PullRequestGenerationFields } from '@/store/slices/pull-request-generation' +import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit' import type { HostedReviewProvider } from '../../../../../../shared/hosted-review' import type { SourceControlAi } from '../ai/use-ai' import { stripBaseRef } from '../../create-pull-request-base-ref-normalization' @@ -26,7 +29,7 @@ import type { SourceControlWorktreeContext } from '../listing/use-worktree-conte import type { SourceControlStatusRefresh } from '../sync/use-status-refresh' /** - * Runs AI generation of the PR title/body/base for the active branch through a store record, so a + * Runs AI generation of the PR details for the active branch through a store record, so a * run started before a tab switch is still resumable when the composer remounts. */ export function useSourceControlPullRequestGeneration({ @@ -80,16 +83,17 @@ export function useSourceControlPullRequestGeneration({ async ( fields: PullRequestGenerationFields, fieldRevisions: PullRequestFieldRevisions, - overrides?: RuntimeGeneratePullRequestFieldsOverrides - ): Promise => { + overrides?: RuntimeGeneratePullRequestFieldsOverrides, + options?: PullRequestGenerationOptions + ): Promise => { if (!activeRepo || !activePullRequestGenerationKey || !worktreePath || !branchName) { - return + return undefined } const generationKey = activePullRequestGenerationKey if ( useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running' ) { - return + return undefined } const requestId = allocatePullRequestGenerationRequestId() const context: PullRequestGenerationContext = { @@ -105,67 +109,70 @@ export function useSourceControlPullRequestGeneration({ // Why: SourceControl can unmount on tab switches; the persisted record lets the PR composer resume on return. setPullRequestGenerationRecord( generationKey, - createRunningPullRequestGenerationRecord(context, seed, fieldRevisions) + createRunningPullRequestGenerationRecord(context, seed, fieldRevisions, options?.autoSubmit) ) - try { - const result = await generateRuntimePullRequestFields( - { - // Why: route generation by the repo OWNER host, not the focused runtime. - settings: context.runtimeTargetSettings, - worktreeId: context.worktreeId, - worktreePath: context.worktreePath, - connectionId: context.connectionId - }, - { - base: stripBaseRef(seed.base.trim()), - title: seed.title, - body: seed.body, - draft: seed.draft, - provider: hostedReviewCreateProvider, - useTemplate: resolvedPrCreationDefaults.useTemplate - }, - overrides - ) - if (result.branchChangedByPreparation) { - await refreshGitStatusAfterPullRequestGeneration(context) - } - if (result.success) { - useAppStore.getState().recordFeatureInteraction('ai-pr-generation') - } - updatePullRequestGenerationRecord(generationKey, (record) => { - if (!result.success) { - return resolvePullRequestGenerationFailure({ + const request = (async (): Promise => { + try { + const result = await generateRuntimePullRequestFields( + { + // Why: route generation by the repo OWNER host, not the focused runtime. + settings: context.runtimeTargetSettings, + worktreeId: context.worktreeId, + worktreePath: context.worktreePath, + connectionId: context.connectionId + }, + { + base: stripBaseRef(seed.base.trim()), + title: seed.title, + body: seed.body, + draft: seed.draft, + provider: hostedReviewCreateProvider, + useTemplate: resolvedPrCreationDefaults.useTemplate + }, + overrides + ) + if (result.branchChangedByPreparation) { + await refreshGitStatusAfterPullRequestGeneration(context) + } + if (result.success) { + useAppStore.getState().recordFeatureInteraction('ai-pr-generation') + } + updatePullRequestGenerationRecord(generationKey, (record) => { + if (!result.success) { + return resolvePullRequestGenerationFailure({ + record, + requestId, + canceled: result.canceled, + error: result.canceled ? null : result.error + }) + } + if (!record) { + return null + } + return resolvePullRequestGenerationSuccess({ record, requestId, - canceled: result.canceled, - error: result.canceled ? null : result.error + result: { + base: stripBaseRef(result.fields.base), + title: result.fields.title, + body: result.fields.body, + draft: result.fields.draft + } }) - } - if (!record) { - return null - } - return resolvePullRequestGenerationSuccess({ - record, - requestId, - result: { - base: stripBaseRef(result.fields.base), - title: result.fields.title, - body: result.fields.body, - draft: result.fields.draft - } }) - }) - } catch (error) { - updatePullRequestGenerationRecord(generationKey, (record) => - resolvePullRequestGenerationFailure({ - record, - requestId, - error: - error instanceof Error ? error.message : 'Failed to generate pull request details' - }) - ) - } + } catch (error) { + updatePullRequestGenerationRecord(generationKey, (record) => + resolvePullRequestGenerationFailure({ + record, + requestId, + error: + error instanceof Error ? error.message : 'Failed to generate pull request details' + }) + ) + } + })() + return await settlePullRequestGenerationRequest(generationKey, requestId, request) }, [ activePullRequestGenerationKey, diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts index 18e2716d388..f2bab0d1566 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts @@ -154,24 +154,29 @@ export function useSourceControlReviewFlows(foundation: SourceControlPanelFounda worktreePath }) const hostedReviewCreationAction = useSourceControlHostedReviewCreation({ + activePullRequestGenerationKey: pullRequestGeneration.activePullRequestGenerationKey, activeRepo, activeWorktreeId, branchName, createHostedReview, createPrInFlightRef, createStackedHostedReview, + handleGeneratePullRequestFields: createReviewComposer.handleGeneratePullRequestFields, handlePullRequestCreated: hostedReviewCreated.handlePullRequestCreated, hostedReviewCreateCopy, hostedReviewCreateProvider, hostedReviewCreation, + prAiGenerationEnabled: createReviewComposer.prAiGenerationEnabled, prBase, prBody, prDraft, + prFieldsAreSeedPlaceholders: createReviewComposer.prFieldsAreSeedPlaceholders, prGenerating, prTitle, resolvedPrCreationDefaults, setCreatePrInFlightByWorktree, setCreatePrIntentNoticeForWorktree, + settings, worktreePath }) diff --git a/src/renderer/src/components/right-sidebar/use-create-pull-request-field-generation.ts b/src/renderer/src/components/right-sidebar/use-create-pull-request-field-generation.ts index 3ea2d56923c..092ae2c52b6 100644 --- a/src/renderer/src/components/right-sidebar/use-create-pull-request-field-generation.ts +++ b/src/renderer/src/components/right-sidebar/use-create-pull-request-field-generation.ts @@ -10,9 +10,11 @@ import type { HostedReviewCreationEligibility } from '../../../../shared/hosted- import type { ResolveSourceControlAiResult } from '../../../../shared/source-control-ai' import type { SourceControlAiPrCreationDefaults } from '../../../../shared/source-control-ai-types' import type { PullRequestFieldRevisions } from '@/store/slices/pull-request-generation' +import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit' import { stripBaseRef } from './create-pull-request-base-ref-normalization' import type { GenerationSeed, + PullRequestGenerationOutcome, PullRequestDraftFields, UseCreatePullRequestDialogFieldsOptions } from './create-pull-request-dialog-field-model' @@ -53,7 +55,10 @@ type CreatePullRequestFieldGenerationResult = { effectiveGenerateError: string | null generateDisabled: boolean generateDisabledReason: string | undefined - handleGenerate: (overrides?: RuntimeGeneratePullRequestFieldsOverrides) => Promise + handleGenerate: ( + overrides?: RuntimeGeneratePullRequestFieldsOverrides, + options?: PullRequestGenerationOptions + ) => Promise handleCancelGenerate: () => void } @@ -99,17 +104,20 @@ export function useCreatePullRequestFieldGeneration({ const generateDisabled = !effectiveGenerating && Boolean(generateDisabledReason) const handleGenerate = useCallback( - async (overrides?: RuntimeGeneratePullRequestFieldsOverrides): Promise => { + async ( + overrides?: RuntimeGeneratePullRequestFieldsOverrides, + options?: PullRequestGenerationOptions + ): Promise => { if (!worktreePath || !base.trim() || effectiveGenerating || generateDisabled) { return } if (generation) { - generation.onGenerate( + return await generation.onGenerate( { base, title, body, draft }, { ...fieldRevisionsRef.current }, - overrides + overrides, + options ) - return } const requestId = generationRequestIdRef.current + 1 generationRequestIdRef.current = requestId @@ -149,31 +157,33 @@ export function useCreatePullRequestFieldGeneration({ } const isCurrentRequest = generationRequestIdRef.current === requestId if (!isCurrentRequest) { - return + return { result: null } } if (!result.success) { if (result.canceled) { setGenerateError(null) - return + return { result: null } } setGenerateError(result.error) - return + return { result: null } } const currentSeed = generationSeedRef.current if (!currentSeed || currentSeed.requestId !== requestId) { - return + return { result: null } } - applyGeneratedFields(result.fields, currentSeed.fieldRevisions) + const shownFields = applyGeneratedFields(result.fields, currentSeed.fieldRevisions) useAppStore.getState().recordFeatureInteraction('ai-pr-generation') setGenerateError(null) + return { result: shownFields } } catch (error) { if (generationRequestIdRef.current !== requestId) { - return + return { result: null } } setGenerateError( error instanceof Error ? error.message : 'Failed to generate pull request details' ) + return { result: null } } finally { if (generationRequestIdRef.current === requestId) { generateInFlightRef.current = false diff --git a/src/renderer/src/components/right-sidebar/use-create-pull-request-field-seeding.ts b/src/renderer/src/components/right-sidebar/use-create-pull-request-field-seeding.ts index 8ae4b473806..15da41120b3 100644 --- a/src/renderer/src/components/right-sidebar/use-create-pull-request-field-seeding.ts +++ b/src/renderer/src/components/right-sidebar/use-create-pull-request-field-seeding.ts @@ -6,7 +6,7 @@ import type { PullRequestFieldName, PullRequestFieldRevisions } from '@/store/slices/pull-request-generation' -import { resolveCreateReviewDraftTitle } from './create-review-draft-title' +import { resolveCreateReviewSeedText } from './create-review-draft-title' import { stripBaseRef } from './create-pull-request-base-ref-normalization' import { createInitialPullRequestFieldRevisions, @@ -146,8 +146,9 @@ export function useCreatePullRequestFieldSeeding({ baseEditedByUserRef.current = false syncedDefaultBaseRef.current = resolvedDefaultBaseRef || null setBase(resolvedDefaultBaseRef) - setTitle(resolveCreateReviewDraftTitle({ branch, eligibilityTitle: eligibility.title })) - setBody(eligibility.body ?? '') + const seedText = resolveCreateReviewSeedText({ branch, eligibility }) + setTitle(seedText.title) + setBody(seedText.body) setDraft(resolvedPrDefaults.draft) setBaseQuery('') setBaseResults([]) diff --git a/src/renderer/src/components/right-sidebar/use-generate-before-create-pull-request.ts b/src/renderer/src/components/right-sidebar/use-generate-before-create-pull-request.ts new file mode 100644 index 00000000000..f6737d8c25e --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-generate-before-create-pull-request.ts @@ -0,0 +1,89 @@ +import { useCallback } from 'react' +import { useAppStore, type AppState } from '@/store' +import type { PullRequestGenerationFields } from '@/store/slices/pull-request-generation' +import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit' +import type { Repo } from '../../../../shared/repo-types' +import type { PullRequestGenerationOutcome } from './create-pull-request-dialog-field-model' +import { hasConfiguredSourceControlTextGenerationDefaults } from './source-control/ai/text-generation-defaults' + +type CreatePullRequest = (stacked?: boolean, fields?: PullRequestGenerationFields) => Promise + +/** + * Create PR on a ready branch: when the composer still holds Orca's seeded placeholders and a PR + * agent is configured, run the composer's own generation and create from its result, like the + * prepare-branch path does. + */ +export function useGenerateBeforeCreatePullRequest({ + aiGenerationEnabled, + canCreate, + createPullRequest, + fieldsAreSeedPlaceholders, + generatePullRequestFields, + generationKey, + repo, + settings +}: { + aiGenerationEnabled: boolean + canCreate: boolean + createPullRequest: CreatePullRequest + fieldsAreSeedPlaceholders: boolean + generatePullRequestFields: ( + overrides: undefined, + options: PullRequestGenerationOptions + ) => Promise + generationKey: string | null + repo: Pick | null + settings: AppState['settings'] +}) { + const handleCreatePullRequest = useCallback( + async (stacked = false): Promise => { + const status = generationKey + ? useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status + : undefined + // Why: the record is written before generation's first await, so repeated clicks see it. + if (status === 'running') { + return + } + if ( + !generationKey || + !canCreate || + !aiGenerationEnabled || + !fieldsAreSeedPlaceholders || + // Why: after a failed or stopped run the next click submits as shown, so a broken agent never blocks Create PR. + status === 'failed' || + status === 'canceled' || + !hasConfiguredSourceControlTextGenerationDefaults({ + actionId: 'pullRequest', + settings, + repo + }) + ) { + await createPullRequest(stacked) + return + } + const outcome = await generatePullRequestFields(undefined, { autoSubmit: true }) + if (!outcome) { + await createPullRequest(stacked) + return + } + if (!outcome.result) { + return + } + // Why: the click owns the run, so create through its closure wherever the user went; a later render can show another branch or cleared eligibility. + // The result is what the composer fills in, so the PR matches the form. + await createPullRequest(stacked, outcome.result) + }, + [ + aiGenerationEnabled, + canCreate, + createPullRequest, + fieldsAreSeedPlaceholders, + generatePullRequestFields, + generationKey, + repo, + settings + ] + ) + + return { handleCreatePullRequest } +} diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts index 6b4ec857abe..4e09a248a17 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts @@ -3,7 +3,9 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' +import { getDefaultSourceControlAiSettings } from '../../../../shared/source-control-ai-settings' import { normalizeCreateReviewBaseSearchResults } from './create-pull-request-base-ref-normalization' import { useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields' @@ -67,6 +69,7 @@ type DialogFieldsRenderInput = { generation?: DialogGeneration worktreeId?: string | null branch?: string + settings?: Parameters[0]['settings'] } function renderDialogFields(input: DialogFieldsRenderInput): { @@ -90,7 +93,7 @@ function renderDialogFields(input: DialogFieldsRenderInput): { branch: currentInput.branch ?? 'feature/base-change', eligibility: currentInput.eligibility, currentBaseRef: currentInput.currentBaseRef, - settings: null, + settings: currentInput.settings ?? null, submitting: false, generation: currentInput.generation }) @@ -258,6 +261,87 @@ describe('useCreatePullRequestDialogFields', () => { } }) + it('reports seed placeholders until the title or body is edited', async () => { + const harness = renderDialogFields({ eligibility: createEligibility() }) + try { + await harness.rerender({ eligibility: createEligibility() }) + expect(harness.current().fieldsAreSeedPlaceholders).toBe(true) + + act(() => { + harness.current().setBody('My notes') + }) + + expect(harness.current().fieldsAreSeedPlaceholders).toBe(false) + } finally { + harness.unmount() + } + }) + + it('keeps the base during a run when the compare base changes', async () => { + const generation: DialogGeneration = { + generating: true, + generateError: null, + seedRestoreKey: 'repo-1:wt-1:feature:1:running', + seed: { base: 'main', title: 'Review title', body: 'Review body', draft: false }, + seedFieldRevisions: { base: 0, title: 0, body: 0, draft: 0 }, + onSeedRestored: vi.fn(), + onGenerate: () => undefined, + onCancelGenerate: () => undefined + } + const harness = renderDialogFields({ + eligibility: createEligibility(), + currentBaseRef: 'refs/remotes/origin/main', + generation + }) + try { + await harness.rerender({ + eligibility: createEligibility(), + currentBaseRef: 'refs/remotes/origin/main', + generation + }) + await harness.rerender({ + eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/release' }), + currentBaseRef: 'refs/remotes/origin/release', + generation + }) + + expect(harness.current().base).toBe('main') + expect(harness.current().fieldRevisions.base).toBe(0) + } finally { + harness.unmount() + } + }) + + it('passes run options to the external generation', async () => { + const generation: DialogGeneration = { + generating: false, + generateError: null, + onGenerate: vi.fn(), + onCancelGenerate: () => undefined + } + const settings = { + ...getDefaultSettings('/home/test'), + sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' as const } + } + const harness = renderDialogFields({ eligibility: createEligibility(), generation, settings }) + try { + await harness.rerender({ eligibility: createEligibility(), generation, settings }) + + await act(async () => { + await harness.current().handleGenerate(undefined, { autoSubmit: true }) + }) + + expect(generation.onGenerate).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Review title' }), + expect.any(Object), + undefined, + { autoSubmit: true } + ) + } finally { + harness.unmount() + } + }) + it('restores an external generation seed once after remount', async () => { const seedFieldRevisions = { base: 0, diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index fbb0508ee24..c9b217e3587 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -11,6 +11,7 @@ import { resolveCreateReviewDefaultBaseRef, stripBaseRef } from './create-pull-request-base-ref-normalization' +import { resolveCreateReviewSeedText } from './create-review-draft-title' import { createInitialPullRequestFieldRevisions, type GenerationSeed, @@ -75,6 +76,7 @@ export function useCreatePullRequestDialogFields({ const hasExternalGeneration = Boolean(generation) const currentEligibilityKey = open && eligibility ? `${repoId}:${worktreeId ?? worktreePath}:${branch}` : null + const seedText = resolveCreateReviewSeedText({ branch, eligibility }) const resolvedDefaultBaseRef = resolveCreateReviewDefaultBaseRef({ currentBaseRef, eligibilityDefaultBaseRef: eligibility?.defaultBaseRef @@ -243,6 +245,7 @@ export function useCreatePullRequestDialogFields({ setBody: setUserBody, draft, setDraft: setUserDraft, + fieldsAreSeedPlaceholders: title === seedText.title && body === seedText.body, stackedCreationSupported: eligibility?.stackedCreationSupported === true, repoDefaultBaseRef, fieldRevisions: fieldRevisionsRef.current, diff --git a/src/renderer/src/store/slices/pull-request-generation-auto-submit.ts b/src/renderer/src/store/slices/pull-request-generation-auto-submit.ts new file mode 100644 index 00000000000..c611818194f --- /dev/null +++ b/src/renderer/src/store/slices/pull-request-generation-auto-submit.ts @@ -0,0 +1,36 @@ +import type { + PullRequestGenerationFields, + PullRequestGenerationRecord +} from './pull-request-generation' + +// Why: an auto-submitted run (Create PR) is sent without the user reviewing it in the form. +export type PullRequestGenerationOptions = { autoSubmit?: boolean } + +type ResolvableRecord = Pick< + PullRequestGenerationRecord, + 'autoSubmit' | 'seed' | 'seedFieldRevisions' +> + +/** + * Draft is a decision, not prose: the agent may flag unfinished work, but it never reverts a box the + * user set — a silently unchecked Draft opens a real review ready for everyone, which no edit undoes. + */ +export function resolveGeneratedDraft( + { seed, seedFieldRevisions }: Omit, + result: PullRequestGenerationFields +): boolean { + return seed.draft || (seedFieldRevisions.draft === 0 && result.draft) +} + +/** + * What a finished run is allowed to change. Every run honours the Draft choice; only a run sent + * unreviewed also keeps the user's base, as the prepare-branch route does — a Generate-button base + * lands in the form, where the user still sees it and can change it before creating. + */ +export function resolveGeneratedFields( + record: ResolvableRecord, + result: PullRequestGenerationFields +): PullRequestGenerationFields { + const draft = resolveGeneratedDraft(record, result) + return record.autoSubmit ? { ...result, base: record.seed.base, draft } : { ...result, draft } +} diff --git a/src/renderer/src/store/slices/pull-request-generation.ts b/src/renderer/src/store/slices/pull-request-generation.ts index 4c4ec85ce63..377e1b746c6 100644 --- a/src/renderer/src/store/slices/pull-request-generation.ts +++ b/src/renderer/src/store/slices/pull-request-generation.ts @@ -1,6 +1,7 @@ import type { StateCreator } from 'zustand' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { AppState } from '../types' +import { resolveGeneratedFields } from './pull-request-generation-auto-submit' export type PullRequestFieldName = 'base' | 'title' | 'body' | 'draft' export type PullRequestFieldRevisions = Record @@ -38,6 +39,7 @@ export type PullRequestGenerationRecord = { result: PullRequestGenerationFields | null error: string | null hydrated: boolean + autoSubmit?: boolean } export type PullRequestGenerationRecords = Record @@ -135,7 +137,8 @@ export function getPullRequestGenerationSeedRestoreKey({ export function createRunningPullRequestGenerationRecord( context: PullRequestGenerationContext, seed: PullRequestGenerationFields, - seedFieldRevisions: PullRequestFieldRevisions + seedFieldRevisions: PullRequestFieldRevisions, + autoSubmit = false ): PullRequestGenerationRecord { return { context, @@ -145,7 +148,8 @@ export function createRunningPullRequestGenerationRecord( status: 'running', result: null, error: null, - hydrated: false + hydrated: false, + autoSubmit } } @@ -164,7 +168,7 @@ export function resolvePullRequestGenerationSuccess({ return { ...record, status: 'succeeded', - result, + result: resolveGeneratedFields(record, result), error: null, hydrated: false }