diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 45a9e212c99..9d25860cc7f 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -258,6 +258,8 @@ import { createPrIntentRunTokenMatches, getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, + resolveCreatePrIntentGeneratedFields, + resolveCreatePrIntentGenerationErrorDetail, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep, type CreatePrIntentRunToken @@ -3523,13 +3525,26 @@ function SourceControlInner(): React.JSX.Element { ) }) const target = getCreatePrIntentOperationTarget(token) + // Why: the intent auto-submits, so a swallowed failure would ship a + // blank-template PR; abort and surface the error instead. + const surfaceCreatePrIntentDetailGenerationFailure = (errorDetail: string): void => { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'destructive', + message: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentDetailGenerationFailed', + 'Couldn’t generate {{value0}} details: {{value1}} Retry Create PR.', + { value0: hostedReviewCreateCopy.reviewLabel, value1: errorDetail } + ) + }) + } try { const generated = await generateRuntimePullRequestFields(target, { ...fields, provider: eligibility.provider, useTemplate: resolvedPrCreationDefaults.useTemplate }) - if (generated.branchChangedByPreparation) { + const resolution = resolveCreatePrIntentGeneratedFields({ generated, fallback: fields }) + if (resolution.outcome === 'branchChanged') { setCreatePrIntentNoticeForWorktree(token.worktreeId, { tone: 'muted', message: translate( @@ -3539,18 +3554,20 @@ function SourceControlInner(): React.JSX.Element { }) return false } - if (generated.success) { - fields = { - // Why: Create PR intent auto-submits; generated details should - // not retarget the review without user confirmation. - base: fields.base, - title: generated.fields.title.trim() || fields.title, - body: generated.fields.body, - draft: generated.fields.draft - } + if (resolution.outcome === 'applied') { + fields = resolution.fields + } else if (resolution.outcome === 'canceled') { + setCreatePrIntentNoticeForWorktree(token.worktreeId, null) + return false + } else { + surfaceCreatePrIntentDetailGenerationFailure(resolution.errorDetail) + return false } } catch (error) { - console.warn('[SourceControl] Create PR intent detail generation failed', error) + surfaceCreatePrIntentDetailGenerationFailure( + resolveCreatePrIntentGenerationErrorDetail(error) + ) + return false } } 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 516c0711369..d2d2ef71ca9 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 @@ -7,6 +7,8 @@ import { createPrIntentRunTokenMatches, getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, + resolveCreatePrIntentGeneratedFields, + resolveCreatePrIntentGenerationErrorDetail, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep } from './source-control-create-pr-intent-flow' @@ -278,4 +280,65 @@ describe('source-control Create PR intent flow helpers', () => { }) ).toBe('localized Pre-commit hook failed.') }) + + it('resolves generated PR details without auto-submitting a blank template on failure', () => { + const fallback = { base: 'main', title: 'fallback title', body: 'fallback body', draft: false } + + expect( + resolveCreatePrIntentGeneratedFields({ + generated: { success: true, branchChangedByPreparation: true }, + fallback + }) + ).toEqual({ outcome: 'branchChanged' }) + + expect( + resolveCreatePrIntentGeneratedFields({ + generated: { + success: true, + fields: { title: ' Generated title ', body: 'Generated body', draft: true } + }, + fallback + }) + ).toEqual({ + outcome: 'applied', + fields: { base: 'main', title: 'Generated title', body: 'Generated body', draft: true } + }) + + // Why: a blank generated title must not overwrite the seeded fallback title. + expect( + resolveCreatePrIntentGeneratedFields({ + generated: { success: true, fields: { title: ' ', body: 'Generated body', draft: true } }, + fallback + }) + ).toEqual({ + outcome: 'applied', + fields: { base: 'main', title: 'fallback title', body: 'Generated body', draft: true } + }) + + expect( + resolveCreatePrIntentGeneratedFields({ + generated: { success: false, canceled: true }, + fallback + }) + ).toEqual({ outcome: 'canceled' }) + + expect( + resolveCreatePrIntentGeneratedFields({ + generated: { success: false, error: 'Model request timed out' }, + fallback + }) + ).toEqual({ outcome: 'failed', errorDetail: 'Model request timed out' }) + + expect( + resolveCreatePrIntentGeneratedFields({ generated: { success: false }, fallback }) + ).toEqual({ outcome: 'failed', errorDetail: '' }) + }) + + it('stringifies both Error and non-Error thrown values for the failure notice', () => { + expect(resolveCreatePrIntentGenerationErrorDetail(new Error('boom'))).toBe('boom') + expect(resolveCreatePrIntentGenerationErrorDetail('raw string throw')).toBe('raw string throw') + expect(resolveCreatePrIntentGenerationErrorDetail({ code: 'ECONNRESET' })).toBe( + '[object Object]' + ) + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts index 566ee54e1d7..f32ed89d6da 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts @@ -152,6 +152,58 @@ export function resolveCreatePrIntentRemoteStep({ return 'none' } +export type CreatePrIntentGeneratedFields = { + base: string + title: string + body: string + draft: boolean +} + +export type CreatePrIntentGeneratedFieldsResolution = + | { outcome: 'branchChanged' } + | { outcome: 'canceled' } + | { outcome: 'failed'; errorDetail: string } + | { outcome: 'applied'; fields: CreatePrIntentGeneratedFields } + +export function resolveCreatePrIntentGeneratedFields({ + generated, + fallback +}: { + generated: { + success: boolean + error?: string + canceled?: boolean + branchChangedByPreparation?: boolean + fields?: { title: string; body: string; draft: boolean } + } + fallback: CreatePrIntentGeneratedFields +}): CreatePrIntentGeneratedFieldsResolution { + if (generated.branchChangedByPreparation) { + return { outcome: 'branchChanged' } + } + if (generated.success && generated.fields) { + return { + outcome: 'applied', + fields: { + // Why: Create PR intent auto-submits; generated details should + // not retarget the review without user confirmation. + base: fallback.base, + title: generated.fields.title.trim() || fallback.title, + body: generated.fields.body, + draft: generated.fields.draft + } + } + } + if (generated.canceled) { + return { outcome: 'canceled' } + } + return { outcome: 'failed', errorDetail: generated.error ?? '' } +} + +export function resolveCreatePrIntentGenerationErrorDetail(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + export function getCreatePrIntentCommitFailureNoticeMessage( commitError: string | null | undefined, copy: { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f275b3923b6..f454836d389 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9644,7 +9644,8 @@ "a9bf7c171a": "Push Failed", "834cb3f23d": "Fix with AI", "783a808870": "Close" - } + }, + "createPrIntentDetailGenerationFailed": "Couldn’t generate {{value0}} details: {{value1}} Retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "Could not start the selected agent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9261f9971d0..694a9e537d6 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9644,7 +9644,8 @@ "a9bf7c171a": "Push fallido", "834cb3f23d": "Corregir con AI", "783a808870": "Cerrar" - } + }, + "createPrIntentDetailGenerationFailed": "Couldn’t generate {{value0}} details: {{value1}} Retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "No se pudo iniciar el agente seleccionado.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e1cf3313cdc..dae854fb547 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9644,7 +9644,8 @@ "a9bf7c171a": "プッシュ失敗", "834cb3f23d": "AIで修正", "783a808870": "閉じる" - } + }, + "createPrIntentDetailGenerationFailed": "Couldn’t generate {{value0}} details: {{value1}} Retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "選択した agent を開始できませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d22ad9a5db8..6d59d9dc3b1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9644,7 +9644,8 @@ "a9bf7c171a": "푸시 실패", "834cb3f23d": "AI로 수정", "783a808870": "닫기" - } + }, + "createPrIntentDetailGenerationFailed": "Couldn’t generate {{value0}} details: {{value1}} Retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "선택한 agent를 시작할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b9fe5b678d6..abc68c1c66e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9644,7 +9644,8 @@ "a9bf7c171a": "推送失败", "834cb3f23d": "使用 AI 修复", "783a808870": "关闭" - } + }, + "createPrIntentDetailGenerationFailed": "Couldn’t generate {{value0}} details: {{value1}} Retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "无法启动选定的智能体。",