fix: address review findings (#4936)

This commit is contained in:
Jinjing 2026-06-08 20:10:41 -07:00 committed by GitHub
parent 9c8e67c271
commit 50831e79eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 563 additions and 98 deletions

View File

@ -10,6 +10,7 @@ import { AGENT_CATALOG, getAgentLabel } from '@/lib/agent-catalog'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { useAppStore } from '@/store'
import { useRepoById } from '@/store/selectors'
import {
renderSourceControlActionCommandTemplate,
type SourceControlActionRecipe,
@ -26,6 +27,7 @@ import {
SourceControlAgentActionDialogForm,
type SourceControlAgentActionDeliveryPlanState
} from './SourceControlAgentActionDialogForm'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
export type SourceControlAgentActionDialogProps = {
open: boolean
@ -93,6 +95,7 @@ export function SourceControlAgentActionDialog({
onStart
}: SourceControlAgentActionDialogProps): React.JSX.Element {
const settings = useAppStore((state) => state.settings)
const repo = useRepoById(repoId ?? null)
const ensureDetectedAgents = useAppStore((state) => state.ensureDetectedAgents)
const ensureRemoteDetectedAgents = useAppStore((state) => state.ensureRemoteDetectedAgents)
const [commandTemplate, setCommandTemplate] = useState(
@ -311,12 +314,23 @@ export function SourceControlAgentActionDialog({
: saveTargetValue === 'global'
? ({ type: 'global' } as const)
: null
if (saveTarget && onSaveAgentDefault) {
await onSaveAgentDefault(saveTarget, actionId, {
agentId: selectedAgent,
commandInputTemplate: commandTemplate,
agentArgs
const launchRecipe = {
agentId: selectedAgent,
commandInputTemplate: commandTemplate,
agentArgs
}
const launchRecipeAlreadySaved = Boolean(
saveTarget &&
sourceControlActionRecipeMatchesTarget({
actionId,
target: saveTarget,
recipe: launchRecipe,
settings,
repo
})
)
if (saveTarget && onSaveAgentDefault && !launchRecipeAlreadySaved) {
await onSaveAgentDefault(saveTarget, actionId, launchRecipe)
}
onLaunched?.()
handleOpenChange(false)
@ -339,8 +353,10 @@ export function SourceControlAgentActionDialog({
onStart,
promptDelivery,
refreshDetectedAgents,
repo,
repoId,
saveTargetValue,
settings,
selectedAgent,
trimmedCommandInput,
worktreeId
@ -356,7 +372,7 @@ export function SourceControlAgentActionDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">{description}</DialogDescription>
@ -374,6 +390,8 @@ export function SourceControlAgentActionDialog({
baseCommandInput={baseCommandInput}
saveTargetValue={saveTargetValue}
saveTargets={saveTargets}
settings={settings}
repo={repo}
canSaveAgentDefault={Boolean(onSaveAgentDefault)}
deliveryPlan={deliveryPlan}
canStart={canStart}

View File

@ -15,8 +15,10 @@ import {
import type { AgentCatalogEntry } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import type { SourceControlLaunchActionId } from '../../../../shared/source-control-ai-actions'
import type { TuiAgent } from '../../../../shared/types'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
export type SourceControlAgentActionDeliveryPlanState =
| { status: 'idle' }
@ -36,6 +38,8 @@ type SourceControlAgentActionDialogFormProps = {
baseCommandInput: string
saveTargetValue: string
saveTargets: { value: string; label: string }[]
settings: GlobalSettings | null
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
canSaveAgentDefault: boolean
deliveryPlan: SourceControlAgentActionDeliveryPlanState
canStart: boolean
@ -49,6 +53,19 @@ type SourceControlAgentActionDialogFormProps = {
onStart: () => void
}
function sourceControlLaunchSaveTargetFromValue(
value: string,
repo: Pick<Repo, 'id'> | null
): SourceControlAiWriteTarget | null {
if (value === 'repo' && repo?.id) {
return { type: 'repo', repoId: repo.id }
}
if (value === 'global') {
return { type: 'global' }
}
return null
}
export function SourceControlAgentActionDialogForm({
actionId,
agentOptions,
@ -62,6 +79,8 @@ export function SourceControlAgentActionDialogForm({
baseCommandInput,
saveTargetValue,
saveTargets,
settings,
repo,
canSaveAgentDefault,
deliveryPlan,
canStart,
@ -74,9 +93,34 @@ export function SourceControlAgentActionDialogForm({
onOpenSettings,
onStart
}: SourceControlAgentActionDialogFormProps): React.JSX.Element {
const selectedRecipe = selectedAgent
? {
agentId: selectedAgent,
commandInputTemplate: commandTemplate,
agentArgs
}
: null
const savableTargets = saveTargets
.map((target) => sourceControlLaunchSaveTargetFromValue(target.value, repo))
.filter((target): target is SourceControlAiWriteTarget => target !== null)
const allLaunchRecipesAlreadySaved = Boolean(
selectedRecipe &&
savableTargets.length > 0 &&
savableTargets.every((target) =>
sourceControlActionRecipeMatchesTarget({
actionId,
target,
recipe: selectedRecipe,
settings,
repo
})
)
)
const showSaveLaunchRecipe = canSaveAgentDefault && selectedAgent && !allLaunchRecipesAlreadySaved
return (
<>
<div className="space-y-4">
<div className="min-w-0 space-y-4">
<div className="space-y-2">
<Label className="text-xs">Agent</Label>
{hasEnabledAgents || selectedAgent ? (
@ -140,7 +184,7 @@ export function SourceControlAgentActionDialogForm({
rows={12}
value={commandTemplate}
onChange={(event) => onCommandTemplateChange(event.target.value)}
className="min-h-[14rem] w-full resize-y rounded-md border border-border bg-background px-2.5 py-2 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
className="box-border min-h-[14rem] min-w-0 w-full max-w-full resize-y rounded-md border border-border bg-background px-2.5 py-2 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
/>
<SourceControlActionVariableChips
actionId={actionId}
@ -153,7 +197,7 @@ export function SourceControlAgentActionDialogForm({
/>
</div>
{canSaveAgentDefault && selectedAgent ? (
{showSaveLaunchRecipe ? (
<div className="space-y-2">
<Label className="text-xs">Save launch recipe</Label>
<Select value={saveTargetValue} onValueChange={onSaveAgentDefaultChange}>
@ -201,7 +245,7 @@ export function SourceControlAgentActionDialogForm({
) : null}
</div>
<DialogFooter className="gap-2">
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button type="button" size="sm" disabled={!canStart} onClick={onStart}>
{isStarting ? (
<RefreshCw className="size-4 animate-spin" />

View File

@ -105,7 +105,7 @@ export function SourceControlTextGenerationDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">{description}</DialogDescription>
@ -123,6 +123,7 @@ export function SourceControlTextGenerationDialog({
actionId={actionId}
generateLabel={generateLabel}
settings={settings}
repo={repo ?? null}
baseParams={baseParams}
saveTargets={saveTargets}
onGenerate={onGenerate}

View File

@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useState } from 'react'
import { CheckCircle2, RefreshCw, Save, Sparkles, Terminal, TriangleAlert } from 'lucide-react'
import { RefreshCw, Save, Sparkles, Terminal, TriangleAlert } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DialogFooter } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
@ -21,9 +21,10 @@ import {
import type { ResolvedSourceControlAiGenerationParams } from '../../../../shared/source-control-ai'
import type { SourceControlTextActionId } from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
import { toast } from 'sonner'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
import { sourceControlTextGenerationDefaultsMatchTarget } from './source-control-text-generation-defaults'
import {
buildCommitMessageGenerationParams,
type CommitMessageGenerationAgentChoice
@ -31,11 +32,6 @@ import {
const UNCONFIGURED_AGENT_SELECT_VALUE = ''
type PlanState =
| { status: 'idle' }
| { status: 'success'; commandLabel: string; delivery: string; caveat: string }
| { status: 'error'; error: string }
export type SourceControlTextGenerationSaveTarget = {
target: SourceControlAiWriteTarget
label: string
@ -46,6 +42,7 @@ type SourceControlTextGenerationDialogFormProps = {
actionId: SourceControlTextActionId
generateLabel: string
settings: GlobalSettings | null
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
baseParams: ResolvedSourceControlAiGenerationParams | null
saveTargets: SourceControlTextGenerationSaveTarget[]
onGenerate: (params: ResolvedSourceControlAiGenerationParams) => void
@ -56,6 +53,10 @@ type SourceControlTextGenerationDialogFormProps = {
) => Promise<void> | void
}
function sourceControlTextGenerationSaveTargetKey(target: SourceControlAiWriteTarget): string {
return target.type === 'repo' ? `repo:${target.repoId}` : 'global'
}
function agentLabel(agentId: TuiAgent): string {
return AGENT_CATALOG.find((agent) => agent.id === agentId)?.label ?? agentId
}
@ -64,6 +65,7 @@ export function SourceControlTextGenerationDialogForm({
actionId,
generateLabel,
settings,
repo,
baseParams,
saveTargets,
onGenerate,
@ -81,9 +83,17 @@ export function SourceControlTextGenerationDialogForm({
baseParams?.commandInputTemplate ?? '{basePrompt}'
)
const [agentArgs, setAgentArgs] = useState(baseParams?.agentArgs ?? '')
const [plan, setPlan] = useState<PlanState>({ status: 'idle' })
const [generationError, setGenerationError] = useState<string | null>(null)
const [savingTargetKey, setSavingTargetKey] = useState<string | null>(null)
const defaultSaveTargetKey = saveTargets[0]
? sourceControlTextGenerationSaveTargetKey(saveTargets[0].target)
: 'global'
const [saveTargetKey, setSaveTargetKey] = useState(defaultSaveTargetKey)
const commandTemplateId = `source-control-${actionId}-command-template`
const selectedSaveTarget =
saveTargets.find((saveTarget) => {
return sourceControlTextGenerationSaveTargetKey(saveTarget.target) === saveTargetKey
}) ?? saveTargets[0]
const params = buildCommitMessageGenerationParams({
agentId,
@ -96,23 +106,31 @@ export function SourceControlTextGenerationDialogForm({
const paramsPlanResult = params ? planSourceControlTextGeneration(actionId, params) : null
const canRunGeneration = Boolean(params && paramsPlanResult?.ok)
const saving = savingTargetKey !== null
const handlePlan = (): void => {
if (!params || !paramsPlanResult) {
setPlan({ status: 'error', error: 'Choose an agent before checking generation.' })
return
}
setPlan(
paramsPlanResult.ok
? {
status: 'success',
commandLabel: paramsPlanResult.commandLabel,
delivery: paramsPlanResult.delivery,
caveat: paramsPlanResult.caveat
}
: { status: 'error', error: paramsPlanResult.error }
const defaultsAlreadySaved = Boolean(
params &&
selectedSaveTarget &&
sourceControlTextGenerationDefaultsMatchTarget({
actionId,
target: selectedSaveTarget.target,
params,
settings,
repo
})
)
const allSaveTargetsAlreadySaved = Boolean(
params &&
saveTargets.length > 0 &&
saveTargets.every((saveTarget) =>
sourceControlTextGenerationDefaultsMatchTarget({
actionId,
target: saveTarget.target,
params,
settings,
repo
})
)
}
)
const showSaveRecipeControl = Boolean(selectedSaveTarget && !allSaveTargetsAlreadySaved)
const saveCurrentDefaults = useCallback(
async (
@ -120,13 +138,16 @@ export function SourceControlTextGenerationDialogForm({
options: { showToast: boolean; showErrors: boolean }
): Promise<boolean> => {
if (!params || saving || !paramsPlanResult?.ok) {
if (options.showErrors && paramsPlanResult && !paramsPlanResult.ok) {
setPlan({ status: 'error', error: paramsPlanResult.error })
if (options.showErrors) {
setGenerationError(
paramsPlanResult && !paramsPlanResult.ok
? paramsPlanResult.error
: 'Choose an agent before saving defaults.'
)
}
return false
}
const targetKey =
saveTarget.target.type === 'repo' ? `repo:${saveTarget.target.repoId}` : 'global'
const targetKey = sourceControlTextGenerationSaveTargetKey(saveTarget.target)
setSavingTargetKey(targetKey)
try {
await onSaveDefaults(saveTarget.target, params)
@ -143,9 +164,11 @@ export function SourceControlTextGenerationDialogForm({
const handleGenerate = (): void => {
if (!params || !paramsPlanResult?.ok) {
if (paramsPlanResult && !paramsPlanResult.ok) {
setPlan({ status: 'error', error: paramsPlanResult.error })
}
setGenerationError(
paramsPlanResult && !paramsPlanResult.ok
? paramsPlanResult.error
: 'Choose an agent before generating.'
)
return
}
onGenerate(params)
@ -160,7 +183,7 @@ export function SourceControlTextGenerationDialogForm({
return (
<>
<div className="space-y-4">
<div className="min-w-0 space-y-4">
<div className="space-y-2">
<Label className="text-xs">Agent</Label>
<Select
@ -170,7 +193,7 @@ export function SourceControlTextGenerationDialogForm({
return
}
setAgentId(value === CUSTOM_AGENT_ID ? CUSTOM_AGENT_ID : (value as TuiAgent))
setPlan({ status: 'idle' })
setGenerationError(null)
}}
>
<SelectTrigger size="sm" className="h-8 text-xs">
@ -208,7 +231,7 @@ export function SourceControlTextGenerationDialogForm({
placeholder="--model sonnet"
onChange={(event) => {
setAgentArgs(event.target.value)
setPlan({ status: 'idle' })
setGenerationError(null)
}}
className="h-8 font-mono text-xs"
/>
@ -225,9 +248,9 @@ export function SourceControlTextGenerationDialogForm({
spellCheck={false}
onChange={(event) => {
setCommandTemplate(event.target.value)
setPlan({ status: 'idle' })
setGenerationError(null)
}}
className="w-full resize-y rounded-md border border-border bg-background px-2.5 py-2 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
className="box-border min-w-0 w-full max-w-full resize-y rounded-md border border-border bg-background px-2.5 py-2 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
/>
<SourceControlActionVariableChips
actionId={actionId}
@ -235,64 +258,57 @@ export function SourceControlTextGenerationDialogForm({
const separator =
commandTemplate.endsWith('\n') || commandTemplate.length === 0 ? '' : ' '
setCommandTemplate(`${commandTemplate}${separator}{${variable}}`)
setPlan({ status: 'idle' })
setGenerationError(null)
}}
/>
</div>
{plan.status !== 'idle' ? (
<div
className={
plan.status === 'error'
? 'rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive'
: 'space-y-1 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground'
}
>
{plan.status === 'error' ? (
<span className="flex items-start gap-2">
<TriangleAlert className="mt-px size-3.5 shrink-0" />
{plan.error}
</span>
) : (
<>
<div className="flex items-start gap-2 text-foreground">
<CheckCircle2 className="mt-px size-3.5 shrink-0 text-status-success" />
{plan.delivery}
</div>
<div className="truncate font-mono text-[11px]">Launch: {plan.commandLabel}</div>
<div className="text-[11px]">{plan.caveat}</div>
</>
)}
{showSaveRecipeControl ? (
<div className="space-y-2">
<Label className="text-xs">Save recipe</Label>
<Select value={saveTargetKey} onValueChange={setSaveTargetKey}>
<SelectTrigger size="sm" className="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{saveTargets.map((saveTarget) => {
const targetKey = sourceControlTextGenerationSaveTargetKey(saveTarget.target)
return (
<SelectItem key={targetKey} value={targetKey}>
{saveTarget.label}
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
) : null}
{generationError ? (
<p className="flex items-start gap-1.5 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<TriangleAlert className="mt-px size-3.5 shrink-0" />
{generationError}
</p>
) : null}
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" size="sm" onClick={handlePlan}>
<CheckCircle2 className="size-4" />
Check generation
</Button>
{saveTargets.map((saveTarget) => {
const targetKey =
saveTarget.target.type === 'repo' ? `repo:${saveTarget.target.repoId}` : 'global'
return (
<Button
key={targetKey}
type="button"
variant="outline"
size="sm"
disabled={!canRunGeneration || saving}
onClick={() => void handleSaveDefaults(saveTarget)}
>
{savingTargetKey === targetKey ? (
<RefreshCw className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
{saveTarget.label}
</Button>
)
})}
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
{selectedSaveTarget && !defaultsAlreadySaved ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={!canRunGeneration || saving}
onClick={() => void handleSaveDefaults(selectedSaveTarget)}
>
{savingTargetKey === saveTargetKey ? (
<RefreshCw className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
Save defaults
</Button>
) : null}
<Button
type="button"
size="sm"

View File

@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings, Repo } from '../../../../shared/types'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
function settings(): GlobalSettings {
const base = getDefaultSettings('/tmp')
return {
...base,
sourceControlAi: {
...base.sourceControlAi!,
enabled: true,
agentId: 'codex',
actions: {
resolveConflicts: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: '--model sonnet'
}
}
}
}
}
describe('sourceControlActionRecipeMatchesTarget', () => {
it('returns true when the launch recipe matches the global saved recipe', () => {
expect(
sourceControlActionRecipeMatchesTarget({
actionId: 'resolveConflicts',
target: { type: 'global' },
recipe: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: '--model sonnet'
},
settings: settings()
})
).toBe(true)
})
it('returns false when the repo target has no saved override yet', () => {
expect(
sourceControlActionRecipeMatchesTarget({
actionId: 'resolveConflicts',
target: { type: 'repo', repoId: 'repo-1' },
recipe: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: '--model sonnet'
},
settings: settings(),
repo: { sourceControlAi: { enabled: true } } satisfies Pick<Repo, 'sourceControlAi'>
})
).toBe(false)
})
it('returns true when the launch recipe matches the repo saved recipe', () => {
expect(
sourceControlActionRecipeMatchesTarget({
actionId: 'fixCommitFailure',
target: { type: 'repo', repoId: 'repo-1' },
recipe: {
agentId: 'claude',
commandInputTemplate: '{basePrompt}\n\nrepo only',
agentArgs: ''
},
settings: settings(),
repo: {
sourceControlAi: {
enabled: true,
actionOverrides: {
fixCommitFailure: {
agentId: 'claude',
commandInputTemplate: '{basePrompt}\n\nrepo only'
}
}
}
} satisfies Pick<Repo, 'sourceControlAi'>
})
).toBe(true)
})
it('returns true when a repo recipe inherits the global command template', () => {
const currentSettings = settings()
currentSettings.sourceControlAi = {
...currentSettings.sourceControlAi!,
actions: {
resolveConflicts: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}\n\ncustom global',
agentArgs: '--model sonnet'
}
}
}
expect(
sourceControlActionRecipeMatchesTarget({
actionId: 'resolveConflicts',
target: { type: 'repo', repoId: 'repo-1' },
recipe: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}\n\ncustom global',
agentArgs: '--model sonnet'
},
settings: currentSettings,
repo: {
sourceControlAi: {
enabled: true,
actionOverrides: {
resolveConflicts: {
commandInputTemplate: null
}
}
}
} satisfies Pick<Repo, 'sourceControlAi'>
})
).toBe(true)
})
it('returns true when a repo recipe explicitly clears inherited agent args', () => {
expect(
sourceControlActionRecipeMatchesTarget({
actionId: 'resolveConflicts',
target: { type: 'repo', repoId: 'repo-1' },
recipe: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
},
settings: settings(),
repo: {
sourceControlAi: {
enabled: true,
actionOverrides: {
resolveConflicts: {
agentArgs: null
}
}
}
} satisfies Pick<Repo, 'sourceControlAi'>
})
).toBe(true)
})
})

View File

@ -0,0 +1,116 @@
import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec'
import {
normalizeRepoSourceControlAiOverrides,
normalizeSourceControlAiSettings,
resolveSourceControlActionRecipe
} from '../../../../shared/source-control-ai'
import {
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
type SourceControlActionId,
type SourceControlActionRecipe
} from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings, Repo } from '../../../../shared/types'
type NormalizedSourceControlActionRecipe = {
agentId: SourceControlActionRecipe['agentId'] | null
commandInputTemplate: string
agentArgs: string
}
type PersistedSourceControlActionRecipe = {
agentId?: SourceControlActionRecipe['agentId']
commandInputTemplate?: string | null
agentArgs?: string | null
}
function normalizeSourceControlActionRecipeForComparison(
actionId: SourceControlActionId,
recipe: PersistedSourceControlActionRecipe | null | undefined
): NormalizedSourceControlActionRecipe {
return {
agentId: recipe?.agentId ?? null,
commandInputTemplate:
typeof recipe?.commandInputTemplate === 'string'
? recipe.commandInputTemplate.trim()
: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId],
agentArgs: typeof recipe?.agentArgs === 'string' ? recipe.agentArgs.trim() : ''
}
}
function sourceControlActionRecipesMatch(
left: NormalizedSourceControlActionRecipe,
right: NormalizedSourceControlActionRecipe
): boolean {
return (
left.agentId === right.agentId &&
left.commandInputTemplate === right.commandInputTemplate &&
left.agentArgs === right.agentArgs
)
}
function readSavedSourceControlActionRecipeAtTarget(input: {
actionId: SourceControlActionId
target: SourceControlAiWriteTarget
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo?: Pick<Repo, 'sourceControlAi'> | null
}): PersistedSourceControlActionRecipe | null {
if (input.target.type === 'repo') {
const repoRecipe = normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)
?.actionOverrides?.[input.actionId]
if (!repoRecipe) {
return null
}
return resolveSourceControlActionRecipe({
actionId: input.actionId,
settings: input.settings,
repo: input.repo
})
}
const source = normalizeSourceControlAiSettings(
input.settings?.sourceControlAi,
input.settings?.commitMessageAi
)
return source.actions?.[input.actionId] ?? null
}
function readSavedCustomAgentCommandAtTarget(input: {
target: SourceControlAiWriteTarget
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo?: Pick<Repo, 'sourceControlAi'> | null
}): string {
if (input.target.type === 'repo') {
return (
normalizeRepoSourceControlAiOverrides(
input.repo?.sourceControlAi
)?.customAgentCommand?.trim() ?? ''
)
}
return normalizeSourceControlAiSettings(
input.settings?.sourceControlAi,
input.settings?.commitMessageAi
).customAgentCommand.trim()
}
export function sourceControlActionRecipeMatchesTarget(input: {
actionId: SourceControlActionId
target: SourceControlAiWriteTarget
recipe: SourceControlActionRecipe
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo?: Pick<Repo, 'sourceControlAi'> | null
customAgentCommand?: string
}): boolean {
const savedRecipe = readSavedSourceControlActionRecipeAtTarget(input)
if (!savedRecipe) {
return false
}
const current = normalizeSourceControlActionRecipeForComparison(input.actionId, input.recipe)
const saved = normalizeSourceControlActionRecipeForComparison(input.actionId, savedRecipe)
if (!sourceControlActionRecipesMatch(current, saved)) {
return false
}
if (!isCustomAgentId(current.agentId)) {
return true
}
return (input.customAgentCommand ?? '').trim() === readSavedCustomAgentCommandAtTarget(input)
}

View File

@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings, Repo } from '../../../../shared/types'
import {
generationParamsToActionRecipe,
sourceControlTextGenerationDefaultsMatchTarget
} from './source-control-text-generation-defaults'
function settings(): GlobalSettings {
const base = getDefaultSettings('/tmp')
return {
...base,
sourceControlAi: {
...base.sourceControlAi!,
enabled: true,
agentId: 'codex',
actions: {
commitMessage: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: '--model sonnet'
}
}
}
}
}
describe('sourceControlTextGenerationDefaultsMatchTarget', () => {
it('returns true when the current params match the global saved recipe', () => {
const currentSettings = settings()
expect(
sourceControlTextGenerationDefaultsMatchTarget({
actionId: 'commitMessage',
target: { type: 'global' },
params: {
agentId: 'codex',
model: 'gpt-5.5',
commandInputTemplate: '{basePrompt}',
agentArgs: '--model sonnet'
},
settings: currentSettings
})
).toBe(true)
})
it('returns false when the repo target has no saved override yet', () => {
expect(
sourceControlTextGenerationDefaultsMatchTarget({
actionId: 'commitMessage',
target: { type: 'repo', repoId: 'repo-1' },
params: {
agentId: 'codex',
model: 'gpt-5.5',
commandInputTemplate: '{basePrompt}',
agentArgs: '--model sonnet'
},
settings: settings(),
repo: { sourceControlAi: { enabled: true } } satisfies Pick<Repo, 'sourceControlAi'>
})
).toBe(false)
})
it('returns true when the current params match the repo saved recipe', () => {
expect(
sourceControlTextGenerationDefaultsMatchTarget({
actionId: 'commitMessage',
target: { type: 'repo', repoId: 'repo-1' },
params: {
agentId: 'opencode',
model: '',
commandInputTemplate: '{basePrompt}\n\nrepo only',
agentArgs: ''
},
settings: settings(),
repo: {
sourceControlAi: {
enabled: true,
actionOverrides: {
commitMessage: generationParamsToActionRecipe({
agentId: 'opencode',
model: '',
commandInputTemplate: '{basePrompt}\n\nrepo only',
agentArgs: ''
})
}
}
} satisfies Pick<Repo, 'sourceControlAi'>
})
).toBe(true)
})
it('returns false when the command template differs from the saved recipe', () => {
expect(
sourceControlTextGenerationDefaultsMatchTarget({
actionId: 'commitMessage',
target: { type: 'global' },
params: {
agentId: 'codex',
model: 'gpt-5.5',
commandInputTemplate: '{basePrompt}\n\nchanged',
agentArgs: '--model sonnet'
},
settings: settings()
})
).toBe(false)
})
})

View File

@ -7,7 +7,9 @@ import {
type SourceControlActionRecipe,
type SourceControlTextActionId
} from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings, Repo } from '../../../../shared/types'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
type TextGenerationRecipeConfiguration = {
agentId?: SourceControlActionRecipe['agentId']
@ -41,6 +43,23 @@ export function generationParamsToActionRecipe(
}
}
export function sourceControlTextGenerationDefaultsMatchTarget(input: {
actionId: SourceControlTextActionId
target: SourceControlAiWriteTarget
params: ResolvedSourceControlAiGenerationParams
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo?: Pick<Repo, 'sourceControlAi'> | null
}): boolean {
return sourceControlActionRecipeMatchesTarget({
actionId: input.actionId,
target: input.target,
recipe: generationParamsToActionRecipe(input.params),
settings: input.settings,
repo: input.repo,
customAgentCommand: input.params.customAgentCommand
})
}
export function hasConfiguredSourceControlTextGenerationDefaults(input: {
actionId: SourceControlTextActionId
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined