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 } : {} }) })