fix: address review findings (#4936)
This commit is contained in:
parent
9c8e67c271
commit
50831e79eb
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue