From ad741f3f0bd0b26ca00723df03235757417f2b76 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:22:12 -0700 Subject: [PATCH] Route cancellations to starting runtime and prune stale worktree records Ensure commit message and pull request generation cancellation requests are always routed to the runtime environment that was selected when the generation started, even if active settings change before cancellation. Additionally, prune commit message and pull request generation records when worktrees are removed to prevent stale records from lingering in the store. --- ...eControl.commit-generation-records.test.ts | 34 +++++++++++- ...ourceControl.pr-generation-records.test.ts | 52 ++++++++++++++++++- .../right-sidebar/SourceControl.tsx | 44 ++++++++++++---- .../store/slices/commit-message-generation.ts | 25 +++++++++ .../store/slices/pull-request-generation.ts | 25 +++++++++ 5 files changed, 168 insertions(+), 12 deletions(-) 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 index 42d437a6997..7581d393ade 100644 --- 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 @@ -116,13 +116,43 @@ describe('SourceControl commit message generation records', () => { worktreeId: 'wt-a', worktreePath: '/repo/a', connectionId: 'conn-a', - requestId + requestId, + runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' } }) ) expect(store.getState().commitMessageGenerationRecords[key]).toMatchObject({ - context: { requestId, worktreeId: 'wt-a', worktreePath: '/repo/a' }, + context: { + requestId, + worktreeId: 'wt-a', + worktreePath: '/repo/a', + runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' } + }, status: 'running' }) }) + + it('prunes commit generation records for removed worktrees', () => { + const store = createCommitMessageGenerationTestStore() + store.getState().setCommitMessageGenerationRecord( + 'wt-a', + createRunningCommitMessageGenerationRecord({ + worktreeId: 'wt-a', + worktreePath: '/repo/a', + requestId: 1 + }) + ) + store.getState().setCommitMessageGenerationRecord( + 'wt-b', + createRunningCommitMessageGenerationRecord({ + worktreeId: 'wt-b', + worktreePath: '/repo/b', + requestId: 2 + }) + ) + + store.getState().pruneCommitMessageGenerationRecords(new Set(['wt-a'])) + + expect(Object.keys(store.getState().commitMessageGenerationRecords)).toEqual(['wt-a']) + }) }) 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 fe1916328a1..aabaf5e5e8b 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 @@ -186,7 +186,8 @@ describe('SourceControl pull request generation records', () => { connectionId: 'conn-a', requestId: 1, repoId: 'repo-1', - branch: 'feature-a' + branch: 'feature-a', + runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' } }, seed, fieldRevisions @@ -208,12 +209,61 @@ describe('SourceControl pull request generation records', () => { ) expect(store.getState().pullRequestGenerationRecords[key!]).toMatchObject({ + context: { runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' } }, status: 'succeeded', result: generated, hydrated: false }) }) + it('prunes PR generation records for removed worktrees', () => { + const store = createPullRequestGenerationTestStore() + const keyA = getPullRequestGenerationRecordKey({ + worktreeId: 'wt-a', + worktreePath: '/repo/a', + repoId: 'repo-1', + branch: 'feature-a' + })! + const keyB = getPullRequestGenerationRecordKey({ + worktreeId: 'wt-b', + worktreePath: '/repo/b', + repoId: 'repo-1', + branch: 'feature-b' + })! + store.getState().setPullRequestGenerationRecord( + keyA, + createRunningPullRequestGenerationRecord( + { + worktreeId: 'wt-a', + worktreePath: '/repo/a', + requestId: 1, + repoId: 'repo-1', + branch: 'feature-a' + }, + seed, + fieldRevisions + ) + ) + store.getState().setPullRequestGenerationRecord( + keyB, + createRunningPullRequestGenerationRecord( + { + worktreeId: 'wt-b', + worktreePath: '/repo/b', + requestId: 2, + repoId: 'repo-1', + branch: 'feature-b' + }, + seed, + fieldRevisions + ) + ) + + store.getState().prunePullRequestGenerationRecords(new Set(['wt-a'])) + + expect(Object.keys(store.getState().pullRequestGenerationRecords)).toEqual([keyA]) + }) + it('does not reuse PR generation request ids across composer remounts', () => { const store = createPullRequestGenerationTestStore() const key = getPullRequestGenerationRecordKey({ diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 3d027eb82d9..a93c5f611d9 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -895,12 +895,16 @@ function SourceControlInner(): React.JSX.Element { const updateCommitMessageGenerationRecord = useAppStore( (s) => s.updateCommitMessageGenerationRecord ) + const pruneCommitMessageGenerationRecords = useAppStore( + (s) => s.pruneCommitMessageGenerationRecords + ) const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords) const allocatePullRequestGenerationRequestId = useAppStore( (s) => s.allocatePullRequestGenerationRequestId ) const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord) const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord) + const prunePullRequestGenerationRecords = useAppStore((s) => s.prunePullRequestGenerationRecords) const filterInputRef = useRef(null) const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId) const commitError = commitErrors[activeWorktreeId ?? ''] ?? null @@ -1382,11 +1386,18 @@ function SourceControlInner(): React.JSX.Element { // state — especially commitInFlightRef, which would permanently disable // Commit for that ID if left stuck at `true`. useEffect(() => { + const liveWorktreeKeys = new Set() + for (const worktree of worktreeMap.values()) { + liveWorktreeKeys.add(worktree.id) + if (worktree.path.trim()) { + liveWorktreeKeys.add(worktree.path) + } + } const pruneRecord = (prev: Record): Record => { let changed = false const next: Record = {} for (const key of Object.keys(prev)) { - if (worktreeMap.has(key)) { + if (liveWorktreeKeys.has(key)) { next[key] = prev[key] } else { changed = true @@ -1400,18 +1411,20 @@ function SourceControlInner(): React.JSX.Element { setCommitInFlightByWorktree((prev) => pruneRecord(prev)) setAbortOperationInFlightByWorktree((prev) => pruneRecord(prev)) setGitHistoryByWorktree((prev) => pruneRecord(prev)) + pruneCommitMessageGenerationRecords(liveWorktreeKeys) + prunePullRequestGenerationRecords(liveWorktreeKeys) // Refs don't need setState — mutate in place to drop stale keys. for (const key of Object.keys(commitInFlightRef.current)) { - if (!worktreeMap.has(key)) { + if (!liveWorktreeKeys.has(key)) { delete commitInFlightRef.current[key] } } for (const key of Object.keys(gitHistoryRequestByWorktreeRef.current)) { - if (!worktreeMap.has(key)) { + if (!liveWorktreeKeys.has(key)) { delete gitHistoryRequestByWorktreeRef.current[key] } } - }, [worktreeMap]) + }, [pruneCommitMessageGenerationRecords, prunePullRequestGenerationRecords, worktreeMap]) useEffect(() => { // Why: users often finish merge/rebase conflicts in a terminal. Once git @@ -1595,7 +1608,10 @@ function SourceControlInner(): React.JSX.Element { worktreeId, worktreePath: generationWorktreePath, connectionId, - requestId + requestId, + runtimeTargetSettings: { + activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null + } }), requestId, error: 'Custom command is empty. Add one in Settings -> Git -> Source Control AI.' @@ -1608,13 +1624,18 @@ function SourceControlInner(): React.JSX.Element { } const requestId = allocateCommitMessageGenerationRequestId() + // Why: Stop must route to the runtime selected at generation start, even + // if the user changes settings before cancellation. setCommitMessageGenerationRecord( generationKey, createRunningCommitMessageGenerationRecord({ worktreeId, worktreePath: generationWorktreePath, connectionId, - requestId + requestId, + runtimeTargetSettings: { + activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null + } }) ) try { @@ -1743,7 +1764,7 @@ function SourceControlInner(): React.JSX.Element { // resolves with `{canceled: true}` once the kill propagates, while the // durable record lets the visible spinner clear immediately. void cancelRuntimeGenerateCommitMessage({ - settings: useAppStore.getState().settings, + settings: context.runtimeTargetSettings, worktreeId: context.worktreeId, worktreePath: context.worktreePath, connectionId: context.connectionId @@ -2147,13 +2168,18 @@ function SourceControlInner(): React.JSX.Element { return } const requestId = allocatePullRequestGenerationRequestId() + // Why: Stop must route to the runtime selected at generation start, even + // if the user changes settings before cancellation. const context: PullRequestGenerationContext = { worktreeId: target.worktreeId, worktreePath: target.worktreePath, connectionId: target.connectionId, requestId, repoId: target.repoId, - branch: target.branch + branch: target.branch, + runtimeTargetSettings: { + activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null + } } const seed = { ...target.fields } // Why: SourceControl can unmount on tab/worktree switches; the record is @@ -2245,7 +2271,7 @@ function SourceControlInner(): React.JSX.Element { return resolvePullRequestGenerationCancel(current) }) void cancelRuntimeGeneratePullRequestFields({ - settings: useAppStore.getState().settings, + settings: record.context.runtimeTargetSettings, worktreeId: record.context.worktreeId, worktreePath: record.context.worktreePath, connectionId: record.context.connectionId diff --git a/src/renderer/src/store/slices/commit-message-generation.ts b/src/renderer/src/store/slices/commit-message-generation.ts index df5454918e1..77f271781cf 100644 --- a/src/renderer/src/store/slices/commit-message-generation.ts +++ b/src/renderer/src/store/slices/commit-message-generation.ts @@ -1,11 +1,18 @@ import type { StateCreator } from 'zustand' +import type { GlobalSettings } from '../../../../shared/types' import type { AppState } from '../types' +export type CommitMessageGenerationRuntimeTargetSettings = Pick< + GlobalSettings, + 'activeRuntimeEnvironmentId' +> + export type CommitMessageGenerationContext = { worktreeId: string worktreePath: string connectionId?: string requestId: number + runtimeTargetSettings?: CommitMessageGenerationRuntimeTargetSettings | null } export type CommitMessageGenerationStatus = 'idle' | 'running' | 'canceled' | 'failed' | 'succeeded' @@ -29,6 +36,7 @@ export type CommitMessageGenerationSlice = { key: string, updater: (record: CommitMessageGenerationRecord | null) => CommitMessageGenerationRecord | null ) => void + pruneCommitMessageGenerationRecords: (liveWorktreeKeys: ReadonlySet) => void } export function getCommitMessageGenerationRecordKey( @@ -160,5 +168,22 @@ export const createCommitMessageGenerationSlice: StateCreator< [key]: nextRecord } } + }), + pruneCommitMessageGenerationRecords: (liveWorktreeKeys) => + set((state) => { + let changed = false + const nextRecords: CommitMessageGenerationRecords = {} + for (const [key, record] of Object.entries(state.commitMessageGenerationRecords)) { + const worktreeKey = getCommitMessageGenerationRecordKey( + record.context.worktreeId, + record.context.worktreePath + ) + if (worktreeKey && liveWorktreeKeys.has(worktreeKey)) { + nextRecords[key] = record + } else { + changed = true + } + } + return changed ? { commitMessageGenerationRecords: nextRecords } : {} }) }) diff --git a/src/renderer/src/store/slices/pull-request-generation.ts b/src/renderer/src/store/slices/pull-request-generation.ts index 6775a6fbb98..ed77d9f5169 100644 --- a/src/renderer/src/store/slices/pull-request-generation.ts +++ b/src/renderer/src/store/slices/pull-request-generation.ts @@ -1,4 +1,5 @@ import type { StateCreator } from 'zustand' +import type { GlobalSettings } from '../../../../shared/types' import type { AppState } from '../types' export type PullRequestFieldName = 'base' | 'title' | 'body' | 'draft' @@ -11,6 +12,11 @@ export type PullRequestGenerationFields = { draft: boolean } +export type PullRequestGenerationRuntimeTargetSettings = Pick< + GlobalSettings, + 'activeRuntimeEnvironmentId' +> + export type PullRequestGenerationContext = { worktreeId: string | null worktreePath: string @@ -18,6 +24,7 @@ export type PullRequestGenerationContext = { requestId: number repoId: string branch: string + runtimeTargetSettings?: PullRequestGenerationRuntimeTargetSettings | null } export type PullRequestGenerationStatus = 'idle' | 'running' | 'canceled' | 'failed' | 'succeeded' @@ -43,6 +50,7 @@ export type PullRequestGenerationSlice = { key: string, updater: (record: PullRequestGenerationRecord | null) => PullRequestGenerationRecord | null ) => void + prunePullRequestGenerationRecords: (liveWorktreeKeys: ReadonlySet) => void } export function getPullRequestGenerationWorktreeKey( @@ -214,5 +222,22 @@ export const createPullRequestGenerationSlice: StateCreator< [key]: nextRecord } } + }), + prunePullRequestGenerationRecords: (liveWorktreeKeys) => + set((state) => { + let changed = false + const nextRecords: PullRequestGenerationRecords = {} + for (const [key, record] of Object.entries(state.pullRequestGenerationRecords)) { + const worktreeKey = getPullRequestGenerationWorktreeKey( + record.context.worktreeId, + record.context.worktreePath + ) + if (worktreeKey && liveWorktreeKeys.has(worktreeKey)) { + nextRecords[key] = record + } else { + changed = true + } + } + return changed ? { pullRequestGenerationRecords: nextRecords } : {} }) })