From 37cf284f2a15e6891696cedb0b6f662ae233d3cb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 06:20:58 -0700 Subject: [PATCH] Remove repo source control AI draft sync effect --- .../RepositorySourceControlAiSection.test.ts | 70 ++++++++++++ .../RepositorySourceControlAiSection.tsx | 104 ++++++++++++------ 2 files changed, 138 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts b/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts new file mode 100644 index 00000000000..cfd6512a3fe --- /dev/null +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' +import { createRepoAiDraftState, resolveRepoAiDraftState } from './RepositorySourceControlAiSection' + +describe('RepositorySourceControlAiSection draft state', () => { + it('refreshes clean drafts when persisted repo overrides change', () => { + const state = createRepoAiDraftState('repo-1', { + instructionsByOperation: { + commitMessage: 'old' + } + }) + const persisted: RepoSourceControlAiOverrides = { + instructionsByOperation: { + commitMessage: 'new' + } + } + + expect(resolveRepoAiDraftState(state, 'repo-1', persisted)).toEqual({ + repoId: 'repo-1', + value: persisted, + baseSerialized: JSON.stringify(persisted) + }) + }) + + it('preserves dirty drafts across external repo override changes', () => { + const state = createRepoAiDraftState('repo-1', { + instructionsByOperation: { + commitMessage: 'old' + } + }) + state.value = { + instructionsByOperation: { + commitMessage: 'local edit' + } + } + + const persisted: RepoSourceControlAiOverrides = { + instructionsByOperation: { + commitMessage: 'external edit' + } + } + + expect(resolveRepoAiDraftState(state, 'repo-1', persisted)).toBe(state) + }) + + it('resets the draft when the selected repo changes', () => { + const state = createRepoAiDraftState('repo-1', { + prCreationDefaults: { + draft: true + } + }) + state.value = { + prCreationDefaults: { + draft: false + } + } + + const persisted: RepoSourceControlAiOverrides = { + prCreationDefaults: { + useTemplate: true + } + } + + expect(resolveRepoAiDraftState(state, 'repo-2', persisted)).toEqual({ + repoId: 'repo-2', + value: persisted, + baseSerialized: JSON.stringify(persisted) + }) + }) +}) diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx index 05ef0e08c78..03a5dff9b36 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: repo Source Control AI settings keep one draft/save flow across model, instruction, and PR-default override groups. */ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import type { Repo } from '../../../../shared/types' import type { RepoSourceControlAiOverrides, @@ -98,6 +98,39 @@ function serializeRepoAiDraft(value: RepoSourceControlAiOverrides): string { return JSON.stringify(normalizeRepoAiDraft(value)) } +export function createRepoAiDraftState( + repoId: string, + value: RepoSourceControlAiOverrides +): RepoAiDraftState { + const normalized = normalizeRepoAiDraft(value) + return { + repoId, + value: normalized, + baseSerialized: serializeRepoAiDraft(normalized) + } +} + +export function resolveRepoAiDraftState( + current: RepoAiDraftState, + repoId: string, + persistedRepoAi: RepoSourceControlAiOverrides, + persistedSerialized = serializeRepoAiDraft(persistedRepoAi) +): RepoAiDraftState { + const currentSerialized = serializeRepoAiDraft(current.value) + if ( + current.repoId !== repoId || + currentSerialized === current.baseSerialized || + currentSerialized === persistedSerialized + ) { + return { + repoId, + value: persistedRepoAi, + baseSerialized: persistedSerialized + } + } + return current +} + export function RepositorySourceControlAiSection({ repo, updateRepo @@ -137,44 +170,47 @@ export function RepositorySourceControlAiSection({ ) // Why: repo.sourceControlAi is saved as one nested value; a local draft keeps // textarea keystrokes and sibling controls from racing over IPC/RPC. - const [draftState, setDraftState] = useState(() => ({ - repoId: repo.id, - value: persistedRepoAi, - baseSerialized: persistedSerialized - })) + const [draftState, setDraftState] = useState(() => + createRepoAiDraftState(repo.id, persistedRepoAi) + ) const [isSaving, setIsSaving] = useState(false) const [saveError, setSaveError] = useState(null) - useEffect(() => { - setDraftState((current) => { - const currentSerialized = serializeRepoAiDraft(current.value) - if ( - current.repoId !== repo.id || - currentSerialized === current.baseSerialized || - currentSerialized === persistedSerialized - ) { - return { - repoId: repo.id, - value: persistedRepoAi, - baseSerialized: persistedSerialized - } - } - return current - }) - setSaveError(null) - }, [persistedRepoAi, persistedSerialized, repo.id]) + const resolvedDraftState = resolveRepoAiDraftState( + draftState, + repo.id, + persistedRepoAi, + persistedSerialized + ) + if (resolvedDraftState !== draftState) { + // Why: repo settings may be refreshed externally; clean drafts should + // follow that source before paint, while dirty edits stay in place. + setDraftState(resolvedDraftState) + if (saveError !== null) { + setSaveError(null) + } + } - const repoAi = draftState.value + const repoAi = resolvedDraftState.value const draftSerialized = useMemo(() => serializeRepoAiDraft(repoAi), [repoAi]) - const isDirty = draftState.repoId !== repo.id || draftSerialized !== draftState.baseSerialized + const isDirty = + resolvedDraftState.repoId !== repo.id || draftSerialized !== resolvedDraftState.baseSerialized const updateDraftRepoAi = ( update: (current: RepoSourceControlAiOverrides) => RepoSourceControlAiOverrides ): void => { - setDraftState((current) => ({ - ...current, - value: normalizeRepoAiDraft(update(current.value)) - })) + setDraftState((current) => { + const resolved = resolveRepoAiDraftState( + current, + repo.id, + persistedRepoAi, + persistedSerialized + ) + return { + ...resolved, + value: normalizeRepoAiDraft(update(resolved.value)) + } + }) setSaveError(null) } @@ -182,7 +218,7 @@ export function RepositorySourceControlAiSection({ if (!isDirty || isSaving) { return } - const next = normalizeRepoAiDraft(draftState.value) + const next = normalizeRepoAiDraft(resolvedDraftState.value) const nextSerialized = serializeRepoAiDraft(next) setIsSaving(true) setSaveError(null) @@ -211,11 +247,7 @@ export function RepositorySourceControlAiSection({ } const discardDraft = (): void => { - setDraftState({ - repoId: repo.id, - value: persistedRepoAi, - baseSerialized: persistedSerialized - }) + setDraftState(createRepoAiDraftState(repo.id, persistedRepoAi)) setSaveError(null) }