Persist commit message generation state across worktree switches

Introduce a new global store slice for commit message generation to prevent
in-flight requests from being orphaned or lost when the Source Control
sidebar is unmounted or when switching between worktrees.

- Key commit generation records by worktree ID/path in the Zustand store
- Hydrate completed messages back into the draft input when returning to
  the originating worktree without overwriting active user edits
- Ensure the agent-selection dialog state remains bound to the target
  worktree that initiated the generation
- Wait for the PR composer eligibility check before hydrating completed
  PR field generations on sidebar remounts
This commit is contained in:
Jinjing
2026-06-11 21:11:35 -07:00
parent 36e439907a
commit 793b356c11
8 changed files with 804 additions and 110 deletions
@@ -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<CommitMessageGenerationRecord> = {}) {
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<CommitMessageGenerationSlice>()((...args) =>
createCommitMessageGenerationSlice(
...(args as unknown as Parameters<typeof createCommitMessageGenerationSlice>)
)
)
}
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'
})
})
})
@@ -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<GitConflictOperation, 'merge' | 'rebase'>
type AbortActionErrorKind = 'abort_merge' | 'abort_rebase'
type CommitMessageGenerationTargetSnapshot = {
worktreeId: string
worktreePath: string
connectionId?: string
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
settings: GlobalSettings | null
discoveryHostKey: string
}
type CommitMessageGenerationOptions = RuntimeGenerateCommitMessageOverrides & {
target?: CommitMessageGenerationTargetSnapshot
}
type PullRequestGenerationTargetSnapshot = {
worktreeId: string | null
worktreePath: string
connectionId?: string
repo: Pick<Repo, 'id' | 'sourceControlAi'> | 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<Record<string, boolean>>({})
const [generateInFlightByWorktree, setGenerateInFlightByWorktree] = useState<
Record<string, boolean>
>({})
const [generateErrors, setGenerateErrors] = useState<Record<string, string | null>>({})
const isGenerating = generateInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const generateError = generateErrors[activeWorktreeId ?? ''] ?? null
const [hostedReviewCreationState, setHostedReviewCreationState] =
useState<HostedReviewCreationState | null>(null)
const createPrInFlightRef = useRef<Record<string, boolean>>({})
@@ -862,6 +887,14 @@ function SourceControlInner(): React.JSX.Element {
const [createPrErrors, setCreatePrErrors] = useState<Record<string, string | null>>({})
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<CommitMessageGenerationTargetSnapshot | null>(null)
const [pullRequestGenerationDialogTarget, setPullRequestGenerationDialogTarget] =
useState<PullRequestGenerationTargetSnapshot | null>(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<void> => {
if (!activeWorktreeId || !worktreePath) {
async (options?: CommitMessageGenerationOptions): Promise<void> => {
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<void> => {
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 {
/>
<SourceControlTextGenerationDialog
open={commitGenerationDialogOpen}
onOpenChange={setCommitGenerationDialogOpen}
onOpenChange={handleCommitGenerationDialogOpenChange}
actionId="commitMessage"
title={translate(
'auto.components.right.sidebar.SourceControl.6b122529d4',
@@ -4487,17 +4731,23 @@ function SourceControlInner(): React.JSX.Element {
'Choose the agent and command template for this run.'
)}
generateLabel="Generate"
settings={settings}
repo={activeRepo ?? null}
discoveryHostKey={sourceControlAiDiscoveryHostKey}
settings={commitGenerationDialogTarget?.settings ?? settings}
repo={commitGenerationDialogTarget?.repo ?? activeRepo ?? null}
discoveryHostKey={
commitGenerationDialogTarget?.discoveryHostKey ?? sourceControlAiDiscoveryHostKey
}
onGenerate={(params) => {
void handleGenerate({ sourceControlAiResolvedParams: params })
const target = commitGenerationDialogTarget ?? createCommitMessageGenerationTarget()
if (!target) {
return
}
void handleGenerate({ sourceControlAiResolvedParams: params, target })
}}
onSaveDefaults={handleSaveCommitMessageGenerationDefaults}
/>
<SourceControlTextGenerationDialog
open={pullRequestGenerationDialogOpen}
onOpenChange={setPullRequestGenerationDialogOpen}
onOpenChange={handlePullRequestGenerationDialogOpenChange}
actionId="pullRequest"
title={translate(
'auto.components.right.sidebar.SourceControl.1a6a6e0bc5',
@@ -4508,11 +4758,27 @@ function SourceControlInner(): React.JSX.Element {
'Choose the agent and command template for this run.'
)}
generateLabel="Generate"
settings={settings}
repo={activeRepo ?? null}
discoveryHostKey={sourceControlAiDiscoveryHostKey}
settings={pullRequestGenerationDialogTarget?.settings ?? settings}
repo={pullRequestGenerationDialogTarget?.repo ?? activeRepo ?? null}
discoveryHostKey={
pullRequestGenerationDialogTarget?.discoveryHostKey ?? sourceControlAiDiscoveryHostKey
}
onGenerate={(params) => {
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}
/>
+3 -1
View File
@@ -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<AppState>()((...a) => ({
...createWorktreeNavHistorySlice(...a),
...createDictationSlice(...a),
...createWorkspaceCleanupSlice(...a),
...createPullRequestGenerationSlice(...a)
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a)
}))
registerHttpLinkStoreAccessor(() => useAppStore.getState())
@@ -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<string, CommitMessageGenerationRecord>
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
}
}
})
})
@@ -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<AppState>()((...a) => ({
@@ -169,7 +170,8 @@ function createTestStore() {
...createWorktreeNavHistorySlice(...a),
...createDictationSlice(...a),
...createWorkspaceCleanupSlice(...a),
...createPullRequestGenerationSlice(...a)
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a)
}))
}
@@ -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)
}))
}
+3 -1
View File
@@ -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
@@ -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) => {