diff --git a/src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts new file mode 100644 index 00000000000..42d437a6997 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { create } from 'zustand' +import { + createCommitMessageGenerationSlice, + createRunningCommitMessageGenerationRecord, + getCommitMessageGenerationRecordKey, + markCommitMessageGenerationHydrated, + resolveCommitMessageGenerationCancel, + resolveCommitMessageGenerationFailure, + resolveCommitMessageGenerationSuccess, + type CommitMessageGenerationRecord, + type CommitMessageGenerationSlice +} from '@/store/slices/commit-message-generation' + +function runningRecord(overrides: Partial = {}) { + return { + context: { + worktreeId: 'wt-a', + worktreePath: '/repo/a', + connectionId: 'conn-a', + requestId: 3 + }, + status: 'running' as const, + message: null, + error: null, + hydrated: false, + ...overrides + } +} + +function createCommitMessageGenerationTestStore() { + return create()((...args) => + createCommitMessageGenerationSlice( + ...(args as unknown as Parameters) + ) + ) +} + +describe('SourceControl commit message generation records', () => { + it('keys commit-message generation by worktree id and falls back to path', () => { + expect(getCommitMessageGenerationRecordKey('wt-a', '/repo/a')).toBe('wt-a') + expect(getCommitMessageGenerationRecordKey(null, '/repo/a')).toBe('/repo/a') + expect(getCommitMessageGenerationRecordKey(null, '')).toBeNull() + }) + + it('applies generated messages only to the original running request', () => { + expect( + resolveCommitMessageGenerationSuccess({ + record: runningRecord(), + requestId: 3, + message: 'feat: generated' + }) + ).toMatchObject({ + status: 'succeeded', + message: 'feat: generated', + hydrated: false + }) + + expect( + resolveCommitMessageGenerationSuccess({ + record: runningRecord(), + requestId: 4, + message: 'feat: stale' + }) + ).toBeNull() + + expect( + resolveCommitMessageGenerationSuccess({ + record: runningRecord({ status: 'canceled' }), + requestId: 3, + message: 'feat: stale' + }) + ).toBeNull() + }) + + it('preserves cancellation over later generator resolution', () => { + const canceled = resolveCommitMessageGenerationCancel(runningRecord()) + + expect(canceled).toMatchObject({ + status: 'canceled', + error: null + }) + expect( + resolveCommitMessageGenerationFailure({ + record: canceled, + requestId: 3, + canceled: true, + error: null + }) + ).toBeNull() + }) + + it('marks completed messages as hydrated once the UI consumes them', () => { + const hydrated = markCommitMessageGenerationHydrated( + runningRecord({ + status: 'succeeded', + message: 'docs: generated' + }) + ) + + expect(hydrated).toMatchObject({ + status: 'succeeded', + message: 'docs: generated', + hydrated: true + }) + }) + + it('stores running records in the generation slice', () => { + const store = createCommitMessageGenerationTestStore() + const key = getCommitMessageGenerationRecordKey('wt-a', '/repo/a')! + const requestId = store.getState().allocateCommitMessageGenerationRequestId() + + store.getState().setCommitMessageGenerationRecord( + key, + createRunningCommitMessageGenerationRecord({ + worktreeId: 'wt-a', + worktreePath: '/repo/a', + connectionId: 'conn-a', + requestId + }) + ) + + expect(store.getState().commitMessageGenerationRecords[key]).toMatchObject({ + context: { requestId, worktreeId: 'wt-a', worktreePath: '/repo/a' }, + status: 'running' + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 83be74d11ca..3d027eb82d9 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -155,6 +155,8 @@ import type { GitBranchCompareSummary, GitConflictOperation, GitStatusEntry, + GlobalSettings, + Repo, SourceControlViewMode, TuiAgent } from '../../../../shared/types' @@ -207,6 +209,14 @@ import { type PullRequestGenerationContext, type PullRequestGenerationFields } from '@/store/slices/pull-request-generation' +import { + createRunningCommitMessageGenerationRecord, + getCommitMessageGenerationRecordKey, + markCommitMessageGenerationHydrated, + resolveCommitMessageGenerationCancel, + resolveCommitMessageGenerationFailure, + resolveCommitMessageGenerationSuccess +} from '@/store/slices/commit-message-generation' export { appendCommitFailureCustomInstruction, @@ -223,6 +233,30 @@ export { export type SourceControlScope = 'all' | 'uncommitted' type AbortConflictOperation = Extract type AbortActionErrorKind = 'abort_merge' | 'abort_rebase' +type CommitMessageGenerationTargetSnapshot = { + worktreeId: string + worktreePath: string + connectionId?: string + repo: Pick | null + settings: GlobalSettings | null + discoveryHostKey: string +} +type CommitMessageGenerationOptions = RuntimeGenerateCommitMessageOverrides & { + target?: CommitMessageGenerationTargetSnapshot +} +type PullRequestGenerationTargetSnapshot = { + worktreeId: string | null + worktreePath: string + connectionId?: string + repo: Pick | null + repoId: string + branch: string + settings: GlobalSettings | null + discoveryHostKey: string + fields: PullRequestGenerationFields + fieldRevisions: PullRequestFieldRevisions +} + export type SourceControlActionError = { kind: RemoteOpKind | AbortActionErrorKind message: string @@ -844,15 +878,6 @@ function SourceControlInner(): React.JSX.Element { const isAbortingOperation = abortOperationInFlightByWorktree[activeWorktreeId ?? ''] ?? false const confirmAction = useConfirmationDialog() const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false - // Why: parallel state to commit. Same per-worktree shape so navigating between - // worktrees mid-generation never silently cancels the in-flight request. - const generateInFlightRef = useRef>({}) - const [generateInFlightByWorktree, setGenerateInFlightByWorktree] = useState< - Record - >({}) - const [generateErrors, setGenerateErrors] = useState>({}) - const isGenerating = generateInFlightByWorktree[activeWorktreeId ?? ''] ?? false - const generateError = generateErrors[activeWorktreeId ?? ''] ?? null const [hostedReviewCreationState, setHostedReviewCreationState] = useState(null) const createPrInFlightRef = useRef>({}) @@ -862,6 +887,14 @@ function SourceControlInner(): React.JSX.Element { const [createPrErrors, setCreatePrErrors] = useState>({}) const isCreatingPr = createPrInFlightByWorktree[activeWorktreeId ?? ''] ?? false const createPrError = createPrErrors[activeWorktreeId ?? ''] ?? null + const commitGenerationRecords = useAppStore((s) => s.commitMessageGenerationRecords) + const allocateCommitMessageGenerationRequestId = useAppStore( + (s) => s.allocateCommitMessageGenerationRequestId + ) + const setCommitMessageGenerationRecord = useAppStore((s) => s.setCommitMessageGenerationRecord) + const updateCommitMessageGenerationRecord = useAppStore( + (s) => s.updateCommitMessageGenerationRecord + ) const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords) const allocatePullRequestGenerationRequestId = useAppStore( (s) => s.allocatePullRequestGenerationRequestId @@ -894,6 +927,16 @@ function SourceControlInner(): React.JSX.Element { const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : '' + const activeCommitGenerationKey = getCommitMessageGenerationRecordKey( + activeWorktreeId, + worktreePath + ) + const activeCommitGenerationRecord = activeCommitGenerationKey + ? (commitGenerationRecords[activeCommitGenerationKey] ?? null) + : null + const isGenerating = activeCommitGenerationRecord?.status === 'running' + const generateError = + activeCommitGenerationRecord?.status === 'failed' ? activeCommitGenerationRecord.error : null const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({ worktreeId: activeWorktreeId, worktreePath, @@ -1287,6 +1330,52 @@ function SourceControlInner(): React.JSX.Element { openSettingsPage }) + const [commitGenerationDialogTarget, setCommitGenerationDialogTarget] = + useState(null) + const [pullRequestGenerationDialogTarget, setPullRequestGenerationDialogTarget] = + useState(null) + + const createCommitMessageGenerationTarget = useCallback(() => { + if (!activeWorktreeId || !worktreePath) { + return null + } + return { + worktreeId: activeWorktreeId, + worktreePath, + connectionId: activeConnectionId ?? undefined, + repo: activeRepo ?? null, + settings, + discoveryHostKey: sourceControlAiDiscoveryHostKey + } + }, [ + activeConnectionId, + activeRepo, + activeWorktreeId, + settings, + sourceControlAiDiscoveryHostKey, + worktreePath + ]) + + const handleCommitGenerationDialogOpenChange = useCallback( + (open: boolean): void => { + setCommitGenerationDialogOpen(open) + if (!open) { + setCommitGenerationDialogTarget(null) + } + }, + [setCommitGenerationDialogOpen] + ) + + const handlePullRequestGenerationDialogOpenChange = useCallback( + (open: boolean): void => { + setPullRequestGenerationDialogOpen(open) + if (!open) { + setPullRequestGenerationDialogTarget(null) + } + }, + [setPullRequestGenerationDialogOpen] + ) + // Why: orphaned draft/error/in-flight entries accumulate when worktrees are // removed from the store (long sessions with many create/destroy cycles). // Prune them so a deleted-then-reused worktree ID doesn't inherit stale @@ -1310,8 +1399,6 @@ function SourceControlInner(): React.JSX.Element { setRemoteActionErrors((prev) => pruneRecord(prev)) setCommitInFlightByWorktree((prev) => pruneRecord(prev)) setAbortOperationInFlightByWorktree((prev) => pruneRecord(prev)) - setGenerateInFlightByWorktree((prev) => pruneRecord(prev)) - setGenerateErrors((prev) => pruneRecord(prev)) setGitHistoryByWorktree((prev) => pruneRecord(prev)) // Refs don't need setState — mutate in place to drop stale keys. for (const key of Object.keys(commitInFlightRef.current)) { @@ -1319,11 +1406,6 @@ function SourceControlInner(): React.JSX.Element { delete commitInFlightRef.current[key] } } - for (const key of Object.keys(generateInFlightRef.current)) { - if (!worktreeMap.has(key)) { - delete generateInFlightRef.current[key] - } - } for (const key of Object.keys(gitHistoryRequestByWorktreeRef.current)) { if (!worktreeMap.has(key)) { delete gitHistoryRequestByWorktreeRef.current[key] @@ -1474,117 +1556,212 @@ function SourceControlInner(): React.JSX.Element { ]) const handleGenerate = useCallback( - async (overrides?: RuntimeGenerateCommitMessageOverrides): Promise => { - if (!activeWorktreeId || !worktreePath) { + async (options?: CommitMessageGenerationOptions): Promise => { + const target = options?.target ?? createCommitMessageGenerationTarget() + if (!target) { return } - if (generateInFlightRef.current[activeWorktreeId]) { + const overrides: RuntimeGenerateCommitMessageOverrides = { + ...(options?.sourceControlAiResolvedParams + ? { sourceControlAiResolvedParams: options.sourceControlAiResolvedParams } + : {}), + ...(options?.sourceControlAi ? { sourceControlAi: options.sourceControlAi } : {}), + ...(options?.agentCmdOverrides ? { agentCmdOverrides: options.agentCmdOverrides } : {}) + } + const { worktreeId, worktreePath: generationWorktreePath, connectionId } = target + const generationKey = getCommitMessageGenerationRecordKey(worktreeId, generationWorktreePath) + if (!generationKey) { return } - if (!overrides?.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) { + if ( + useAppStore.getState().commitMessageGenerationRecords[generationKey]?.status === 'running' + ) { + return + } + if (!overrides.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) { return } if ( - !overrides?.sourceControlAiResolvedParams && + !overrides.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok === true && isCustomAgentId(resolvedCommitMessageAi.value.params.agentId) ) { const command = resolvedCommitMessageAi.value.params.customAgentCommand?.trim() ?? '' if (!command) { - setGenerateErrors((prev) => ({ - ...prev, - [activeWorktreeId]: - 'Custom command is empty. Add one in Settings -> Git -> Source Control AI.' - })) + const requestId = allocateCommitMessageGenerationRequestId() + const failedRecord = resolveCommitMessageGenerationFailure({ + record: createRunningCommitMessageGenerationRecord({ + worktreeId, + worktreePath: generationWorktreePath, + connectionId, + requestId + }), + requestId, + error: 'Custom command is empty. Add one in Settings -> Git -> Source Control AI.' + }) + if (failedRecord) { + setCommitMessageGenerationRecord(generationKey, failedRecord) + } return } } - generateInFlightRef.current[activeWorktreeId] = true - const connectionId = getConnectionId(activeWorktreeId) ?? undefined - setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) - setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + const requestId = allocateCommitMessageGenerationRequestId() + setCommitMessageGenerationRecord( + generationKey, + createRunningCommitMessageGenerationRecord({ + worktreeId, + worktreePath: generationWorktreePath, + connectionId, + requestId + }) + ) try { const result = await generateRuntimeCommitMessage( { - settings: useAppStore.getState().settings, - worktreeId: activeWorktreeId, - worktreePath, + settings: target.settings ?? useAppStore.getState().settings, + worktreeId, + worktreePath: generationWorktreePath, connectionId }, overrides ) if (!result.success) { - // Why: cancellation is a deliberate user action, not a failure to - // surface. Clear any prior error and stay quiet. - if (result.canceled) { - setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) - return - } - setGenerateErrors((prev) => ({ - ...prev, - [activeWorktreeId]: result.error - })) + updateCommitMessageGenerationRecord(generationKey, (record) => + resolveCommitMessageGenerationFailure({ + record, + requestId, + canceled: result.canceled, + error: result.canceled ? null : result.error + }) + ) return } - // Why: race protection — the user may have started typing into the - // textarea while the agent was running. In that case we silently drop - // the generated message rather than overwrite their in-progress edits. - setCommitDrafts((prev) => { - const current = prev[activeWorktreeId] - if (current && current.length > 0) { - return prev - } - return writeCommitDraftForWorktree(prev, activeWorktreeId, result.message) - }) useAppStore.getState().recordFeatureInteraction('ai-commit-generation') - setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + updateCommitMessageGenerationRecord(generationKey, (record) => + resolveCommitMessageGenerationSuccess({ + record, + requestId, + message: result.message + }) + ) } catch (error) { - setGenerateErrors((prev) => ({ - ...prev, - [activeWorktreeId]: - error instanceof Error ? error.message : 'Failed to generate commit message' - })) - } finally { - setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) - generateInFlightRef.current[activeWorktreeId] = false + updateCommitMessageGenerationRecord(generationKey, (record) => + resolveCommitMessageGenerationFailure({ + record, + requestId, + error: error instanceof Error ? error.message : 'Failed to generate commit message' + }) + ) } }, - [activeWorktreeId, resolvedCommitMessageAi, worktreePath] + [ + allocateCommitMessageGenerationRequestId, + createCommitMessageGenerationTarget, + resolvedCommitMessageAi, + setCommitMessageGenerationRecord, + updateCommitMessageGenerationRecord + ] ) + useEffect(() => { + if ( + !activeCommitGenerationKey || + !activeCommitGenerationRecord || + activeCommitGenerationRecord.status !== 'succeeded' || + !activeCommitGenerationRecord.message || + activeCommitGenerationRecord.hydrated + ) { + return + } + + const { + context: { worktreeId }, + message + } = activeCommitGenerationRecord + // Why: generation can finish while Source Control is showing a different + // worktree or is temporarily unmounted; hydrate the saved result when its + // originating worktree is visible again without overwriting user edits. + setCommitDrafts((prev) => { + const current = prev[worktreeId] + if (current && current.length > 0) { + return prev + } + return writeCommitDraftForWorktree(prev, worktreeId, message) + }) + updateCommitMessageGenerationRecord( + activeCommitGenerationKey, + markCommitMessageGenerationHydrated + ) + }, [activeCommitGenerationKey, activeCommitGenerationRecord, updateCommitMessageGenerationRecord]) + const handleGenerateCommitMessageClick = useCallback((): void => { + const target = createCommitMessageGenerationTarget() + if (!target) { + return + } if ( hasConfiguredCommitMessageGenerationDefaults({ settings, repo: activeRepo ?? null }) && resolvedCommitMessageAi?.ok ) { - void handleGenerate({ sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params }) + void handleGenerate({ + sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params, + target + }) return } + // Why: the dialog remains open while users can switch worktrees; keep it + // bound to the worktree that requested generation. + setCommitGenerationDialogTarget(target) openCommitGenerationDialog() - }, [activeRepo, handleGenerate, openCommitGenerationDialog, resolvedCommitMessageAi, settings]) + }, [ + activeRepo, + createCommitMessageGenerationTarget, + handleGenerate, + openCommitGenerationDialog, + resolvedCommitMessageAi, + settings + ]) const handleCancelGenerate = useCallback((): void => { - if (!activeWorktreeId || !worktreePath) { + if ( + !activeCommitGenerationKey || + !activeCommitGenerationRecord || + activeCommitGenerationRecord.status !== 'running' + ) { return } - if (!generateInFlightRef.current[activeWorktreeId]) { - return - } - const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const { context } = activeCommitGenerationRecord + updateCommitMessageGenerationRecord( + activeCommitGenerationKey, + resolveCommitMessageGenerationCancel + ) // Why: fire-and-forget — the in-flight generateCommitMessage promise - // resolves with `{canceled: true}` once the kill propagates, which is - // where the spinner is cleared. Awaiting here would just delay UI feedback. + // resolves with `{canceled: true}` once the kill propagates, while the + // durable record lets the visible spinner clear immediately. void cancelRuntimeGenerateCommitMessage({ settings: useAppStore.getState().settings, - worktreeId: activeWorktreeId, - worktreePath, - connectionId + worktreeId: context.worktreeId, + worktreePath: context.worktreePath, + connectionId: context.connectionId + }).catch((error) => { + updateCommitMessageGenerationRecord(activeCommitGenerationKey, (record) => { + if (!record || record.context.requestId !== context.requestId) { + return null + } + return { + ...record, + status: 'failed', + error: + error instanceof Error ? error.message : 'Failed to stop commit message generation', + hydrated: false + } + }) }) - }, [activeWorktreeId, worktreePath]) + }, [activeCommitGenerationKey, activeCommitGenerationRecord, updateCommitMessageGenerationRecord]) // Why: a single dispatcher for every remote-only action the split button or // chevron dropdown can trigger. Keeps the error-swallow pattern in one @@ -1912,16 +2089,58 @@ function SourceControlInner(): React.JSX.Element { await refreshActiveGitStatusAfterMutation() }, [refreshActiveGitStatusAfterMutation]) + const createPullRequestGenerationTarget = useCallback( + ( + fields: PullRequestGenerationFields, + fieldRevisions: PullRequestFieldRevisions + ): PullRequestGenerationTargetSnapshot | null => { + if (!activeRepo || !worktreePath || !branchName) { + return null + } + return { + worktreeId: activeWorktreeId, + worktreePath, + connectionId: activeConnectionId ?? undefined, + repo: activeRepo, + repoId: activeRepo.id, + branch: branchName, + settings, + discoveryHostKey: sourceControlAiDiscoveryHostKey, + fields: { ...fields }, + fieldRevisions: { ...fieldRevisions } + } + }, + [ + activeConnectionId, + activeRepo, + activeWorktreeId, + branchName, + settings, + sourceControlAiDiscoveryHostKey, + worktreePath + ] + ) + const handleGeneratePullRequestFieldsForActive = useCallback( async ( fields: PullRequestGenerationFields, fieldRevisions: PullRequestFieldRevisions, - overrides?: RuntimeGeneratePullRequestFieldsOverrides + overrides?: RuntimeGeneratePullRequestFieldsOverrides, + targetOverride?: PullRequestGenerationTargetSnapshot ): Promise => { - if (!activeRepo || !activePullRequestGenerationKey || !worktreePath || !branchName) { + const target = targetOverride ?? createPullRequestGenerationTarget(fields, fieldRevisions) + if (!target) { + return + } + const generationKey = getPullRequestGenerationRecordKey({ + worktreeId: target.worktreeId, + worktreePath: target.worktreePath, + repoId: target.repoId, + branch: target.branch + }) + if (!generationKey) { return } - const generationKey = activePullRequestGenerationKey if ( useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running' ) { @@ -1929,25 +2148,26 @@ function SourceControlInner(): React.JSX.Element { } const requestId = allocatePullRequestGenerationRequestId() const context: PullRequestGenerationContext = { - worktreeId: activeWorktreeId, - worktreePath, - connectionId: getConnectionId(activeWorktreeId) ?? undefined, + worktreeId: target.worktreeId, + worktreePath: target.worktreePath, + connectionId: target.connectionId, requestId, - repoId: activeRepo.id, - branch: branchName + repoId: target.repoId, + branch: target.branch } - const seed = { ...fields } - // Why: SourceControl can unmount on tab switches; persisting the running - // record lets the embedded PR composer resume when the user returns. + const seed = { ...target.fields } + // Why: SourceControl can unmount on tab/worktree switches; the record is + // keyed to the originating repo/worktree/branch so stale UI cannot retarget + // or orphan the generated hosted-review fields. setPullRequestGenerationRecord( generationKey, - createRunningPullRequestGenerationRecord(context, seed, fieldRevisions) + createRunningPullRequestGenerationRecord(context, seed, target.fieldRevisions) ) try { const result = await generateRuntimePullRequestFields( { - settings: useAppStore.getState().settings, + settings: target.settings ?? useAppStore.getState().settings, worktreeId: context.worktreeId, worktreePath: context.worktreePath, connectionId: context.connectionId @@ -2001,15 +2221,11 @@ function SourceControlInner(): React.JSX.Element { } }, [ - activePullRequestGenerationKey, - activeRepo, - activeWorktreeId, allocatePullRequestGenerationRequestId, - branchName, + createPullRequestGenerationTarget, refreshGitStatusAfterPullRequestGeneration, setPullRequestGenerationRecord, - updatePullRequestGenerationRecord, - worktreePath + updatePullRequestGenerationRecord ] ) @@ -2058,6 +2274,7 @@ function SourceControlInner(): React.JSX.Element { setBody: setPrBody, draft: prDraft, setDraft: setPrDraft, + fieldRevisions: prFieldRevisions, baseQuery: prBaseQuery, setBaseQuery: setPrBaseQuery, baseResults: prBaseResults, @@ -2103,10 +2320,36 @@ function SourceControlInner(): React.JSX.Element { void handleGeneratePullRequestFields() return } + const target = createPullRequestGenerationTarget( + { base: prBase, title: prTitle, body: prBody, draft: prDraft }, + prFieldRevisions + ) + if (!target) { + return + } + // Why: the agent-picker dialog can stay open while the user switches + // worktrees; keep Generate bound to the PR draft that opened it. + setPullRequestGenerationDialogTarget(target) openPullRequestGenerationDialog() - }, [activeRepo, handleGeneratePullRequestFields, openPullRequestGenerationDialog, settings]) + }, [ + activeRepo, + createPullRequestGenerationTarget, + handleGeneratePullRequestFields, + openPullRequestGenerationDialog, + prBase, + prBody, + prDraft, + prFieldRevisions, + prTitle, + settings + ]) useEffect(() => { + // Why: after a SourceControl remount, the PR composer first reseeds from + // eligibility; hydrate generated fields only after that seed exists. + if (hostedReviewCreation?.canCreate !== true) { + return + } if ( !activePullRequestGenerationKey || !activePullRequestGenerationRecord || @@ -2141,6 +2384,7 @@ function SourceControlInner(): React.JSX.Element { activePullRequestGenerationKey, activePullRequestGenerationRecord, applyGeneratedPullRequestFields, + hostedReviewCreation?.canCreate, updatePullRequestGenerationRecord ]) @@ -4476,7 +4720,7 @@ function SourceControlInner(): React.JSX.Element { /> { - void handleGenerate({ sourceControlAiResolvedParams: params }) + const target = commitGenerationDialogTarget ?? createCommitMessageGenerationTarget() + if (!target) { + return + } + void handleGenerate({ sourceControlAiResolvedParams: params, target }) }} onSaveDefaults={handleSaveCommitMessageGenerationDefaults} /> { - void handleGeneratePullRequestFields({ sourceControlAiResolvedParams: params }) + const target = + pullRequestGenerationDialogTarget ?? + createPullRequestGenerationTarget( + { base: prBase, title: prTitle, body: prBody, draft: prDraft }, + prFieldRevisions + ) + if (!target) { + return + } + void handleGeneratePullRequestFieldsForActive( + target.fields, + target.fieldRevisions, + { sourceControlAiResolvedParams: params }, + target + ) }} onSaveDefaults={handleSavePullRequestGenerationDefaults} /> diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 9668bf7d566..8e430d6e5d1 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -30,6 +30,7 @@ import { createWorktreeNavHistorySlice } from './slices/worktree-nav-history' import { createDictationSlice } from './slices/dictation' import { createWorkspaceCleanupSlice } from './slices/workspace-cleanup' import { createPullRequestGenerationSlice } from './slices/pull-request-generation' +import { createCommitMessageGenerationSlice } from './slices/commit-message-generation' import { e2eConfig } from '@/lib/e2e-config' import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' @@ -63,7 +64,8 @@ export const useAppStore = create()((...a) => ({ ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), ...createWorkspaceCleanupSlice(...a), - ...createPullRequestGenerationSlice(...a) + ...createPullRequestGenerationSlice(...a), + ...createCommitMessageGenerationSlice(...a) })) registerHttpLinkStoreAccessor(() => useAppStore.getState()) diff --git a/src/renderer/src/store/slices/commit-message-generation.ts b/src/renderer/src/store/slices/commit-message-generation.ts new file mode 100644 index 00000000000..df5454918e1 --- /dev/null +++ b/src/renderer/src/store/slices/commit-message-generation.ts @@ -0,0 +1,164 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' + +export type CommitMessageGenerationContext = { + worktreeId: string + worktreePath: string + connectionId?: string + requestId: number +} + +export type CommitMessageGenerationStatus = 'idle' | 'running' | 'canceled' | 'failed' | 'succeeded' + +export type CommitMessageGenerationRecord = { + context: CommitMessageGenerationContext + status: CommitMessageGenerationStatus + message: string | null + error: string | null + hydrated: boolean +} + +export type CommitMessageGenerationRecords = Record + +export type CommitMessageGenerationSlice = { + commitMessageGenerationRequestSeq: number + commitMessageGenerationRecords: CommitMessageGenerationRecords + allocateCommitMessageGenerationRequestId: () => number + setCommitMessageGenerationRecord: (key: string, record: CommitMessageGenerationRecord) => void + updateCommitMessageGenerationRecord: ( + key: string, + updater: (record: CommitMessageGenerationRecord | null) => CommitMessageGenerationRecord | null + ) => void +} + +export function getCommitMessageGenerationRecordKey( + worktreeId: string | null | undefined, + worktreePath: string | null | undefined +): string | null { + if (worktreeId) { + return worktreeId + } + return worktreePath?.trim() ? worktreePath : null +} + +export function createRunningCommitMessageGenerationRecord( + context: CommitMessageGenerationContext +): CommitMessageGenerationRecord { + return { + context, + status: 'running', + message: null, + error: null, + hydrated: false + } +} + +export function resolveCommitMessageGenerationSuccess({ + record, + requestId, + message +}: { + record: CommitMessageGenerationRecord | null | undefined + requestId: number + message: string +}): CommitMessageGenerationRecord | null { + if (!record || record.context.requestId !== requestId || record.status !== 'running') { + return null + } + return { + ...record, + status: 'succeeded', + message, + error: null, + hydrated: false + } +} + +export function resolveCommitMessageGenerationFailure({ + record, + requestId, + error, + canceled = false +}: { + record: CommitMessageGenerationRecord | null | undefined + requestId: number + error: string | null + canceled?: boolean +}): CommitMessageGenerationRecord | null { + if (!record || record.context.requestId !== requestId || record.status !== 'running') { + return null + } + return { + ...record, + status: canceled ? 'canceled' : 'failed', + message: null, + error: canceled ? null : error, + hydrated: false + } +} + +export function resolveCommitMessageGenerationCancel( + record: CommitMessageGenerationRecord | null | undefined +): CommitMessageGenerationRecord | null { + if (!record || record.status !== 'running') { + return null + } + return { + ...record, + status: 'canceled', + error: null, + hydrated: false + } +} + +export function markCommitMessageGenerationHydrated( + record: CommitMessageGenerationRecord | null | undefined +): CommitMessageGenerationRecord | null { + if (!record || record.status !== 'succeeded') { + return null + } + return { + ...record, + hydrated: true + } +} + +export const createCommitMessageGenerationSlice: StateCreator< + AppState, + [], + [], + CommitMessageGenerationSlice +> = (set) => ({ + commitMessageGenerationRequestSeq: 0, + commitMessageGenerationRecords: {}, + allocateCommitMessageGenerationRequestId: () => { + let nextRequestId = 0 + set((state) => { + nextRequestId = state.commitMessageGenerationRequestSeq + 1 + return { + commitMessageGenerationRequestSeq: nextRequestId + } + }) + return nextRequestId + }, + setCommitMessageGenerationRecord: (key, record) => + set((state) => ({ + commitMessageGenerationRecords: { + ...state.commitMessageGenerationRecords, + [key]: record + } + })), + updateCommitMessageGenerationRecord: (key, updater) => + set((state) => { + const nextRecord = updater(state.commitMessageGenerationRecords[key] ?? null) + if (!nextRecord) { + return {} + } + return { + commitMessageGenerationRecords: { + ...state.commitMessageGenerationRecords, + [key]: nextRecord + } + } + }) +}) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 96d6e8f097f..d5672d751cb 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -137,6 +137,7 @@ import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createDictationSlice } from './dictation' import { createWorkspaceCleanupSlice } from './workspace-cleanup' import { createPullRequestGenerationSlice } from './pull-request-generation' +import { createCommitMessageGenerationSlice } from './commit-message-generation' function createTestStore() { return create()((...a) => ({ @@ -169,7 +170,8 @@ function createTestStore() { ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), ...createWorkspaceCleanupSlice(...a), - ...createPullRequestGenerationSlice(...a) + ...createPullRequestGenerationSlice(...a), + ...createCommitMessageGenerationSlice(...a) })) } diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index ce70ee77236..a65cb122a2c 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -38,6 +38,7 @@ import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createDictationSlice } from './dictation' import { createWorkspaceCleanupSlice } from './workspace-cleanup' import { createPullRequestGenerationSlice } from './pull-request-generation' +import { createCommitMessageGenerationSlice } from './commit-message-generation' import { translate } from '@/i18n/i18n' export const TEST_REPO = { @@ -79,7 +80,8 @@ export function createTestStore() { ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), ...createWorkspaceCleanupSlice(...a), - ...createPullRequestGenerationSlice(...a) + ...createPullRequestGenerationSlice(...a), + ...createCommitMessageGenerationSlice(...a) })) } diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index 7ef6ecec84d..e6152999f36 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -28,6 +28,7 @@ import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history' import type { DictationSlice } from './slices/dictation' import type { WorkspaceCleanupSlice } from './slices/workspace-cleanup' import type { PullRequestGenerationSlice } from './slices/pull-request-generation' +import type { CommitMessageGenerationSlice } from './slices/commit-message-generation' export type AppState = RepoSlice & SparsePresetsSlice & @@ -58,4 +59,5 @@ export type AppState = RepoSlice & WorktreeNavHistorySlice & DictationSlice & WorkspaceCleanupSlice & - PullRequestGenerationSlice + PullRequestGenerationSlice & + CommitMessageGenerationSlice diff --git a/tests/e2e/source-control-pr-generation-switch.spec.ts b/tests/e2e/source-control-pr-generation-switch.spec.ts index 189338d7d0d..a41a53bf0d5 100644 --- a/tests/e2e/source-control-pr-generation-switch.spec.ts +++ b/tests/e2e/source-control-pr-generation-switch.spec.ts @@ -140,6 +140,67 @@ test.describe('Source Control AI PR generation worktree switching', () => { }) }) + test('hydrates pending PR generation after Source Control remounts', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const { prWorktreeId, prWorktreePath, primaryBranch } = await seedCreatePrComposer(orcaPage) + createBranchCommit(prWorktreePath) + + const screenshotDir = path.join( + process.cwd(), + 'validation-screenshots', + `pr-generation-remount-${Date.now()}` + ) + mkdirSync(screenshotDir, { recursive: true }) + await testInfo.attach('validation-screenshot-dir', { + body: screenshotDir, + contentType: 'text/plain' + }) + const generatorScriptPath = path.join(screenshotDir, 'delayed-pr-generator.cjs') + const callLogPath = path.join(screenshotDir, 'delayed-pr-generator.log') + await installDelayedPrGenerator(orcaPage, generatorScriptPath, callLogPath, primaryBranch) + + await openSourceControl(orcaPage, prWorktreeId) + const generate = orcaPage.getByRole('button', { + name: 'Generate pull request details with AI' + }) + await expect(generate).toBeVisible({ timeout: 10_000 }) + await expect(generate).toBeEnabled() + await generate.click() + await expect( + orcaPage.getByRole('button', { name: 'Stop generating pull request details' }) + ).toBeVisible() + await expect.poll(() => readLog(callLogPath)).toContain('start') + + await orcaPage.evaluate(() => { + window.__store?.getState().setRightSidebarTab('explorer') + }) + await expect( + orcaPage.getByRole('button', { name: 'Stop generating pull request details' }) + ).toHaveCount(0) + await expect + .poll(() => readFileSync(callLogPath, 'utf8'), { timeout: 10_000 }) + .toContain('finish') + + await openSourceControl(orcaPage, prWorktreeId) + await expect(orcaPage.getByRole('textbox', { name: 'Pull request title' })).toHaveValue( + 'Generated PR title after switch', + { timeout: 10_000 } + ) + await expect(orcaPage.getByRole('textbox', { name: 'Pull request description' })).toHaveValue( + 'Generated PR body after switch' + ) + await orcaPage.screenshot({ + path: path.join(screenshotDir, '01-remounted-source-control-hydrated-pr-fields.png') + }) + await writeEvidence(testInfo, screenshotDir, 'pr-generation-remount-evidence.json', { + expectedOriginalWorktreeId: prWorktreeId, + generatorLog: readLog(callLogPath) + }) + }) + test('keeps pending commit message generation attached to its original worktree', async ({ orcaPage }, testInfo) => { @@ -242,6 +303,73 @@ test.describe('Source Control AI PR generation worktree switching', () => { }) }) + test('hydrates pending commit message generation after Source Control remounts', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const { commitWorktreeId, commitWorktreePath } = await seedCommitMessageComposer(orcaPage) + createStagedCommitMessageChange(commitWorktreePath) + + const screenshotDir = path.join( + process.cwd(), + 'validation-screenshots', + `commit-message-generation-remount-${Date.now()}` + ) + mkdirSync(screenshotDir, { recursive: true }) + await testInfo.attach('validation-screenshot-dir', { + body: screenshotDir, + contentType: 'text/plain' + }) + const generatorScriptPath = path.join(screenshotDir, 'delayed-commit-generator.cjs') + const callLogPath = path.join(screenshotDir, 'delayed-commit-generator.log') + await installDelayedCommitMessageGenerator(orcaPage, generatorScriptPath, callLogPath) + + await openSourceControl(orcaPage, commitWorktreeId) + const generate = orcaPage.getByRole('button', { + name: 'Generate commit message with AI' + }) + await expect(generate).toBeVisible({ timeout: 10_000 }) + await expect(generate).toBeEnabled() + await generate.click() + await expect( + orcaPage.getByRole('button', { name: 'Stop generating commit message' }) + ).toBeVisible() + await expect.poll(() => readLog(callLogPath)).toContain('start') + + await orcaPage.evaluate(() => { + window.__store?.getState().setRightSidebarTab('explorer') + }) + await expect( + orcaPage.getByRole('button', { name: 'Stop generating commit message' }) + ).toHaveCount(0) + await expect + .poll(() => readFileSync(callLogPath, 'utf8'), { timeout: 10_000 }) + .toContain('finish') + + await openSourceControl(orcaPage, commitWorktreeId) + await expect(orcaPage.getByRole('textbox', { name: 'Commit message' })).toHaveValue( + [ + 'Generated commit message after switch', + '', + 'Generated from staged e2e-commit-message-generation.txt after switching worktrees' + ].join('\n'), + { timeout: 10_000 } + ) + await orcaPage.screenshot({ + path: path.join(screenshotDir, '01-remounted-source-control-hydrated-message.png') + }) + await writeEvidence( + testInfo, + screenshotDir, + 'commit-message-generation-remount-evidence.json', + { + expectedOriginalWorktreeId: commitWorktreeId, + generatorLog: readLog(callLogPath) + } + ) + }) + test('hides the commit AI composer on a clean branch empty state', async ({ orcaPage }, testInfo) => {