mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 16:02:49 +00:00
Add per-repo Source Control AI enablement, custom command, and save-targ
- Repo overrides now support `enabled` and `customAgentCommand`, letting
repositories opt in/out of Source Control AI independently and supply a
repo-scoped custom command that takes precedence over the global one.
- Recipe-save dialogs gained a save-target selector ("Don't save / Save for
this repo / Save as global default") replacing the old boolean checkbox,
routing saves through the new `saveSourceControlActionRecipe` helper in
`source-control-ai-recipe-save.ts`.
- `normalizeRepoSourceControlAiOverrides` now returns `undefined` for empty
objects and passes the `null` sentinel through the IPC/RPC layer so the
persistence layer can clear repo overrides cleanly.
- `resolveSourceControlLaunchPlatform` resolves the correct shell platform
for SSH and WSL worktrees so agent launch commands are built correctly.
- Settings UI gained `RepositorySourceControlAiEnablement` and
`RepositorySourceControlAiCustomCommand` rows; draft/label logic was
extracted into focused modules to stay within lint line limits.
This commit is contained in:
@@ -1071,9 +1071,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
| 'sourceControlAi'
|
||||
>
|
||||
>
|
||||
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
|
||||
}
|
||||
) => {
|
||||
// Why: validate the persisted preference string at the IPC boundary
|
||||
@@ -1144,7 +1143,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
) {
|
||||
delete updates.externalWorktreeVisibilityPromptDismissedAt
|
||||
}
|
||||
if ('sourceControlAi' in updates && updates.sourceControlAi !== undefined) {
|
||||
if ('sourceControlAi' in updates && updates.sourceControlAi === null) {
|
||||
updates.sourceControlAi = undefined
|
||||
} else if ('sourceControlAi' in updates && updates.sourceControlAi !== undefined) {
|
||||
const normalizedSourceControlAi = normalizeRepoSourceControlAiOverrides(
|
||||
updates.sourceControlAi
|
||||
)
|
||||
|
||||
@@ -2303,6 +2303,24 @@ describe('Store', () => {
|
||||
expect(reloaded.getRepo('r1')!.sourceControlAi).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updateRepo treats source-control AI null as a transport clear sentinel', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(
|
||||
makeRepo({
|
||||
sourceControlAi: {
|
||||
enabled: true,
|
||||
customAgentCommand: 'repo-agent {prompt}'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
store.updateRepo('r1', {
|
||||
sourceControlAi: null
|
||||
})
|
||||
|
||||
expect(store.getRepo('r1')!.sourceControlAi).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updateRepo normalizes source-control AI overrides before storing', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
@@ -2366,7 +2384,12 @@ describe('Store', () => {
|
||||
const updated = store.updateRepo('r1', { sourceControlAi: 'bad' as never })
|
||||
|
||||
expect(updated!.sourceControlAi).toEqual({
|
||||
instructionsByOperation: { commitMessage: 'Keep me' }
|
||||
instructionsByOperation: { commitMessage: 'Keep me' },
|
||||
actionOverrides: {
|
||||
commitMessage: {
|
||||
commandInputTemplate: '{basePrompt}\n\nKeep me'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+13
-4
@@ -2576,9 +2576,8 @@ export class Store {
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
| 'sourceControlAi'
|
||||
>
|
||||
>
|
||||
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
|
||||
): Repo | null {
|
||||
const repo = this.state.repos.find((r) => r.id === id)
|
||||
if (!repo) {
|
||||
@@ -2628,7 +2627,10 @@ export class Store {
|
||||
// time visibility changes so later hide/show choices keep legacy safety.
|
||||
repo.externalWorktreeVisibilityLegacy = externalWorktreeVisibilityLegacy
|
||||
}
|
||||
if ('sourceControlAi' in sanitizedUpdates && sanitizedUpdates.sourceControlAi === undefined) {
|
||||
if (
|
||||
'sourceControlAi' in sanitizedUpdates &&
|
||||
(sanitizedUpdates.sourceControlAi === undefined || sanitizedUpdates.sourceControlAi === null)
|
||||
) {
|
||||
delete repo.sourceControlAi
|
||||
delete sanitizedUpdates.sourceControlAi
|
||||
} else if ('sourceControlAi' in sanitizedUpdates) {
|
||||
@@ -2647,9 +2649,15 @@ export class Store {
|
||||
}
|
||||
|
||||
private hydrateRepo(repo: Repo): Repo {
|
||||
const { repoIcon: rawRepoIcon, upstream: rawUpstream, ...repoWithoutIcon } = repo
|
||||
const {
|
||||
repoIcon: rawRepoIcon,
|
||||
upstream: rawUpstream,
|
||||
sourceControlAi: rawSourceControlAi,
|
||||
...repoWithoutIcon
|
||||
} = repo
|
||||
const repoIcon = sanitizeRepoIcon(rawRepoIcon)
|
||||
const upstream = sanitizeRepoUpstream(rawUpstream)
|
||||
const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi)
|
||||
const gitUsername = isFolderRepo(repo)
|
||||
? ''
|
||||
: (this.gitUsernameCache.get(repo.path) ??
|
||||
@@ -2663,6 +2671,7 @@ export class Store {
|
||||
...repoWithoutIcon,
|
||||
...(repoIcon !== undefined ? { repoIcon } : {}),
|
||||
...(upstream !== undefined ? { upstream } : {}),
|
||||
...(sourceControlAi !== undefined ? { sourceControlAi } : {}),
|
||||
kind: isFolderRepo(repo) ? 'folder' : 'git',
|
||||
gitUsername,
|
||||
hookSettings: {
|
||||
|
||||
@@ -6748,9 +6748,8 @@ export class OrcaRuntimeService {
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
| 'sourceControlAi'
|
||||
>
|
||||
>
|
||||
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
|
||||
): Promise<Repo> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
@@ -6760,6 +6759,9 @@ export class OrcaRuntimeService {
|
||||
if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) {
|
||||
sanitizedUpdates.worktreeBasePath = undefined
|
||||
}
|
||||
if ('sourceControlAi' in updates && updates.sourceControlAi === null) {
|
||||
sanitizedUpdates.sourceControlAi = null
|
||||
}
|
||||
const updated = this.store.updateRepo(repo.id, sanitizedUpdates)
|
||||
if (!updated) {
|
||||
throw new Error('repo_not_found')
|
||||
|
||||
@@ -34,7 +34,11 @@ const RepoSourceControlAiOverrides = z
|
||||
.unknown()
|
||||
.optional()
|
||||
.transform((value) =>
|
||||
value === undefined ? undefined : normalizeRepoSourceControlAiOverrides(value)
|
||||
value === undefined
|
||||
? undefined
|
||||
: value === null
|
||||
? null
|
||||
: normalizeRepoSourceControlAiOverrides(value)
|
||||
)
|
||||
|
||||
const RepoBadgeColor = z
|
||||
|
||||
@@ -737,9 +737,8 @@ export type PreloadApi = {
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
| 'sourceControlAi'
|
||||
>
|
||||
>
|
||||
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
|
||||
}) => Promise<Repo>
|
||||
pickFolder: () => Promise<string | null>
|
||||
pickDirectory: () => Promise<string | null>
|
||||
|
||||
@@ -102,15 +102,16 @@ import { gitLabPipelineJobsToPRChecks } from '../../../../shared/gitlab-pipeline
|
||||
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
|
||||
import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog'
|
||||
import { readSourceControlLaunchRecipeAgentId } from '@/lib/source-control-launch-agent-selection'
|
||||
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai'
|
||||
import {
|
||||
normalizeSourceControlAiSettings,
|
||||
resolveSourceControlActionRecipe
|
||||
} from '../../../../shared/source-control-ai'
|
||||
import {
|
||||
setSourceControlActionDefault,
|
||||
type SourceControlActionRecipe,
|
||||
type SourceControlLaunchActionId
|
||||
} from '../../../../shared/source-control-ai-actions'
|
||||
import {
|
||||
saveSourceControlActionRecipe,
|
||||
type SourceControlAiWriteTarget
|
||||
} from '../../../../shared/source-control-ai-recipe-save'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
|
||||
const RUNTIME_SSH_STATUS_REFRESH_MS = 3000
|
||||
const GIT_STATUS_FAILURE_RETRY_MS = 3000
|
||||
@@ -291,6 +292,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
const repo = useRepoById(activeWorktree?.repoId ?? null)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const updateRepo = useAppStore((s) => s.updateRepo)
|
||||
const prCache = useAppStore((s) => s.prCache)
|
||||
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
|
||||
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
|
||||
@@ -370,22 +372,33 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
|
||||
const saveLaunchActionDefault = useCallback(
|
||||
async (
|
||||
target: SourceControlAiWriteTarget,
|
||||
actionId: SourceControlLaunchActionId,
|
||||
recipe: SourceControlActionRecipe
|
||||
): Promise<void> => {
|
||||
const latestSettings = useAppStore.getState().settings
|
||||
const latestSourceControlAi = normalizeSourceControlAiSettings(
|
||||
latestSettings?.sourceControlAi,
|
||||
latestSettings?.commitMessageAi
|
||||
)
|
||||
await updateSettings({
|
||||
sourceControlAi: {
|
||||
...latestSourceControlAi,
|
||||
actions: setSourceControlActionDefault(latestSourceControlAi.actions, actionId, recipe)
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const latestSettings = state.settings
|
||||
if (!latestSettings) {
|
||||
throw new Error('Settings are not loaded.')
|
||||
}
|
||||
const latestRepo =
|
||||
target.type === 'repo'
|
||||
? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null)
|
||||
: null
|
||||
const result = saveSourceControlActionRecipe({
|
||||
target,
|
||||
settings: latestSettings,
|
||||
repo: latestRepo,
|
||||
actionId,
|
||||
recipe
|
||||
})
|
||||
if ('sourceControlAi' in result) {
|
||||
await updateSettings({ sourceControlAi: result.sourceControlAi })
|
||||
return
|
||||
}
|
||||
await updateRepo(result.target.repoId, result.update)
|
||||
},
|
||||
[updateSettings]
|
||||
[updateRepo, updateSettings]
|
||||
)
|
||||
const asyncResultKeyRef = useRef<string>('')
|
||||
const refreshRequestKeyRef = useRef<string | null>(null)
|
||||
@@ -398,6 +411,10 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
|
||||
const activeWorktreePath = activeWorktree?.path ?? null
|
||||
const activeWorktreePushTarget = activeWorktree?.pushTarget ?? null
|
||||
const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({
|
||||
connectionId: repo?.connectionId ?? null,
|
||||
worktreePath: activeWorktreePath
|
||||
})
|
||||
const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null
|
||||
const repoConnectionId = repo?.connectionId?.trim() || null
|
||||
const sshConnectionStatus = useAppStore((s) =>
|
||||
@@ -2589,7 +2606,9 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
worktreeId={activeWorktreeId}
|
||||
groupId={activeWorktreeId}
|
||||
connectionId={activeWorktreeId ? getConnectionId(activeWorktreeId) : null}
|
||||
repoId={repo?.id ?? null}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchPlatform={activeSourceControlLaunchPlatform}
|
||||
launchSource={agentComposerState?.launchSource ?? 'task_page'}
|
||||
savedAgentId={
|
||||
agentComposerState
|
||||
|
||||
@@ -177,24 +177,28 @@ import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec'
|
||||
import {
|
||||
DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
|
||||
normalizeRepoSourceControlAiOverrides,
|
||||
normalizeSourceControlAiSettings,
|
||||
resolveSourceControlActionRecipe,
|
||||
resolveSourceControlAiForOperation,
|
||||
resolveSourceControlAiPrCreationDefaults
|
||||
resolveSourceControlAiPrCreationDefaults,
|
||||
type ResolvedSourceControlAiGenerationParams
|
||||
} from '../../../../shared/source-control-ai'
|
||||
import {
|
||||
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
|
||||
renderSourceControlActionCommandTemplate,
|
||||
setSourceControlActionDefault,
|
||||
type SourceControlTextActionId,
|
||||
type SourceControlActionRecipe,
|
||||
type SourceControlLaunchActionId
|
||||
} from '../../../../shared/source-control-ai-actions'
|
||||
import {
|
||||
saveSourceControlActionRecipe,
|
||||
type SourceControlAiWriteTarget
|
||||
} from '../../../../shared/source-control-ai-recipe-save'
|
||||
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
|
||||
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key'
|
||||
import { getRuntimeGitScope } from '@/runtime/runtime-git-client'
|
||||
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
|
||||
import { getRepositorySourceControlAiSectionId } from '@/components/settings/repository-settings-targets'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
import {
|
||||
getCommitFailureDialogWorktreeKey,
|
||||
shouldShowCommitFailureDialog,
|
||||
@@ -208,10 +212,6 @@ import {
|
||||
} from './source-control-split-open'
|
||||
import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog'
|
||||
import { SourceControlTextGenerationDialog } from './SourceControlTextGenerationDialog'
|
||||
import {
|
||||
applyCommitMessageGenerationDefaults,
|
||||
applySourceControlTextGenerationDefaults
|
||||
} from './SourceControlTextGenerationDefaults'
|
||||
|
||||
export type SourceControlScope = 'all' | 'uncommitted'
|
||||
type AbortConflictOperation = Extract<GitConflictOperation, 'merge' | 'rebase'>
|
||||
@@ -242,6 +242,16 @@ function textGenerationRecipeIsConfigured(
|
||||
return typeof recipe?.agentArgs === 'string' && recipe.agentArgs.trim().length > 0
|
||||
}
|
||||
|
||||
function generationParamsToActionRecipe(
|
||||
params: ResolvedSourceControlAiGenerationParams
|
||||
): SourceControlActionRecipe {
|
||||
return {
|
||||
agentId: params.agentId,
|
||||
commandInputTemplate: params.commandInputTemplate ?? '{basePrompt}',
|
||||
...(params.agentArgs !== undefined ? { agentArgs: params.agentArgs } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function hasConfiguredSourceControlTextGenerationDefaults(input: {
|
||||
actionId: SourceControlTextActionId
|
||||
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
|
||||
@@ -1369,13 +1379,6 @@ function SourceControlInner(): React.JSX.Element {
|
||||
const prGenerationRequestSeqRef = useRef(0)
|
||||
const prGenerationInFlightRef = useRef<Record<string, boolean>>({})
|
||||
const [prGenerationRecords, setPrGenerationRecords] = useState<PullRequestGenerationRecords>({})
|
||||
const sourceControlAi = useMemo(() => {
|
||||
const normalized = normalizeSourceControlAiSettings(
|
||||
settings?.sourceControlAi,
|
||||
settings?.commitMessageAi
|
||||
)
|
||||
return settings ? normalized : { ...normalized, enabled: false }
|
||||
}, [settings])
|
||||
const getLaunchActionRecipe = useCallback(
|
||||
(actionId: SourceControlLaunchActionId): SourceControlActionRecipe =>
|
||||
resolveSourceControlActionRecipe({
|
||||
@@ -1385,24 +1388,47 @@ function SourceControlInner(): React.JSX.Element {
|
||||
}),
|
||||
[activeRepo, settings]
|
||||
)
|
||||
const saveActionRecipeForTarget = useCallback(
|
||||
async (
|
||||
target: SourceControlAiWriteTarget,
|
||||
actionId: SourceControlTextActionId | SourceControlLaunchActionId,
|
||||
recipe: SourceControlActionRecipe,
|
||||
customAgentCommand?: string
|
||||
): Promise<void> => {
|
||||
const state = useAppStore.getState()
|
||||
const latestSettings = state.settings
|
||||
if (!latestSettings) {
|
||||
throw new Error('Settings are not loaded.')
|
||||
}
|
||||
const latestRepo =
|
||||
target.type === 'repo'
|
||||
? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null)
|
||||
: null
|
||||
const result = saveSourceControlActionRecipe({
|
||||
target,
|
||||
settings: latestSettings,
|
||||
repo: latestRepo,
|
||||
actionId,
|
||||
recipe,
|
||||
customAgentCommand
|
||||
})
|
||||
if ('sourceControlAi' in result) {
|
||||
await updateSettings({ sourceControlAi: result.sourceControlAi })
|
||||
return
|
||||
}
|
||||
await updateRepo(result.target.repoId, result.update)
|
||||
},
|
||||
[updateRepo, updateSettings]
|
||||
)
|
||||
const saveLaunchActionDefault = useCallback(
|
||||
async (
|
||||
target: SourceControlAiWriteTarget,
|
||||
actionId: SourceControlLaunchActionId,
|
||||
recipe: SourceControlActionRecipe
|
||||
): Promise<void> => {
|
||||
const latestSettings = useAppStore.getState().settings
|
||||
const latestSourceControlAi = normalizeSourceControlAiSettings(
|
||||
latestSettings?.sourceControlAi,
|
||||
latestSettings?.commitMessageAi
|
||||
)
|
||||
await updateSettings({
|
||||
sourceControlAi: {
|
||||
...latestSourceControlAi,
|
||||
actions: setSourceControlActionDefault(latestSourceControlAi.actions, actionId, recipe)
|
||||
}
|
||||
})
|
||||
await saveActionRecipeForTarget(target, actionId, recipe)
|
||||
},
|
||||
[updateSettings]
|
||||
[saveActionRecipeForTarget]
|
||||
)
|
||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||
const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId)
|
||||
@@ -1420,6 +1446,10 @@ function SourceControlInner(): React.JSX.Element {
|
||||
|
||||
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
|
||||
const worktreePath = activeWorktree?.path ?? null
|
||||
const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({
|
||||
connectionId: activeRepo?.connectionId ?? null,
|
||||
worktreePath
|
||||
})
|
||||
const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null
|
||||
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
|
||||
const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
|
||||
@@ -1442,8 +1472,6 @@ function SourceControlInner(): React.JSX.Element {
|
||||
: null,
|
||||
[activeRepo, settings, sourceControlAiDiscoveryHostKey]
|
||||
)
|
||||
const effectiveCommitMessageAgentId =
|
||||
resolvedCommitMessageAi?.ok === true ? resolvedCommitMessageAi.value.params.agentId : null
|
||||
const resolvedPrCreationDefaults = useMemo(() => {
|
||||
if (!settings) {
|
||||
return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
|
||||
@@ -1921,6 +1949,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
prompt,
|
||||
agentArgs: savedRecipe.agentArgs,
|
||||
promptDelivery: 'submit-after-ready',
|
||||
launchPlatform: activeSourceControlLaunchPlatform,
|
||||
launchSource: 'source_control_recovery'
|
||||
})
|
||||
if (!result) {
|
||||
@@ -1928,7 +1957,9 @@ function SourceControlInner(): React.JSX.Element {
|
||||
return false
|
||||
}
|
||||
|
||||
focusTerminalTabSurface(result.tabId)
|
||||
if (result.tabId) {
|
||||
focusTerminalTabSurface(result.tabId)
|
||||
}
|
||||
toast.success('Started an AI agent for the commit failure.')
|
||||
return true
|
||||
} finally {
|
||||
@@ -1938,6 +1969,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
[
|
||||
activeGroupId,
|
||||
activeWorktreeId,
|
||||
activeSourceControlLaunchPlatform,
|
||||
commitError,
|
||||
commitFailureRecoveryPrompt,
|
||||
getLaunchActionRecipe,
|
||||
@@ -2139,18 +2171,16 @@ function SourceControlInner(): React.JSX.Element {
|
||||
if (generateInFlightRef.current[activeWorktreeId]) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!overrides?.sourceControlAiResolvedParams &&
|
||||
(!sourceControlAi.enabled || !effectiveCommitMessageAgentId)
|
||||
) {
|
||||
if (!overrides?.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!overrides?.sourceControlAiResolvedParams &&
|
||||
isCustomAgentId(effectiveCommitMessageAgentId)
|
||||
resolvedCommitMessageAi?.ok === true &&
|
||||
isCustomAgentId(resolvedCommitMessageAi.value.params.agentId)
|
||||
) {
|
||||
const command = sourceControlAi.customAgentCommand?.trim() ?? ''
|
||||
const command = resolvedCommitMessageAi.value.params.customAgentCommand?.trim() ?? ''
|
||||
if (!command) {
|
||||
setGenerateErrors((prev) => ({
|
||||
...prev,
|
||||
@@ -2213,7 +2243,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
generateInFlightRef.current[activeWorktreeId] = false
|
||||
}
|
||||
},
|
||||
[activeWorktreeId, effectiveCommitMessageAgentId, sourceControlAi, worktreePath]
|
||||
[activeWorktreeId, resolvedCommitMessageAi, worktreePath]
|
||||
)
|
||||
|
||||
const handleGenerateCommitMessageClick = useCallback((): void => {
|
||||
@@ -2248,44 +2278,34 @@ function SourceControlInner(): React.JSX.Element {
|
||||
|
||||
const handleSaveCommitMessageGenerationDefaults = useCallback(
|
||||
async (
|
||||
target: SourceControlAiWriteTarget,
|
||||
params: NonNullable<RuntimeGenerateCommitMessageOverrides['sourceControlAiResolvedParams']>
|
||||
): Promise<void> => {
|
||||
const latestSettings = useAppStore.getState().settings
|
||||
const latestSourceControlAi = normalizeSourceControlAiSettings(
|
||||
latestSettings?.sourceControlAi,
|
||||
latestSettings?.commitMessageAi
|
||||
await saveActionRecipeForTarget(
|
||||
target,
|
||||
'commitMessage',
|
||||
generationParamsToActionRecipe(params),
|
||||
isCustomAgentId(params.agentId) ? params.customAgentCommand : undefined
|
||||
)
|
||||
await updateSettings({
|
||||
sourceControlAi: applyCommitMessageGenerationDefaults(
|
||||
latestSourceControlAi,
|
||||
sourceControlAiDiscoveryHostKey,
|
||||
params
|
||||
)
|
||||
})
|
||||
},
|
||||
[sourceControlAiDiscoveryHostKey, updateSettings]
|
||||
[saveActionRecipeForTarget]
|
||||
)
|
||||
|
||||
const handleSavePullRequestGenerationDefaults = useCallback(
|
||||
async (
|
||||
target: SourceControlAiWriteTarget,
|
||||
params: NonNullable<
|
||||
RuntimeGeneratePullRequestFieldsOverrides['sourceControlAiResolvedParams']
|
||||
>
|
||||
): Promise<void> => {
|
||||
const latestSettings = useAppStore.getState().settings
|
||||
const latestSourceControlAi = normalizeSourceControlAiSettings(
|
||||
latestSettings?.sourceControlAi,
|
||||
latestSettings?.commitMessageAi
|
||||
await saveActionRecipeForTarget(
|
||||
target,
|
||||
'pullRequest',
|
||||
generationParamsToActionRecipe(params),
|
||||
isCustomAgentId(params.agentId) ? params.customAgentCommand : undefined
|
||||
)
|
||||
await updateSettings({
|
||||
sourceControlAi: applySourceControlTextGenerationDefaults(
|
||||
latestSourceControlAi,
|
||||
'pullRequest',
|
||||
params
|
||||
)
|
||||
})
|
||||
},
|
||||
[updateSettings]
|
||||
[saveActionRecipeForTarget]
|
||||
)
|
||||
|
||||
// Why: a single dispatcher for every remote-only action the split button or
|
||||
@@ -2791,6 +2811,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
worktreePath: worktreePath ?? '',
|
||||
branch: branchName,
|
||||
eligibility: hostedReviewCreation,
|
||||
repo: activeRepo ?? null,
|
||||
settings,
|
||||
submitting: isCreatingPr,
|
||||
prCreationDefaults: resolvedPrCreationDefaults,
|
||||
@@ -3034,6 +3055,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
])
|
||||
|
||||
const hasUnstagedChanges = grouped.unstaged.length > 0 || grouped.untracked.length > 0
|
||||
const hasStageableChanges = hasUnstagedChanges
|
||||
const hasPartiallyStagedChanges = useMemo(() => {
|
||||
if (grouped.staged.length === 0 || grouped.unstaged.length === 0) {
|
||||
return false
|
||||
@@ -3046,6 +3068,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
const action = resolvePrimaryAction({
|
||||
stagedCount: grouped.staged.length,
|
||||
hasUnstagedChanges,
|
||||
hasStageableChanges,
|
||||
hasPartiallyStagedChanges,
|
||||
hasMessage: commitMessage.trim().length > 0,
|
||||
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
|
||||
@@ -3069,6 +3092,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
}, [
|
||||
commitMessage,
|
||||
grouped.staged.length,
|
||||
hasStageableChanges,
|
||||
hasUnstagedChanges,
|
||||
hasPartiallyStagedChanges,
|
||||
isCommitting,
|
||||
@@ -3091,6 +3115,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
resolveDropdownItems({
|
||||
stagedCount: grouped.staged.length,
|
||||
hasUnstagedChanges,
|
||||
hasStageableChanges,
|
||||
hasPartiallyStagedChanges,
|
||||
hasMessage: commitMessage.trim().length > 0,
|
||||
hasUnresolvedConflicts: unresolvedConflicts.length > 0,
|
||||
@@ -3110,6 +3135,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
[
|
||||
commitMessage,
|
||||
grouped.staged.length,
|
||||
hasStageableChanges,
|
||||
hasUnstagedChanges,
|
||||
hasPartiallyStagedChanges,
|
||||
isCommitting,
|
||||
@@ -4572,6 +4598,8 @@ function SourceControlInner(): React.JSX.Element {
|
||||
<CommitArea
|
||||
worktreeId={activeWorktreeId}
|
||||
connectionId={activeWorktreeId ? getConnectionId(activeWorktreeId) : null}
|
||||
repoId={activeRepo?.id ?? null}
|
||||
launchPlatform={activeSourceControlLaunchPlatform}
|
||||
commitMessage={commitMessage}
|
||||
commitError={commitError}
|
||||
commitFailureRecoveryPrompt={commitFailureRecoveryPrompt}
|
||||
@@ -4580,16 +4608,8 @@ function SourceControlInner(): React.JSX.Element {
|
||||
isFixingCommitFailureWithAI={isLaunchingCommitFailureAgent}
|
||||
groupId={activeGroupId ?? activeWorktreeId}
|
||||
showComposer={!(scope === 'all' && showGenericEmptyState)}
|
||||
aiEnabled={sourceControlAi.enabled === true}
|
||||
aiAgentConfigured={
|
||||
sourceControlAi.enabled === true &&
|
||||
effectiveCommitMessageAgentId !== null &&
|
||||
// Why: 'custom' is configured only once the user types a command.
|
||||
// Without this guard, Generate would spawn an empty command and
|
||||
// fail with a confusing error.
|
||||
(!isCustomAgentId(effectiveCommitMessageAgentId) ||
|
||||
(sourceControlAi.customAgentCommand ?? '').trim().length > 0)
|
||||
}
|
||||
aiEnabled={resolvedCommitMessageAi?.ok === true}
|
||||
aiAgentConfigured={resolvedCommitMessageAi?.ok === true}
|
||||
isGenerating={isGenerating}
|
||||
generateError={generateError}
|
||||
stagedCount={grouped.staged.length}
|
||||
@@ -4999,7 +5019,9 @@ function SourceControlInner(): React.JSX.Element {
|
||||
worktreeId={activeWorktreeId}
|
||||
groupId={activeGroupId ?? activeWorktreeId}
|
||||
connectionId={activeWorktreeId ? getConnectionId(activeWorktreeId) : null}
|
||||
repoId={activeRepo?.id ?? null}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchPlatform={activeSourceControlLaunchPlatform}
|
||||
launchSource="conflict_resolution"
|
||||
savedAgentId={readSourceControlLaunchRecipeAgentId(
|
||||
getLaunchActionRecipe('resolveConflicts')
|
||||
@@ -5398,6 +5420,8 @@ type CommitFailureFixSplitButtonProps = {
|
||||
worktreeId: string | null
|
||||
groupId: string | null
|
||||
connectionId?: string | null
|
||||
repoId?: string | null
|
||||
launchPlatform?: NodeJS.Platform
|
||||
prompt: string | null
|
||||
isLaunching: boolean
|
||||
variant: React.ComponentProps<typeof Button>['variant']
|
||||
@@ -5409,6 +5433,7 @@ type CommitFailureFixSplitButtonProps = {
|
||||
savedCommandInputTemplate?: string | null
|
||||
savedAgentArgs?: string | null
|
||||
onSaveAgentDefault?: (
|
||||
target: SourceControlAiWriteTarget,
|
||||
actionId: SourceControlLaunchActionId,
|
||||
recipe: SourceControlActionRecipe
|
||||
) => void | Promise<void>
|
||||
@@ -5422,6 +5447,8 @@ function CommitFailureFixSplitButton({
|
||||
worktreeId,
|
||||
groupId,
|
||||
connectionId,
|
||||
repoId,
|
||||
launchPlatform,
|
||||
prompt,
|
||||
isLaunching,
|
||||
variant,
|
||||
@@ -5501,7 +5528,9 @@ function CommitFailureFixSplitButton({
|
||||
worktreeId={worktreeId}
|
||||
groupId={groupId}
|
||||
connectionId={connectionId}
|
||||
repoId={repoId}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchPlatform={launchPlatform}
|
||||
launchSource="source_control_recovery"
|
||||
savedAgentId={savedAgentId}
|
||||
savedCommandInputTemplate={savedCommandInputTemplate}
|
||||
@@ -5531,6 +5560,8 @@ type CommitAreaProps = {
|
||||
worktreeId: string | null
|
||||
groupId: string | null
|
||||
connectionId?: string | null
|
||||
repoId?: string | null
|
||||
launchPlatform?: NodeJS.Platform
|
||||
commitMessage: string
|
||||
commitError: string | null
|
||||
commitFailureRecoveryPrompt: string | null
|
||||
@@ -5554,6 +5585,7 @@ type CommitAreaProps = {
|
||||
onGenerate: () => void
|
||||
onCancelGenerate: () => void
|
||||
onSaveLaunchActionDefault?: (
|
||||
target: SourceControlAiWriteTarget,
|
||||
actionId: SourceControlLaunchActionId,
|
||||
recipe: SourceControlActionRecipe
|
||||
) => void | Promise<void>
|
||||
@@ -5567,6 +5599,8 @@ export function CommitArea({
|
||||
worktreeId,
|
||||
groupId,
|
||||
connectionId,
|
||||
repoId,
|
||||
launchPlatform,
|
||||
commitMessage,
|
||||
commitError,
|
||||
commitFailureRecoveryPrompt,
|
||||
@@ -5919,6 +5953,8 @@ export function CommitArea({
|
||||
worktreeId={worktreeId}
|
||||
groupId={groupId}
|
||||
connectionId={connectionId}
|
||||
repoId={repoId}
|
||||
launchPlatform={launchPlatform}
|
||||
prompt={commitFailureRecoveryPrompt}
|
||||
isLaunching={isFixingCommitFailureWithAI}
|
||||
variant="secondary"
|
||||
@@ -5969,6 +6005,8 @@ export function CommitArea({
|
||||
worktreeId={worktreeId}
|
||||
groupId={groupId}
|
||||
connectionId={connectionId}
|
||||
repoId={repoId}
|
||||
launchPlatform={launchPlatform}
|
||||
prompt={commitFailureRecoveryPrompt}
|
||||
isLaunching={isFixingCommitFailureWithAI}
|
||||
variant="default"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { LaunchSource } from '../../../../shared/telemetry-events'
|
||||
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
|
||||
import { planSourceControlAgentActionLaunch } from '@/lib/source-control-agent-action-plan'
|
||||
import { pickSourceControlLaunchAgent } from '@/lib/source-control-launch-agent-selection'
|
||||
import { toast } from 'sonner'
|
||||
@@ -38,10 +39,13 @@ export type SourceControlAgentActionDialogProps = {
|
||||
worktreeId?: string | null
|
||||
groupId?: string | null
|
||||
connectionId?: string | null
|
||||
repoId?: string | null
|
||||
promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready'
|
||||
launchPlatform?: NodeJS.Platform
|
||||
launchSource: LaunchSource
|
||||
savedAgentId?: TuiAgent | null
|
||||
onSaveAgentDefault?: (
|
||||
target: SourceControlAiWriteTarget,
|
||||
actionId: SourceControlLaunchActionId,
|
||||
recipe: SourceControlActionRecipe
|
||||
) => void | Promise<void>
|
||||
@@ -77,7 +81,9 @@ export function SourceControlAgentActionDialog({
|
||||
worktreeId,
|
||||
groupId,
|
||||
connectionId,
|
||||
repoId,
|
||||
promptDelivery = 'submit-after-ready',
|
||||
launchPlatform,
|
||||
launchSource,
|
||||
savedAgentId,
|
||||
onSaveAgentDefault,
|
||||
@@ -100,7 +106,18 @@ export function SourceControlAgentActionDialog({
|
||||
status: 'idle'
|
||||
})
|
||||
const [isStarting, setIsStarting] = useState(false)
|
||||
const [saveAgentDefault, setSaveAgentDefault] = useState(true)
|
||||
const saveTargets = useMemo(() => {
|
||||
const targets = [{ value: 'none', label: "Don't save" }]
|
||||
if (repoId) {
|
||||
targets.push({ value: 'repo', label: 'Save for this repository only' })
|
||||
}
|
||||
targets.push({
|
||||
value: 'global',
|
||||
label: repoId ? 'Save as default for all repositories' : 'Save as global default'
|
||||
})
|
||||
return targets
|
||||
}, [repoId])
|
||||
const [saveTargetValue, setSaveTargetValue] = useState(repoId ? 'repo' : 'global')
|
||||
|
||||
const disabledAgents = settings?.disabledTuiAgents
|
||||
const connectionUnavailable = Boolean(worktreeId && connectionId === undefined)
|
||||
@@ -131,6 +148,7 @@ export function SourceControlAgentActionDialog({
|
||||
setCommandTemplate(savedCommandInputTemplate ?? '{basePrompt}')
|
||||
setAgentArgs(savedAgentArgs ?? '')
|
||||
setSelectedAgent(savedAgentId ?? null)
|
||||
setSaveTargetValue(repoId ? 'repo' : 'global')
|
||||
let stale = false
|
||||
void refreshDetectedAgents().then((nextAgents) => {
|
||||
if (stale) {
|
||||
@@ -163,6 +181,7 @@ export function SourceControlAgentActionDialog({
|
||||
savedAgentId,
|
||||
savedAgentArgs,
|
||||
savedCommandInputTemplate,
|
||||
repoId,
|
||||
settings?.defaultTuiAgent
|
||||
])
|
||||
|
||||
@@ -170,11 +189,11 @@ export function SourceControlAgentActionDialog({
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
setDeliveryPlan({ status: 'idle' })
|
||||
setSaveAgentDefault(true)
|
||||
setSaveTargetValue(repoId ? 'repo' : 'global')
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
},
|
||||
[onOpenChange]
|
||||
[onOpenChange, repoId]
|
||||
)
|
||||
|
||||
const enabledDetectedAgents = useMemo(
|
||||
@@ -217,7 +236,8 @@ export function SourceControlAgentActionDialog({
|
||||
promptDelivery,
|
||||
detectedAgents: currentDetectedAgents,
|
||||
disabledAgents: useAppStore.getState().settings?.disabledTuiAgents,
|
||||
cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides
|
||||
cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides,
|
||||
platform: launchPlatform
|
||||
})
|
||||
if (!result.ok) {
|
||||
return { status: 'error', error: result.error }
|
||||
@@ -235,7 +255,8 @@ export function SourceControlAgentActionDialog({
|
||||
connectionUnavailable,
|
||||
promptDelivery,
|
||||
refreshDetectedAgents,
|
||||
selectedAgent
|
||||
selectedAgent,
|
||||
launchPlatform
|
||||
]
|
||||
)
|
||||
|
||||
@@ -272,6 +293,7 @@ export function SourceControlAgentActionDialog({
|
||||
prompt: trimmedCommandInput,
|
||||
agentArgs,
|
||||
promptDelivery,
|
||||
launchPlatform,
|
||||
launchSource
|
||||
})
|
||||
launched = Boolean(result)
|
||||
@@ -283,8 +305,14 @@ export function SourceControlAgentActionDialog({
|
||||
toast.error('Could not start the selected agent.')
|
||||
return
|
||||
}
|
||||
if (saveAgentDefault && onSaveAgentDefault) {
|
||||
await onSaveAgentDefault(actionId, {
|
||||
const saveTarget =
|
||||
saveTargetValue === 'repo' && repoId
|
||||
? ({ type: 'repo', repoId } as const)
|
||||
: saveTargetValue === 'global'
|
||||
? ({ type: 'global' } as const)
|
||||
: null
|
||||
if (saveTarget && onSaveAgentDefault) {
|
||||
await onSaveAgentDefault(saveTarget, actionId, {
|
||||
agentId: selectedAgent,
|
||||
commandInputTemplate: commandTemplate,
|
||||
agentArgs
|
||||
@@ -304,13 +332,15 @@ export function SourceControlAgentActionDialog({
|
||||
groupId,
|
||||
isStarting,
|
||||
launchSource,
|
||||
launchPlatform,
|
||||
handleOpenChange,
|
||||
onLaunched,
|
||||
onSaveAgentDefault,
|
||||
onStart,
|
||||
promptDelivery,
|
||||
refreshDetectedAgents,
|
||||
saveAgentDefault,
|
||||
repoId,
|
||||
saveTargetValue,
|
||||
selectedAgent,
|
||||
trimmedCommandInput,
|
||||
worktreeId
|
||||
@@ -342,7 +372,8 @@ export function SourceControlAgentActionDialog({
|
||||
commandTemplate={commandTemplate}
|
||||
savedCommandInputTemplate={savedCommandInputTemplate}
|
||||
baseCommandInput={baseCommandInput}
|
||||
saveAgentDefault={saveAgentDefault}
|
||||
saveTargetValue={saveTargetValue}
|
||||
saveTargets={saveTargets}
|
||||
canSaveAgentDefault={Boolean(onSaveAgentDefault)}
|
||||
deliveryPlan={deliveryPlan}
|
||||
canStart={canStart}
|
||||
@@ -360,7 +391,7 @@ export function SourceControlAgentActionDialog({
|
||||
setCommandTemplate(value)
|
||||
setDeliveryPlan({ status: 'idle' })
|
||||
}}
|
||||
onSaveAgentDefaultChange={setSaveAgentDefault}
|
||||
onSaveAgentDefaultChange={setSaveTargetValue}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onStart={() => void handleStart()}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,14 @@ import { Button } from '@/components/ui/button'
|
||||
import { DialogFooter } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { getAgentLabel, type AgentCatalogEntry } from '@/lib/agent-catalog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
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'
|
||||
@@ -27,7 +34,8 @@ type SourceControlAgentActionDialogFormProps = {
|
||||
commandTemplate: string
|
||||
savedCommandInputTemplate?: string | null
|
||||
baseCommandInput: string
|
||||
saveAgentDefault: boolean
|
||||
saveTargetValue: string
|
||||
saveTargets: { value: string; label: string }[]
|
||||
canSaveAgentDefault: boolean
|
||||
deliveryPlan: SourceControlAgentActionDeliveryPlanState
|
||||
canStart: boolean
|
||||
@@ -36,7 +44,7 @@ type SourceControlAgentActionDialogFormProps = {
|
||||
onSelectedAgentChange: (agent: TuiAgent | null) => void
|
||||
onAgentArgsChange: (value: string) => void
|
||||
onCommandTemplateChange: (value: string) => void
|
||||
onSaveAgentDefaultChange: (value: boolean) => void
|
||||
onSaveAgentDefaultChange: (value: string) => void
|
||||
onOpenSettings?: () => void
|
||||
onStart: () => void
|
||||
}
|
||||
@@ -52,7 +60,8 @@ export function SourceControlAgentActionDialogForm({
|
||||
commandTemplate,
|
||||
savedCommandInputTemplate,
|
||||
baseCommandInput,
|
||||
saveAgentDefault,
|
||||
saveTargetValue,
|
||||
saveTargets,
|
||||
canSaveAgentDefault,
|
||||
deliveryPlan,
|
||||
canStart,
|
||||
@@ -145,16 +154,21 @@ export function SourceControlAgentActionDialogForm({
|
||||
</div>
|
||||
|
||||
{canSaveAgentDefault && selectedAgent ? (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saveAgentDefault}
|
||||
onChange={(event) => onSaveAgentDefaultChange(event.target.checked)}
|
||||
className="size-3.5 rounded border-border"
|
||||
/>
|
||||
Save {getAgentLabel(selectedAgent)}, these CLI arguments, and this command template as
|
||||
the default for this action
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Save launch recipe</Label>
|
||||
<Select value={saveTargetValue} onValueChange={onSaveAgentDefaultChange}>
|
||||
<SelectTrigger size="sm" className="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{saveTargets.map((target) => (
|
||||
<SelectItem key={target.value} value={target.value}>
|
||||
{target.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{deliveryPlan.status !== 'idle' ? (
|
||||
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
normalizeSourceControlAiSettings,
|
||||
resolveSourceControlAiForOperation,
|
||||
type ResolvedSourceControlAiGenerationParams
|
||||
} from '../../../../shared/source-control-ai'
|
||||
import type { SourceControlTextActionId } from '../../../../shared/source-control-ai-actions'
|
||||
import type { GlobalSettings, Repo } from '../../../../shared/types'
|
||||
import { SourceControlTextGenerationDialogForm } from './SourceControlTextGenerationDialogForm'
|
||||
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
|
||||
import {
|
||||
SourceControlTextGenerationDialogForm,
|
||||
type SourceControlTextGenerationSaveTarget
|
||||
} from './SourceControlTextGenerationDialogForm'
|
||||
|
||||
export { buildCommitMessageGenerationParams } from './SourceControlTextGenerationParams'
|
||||
|
||||
@@ -22,10 +25,13 @@ type SourceControlTextGenerationBaseDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
settings: GlobalSettings | null
|
||||
repo?: Pick<Repo, 'sourceControlAi'> | null
|
||||
repo?: Pick<Repo, 'id' | 'sourceControlAi'> | null
|
||||
discoveryHostKey: string
|
||||
onGenerate: (params: ResolvedSourceControlAiGenerationParams) => void
|
||||
onSaveDefaults: (params: ResolvedSourceControlAiGenerationParams) => Promise<void> | void
|
||||
onSaveDefaults: (
|
||||
target: SourceControlAiWriteTarget,
|
||||
params: ResolvedSourceControlAiGenerationParams
|
||||
) => Promise<void> | void
|
||||
}
|
||||
|
||||
type SourceControlTextGenerationDialogProps = SourceControlTextGenerationBaseDialogProps & {
|
||||
@@ -61,13 +67,32 @@ export function SourceControlTextGenerationDialog({
|
||||
[actionId, discoveryHostKey, repo, settings]
|
||||
)
|
||||
const baseParams = resolved.ok ? resolved.value.params : null
|
||||
const sourceControlAi = useMemo(
|
||||
() =>
|
||||
settings
|
||||
? normalizeSourceControlAiSettings(settings.sourceControlAi, settings.commitMessageAi)
|
||||
: null,
|
||||
[settings]
|
||||
)
|
||||
const recipeLabel =
|
||||
actionId === 'commitMessage'
|
||||
? 'commit-message recipe'
|
||||
: actionId === 'pullRequest'
|
||||
? 'hosted-review recipe'
|
||||
: 'branch-name recipe'
|
||||
const saveTargets: SourceControlTextGenerationSaveTarget[] = repo?.id
|
||||
? [
|
||||
{
|
||||
target: { type: 'repo', repoId: repo.id },
|
||||
label: 'Save for this repository only',
|
||||
successMessage: `Saved ${recipeLabel} for this repository.`
|
||||
},
|
||||
{
|
||||
target: { type: 'global' },
|
||||
label: 'Save as default for all repositories',
|
||||
successMessage: `Saved ${recipeLabel} as a global default.`
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
target: { type: 'global' },
|
||||
label: 'Save as global default',
|
||||
successMessage: `Saved ${recipeLabel} as a global default.`
|
||||
}
|
||||
]
|
||||
const formKey = open
|
||||
? JSON.stringify([
|
||||
actionId,
|
||||
@@ -99,7 +124,7 @@ export function SourceControlTextGenerationDialog({
|
||||
generateLabel={generateLabel}
|
||||
settings={settings}
|
||||
baseParams={baseParams}
|
||||
sourceControlAi={sourceControlAi}
|
||||
saveTargets={saveTargets}
|
||||
onGenerate={onGenerate}
|
||||
onOpenChange={onOpenChange}
|
||||
onSaveDefaults={onSaveDefaults}
|
||||
|
||||
+53
-30
@@ -20,7 +20,7 @@ import {
|
||||
} from '../../../../shared/commit-message-agent-spec'
|
||||
import type { ResolvedSourceControlAiGenerationParams } from '../../../../shared/source-control-ai'
|
||||
import type { SourceControlTextActionId } from '../../../../shared/source-control-ai-actions'
|
||||
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
|
||||
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
|
||||
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
|
||||
import { toast } from 'sonner'
|
||||
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
|
||||
@@ -36,15 +36,24 @@ type PlanState =
|
||||
| { status: 'success'; commandLabel: string; delivery: string; caveat: string }
|
||||
| { status: 'error'; error: string }
|
||||
|
||||
export type SourceControlTextGenerationSaveTarget = {
|
||||
target: SourceControlAiWriteTarget
|
||||
label: string
|
||||
successMessage: string
|
||||
}
|
||||
|
||||
type SourceControlTextGenerationDialogFormProps = {
|
||||
actionId: SourceControlTextActionId
|
||||
generateLabel: string
|
||||
settings: GlobalSettings | null
|
||||
baseParams: ResolvedSourceControlAiGenerationParams | null
|
||||
sourceControlAi: SourceControlAiSettings | null
|
||||
saveTargets: SourceControlTextGenerationSaveTarget[]
|
||||
onGenerate: (params: ResolvedSourceControlAiGenerationParams) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaveDefaults: (params: ResolvedSourceControlAiGenerationParams) => Promise<void> | void
|
||||
onSaveDefaults: (
|
||||
target: SourceControlAiWriteTarget,
|
||||
params: ResolvedSourceControlAiGenerationParams
|
||||
) => Promise<void> | void
|
||||
}
|
||||
|
||||
function agentLabel(agentId: TuiAgent): string {
|
||||
@@ -56,15 +65,14 @@ export function SourceControlTextGenerationDialogForm({
|
||||
generateLabel,
|
||||
settings,
|
||||
baseParams,
|
||||
sourceControlAi,
|
||||
saveTargets,
|
||||
onGenerate,
|
||||
onOpenChange,
|
||||
onSaveDefaults
|
||||
}: SourceControlTextGenerationDialogFormProps): React.JSX.Element {
|
||||
const capabilities = useMemo(listCommitMessageAgentCapabilities, [])
|
||||
const showCustomAgent = Boolean(
|
||||
baseParams &&
|
||||
(isCustomAgentId(baseParams.agentId) || sourceControlAi?.customAgentCommand.trim())
|
||||
baseParams && (isCustomAgentId(baseParams.agentId) || baseParams.customAgentCommand?.trim())
|
||||
)
|
||||
const [agentId, setAgentId] = useState<CommitMessageGenerationAgentChoice>(
|
||||
baseParams?.agentId ?? ''
|
||||
@@ -74,7 +82,7 @@ export function SourceControlTextGenerationDialogForm({
|
||||
)
|
||||
const [agentArgs, setAgentArgs] = useState(baseParams?.agentArgs ?? '')
|
||||
const [plan, setPlan] = useState<PlanState>({ status: 'idle' })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savingTargetKey, setSavingTargetKey] = useState<string | null>(null)
|
||||
const commandTemplateId = `source-control-${actionId}-command-template`
|
||||
|
||||
const params = buildCommitMessageGenerationParams({
|
||||
@@ -83,10 +91,11 @@ export function SourceControlTextGenerationDialogForm({
|
||||
agentArgs,
|
||||
baseParams,
|
||||
settings,
|
||||
customAgentCommand: sourceControlAi?.customAgentCommand
|
||||
customAgentCommand: baseParams?.customAgentCommand
|
||||
})
|
||||
const paramsPlanResult = params ? planSourceControlTextGeneration(actionId, params) : null
|
||||
const canRunGeneration = Boolean(params && paramsPlanResult?.ok)
|
||||
const saving = savingTargetKey !== null
|
||||
|
||||
const handlePlan = (): void => {
|
||||
if (!params || !paramsPlanResult) {
|
||||
@@ -106,29 +115,30 @@ export function SourceControlTextGenerationDialogForm({
|
||||
}
|
||||
|
||||
const saveCurrentDefaults = useCallback(
|
||||
async (options: { showToast: boolean; showErrors: boolean }): Promise<boolean> => {
|
||||
async (
|
||||
saveTarget: SourceControlTextGenerationSaveTarget,
|
||||
options: { showToast: boolean; showErrors: boolean }
|
||||
): Promise<boolean> => {
|
||||
if (!params || saving || !paramsPlanResult?.ok) {
|
||||
if (options.showErrors && paramsPlanResult && !paramsPlanResult.ok) {
|
||||
setPlan({ status: 'error', error: paramsPlanResult.error })
|
||||
}
|
||||
return false
|
||||
}
|
||||
setSaving(true)
|
||||
const targetKey =
|
||||
saveTarget.target.type === 'repo' ? `repo:${saveTarget.target.repoId}` : 'global'
|
||||
setSavingTargetKey(targetKey)
|
||||
try {
|
||||
await onSaveDefaults(params)
|
||||
await onSaveDefaults(saveTarget.target, params)
|
||||
if (options.showToast) {
|
||||
toast.success(
|
||||
actionId === 'commitMessage'
|
||||
? 'Saved commit-message recipe.'
|
||||
: 'Saved hosted-review recipe.'
|
||||
)
|
||||
toast.success(saveTarget.successMessage)
|
||||
}
|
||||
return true
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setSavingTargetKey(null)
|
||||
}
|
||||
},
|
||||
[actionId, onSaveDefaults, params, paramsPlanResult, saving]
|
||||
[onSaveDefaults, params, paramsPlanResult, saving]
|
||||
)
|
||||
|
||||
const handleGenerate = (): void => {
|
||||
@@ -142,8 +152,10 @@ export function SourceControlTextGenerationDialogForm({
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleSaveDefaults = async (): Promise<void> => {
|
||||
await saveCurrentDefaults({ showToast: true, showErrors: true })
|
||||
const handleSaveDefaults = async (
|
||||
saveTarget: SourceControlTextGenerationSaveTarget
|
||||
): Promise<void> => {
|
||||
await saveCurrentDefaults(saveTarget, { showToast: true, showErrors: true })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -260,16 +272,27 @@ export function SourceControlTextGenerationDialogForm({
|
||||
<CheckCircle2 className="size-4" />
|
||||
Check generation
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canRunGeneration || saving}
|
||||
onClick={() => void handleSaveDefaults()}
|
||||
>
|
||||
{saving ? <RefreshCw className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
Save as default
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -47,12 +47,14 @@ export function buildCommitMessageGenerationParams(args: {
|
||||
? args.baseParams.thinkingLevel
|
||||
: model?.defaultThinkingLevel
|
||||
const agentCommandOverride = args.settings?.agentCmdOverrides?.[args.agentId]?.trim()
|
||||
const customAgentCommand = args.baseParams?.customAgentCommand ?? args.customAgentCommand
|
||||
return {
|
||||
agentId: args.agentId,
|
||||
model: modelId,
|
||||
...(thinkingLevel ? { thinkingLevel } : {}),
|
||||
commandInputTemplate: args.commandTemplate,
|
||||
...(args.agentArgs !== undefined ? { agentArgs: args.agentArgs } : {}),
|
||||
...(customAgentCommand ? { customAgentCommand } : {}),
|
||||
...(agentCommandOverride ? { agentCommandOverride } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-r
|
||||
import type { BaseRefSearchResult } from '../../../../shared/types'
|
||||
import {
|
||||
DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
|
||||
normalizeSourceControlAiSettings,
|
||||
resolveSourceControlAiForOperation
|
||||
} from '../../../../shared/source-control-ai'
|
||||
import type { SourceControlAiPrCreationDefaults } from '../../../../shared/source-control-ai-types'
|
||||
@@ -108,13 +107,6 @@ export function useCreatePullRequestDialogFields({
|
||||
onBranchChangedByGeneration,
|
||||
generation
|
||||
}: UseCreatePullRequestDialogFieldsOptions) {
|
||||
const normalizedSourceControlAi = normalizeSourceControlAiSettings(
|
||||
settings?.sourceControlAi,
|
||||
settings?.commitMessageAi
|
||||
)
|
||||
const sourceControlAi = settings
|
||||
? normalizedSourceControlAi
|
||||
: { ...normalizedSourceControlAi, enabled: false }
|
||||
const resolvedPullRequestAi = settings
|
||||
? resolveSourceControlAiForOperation({
|
||||
settings,
|
||||
@@ -459,7 +451,7 @@ export function useCreatePullRequestDialogFields({
|
||||
])
|
||||
|
||||
return {
|
||||
aiGenerationEnabled: sourceControlAi.enabled === true,
|
||||
aiGenerationEnabled: resolvedPullRequestAi?.ok === true,
|
||||
base,
|
||||
setBase: setUserBase,
|
||||
title,
|
||||
|
||||
@@ -629,9 +629,18 @@ export function CommitMessageAiPane({
|
||||
if (
|
||||
config.enabled &&
|
||||
matchesSettingsSearch(searchQuery, {
|
||||
title: 'PR creation defaults',
|
||||
description: 'Defaults used when the Create PR composer opens.',
|
||||
keywords: ['pull request', 'pr', 'draft', 'template', 'generate', 'open']
|
||||
title: 'Hosted-review creation defaults',
|
||||
description: 'Defaults used when the hosted-review composer opens.',
|
||||
keywords: [
|
||||
'hosted review',
|
||||
'pull request',
|
||||
'merge request',
|
||||
'pr',
|
||||
'draft',
|
||||
'template',
|
||||
'generate',
|
||||
'open'
|
||||
]
|
||||
})
|
||||
) {
|
||||
const prDefaults = config.prCreationDefaults ?? {}
|
||||
@@ -647,32 +656,41 @@ export function CommitMessageAiPane({
|
||||
},
|
||||
{
|
||||
key: 'useTemplate',
|
||||
label: 'Use PR template when available',
|
||||
label: 'Use review template when available',
|
||||
description: 'Prefer repository pull request templates when no description is set.'
|
||||
},
|
||||
{
|
||||
key: 'generateDetailsOnOpen',
|
||||
label: 'Generate details when opening Create PR',
|
||||
description: 'Run pull-request detail generation once when the composer opens.'
|
||||
description: 'Run hosted-review detail generation once when the composer opens.'
|
||||
},
|
||||
{
|
||||
key: 'openAfterCreate',
|
||||
label: 'Open PR after creation',
|
||||
label: 'Open hosted review after creation',
|
||||
description: 'Open the created hosted review in your browser after submit.'
|
||||
}
|
||||
]
|
||||
sections.push(
|
||||
<SearchableSetting
|
||||
key="pr-creation-defaults"
|
||||
title="PR creation defaults"
|
||||
description="Defaults used when the Create PR composer opens."
|
||||
keywords={['pull request', 'pr', 'draft', 'template', 'generate', 'open']}
|
||||
title="Hosted-review creation defaults"
|
||||
description="Defaults used when the hosted-review composer opens."
|
||||
keywords={[
|
||||
'hosted review',
|
||||
'pull request',
|
||||
'merge request',
|
||||
'pr',
|
||||
'draft',
|
||||
'template',
|
||||
'generate',
|
||||
'open'
|
||||
]}
|
||||
className="space-y-3 px-1 py-2"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Label>PR creation defaults</Label>
|
||||
<Label>Hosted-review creation defaults</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provider-neutral defaults for the Create PR composer.
|
||||
Used by repositories that inherit global hosted-review defaults.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -705,9 +723,9 @@ export function CommitMessageAiPane({
|
||||
className="space-y-4 border-t border-border/40 pt-4"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="text-sm font-semibold">Source Control AI</h3>
|
||||
<h3 className="text-sm font-semibold">Source Control AI defaults</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure the agent and command template behind each Source Control AI button.
|
||||
Used by repositories that have not customized Source Control AI.
|
||||
</p>
|
||||
</div>
|
||||
{sections}
|
||||
|
||||
@@ -21,13 +21,17 @@ import { RepositoryIconPicker } from './RepositoryIconPicker'
|
||||
import { getRepositoryPaneSearchEntries } from './repository-search'
|
||||
export { getRepositoryPaneSearchEntries }
|
||||
|
||||
type RepositoryPaneRepoUpdate = Omit<Partial<Repo>, 'sourceControlAi'> & {
|
||||
sourceControlAi?: Repo['sourceControlAi'] | null
|
||||
}
|
||||
|
||||
type RepositoryPaneProps = {
|
||||
repo: Repo
|
||||
yamlHooks: OrcaHooks | null
|
||||
hasHooksFile: boolean
|
||||
hooksInspectionReady: boolean
|
||||
mayNeedUpdate: boolean
|
||||
updateRepo: (repoId: string, updates: Partial<Repo>) => void
|
||||
updateRepo: (repoId: string, updates: RepositoryPaneRepoUpdate) => void
|
||||
removeProject: (repoId: string) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import type React from 'react'
|
||||
import { Terminal } from 'lucide-react'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec'
|
||||
import type {
|
||||
RepoSourceControlAiOverrides,
|
||||
SourceControlAiSettings
|
||||
} from '../../../../shared/source-control-ai-types'
|
||||
import {
|
||||
SOURCE_CONTROL_ACTION_IDS,
|
||||
SOURCE_CONTROL_ACTION_LABELS,
|
||||
type SourceControlActionId
|
||||
} from '../../../../shared/source-control-ai-actions'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
|
||||
import {
|
||||
ACTION_DESCRIPTIONS,
|
||||
SOURCE_CONTROL_TEXT_ACTION_ID_SET,
|
||||
getAgentCatalogForAction,
|
||||
getSourceControlAgentArgsPlaceholder
|
||||
} from './source-control-action-recipe-options'
|
||||
import {
|
||||
ACTION_MODE_INHERIT,
|
||||
ACTION_MODE_OVERRIDE,
|
||||
DEFAULT_AGENT_VALUE,
|
||||
actionAgentSelectValue,
|
||||
actionScopeLabel,
|
||||
agentArgsStateLabel,
|
||||
commandTemplateStateLabel,
|
||||
readInheritedAgentArgs,
|
||||
readInheritedCommandTemplate,
|
||||
resolveAgentArgsPlaceholderAgent
|
||||
} from './repository-source-control-ai-labels'
|
||||
import { hasOwnActionOverride } from './repository-source-control-ai-draft'
|
||||
|
||||
type RepositorySourceControlAiActionRowsProps = {
|
||||
repoAi: RepoSourceControlAiOverrides
|
||||
source: SourceControlAiSettings
|
||||
defaultTuiAgent: TuiAgent | 'blank' | null | undefined
|
||||
onActionModeChange: (actionId: SourceControlActionId, mode: string) => void
|
||||
onActionAgentChange: (actionId: SourceControlActionId, value: string) => void
|
||||
onActionTemplateChange: (actionId: SourceControlActionId, value: string) => void
|
||||
onActionAgentArgsChange: (actionId: SourceControlActionId, value: string) => void
|
||||
onAppendVariable: (actionId: SourceControlActionId, variable: string) => void
|
||||
}
|
||||
|
||||
export function RepositorySourceControlAiActionRows({
|
||||
repoAi,
|
||||
source,
|
||||
defaultTuiAgent,
|
||||
onActionModeChange,
|
||||
onActionAgentChange,
|
||||
onActionTemplateChange,
|
||||
onActionAgentArgsChange,
|
||||
onAppendVariable
|
||||
}: RepositorySourceControlAiActionRowsProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">Action recipes</Label>
|
||||
{SOURCE_CONTROL_ACTION_IDS.map((actionId) => {
|
||||
const hasOverride = hasOwnActionOverride(repoAi.actionOverrides, actionId)
|
||||
const override = repoAi.actionOverrides?.[actionId]
|
||||
const inheritedTemplate = readInheritedCommandTemplate(source, actionId)
|
||||
const inheritedAgentArgs = readInheritedAgentArgs(source, actionId)
|
||||
const templateValue =
|
||||
hasOverride && typeof override?.commandInputTemplate === 'string'
|
||||
? override.commandInputTemplate
|
||||
: ''
|
||||
const agentArgsValue =
|
||||
hasOverride && typeof override?.agentArgs === 'string' ? override.agentArgs : ''
|
||||
const effectiveAgent = hasOverride ? override?.agentId : source.actions?.[actionId]?.agentId
|
||||
const agentArgsPlaceholder =
|
||||
hasOverride && agentArgsValue
|
||||
? ''
|
||||
: inheritedAgentArgs ||
|
||||
getSourceControlAgentArgsPlaceholder(
|
||||
resolveAgentArgsPlaceholderAgent(effectiveAgent, source, actionId, defaultTuiAgent)
|
||||
)
|
||||
const agentOptions = getAgentCatalogForAction(actionId, effectiveAgent)
|
||||
return (
|
||||
<div key={actionId} className="space-y-3 rounded-md border border-border px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{SOURCE_CONTROL_ACTION_LABELS[actionId]}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">{ACTION_DESCRIPTIONS[actionId]}</p>
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span>{actionScopeLabel(hasOverride)}</span>
|
||||
<span>
|
||||
{commandTemplateStateLabel({ hasOverride, inheritedTemplate, actionId })}
|
||||
</span>
|
||||
<span>
|
||||
{agentArgsStateLabel({
|
||||
hasOverride,
|
||||
inheritedAgentArgs,
|
||||
repoAgentArgs: agentArgsValue
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={hasOverride ? ACTION_MODE_OVERRIDE : ACTION_MODE_INHERIT}
|
||||
onValueChange={(value) => onActionModeChange(actionId, value)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-8 w-full shrink-0 text-xs sm:w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ACTION_MODE_INHERIT}>Use global</SelectItem>
|
||||
<SelectItem value={ACTION_MODE_OVERRIDE}>Customize</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-[180px_1fr]">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[11px] text-muted-foreground">Agent</Label>
|
||||
<Select
|
||||
value={actionAgentSelectValue(effectiveAgent)}
|
||||
onValueChange={(value) => onActionAgentChange(actionId, value)}
|
||||
disabled={!hasOverride}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEFAULT_AGENT_VALUE}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="size-3.5 text-muted-foreground" />
|
||||
Use default agent
|
||||
</span>
|
||||
</SelectItem>
|
||||
{SOURCE_CONTROL_TEXT_ACTION_ID_SET.has(actionId) ? (
|
||||
<SelectItem value={CUSTOM_AGENT_ID}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="size-3.5 text-muted-foreground" />
|
||||
Custom command
|
||||
</span>
|
||||
</SelectItem>
|
||||
) : null}
|
||||
{agentOptions.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<AgentIcon agent={agent.id} size={14} />
|
||||
{agent.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="text-[11px] text-muted-foreground">CLI arguments</Label>
|
||||
<Input
|
||||
value={agentArgsValue}
|
||||
onChange={(event) => onActionAgentArgsChange(actionId, event.target.value)}
|
||||
disabled={!hasOverride}
|
||||
placeholder={agentArgsPlaceholder}
|
||||
spellCheck={false}
|
||||
className="h-8 font-mono text-xs disabled:cursor-not-allowed disabled:bg-muted/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[11px] text-muted-foreground">Command template</Label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={templateValue}
|
||||
onChange={(event) => onActionTemplateChange(actionId, event.target.value)}
|
||||
disabled={!hasOverride}
|
||||
placeholder={inheritedTemplate}
|
||||
spellCheck={false}
|
||||
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 disabled:cursor-not-allowed disabled:bg-muted/40"
|
||||
/>
|
||||
<SourceControlActionVariableChips
|
||||
actionId={actionId}
|
||||
disabled={!hasOverride}
|
||||
onInsert={(variable) => onAppendVariable(actionId, variable)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type React from 'react'
|
||||
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import {
|
||||
CUSTOM_COMMAND_MODE_INHERIT,
|
||||
CUSTOM_COMMAND_MODE_REPO
|
||||
} from './repository-source-control-ai-labels'
|
||||
|
||||
type RepositorySourceControlAiCustomCommandProps = {
|
||||
value: string | undefined
|
||||
source: SourceControlAiSettings
|
||||
onChange: (value: string | undefined) => void
|
||||
}
|
||||
|
||||
export function RepositorySourceControlAiCustomCommand({
|
||||
value,
|
||||
source,
|
||||
onChange
|
||||
}: RepositorySourceControlAiCustomCommandProps): React.JSX.Element {
|
||||
const hasRepoCommand = typeof value === 'string' && value.trim().length > 0
|
||||
const mode = hasRepoCommand ? CUSTOM_COMMAND_MODE_REPO : CUSTOM_COMMAND_MODE_INHERIT
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border border-border px-3 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<Label className="text-xs font-medium">Custom command</Label>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Repo fallback for text actions that select Custom command.
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(nextMode) => {
|
||||
onChange(
|
||||
nextMode === CUSTOM_COMMAND_MODE_REPO
|
||||
? (value ?? source.customAgentCommand)
|
||||
: undefined
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-8 w-full text-xs sm:w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={CUSTOM_COMMAND_MODE_INHERIT}>Use global</SelectItem>
|
||||
<SelectItem value={CUSTOM_COMMAND_MODE_REPO}>Repository command</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={source.customAgentCommand || 'e.g. ollama run llama3.1 {prompt}'}
|
||||
spellCheck={false}
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type React from 'react'
|
||||
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
|
||||
import { Label } from '../ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
|
||||
type RepositorySourceControlAiEnablementProps = {
|
||||
value: boolean | undefined
|
||||
source: SourceControlAiSettings
|
||||
onChange: (value: boolean | undefined) => void
|
||||
}
|
||||
|
||||
function enablementValue(value: boolean | undefined): 'inherit' | 'on' | 'off' {
|
||||
if (value === true) {
|
||||
return 'on'
|
||||
}
|
||||
if (value === false) {
|
||||
return 'off'
|
||||
}
|
||||
return 'inherit'
|
||||
}
|
||||
|
||||
export function RepositorySourceControlAiEnablement({
|
||||
value,
|
||||
source,
|
||||
onChange
|
||||
}: RepositorySourceControlAiEnablementProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-border px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<Label className="text-xs font-medium">Source Control AI enabled</Label>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Global default is {source.enabled ? 'On' : 'Off'}.
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={enablementValue(value)}
|
||||
onValueChange={(nextValue) => {
|
||||
onChange(nextValue === 'inherit' ? undefined : nextValue === 'on')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-8 w-full text-xs sm:w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">Use global</SelectItem>
|
||||
<SelectItem value="on">On</SelectItem>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import type React from 'react'
|
||||
import type {
|
||||
RepoSourceControlAiOverrides,
|
||||
SourceControlAiSettings
|
||||
} from '../../../../shared/source-control-ai-types'
|
||||
import { Label } from '../ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { triStateValue } from './repository-source-control-ai-draft'
|
||||
|
||||
type HostedReviewDefaultKey = keyof NonNullable<RepoSourceControlAiOverrides['prCreationDefaults']>
|
||||
|
||||
type RepositorySourceControlAiHostedReviewDefaultsProps = {
|
||||
value: RepoSourceControlAiOverrides['prCreationDefaults']
|
||||
source: SourceControlAiSettings
|
||||
onChange: (key: HostedReviewDefaultKey, value: string) => void
|
||||
}
|
||||
|
||||
const HOSTED_REVIEW_DEFAULT_ROWS: { key: HostedReviewDefaultKey; label: string }[] = [
|
||||
{ key: 'draft', label: 'Draft by default' },
|
||||
{ key: 'useTemplate', label: 'Use review template when available' },
|
||||
{ key: 'generateDetailsOnOpen', label: 'Generate details when opening Create PR' },
|
||||
{ key: 'openAfterCreate', label: 'Open hosted review after creation' }
|
||||
]
|
||||
|
||||
export function RepositorySourceControlAiHostedReviewDefaults({
|
||||
value,
|
||||
source,
|
||||
onChange
|
||||
}: RepositorySourceControlAiHostedReviewDefaultsProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Hosted-review creation defaults</Label>
|
||||
<div className="space-y-2">
|
||||
{HOSTED_REVIEW_DEFAULT_ROWS.map((row) => {
|
||||
const inherited = source.prCreationDefaults?.[row.key] === true ? 'On' : 'Off'
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 space-y-0.5">
|
||||
<span className="block text-xs text-foreground">{row.label}</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
Global default is {inherited}.
|
||||
</span>
|
||||
</span>
|
||||
<Select
|
||||
value={triStateValue(value?.[row.key])}
|
||||
onValueChange={(nextValue) => onChange(row.key, nextValue)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-8 w-[120px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">Use global</SelectItem>
|
||||
<SelectItem value="on">On</SelectItem>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,177 +1,85 @@
|
||||
/* eslint-disable max-lines -- Why: repo Source Control AI settings keep one
|
||||
draft/save flow across action recipes and PR-default override groups. */
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Terminal } from 'lucide-react'
|
||||
import type { Repo, TuiAgent } from '../../../../shared/types'
|
||||
import {
|
||||
CUSTOM_AGENT_ID,
|
||||
isCustomAgentId,
|
||||
type CustomAgentId
|
||||
} from '../../../../shared/commit-message-agent-spec'
|
||||
import type {
|
||||
RepoSourceControlAiOverrides,
|
||||
SourceControlAiSettings
|
||||
} from '../../../../shared/source-control-ai-types'
|
||||
import type React from 'react'
|
||||
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
|
||||
import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec'
|
||||
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
|
||||
import {
|
||||
normalizeRepoSourceControlAiOverrides,
|
||||
normalizeSourceControlAiSettings
|
||||
normalizeSourceControlAiSettings,
|
||||
resolveSourceControlActionRecipe
|
||||
} from '../../../../shared/source-control-ai'
|
||||
import {
|
||||
SOURCE_CONTROL_ACTION_IDS,
|
||||
SOURCE_CONTROL_ACTION_LABELS,
|
||||
resolveSourceControlActionCommandTemplate,
|
||||
type SourceControlActionId
|
||||
} from '../../../../shared/source-control-ai-actions'
|
||||
import { Label } from '../ui/label'
|
||||
import { Input } from '../ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { useAppStore } from '../../store'
|
||||
import { getRepositorySourceControlAiSectionId } from './repository-settings-targets'
|
||||
import { toSourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save'
|
||||
import type { SourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save'
|
||||
import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
|
||||
import { Button } from '../ui/button'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
|
||||
import { getRepositorySourceControlAiSectionId } from './repository-settings-targets'
|
||||
import { RepositorySourceControlAiActionRows } from './RepositorySourceControlAiActionRows'
|
||||
import { RepositorySourceControlAiCustomCommand } from './RepositorySourceControlAiCustomCommand'
|
||||
import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement'
|
||||
import { RepositorySourceControlAiHostedReviewDefaults } from './RepositorySourceControlAiHostedReviewDefaults'
|
||||
import {
|
||||
ACTION_DESCRIPTIONS,
|
||||
SOURCE_CONTROL_TEXT_ACTION_ID_SET,
|
||||
getAgentCatalogForAction,
|
||||
getSourceControlAgentArgsPlaceholder
|
||||
} from './source-control-action-recipe-options'
|
||||
createRepoAiDraftState,
|
||||
dropRepoLegacyInstructionForAction,
|
||||
hasOwnActionOverride,
|
||||
normalizeRepoAiDraft,
|
||||
resolveRepoAiDraftState,
|
||||
serializeRepoAiDraft,
|
||||
type RepoAiDraftState
|
||||
} from './repository-source-control-ai-draft'
|
||||
import {
|
||||
ACTION_MODE_INHERIT,
|
||||
DEFAULT_AGENT_VALUE,
|
||||
completeRepoActionRecipe,
|
||||
readInheritedCommandTemplate
|
||||
} from './repository-source-control-ai-labels'
|
||||
|
||||
export {
|
||||
createRepoAiDraftState,
|
||||
dropRepoLegacyInstructionForAction,
|
||||
resolveRepoAiDraftState
|
||||
} from './repository-source-control-ai-draft'
|
||||
|
||||
type RepositorySourceControlAiSectionProps = {
|
||||
repo: Repo
|
||||
updateRepo: (repoId: string, updates: Partial<Repo>) => void | Promise<boolean>
|
||||
updateRepo: (repoId: string, updates: SourceControlAiRepoUpdate) => void | Promise<boolean>
|
||||
}
|
||||
|
||||
const ACTION_MODE_INHERIT = 'inherit'
|
||||
const ACTION_MODE_OVERRIDE = 'override'
|
||||
const INHERIT_AGENT_VALUE = '__inherit_agent__'
|
||||
const DEFAULT_AGENT_VALUE = '__default_agent__'
|
||||
type HostedReviewDefaultKey = keyof NonNullable<RepoSourceControlAiOverrides['prCreationDefaults']>
|
||||
|
||||
type PrDefaultKey = keyof NonNullable<RepoSourceControlAiOverrides['prCreationDefaults']>
|
||||
type RepoAiDraftState = {
|
||||
repoId: string
|
||||
value: RepoSourceControlAiOverrides
|
||||
baseSerialized: string
|
||||
}
|
||||
|
||||
function hasOwnActionOverride(
|
||||
overrides: RepoSourceControlAiOverrides['actionOverrides'],
|
||||
function readCompleteRecipeForDraft(
|
||||
current: RepoSourceControlAiOverrides,
|
||||
settings: GlobalSettings | null,
|
||||
actionId: SourceControlActionId
|
||||
): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId)
|
||||
): NonNullable<
|
||||
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
|
||||
> {
|
||||
const recipe = resolveSourceControlActionRecipe({
|
||||
settings,
|
||||
repo: { sourceControlAi: current },
|
||||
actionId
|
||||
})
|
||||
return completeRepoActionRecipe(recipe, actionId)
|
||||
}
|
||||
|
||||
function triStateValue(value: boolean | null | undefined): 'inherit' | 'on' | 'off' {
|
||||
if (value === true) {
|
||||
return 'on'
|
||||
}
|
||||
if (value === false) {
|
||||
return 'off'
|
||||
}
|
||||
return 'inherit'
|
||||
}
|
||||
|
||||
function normalizeRepoAiDraft(
|
||||
value: RepoSourceControlAiOverrides | null | undefined
|
||||
): RepoSourceControlAiOverrides {
|
||||
return normalizeRepoSourceControlAiOverrides(value) ?? {}
|
||||
}
|
||||
|
||||
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)
|
||||
// Why: render-time draft sync relies on object identity to avoid repeating
|
||||
// the same state update during server-rendered settings tests.
|
||||
if (
|
||||
current.repoId === repoId &&
|
||||
currentSerialized === persistedSerialized &&
|
||||
current.baseSerialized === persistedSerialized
|
||||
) {
|
||||
return current
|
||||
}
|
||||
if (
|
||||
current.repoId !== repoId ||
|
||||
currentSerialized === current.baseSerialized ||
|
||||
currentSerialized === persistedSerialized
|
||||
) {
|
||||
return {
|
||||
repoId,
|
||||
value: persistedRepoAi,
|
||||
baseSerialized: persistedSerialized
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function readInheritedCommandTemplate(
|
||||
source: SourceControlAiSettings,
|
||||
actionId: SourceControlActionId
|
||||
): string {
|
||||
return resolveSourceControlActionCommandTemplate(source.actions, actionId)
|
||||
}
|
||||
|
||||
function readInheritedAgentArgs(
|
||||
source: SourceControlAiSettings,
|
||||
actionId: SourceControlActionId
|
||||
): string {
|
||||
return source.actions?.[actionId]?.agentArgs?.trim() ?? ''
|
||||
}
|
||||
|
||||
export function dropRepoLegacyInstructionForAction(
|
||||
value: RepoSourceControlAiOverrides,
|
||||
actionId: SourceControlActionId
|
||||
): RepoSourceControlAiOverrides {
|
||||
if (!SOURCE_CONTROL_TEXT_ACTION_ID_SET.has(actionId) || !value.instructionsByOperation) {
|
||||
return value
|
||||
}
|
||||
const instructionsByOperation = { ...value.instructionsByOperation }
|
||||
delete instructionsByOperation[actionId as keyof typeof instructionsByOperation]
|
||||
return {
|
||||
...value,
|
||||
instructionsByOperation:
|
||||
Object.keys(instructionsByOperation).length > 0 ? instructionsByOperation : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function actionAgentSelectValue(agentId: TuiAgent | CustomAgentId | null | undefined): string {
|
||||
if (agentId === undefined) {
|
||||
return INHERIT_AGENT_VALUE
|
||||
}
|
||||
return agentId ?? DEFAULT_AGENT_VALUE
|
||||
}
|
||||
|
||||
function resolveAgentArgsPlaceholderAgent(
|
||||
agentId: TuiAgent | CustomAgentId | null | undefined,
|
||||
source: SourceControlAiSettings,
|
||||
function setActionOverride(
|
||||
current: RepoSourceControlAiOverrides,
|
||||
actionId: SourceControlActionId,
|
||||
defaultTuiAgent: TuiAgent | 'blank' | null | undefined
|
||||
): TuiAgent | null {
|
||||
const effectiveAgent = agentId === undefined ? source.actions?.[actionId]?.agentId : agentId
|
||||
if (effectiveAgent && !isCustomAgentId(effectiveAgent)) {
|
||||
return effectiveAgent
|
||||
}
|
||||
return defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null
|
||||
recipe: NonNullable<
|
||||
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
|
||||
>
|
||||
): RepoSourceControlAiOverrides {
|
||||
return dropRepoLegacyInstructionForAction(
|
||||
{
|
||||
...current,
|
||||
actionOverrides: {
|
||||
...current.actionOverrides,
|
||||
[actionId]: recipe
|
||||
}
|
||||
},
|
||||
actionId
|
||||
)
|
||||
}
|
||||
|
||||
export function RepositorySourceControlAiSection({
|
||||
@@ -244,10 +152,11 @@ export function RepositorySourceControlAiSection({
|
||||
}
|
||||
const next = normalizeRepoAiDraft(resolvedDraftState.value)
|
||||
const nextSerialized = serializeRepoAiDraft(next)
|
||||
const repoUpdate = toSourceControlAiRepoUpdate(next)
|
||||
setIsSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
const result = await updateRepo(repo.id, { sourceControlAi: next })
|
||||
const result = await updateRepo(repo.id, repoUpdate)
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -255,6 +164,10 @@ export function RepositorySourceControlAiSection({
|
||||
setSaveError('Failed to save Source Control AI settings.')
|
||||
return
|
||||
}
|
||||
const savedValue =
|
||||
repoUpdate.sourceControlAi === null
|
||||
? {}
|
||||
: (normalizeRepoSourceControlAiOverrides(repoUpdate.sourceControlAi) ?? {})
|
||||
setDraftState((current) => {
|
||||
if (current.repoId !== repo.id) {
|
||||
return current
|
||||
@@ -262,8 +175,8 @@ export function RepositorySourceControlAiSection({
|
||||
const currentSerialized = serializeRepoAiDraft(current.value)
|
||||
return {
|
||||
repoId: repo.id,
|
||||
value: currentSerialized === nextSerialized ? next : current.value,
|
||||
baseSerialized: nextSerialized
|
||||
value: currentSerialized === nextSerialized ? savedValue : current.value,
|
||||
baseSerialized: serializeRepoAiDraft(savedValue)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
@@ -282,16 +195,26 @@ export function RepositorySourceControlAiSection({
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const updateEnablement = (value: boolean | undefined): void => {
|
||||
updateDraftRepoAi((current) => ({ ...current, enabled: value }))
|
||||
}
|
||||
|
||||
const updateCustomCommand = (value: string | undefined): void => {
|
||||
updateDraftRepoAi((current) => ({ ...current, customAgentCommand: value }))
|
||||
}
|
||||
|
||||
const updateActionMode = (actionId: SourceControlActionId, mode: string): void => {
|
||||
updateDraftRepoAi((current) => {
|
||||
const nextActionOverrides = { ...current.actionOverrides }
|
||||
if (mode === ACTION_MODE_INHERIT) {
|
||||
delete nextActionOverrides[actionId]
|
||||
} else if (!hasOwnActionOverride(nextActionOverrides, actionId)) {
|
||||
nextActionOverrides[actionId] = {
|
||||
commandInputTemplate: readInheritedCommandTemplate(source, actionId),
|
||||
agentArgs: readInheritedAgentArgs(source, actionId)
|
||||
}
|
||||
return dropRepoLegacyInstructionForAction(
|
||||
{ ...current, actionOverrides: nextActionOverrides },
|
||||
actionId
|
||||
)
|
||||
}
|
||||
if (!hasOwnActionOverride(nextActionOverrides, actionId)) {
|
||||
nextActionOverrides[actionId] = readCompleteRecipeForDraft(current, settings, actionId)
|
||||
}
|
||||
return dropRepoLegacyInstructionForAction(
|
||||
{ ...current, actionOverrides: nextActionOverrides },
|
||||
@@ -302,66 +225,44 @@ export function RepositorySourceControlAiSection({
|
||||
|
||||
const updateActionAgent = (actionId: SourceControlActionId, value: string): void => {
|
||||
updateDraftRepoAi((current) => {
|
||||
const nextActionOverrides = { ...current.actionOverrides }
|
||||
const currentOverride = nextActionOverrides[actionId] ?? {
|
||||
commandInputTemplate: readInheritedCommandTemplate(source, actionId),
|
||||
agentArgs: readInheritedAgentArgs(source, actionId)
|
||||
const currentRecipe =
|
||||
current.actionOverrides?.[actionId] ??
|
||||
readCompleteRecipeForDraft(current, settings, actionId)
|
||||
const nextRecipe = {
|
||||
...currentRecipe,
|
||||
agentId:
|
||||
value === DEFAULT_AGENT_VALUE
|
||||
? null
|
||||
: value === CUSTOM_AGENT_ID
|
||||
? CUSTOM_AGENT_ID
|
||||
: (value as TuiAgent)
|
||||
}
|
||||
if (value === INHERIT_AGENT_VALUE) {
|
||||
const { agentId: _agentId, ...rest } = currentOverride
|
||||
nextActionOverrides[actionId] = rest
|
||||
} else {
|
||||
nextActionOverrides[actionId] = {
|
||||
...currentOverride,
|
||||
agentId:
|
||||
value === DEFAULT_AGENT_VALUE
|
||||
? null
|
||||
: value === CUSTOM_AGENT_ID
|
||||
? CUSTOM_AGENT_ID
|
||||
: (value as TuiAgent)
|
||||
}
|
||||
}
|
||||
return dropRepoLegacyInstructionForAction(
|
||||
{ ...current, actionOverrides: nextActionOverrides },
|
||||
actionId
|
||||
)
|
||||
return setActionOverride(current, actionId, nextRecipe)
|
||||
})
|
||||
}
|
||||
|
||||
const updateActionTemplate = (actionId: SourceControlActionId, value: string): void => {
|
||||
updateDraftRepoAi((current) =>
|
||||
dropRepoLegacyInstructionForAction(
|
||||
{
|
||||
...current,
|
||||
actionOverrides: {
|
||||
...current.actionOverrides,
|
||||
[actionId]: {
|
||||
...current.actionOverrides?.[actionId],
|
||||
commandInputTemplate: value
|
||||
}
|
||||
}
|
||||
},
|
||||
actionId
|
||||
)
|
||||
)
|
||||
updateDraftRepoAi((current) => {
|
||||
const currentRecipe =
|
||||
current.actionOverrides?.[actionId] ??
|
||||
readCompleteRecipeForDraft(current, settings, actionId)
|
||||
return setActionOverride(current, actionId, {
|
||||
...currentRecipe,
|
||||
commandInputTemplate: value
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const updateActionAgentArgs = (actionId: SourceControlActionId, value: string): void => {
|
||||
updateDraftRepoAi((current) =>
|
||||
dropRepoLegacyInstructionForAction(
|
||||
{
|
||||
...current,
|
||||
actionOverrides: {
|
||||
...current.actionOverrides,
|
||||
[actionId]: {
|
||||
...current.actionOverrides?.[actionId],
|
||||
agentArgs: value
|
||||
}
|
||||
}
|
||||
},
|
||||
actionId
|
||||
)
|
||||
)
|
||||
updateDraftRepoAi((current) => {
|
||||
const currentRecipe =
|
||||
current.actionOverrides?.[actionId] ??
|
||||
readCompleteRecipeForDraft(current, settings, actionId)
|
||||
return setActionOverride(current, actionId, {
|
||||
...currentRecipe,
|
||||
agentArgs: value
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const appendVariable = (actionId: SourceControlActionId, variable: string): void => {
|
||||
@@ -374,7 +275,7 @@ export function RepositorySourceControlAiSection({
|
||||
updateActionTemplate(actionId, `${currentTemplate}${separator}{${variable}}`)
|
||||
}
|
||||
|
||||
const updatePrDefault = (key: PrDefaultKey, value: string): void => {
|
||||
const updateHostedReviewDefault = (key: HostedReviewDefaultKey, value: string): void => {
|
||||
updateDraftRepoAi((current) => {
|
||||
const nextDefaults = { ...current.prCreationDefaults }
|
||||
if (value === 'inherit') {
|
||||
@@ -386,13 +287,6 @@ export function RepositorySourceControlAiSection({
|
||||
})
|
||||
}
|
||||
|
||||
const prDefaultRows: { key: PrDefaultKey; label: string }[] = [
|
||||
{ key: 'draft', label: 'Draft by default' },
|
||||
{ key: 'useTemplate', label: 'Use PR template when available' },
|
||||
{ key: 'generateDetailsOnOpen', label: 'Generate details when opening Create PR' },
|
||||
{ key: 'openAfterCreate', label: 'Open PR after creation' }
|
||||
]
|
||||
|
||||
return (
|
||||
<section
|
||||
id={getRepositorySourceControlAiSectionId(repo.id)}
|
||||
@@ -403,8 +297,8 @@ export function RepositorySourceControlAiSection({
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h3 className="text-sm font-semibold">Source Control AI</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Repo-specific action recipes. Each action inherits global settings until you customize
|
||||
it here.
|
||||
Repository action recipes. Global settings are used until this repository customizes
|
||||
them.
|
||||
</p>
|
||||
{saveError ? <p className="text-xs text-destructive">{saveError}</p> : null}
|
||||
</div>
|
||||
@@ -435,155 +329,31 @@ export function RepositorySourceControlAiSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">Action recipes</Label>
|
||||
{SOURCE_CONTROL_ACTION_IDS.map((actionId) => {
|
||||
const hasOverride = hasOwnActionOverride(repoAi.actionOverrides, actionId)
|
||||
const override = repoAi.actionOverrides?.[actionId]
|
||||
const inheritedTemplate = readInheritedCommandTemplate(source, actionId)
|
||||
const inheritedAgentArgs = readInheritedAgentArgs(source, actionId)
|
||||
const templateValue =
|
||||
hasOverride && typeof override?.commandInputTemplate === 'string'
|
||||
? override.commandInputTemplate
|
||||
: ''
|
||||
const agentArgsValue =
|
||||
hasOverride && typeof override?.agentArgs === 'string' ? override.agentArgs : ''
|
||||
const agentArgsPlaceholder =
|
||||
inheritedAgentArgs ||
|
||||
getSourceControlAgentArgsPlaceholder(
|
||||
resolveAgentArgsPlaceholderAgent(
|
||||
override?.agentId,
|
||||
source,
|
||||
actionId,
|
||||
settings?.defaultTuiAgent
|
||||
)
|
||||
)
|
||||
const agentOptions = getAgentCatalogForAction(actionId, override?.agentId)
|
||||
return (
|
||||
<div key={actionId} className="space-y-3 rounded-md border border-border px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{SOURCE_CONTROL_ACTION_LABELS[actionId]}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{ACTION_DESCRIPTIONS[actionId]}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={hasOverride ? ACTION_MODE_OVERRIDE : ACTION_MODE_INHERIT}
|
||||
onValueChange={(value) => updateActionMode(actionId, value)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-8 w-full shrink-0 text-xs sm:w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ACTION_MODE_INHERIT}>Use global</SelectItem>
|
||||
<SelectItem value={ACTION_MODE_OVERRIDE}>Customize</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-[180px_1fr]">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[11px] text-muted-foreground">Agent</Label>
|
||||
<Select
|
||||
value={actionAgentSelectValue(override?.agentId)}
|
||||
onValueChange={(value) => updateActionAgent(actionId, value)}
|
||||
disabled={!hasOverride}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={INHERIT_AGENT_VALUE}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="size-3.5 text-muted-foreground" />
|
||||
Use global agent
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value={DEFAULT_AGENT_VALUE}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="size-3.5 text-muted-foreground" />
|
||||
Use default agent
|
||||
</span>
|
||||
</SelectItem>
|
||||
{SOURCE_CONTROL_TEXT_ACTION_ID_SET.has(actionId) ? (
|
||||
<SelectItem value={CUSTOM_AGENT_ID}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="size-3.5 text-muted-foreground" />
|
||||
Custom command
|
||||
</span>
|
||||
</SelectItem>
|
||||
) : null}
|
||||
{agentOptions.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<AgentIcon agent={agent.id} size={14} />
|
||||
{agent.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="text-[11px] text-muted-foreground">CLI arguments</Label>
|
||||
<Input
|
||||
value={agentArgsValue}
|
||||
onChange={(event) => updateActionAgentArgs(actionId, event.target.value)}
|
||||
disabled={!hasOverride}
|
||||
placeholder={agentArgsPlaceholder}
|
||||
spellCheck={false}
|
||||
className="h-8 font-mono text-xs disabled:cursor-not-allowed disabled:bg-muted/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[11px] text-muted-foreground">Command template</Label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={templateValue}
|
||||
onChange={(event) => updateActionTemplate(actionId, event.target.value)}
|
||||
disabled={!hasOverride}
|
||||
placeholder={inheritedTemplate}
|
||||
spellCheck={false}
|
||||
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 disabled:cursor-not-allowed disabled:bg-muted/40"
|
||||
/>
|
||||
<SourceControlActionVariableChips
|
||||
actionId={actionId}
|
||||
disabled={!hasOverride}
|
||||
onInsert={(variable) => appendVariable(actionId, variable)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">PR creation defaults</Label>
|
||||
<div className="space-y-2">
|
||||
{prDefaultRows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border px-3 py-2"
|
||||
>
|
||||
<span className="text-xs text-foreground">{row.label}</span>
|
||||
<Select
|
||||
value={triStateValue(repoAi.prCreationDefaults?.[row.key])}
|
||||
onValueChange={(value) => updatePrDefault(row.key, value)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-8 w-[120px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">Use global</SelectItem>
|
||||
<SelectItem value="on">On</SelectItem>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<RepositorySourceControlAiEnablement
|
||||
value={repoAi.enabled}
|
||||
source={source}
|
||||
onChange={updateEnablement}
|
||||
/>
|
||||
<RepositorySourceControlAiCustomCommand
|
||||
value={repoAi.customAgentCommand}
|
||||
source={source}
|
||||
onChange={updateCustomCommand}
|
||||
/>
|
||||
<RepositorySourceControlAiActionRows
|
||||
repoAi={repoAi}
|
||||
source={source}
|
||||
defaultTuiAgent={settings?.defaultTuiAgent}
|
||||
onActionModeChange={updateActionMode}
|
||||
onActionAgentChange={updateActionAgent}
|
||||
onActionTemplateChange={updateActionTemplate}
|
||||
onActionAgentArgsChange={updateActionAgentArgs}
|
||||
onAppendVariable={appendVariable}
|
||||
/>
|
||||
<RepositorySourceControlAiHostedReviewDefaults
|
||||
value={repoAi.prCreationDefaults}
|
||||
source={source}
|
||||
onChange={updateHostedReviewDefault}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
|
||||
import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
|
||||
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
|
||||
import { SOURCE_CONTROL_TEXT_ACTION_ID_SET } from './source-control-action-recipe-options'
|
||||
|
||||
export type RepoAiDraftState = {
|
||||
repoId: string
|
||||
value: RepoSourceControlAiOverrides
|
||||
baseSerialized: string
|
||||
}
|
||||
|
||||
export function hasOwnActionOverride(
|
||||
overrides: RepoSourceControlAiOverrides['actionOverrides'],
|
||||
actionId: SourceControlActionId
|
||||
): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId)
|
||||
}
|
||||
|
||||
export function triStateValue(value: boolean | null | undefined): 'inherit' | 'on' | 'off' {
|
||||
if (value === true) {
|
||||
return 'on'
|
||||
}
|
||||
if (value === false) {
|
||||
return 'off'
|
||||
}
|
||||
return 'inherit'
|
||||
}
|
||||
|
||||
export function normalizeRepoAiDraft(
|
||||
value: RepoSourceControlAiOverrides | null | undefined
|
||||
): RepoSourceControlAiOverrides {
|
||||
return normalizeRepoSourceControlAiOverrides(value) ?? {}
|
||||
}
|
||||
|
||||
export 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)
|
||||
// Why: render-time draft sync relies on object identity to avoid repeating
|
||||
// the same state update during server-rendered settings tests.
|
||||
if (
|
||||
current.repoId === repoId &&
|
||||
currentSerialized === persistedSerialized &&
|
||||
current.baseSerialized === persistedSerialized
|
||||
) {
|
||||
return current
|
||||
}
|
||||
if (
|
||||
current.repoId !== repoId ||
|
||||
currentSerialized === current.baseSerialized ||
|
||||
currentSerialized === persistedSerialized
|
||||
) {
|
||||
return {
|
||||
repoId,
|
||||
value: persistedRepoAi,
|
||||
baseSerialized: persistedSerialized
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
export function dropRepoLegacyInstructionForAction(
|
||||
value: RepoSourceControlAiOverrides,
|
||||
actionId: SourceControlActionId
|
||||
): RepoSourceControlAiOverrides {
|
||||
if (!SOURCE_CONTROL_TEXT_ACTION_ID_SET.has(actionId) || !value.instructionsByOperation) {
|
||||
return value
|
||||
}
|
||||
const instructionsByOperation = { ...value.instructionsByOperation }
|
||||
delete instructionsByOperation[actionId as keyof typeof instructionsByOperation]
|
||||
return {
|
||||
...value,
|
||||
instructionsByOperation:
|
||||
Object.keys(instructionsByOperation).length > 0 ? instructionsByOperation : undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import {
|
||||
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
|
||||
resolveSourceControlActionCommandTemplate,
|
||||
type SourceControlActionId,
|
||||
type SourceControlActionRecipe
|
||||
} from '../../../../shared/source-control-ai-actions'
|
||||
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
|
||||
import type { CustomAgentId } from '../../../../shared/commit-message-agent-spec'
|
||||
import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec'
|
||||
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
|
||||
|
||||
export const ACTION_MODE_INHERIT = 'inherit'
|
||||
export const ACTION_MODE_OVERRIDE = 'override'
|
||||
export const DEFAULT_AGENT_VALUE = '__default_agent__'
|
||||
export const CUSTOM_COMMAND_MODE_INHERIT = 'inherit'
|
||||
export const CUSTOM_COMMAND_MODE_REPO = 'repo'
|
||||
|
||||
export function readInheritedCommandTemplate(
|
||||
source: SourceControlAiSettings,
|
||||
actionId: SourceControlActionId
|
||||
): string {
|
||||
return resolveSourceControlActionCommandTemplate(source.actions, actionId)
|
||||
}
|
||||
|
||||
export function readInheritedAgentArgs(
|
||||
source: SourceControlAiSettings,
|
||||
actionId: SourceControlActionId
|
||||
): string {
|
||||
return source.actions?.[actionId]?.agentArgs?.trim() ?? ''
|
||||
}
|
||||
|
||||
export function actionAgentSelectValue(
|
||||
agentId: TuiAgent | CustomAgentId | null | undefined
|
||||
): string {
|
||||
return agentId ?? DEFAULT_AGENT_VALUE
|
||||
}
|
||||
|
||||
export function resolveAgentArgsPlaceholderAgent(
|
||||
agentId: TuiAgent | CustomAgentId | null | undefined,
|
||||
source: SourceControlAiSettings,
|
||||
actionId: SourceControlActionId,
|
||||
defaultTuiAgent: TuiAgent | 'blank' | null | undefined
|
||||
): TuiAgent | null {
|
||||
const effectiveAgent = agentId === undefined ? source.actions?.[actionId]?.agentId : agentId
|
||||
if (effectiveAgent && !isCustomAgentId(effectiveAgent)) {
|
||||
return effectiveAgent
|
||||
}
|
||||
return defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null
|
||||
}
|
||||
|
||||
export function completeRepoActionRecipe(
|
||||
recipe: SourceControlActionRecipe,
|
||||
actionId: SourceControlActionId
|
||||
): NonNullable<
|
||||
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
|
||||
> {
|
||||
const commandInputTemplate =
|
||||
typeof recipe.commandInputTemplate === 'string'
|
||||
? recipe.commandInputTemplate
|
||||
: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId]
|
||||
const agentArgs = typeof recipe.agentArgs === 'string' ? recipe.agentArgs : ''
|
||||
return {
|
||||
agentId: recipe.agentId ?? null,
|
||||
commandInputTemplate,
|
||||
...(agentArgs ? { agentArgs } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function actionScopeLabel(hasOverride: boolean): string {
|
||||
return hasOverride ? 'Customized for this repository' : 'Using global settings'
|
||||
}
|
||||
|
||||
export function commandTemplateStateLabel(args: {
|
||||
hasOverride: boolean
|
||||
inheritedTemplate: string
|
||||
actionId: SourceControlActionId
|
||||
}): string {
|
||||
if (args.hasOverride) {
|
||||
return 'Repository custom prompt'
|
||||
}
|
||||
return args.inheritedTemplate === DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[args.actionId]
|
||||
? 'Orca default prompt'
|
||||
: 'Global custom prompt'
|
||||
}
|
||||
|
||||
export function agentArgsStateLabel(args: {
|
||||
hasOverride: boolean
|
||||
inheritedAgentArgs: string
|
||||
repoAgentArgs: string
|
||||
}): string {
|
||||
if (args.hasOverride) {
|
||||
return args.repoAgentArgs.trim() ? 'Repository custom args' : 'No args'
|
||||
}
|
||||
return args.inheritedAgentArgs.trim() ? 'Global custom args' : 'No args'
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
pickSourceControlLaunchAgent,
|
||||
readSourceControlLaunchRecipeAgentId
|
||||
} from '@/lib/source-control-launch-agent-selection'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { useAppStore } from '@/store'
|
||||
import { resolveSourceControlActionRecipe } from '../../../shared/source-control-ai'
|
||||
@@ -119,6 +120,7 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis
|
||||
: findGithubPrWorkspaceAttachment(store.allWorktrees(), args.repoId, args.item.number)
|
||||
const targetWorktreeId = args.worktreeId ?? attachedWorkspace?.id ?? null
|
||||
if (targetWorktreeId) {
|
||||
const targetWorktree = store.allWorktrees().find((worktree) => worktree.id === targetWorktreeId)
|
||||
const agent = await pickExistingWorktreeAgent(targetWorktreeId, savedAgentId)
|
||||
if (!agent) {
|
||||
return false
|
||||
@@ -134,13 +136,19 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis
|
||||
prompt: commandInput,
|
||||
agentArgs: recipe.agentArgs,
|
||||
promptDelivery: 'submit-after-ready',
|
||||
launchPlatform: resolveSourceControlLaunchPlatform({
|
||||
connectionId: repo?.connectionId ?? null,
|
||||
worktreePath: targetWorktree?.path
|
||||
}),
|
||||
launchSource: args.launchSource
|
||||
})
|
||||
if (!result) {
|
||||
toast.error('Could not build the agent launch command.')
|
||||
return false
|
||||
}
|
||||
focusTerminalTabSurface(result.tabId)
|
||||
if (result.tabId) {
|
||||
focusTerminalTabSurface(result.tabId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' }))
|
||||
|
||||
describe('resolveSourceControlLaunchPlatform', () => {
|
||||
it('uses linux shell planning for ssh POSIX paths and local WSL paths', async () => {
|
||||
const { resolveSourceControlLaunchPlatform } = await import('./source-control-launch-platform')
|
||||
|
||||
expect(
|
||||
resolveSourceControlLaunchPlatform({
|
||||
connectionId: 'ssh-1',
|
||||
worktreePath: '/home/alice/repo'
|
||||
})
|
||||
).toBe('linux')
|
||||
expect(
|
||||
resolveSourceControlLaunchPlatform({
|
||||
connectionId: null,
|
||||
worktreePath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`
|
||||
})
|
||||
).toBe('linux')
|
||||
})
|
||||
|
||||
it('uses windows shell planning for ssh Windows paths', async () => {
|
||||
const { resolveSourceControlLaunchPlatform } = await import('./source-control-launch-platform')
|
||||
|
||||
expect(
|
||||
resolveSourceControlLaunchPlatform({
|
||||
connectionId: 'ssh-1',
|
||||
worktreePath: String.raw`C:\Users\alice\repo`
|
||||
})
|
||||
).toBe('win32')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
|
||||
import { isWslUncPath } from '../../../shared/wsl-paths'
|
||||
import { CLIENT_PLATFORM } from './new-workspace'
|
||||
|
||||
export function resolveSourceControlLaunchPlatform(args: {
|
||||
connectionId?: string | null
|
||||
worktreePath?: string | null
|
||||
}): NodeJS.Platform {
|
||||
const path = args.worktreePath?.trim() ?? ''
|
||||
if (typeof args.connectionId === 'string') {
|
||||
return path && isWindowsAbsolutePathLike(path) && !isWslUncPath(path) ? 'win32' : 'linux'
|
||||
}
|
||||
if (path && isWslUncPath(path)) {
|
||||
return 'linux'
|
||||
}
|
||||
return CLIENT_PLATFORM
|
||||
}
|
||||
@@ -42,9 +42,8 @@ type RepoUpdate = Partial<
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
| 'sourceControlAi'
|
||||
>
|
||||
>
|
||||
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
|
||||
|
||||
type NestedRepoScanControls = {
|
||||
scanId?: string
|
||||
@@ -663,16 +662,38 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
try {
|
||||
const sanitizedUpdates = sanitizeRepoUpdate(updates)
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
await (target.kind === 'local'
|
||||
? window.api.repos.update({ repoId: projectId, updates: sanitizedUpdates })
|
||||
: callRuntimeRpc(
|
||||
target,
|
||||
'repo.update',
|
||||
{ repo: projectId, updates: sanitizedUpdates },
|
||||
{ timeoutMs: 15_000 }
|
||||
))
|
||||
const updatedRepo =
|
||||
target.kind === 'local'
|
||||
? await window.api.repos.update({ repoId: projectId, updates: sanitizedUpdates })
|
||||
: (
|
||||
await callRuntimeRpc<{ repo: Repo }>(
|
||||
target,
|
||||
'repo.update',
|
||||
{ repo: projectId, updates: sanitizedUpdates },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).repo
|
||||
set((s) => ({
|
||||
repos: s.repos.map((r) => (r.id === projectId ? { ...r, ...sanitizedUpdates } : r))
|
||||
repos: s.repos.map((r) => {
|
||||
if (r.id !== projectId) {
|
||||
return r
|
||||
}
|
||||
if (updatedRepo) {
|
||||
return updatedRepo
|
||||
}
|
||||
if (sanitizedUpdates.sourceControlAi === null) {
|
||||
const { sourceControlAi: _sourceControlAi, ...repoWithoutSourceControlAi } = r
|
||||
const { sourceControlAi: _clearedSourceControlAi, ...updatesWithoutSourceControlAi } =
|
||||
sanitizedUpdates
|
||||
return { ...repoWithoutSourceControlAi, ...updatesWithoutSourceControlAi }
|
||||
}
|
||||
const { sourceControlAi, ...updatesWithoutSourceControlAi } = sanitizedUpdates
|
||||
return {
|
||||
...r,
|
||||
...updatesWithoutSourceControlAi,
|
||||
...(sourceControlAi !== undefined ? { sourceControlAi } : {})
|
||||
}
|
||||
})
|
||||
}))
|
||||
return true
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultSettings } from './constants'
|
||||
import {
|
||||
normalizeWritableRepoSourceControlAiOverrides,
|
||||
saveSourceControlActionRecipe,
|
||||
toSourceControlAiRepoUpdate
|
||||
} from './source-control-ai-recipe-save'
|
||||
import type { GlobalSettings, Repo } from './types'
|
||||
|
||||
function settings(): GlobalSettings {
|
||||
const base = getDefaultSettings('/tmp')
|
||||
return {
|
||||
...base,
|
||||
defaultTuiAgent: 'codex',
|
||||
sourceControlAi: {
|
||||
...base.sourceControlAi!,
|
||||
enabled: true,
|
||||
agentId: 'codex',
|
||||
selectedModelByAgent: { codex: 'gpt-5.5' },
|
||||
selectedThinkingByModel: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('source-control AI recipe saves', () => {
|
||||
it('writes complete repo recipes and removes same-action legacy instructions', () => {
|
||||
const result = saveSourceControlActionRecipe({
|
||||
target: { type: 'repo', repoId: 'repo-1' },
|
||||
settings: settings(),
|
||||
repo: {
|
||||
sourceControlAi: {
|
||||
enabled: false,
|
||||
instructionsByOperation: {
|
||||
commitMessage: 'legacy commit',
|
||||
pullRequest: 'legacy review'
|
||||
},
|
||||
prCreationDefaults: {
|
||||
draft: true,
|
||||
useTemplate: null
|
||||
}
|
||||
}
|
||||
} satisfies Pick<Repo, 'sourceControlAi'>,
|
||||
actionId: 'commitMessage',
|
||||
recipe: {
|
||||
agentId: 'claude',
|
||||
commandInputTemplate: '{basePrompt}\n\nrepo',
|
||||
agentArgs: ' --model sonnet '
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
target: { type: 'repo', repoId: 'repo-1' },
|
||||
update: {
|
||||
sourceControlAi: {
|
||||
enabled: false,
|
||||
instructionsByOperation: {
|
||||
pullRequest: 'legacy review'
|
||||
},
|
||||
actionOverrides: {
|
||||
commitMessage: {
|
||||
agentId: 'claude',
|
||||
commandInputTemplate: '{basePrompt}\n\nrepo',
|
||||
agentArgs: '--model sonnet'
|
||||
},
|
||||
pullRequest: {
|
||||
agentId: null,
|
||||
commandInputTemplate: '{basePrompt}\n\nlegacy review'
|
||||
}
|
||||
},
|
||||
prCreationDefaults: {
|
||||
draft: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('clears empty repo source-control AI writes with the JSON boundary sentinel', () => {
|
||||
expect(toSourceControlAiRepoUpdate({ prCreationDefaults: { draft: null } })).toEqual({
|
||||
sourceControlAi: null
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves boolean-only hosted-review defaults and strips legacy null defaults on write', () => {
|
||||
expect(
|
||||
normalizeWritableRepoSourceControlAiOverrides({
|
||||
prCreationDefaults: {
|
||||
draft: true,
|
||||
useTemplate: null,
|
||||
generateDetailsOnOpen: false
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
prCreationDefaults: {
|
||||
draft: true,
|
||||
generateDetailsOnOpen: false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('stores repo custom commands when saving a custom-command recipe', () => {
|
||||
const result = saveSourceControlActionRecipe({
|
||||
target: { type: 'repo', repoId: 'repo-1' },
|
||||
settings: settings(),
|
||||
repo: null,
|
||||
actionId: 'commitMessage',
|
||||
recipe: {
|
||||
agentId: 'custom',
|
||||
commandInputTemplate: '{basePrompt}'
|
||||
},
|
||||
customAgentCommand: 'repo-agent {prompt}'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
update: {
|
||||
sourceControlAi: {
|
||||
customAgentCommand: 'repo-agent {prompt}',
|
||||
actionOverrides: {
|
||||
commitMessage: {
|
||||
agentId: 'custom',
|
||||
commandInputTemplate: '{basePrompt}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('writes global recipes through a complete normalized source-control AI value', () => {
|
||||
const result = saveSourceControlActionRecipe({
|
||||
target: { type: 'global' },
|
||||
settings: settings(),
|
||||
actionId: 'pullRequest',
|
||||
recipe: {
|
||||
agentId: 'custom',
|
||||
commandInputTemplate: '{basePrompt}\n\nreview',
|
||||
agentArgs: '--verbose'
|
||||
},
|
||||
customAgentCommand: 'review-agent {prompt}'
|
||||
})
|
||||
|
||||
expect(result.target).toEqual({ type: 'global' })
|
||||
expect('sourceControlAi' in result).toBe(true)
|
||||
if (!('sourceControlAi' in result)) {
|
||||
throw new Error('Expected a global save result')
|
||||
}
|
||||
expect(result.sourceControlAi.actions?.pullRequest).toEqual({
|
||||
agentId: 'custom',
|
||||
commandInputTemplate: '{basePrompt}\n\nreview',
|
||||
agentArgs: '--verbose'
|
||||
})
|
||||
expect(result.sourceControlAi.customAgentCommand).toBe('review-agent {prompt}')
|
||||
expect(result.sourceControlAi.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
normalizeRepoSourceControlAiOverrides,
|
||||
normalizeSourceControlAiSettings
|
||||
} from './source-control-ai'
|
||||
import {
|
||||
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
|
||||
SOURCE_CONTROL_ACTION_IDS,
|
||||
SOURCE_CONTROL_TEXT_ACTION_IDS,
|
||||
setSourceControlActionDefault,
|
||||
type SourceControlActionId,
|
||||
type SourceControlActionRecipe
|
||||
} from './source-control-ai-actions'
|
||||
import type {
|
||||
CompleteSourceControlActionRecipe,
|
||||
RepoSourceControlAiOverrides,
|
||||
SourceControlAiModelChoice,
|
||||
SourceControlAiOperation,
|
||||
SourceControlAiSettings,
|
||||
WritableRepoSourceControlAiOverrides
|
||||
} from './source-control-ai-types'
|
||||
import type { GlobalSettings, Repo } from './types'
|
||||
|
||||
export type SourceControlAiWriteTarget = { type: 'repo'; repoId: string } | { type: 'global' }
|
||||
|
||||
export type SourceControlAiRepoUpdate =
|
||||
| { sourceControlAi: WritableRepoSourceControlAiOverrides }
|
||||
| { sourceControlAi: null }
|
||||
|
||||
export type SourceControlActionRecipeSaveResult =
|
||||
| { target: { type: 'global' }; sourceControlAi: SourceControlAiSettings }
|
||||
| { target: { type: 'repo'; repoId: string }; update: SourceControlAiRepoUpdate }
|
||||
|
||||
type SaveSourceControlActionRecipeInput = {
|
||||
target: SourceControlAiWriteTarget
|
||||
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'>
|
||||
repo?: Pick<Repo, 'sourceControlAi'> | null
|
||||
actionId: SourceControlActionId
|
||||
recipe: SourceControlActionRecipe
|
||||
customAgentCommand?: string
|
||||
}
|
||||
|
||||
type ReadCompatibleActionRecipe = {
|
||||
agentId?: SourceControlActionRecipe['agentId']
|
||||
commandInputTemplate?: string | null
|
||||
agentArgs?: string | null
|
||||
}
|
||||
|
||||
const TEXT_ACTION_ID_SET = new Set<SourceControlActionId>(SOURCE_CONTROL_TEXT_ACTION_IDS)
|
||||
|
||||
function hasEntries(value: Record<string, unknown> | null | undefined): boolean {
|
||||
return Object.keys(value ?? {}).length > 0
|
||||
}
|
||||
|
||||
function normalizeStringRecord<T extends string>(
|
||||
value: Partial<Record<T, string | null | undefined>> | undefined
|
||||
): Partial<Record<T, string>> | undefined {
|
||||
const normalized: Partial<Record<T, string>> = {}
|
||||
for (const [key, item] of Object.entries(value ?? {}) as [T, string | null | undefined][]) {
|
||||
if (typeof item === 'string') {
|
||||
normalized[key] = item
|
||||
}
|
||||
}
|
||||
return hasEntries(normalized) ? normalized : undefined
|
||||
}
|
||||
|
||||
function normalizeBooleanRecord<T extends string>(
|
||||
value: Partial<Record<T, boolean | null | undefined>> | undefined
|
||||
): Partial<Record<T, boolean>> | undefined {
|
||||
const normalized: Partial<Record<T, boolean>> = {}
|
||||
for (const [key, item] of Object.entries(value ?? {}) as [T, boolean | null | undefined][]) {
|
||||
if (typeof item === 'boolean') {
|
||||
normalized[key] = item
|
||||
}
|
||||
}
|
||||
return hasEntries(normalized) ? normalized : undefined
|
||||
}
|
||||
|
||||
function normalizeCompleteRecipe(
|
||||
actionId: SourceControlActionId,
|
||||
recipe: ReadCompatibleActionRecipe | undefined
|
||||
): CompleteSourceControlActionRecipe | undefined {
|
||||
if (!recipe) {
|
||||
return undefined
|
||||
}
|
||||
const commandInputTemplate =
|
||||
typeof recipe.commandInputTemplate === 'string'
|
||||
? recipe.commandInputTemplate
|
||||
: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId]
|
||||
const agentArgs = typeof recipe.agentArgs === 'string' ? recipe.agentArgs.trim() : ''
|
||||
return {
|
||||
agentId: recipe.agentId ?? null,
|
||||
commandInputTemplate,
|
||||
...(agentArgs ? { agentArgs } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeActionOverrides(
|
||||
overrides: RepoSourceControlAiOverrides['actionOverrides']
|
||||
): WritableRepoSourceControlAiOverrides['actionOverrides'] {
|
||||
const normalized: WritableRepoSourceControlAiOverrides['actionOverrides'] = {}
|
||||
for (const actionId of SOURCE_CONTROL_ACTION_IDS) {
|
||||
const recipe = normalizeCompleteRecipe(actionId, overrides?.[actionId])
|
||||
if (recipe) {
|
||||
normalized[actionId] = recipe
|
||||
}
|
||||
}
|
||||
return hasEntries(normalized) ? normalized : undefined
|
||||
}
|
||||
|
||||
export function normalizeWritableRepoSourceControlAiOverrides(
|
||||
value: RepoSourceControlAiOverrides | null | undefined
|
||||
): WritableRepoSourceControlAiOverrides | undefined {
|
||||
const readCompatible = normalizeRepoSourceControlAiOverrides(value)
|
||||
if (!readCompatible) {
|
||||
return undefined
|
||||
}
|
||||
const writable: WritableRepoSourceControlAiOverrides = {}
|
||||
if (typeof readCompatible.enabled === 'boolean') {
|
||||
writable.enabled = readCompatible.enabled
|
||||
}
|
||||
if (typeof readCompatible.customAgentCommand === 'string') {
|
||||
const customAgentCommand = readCompatible.customAgentCommand.trim()
|
||||
if (customAgentCommand) {
|
||||
writable.customAgentCommand = customAgentCommand
|
||||
}
|
||||
}
|
||||
if (readCompatible.modelOverridesByOperation) {
|
||||
writable.modelOverridesByOperation = readCompatible.modelOverridesByOperation as Partial<
|
||||
Record<SourceControlAiOperation, SourceControlAiModelChoice>
|
||||
>
|
||||
}
|
||||
const instructionsByOperation = normalizeStringRecord(readCompatible.instructionsByOperation)
|
||||
if (instructionsByOperation) {
|
||||
writable.instructionsByOperation = instructionsByOperation
|
||||
}
|
||||
const actionOverrides = normalizeActionOverrides(readCompatible.actionOverrides)
|
||||
if (actionOverrides) {
|
||||
writable.actionOverrides = actionOverrides
|
||||
}
|
||||
const prCreationDefaults = normalizeBooleanRecord(readCompatible.prCreationDefaults)
|
||||
if (prCreationDefaults) {
|
||||
writable.prCreationDefaults = prCreationDefaults
|
||||
}
|
||||
return Object.keys(writable).length > 0 ? writable : undefined
|
||||
}
|
||||
|
||||
export function toSourceControlAiRepoUpdate(
|
||||
value: RepoSourceControlAiOverrides | null | undefined
|
||||
): SourceControlAiRepoUpdate {
|
||||
const sourceControlAi = normalizeWritableRepoSourceControlAiOverrides(value)
|
||||
return sourceControlAi ? { sourceControlAi } : { sourceControlAi: null }
|
||||
}
|
||||
|
||||
function dropLegacyInstructionForAction(
|
||||
value: WritableRepoSourceControlAiOverrides,
|
||||
actionId: SourceControlActionId
|
||||
): WritableRepoSourceControlAiOverrides {
|
||||
if (!TEXT_ACTION_ID_SET.has(actionId) || !value.instructionsByOperation) {
|
||||
return value
|
||||
}
|
||||
const instructionsByOperation = { ...value.instructionsByOperation }
|
||||
delete instructionsByOperation[actionId as SourceControlAiOperation]
|
||||
return {
|
||||
...value,
|
||||
instructionsByOperation: hasEntries(instructionsByOperation)
|
||||
? instructionsByOperation
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRecipeForSave(
|
||||
actionId: SourceControlActionId,
|
||||
recipe: SourceControlActionRecipe
|
||||
): CompleteSourceControlActionRecipe {
|
||||
return (
|
||||
normalizeCompleteRecipe(actionId, recipe) ?? {
|
||||
agentId: null,
|
||||
commandInputTemplate: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function saveSourceControlActionRecipe(
|
||||
input: SaveSourceControlActionRecipeInput
|
||||
): SourceControlActionRecipeSaveResult {
|
||||
const savedRecipe = normalizeRecipeForSave(input.actionId, input.recipe)
|
||||
if (input.target.type === 'global') {
|
||||
const current = normalizeSourceControlAiSettings(
|
||||
input.settings.sourceControlAi,
|
||||
input.settings.commitMessageAi
|
||||
)
|
||||
return {
|
||||
target: { type: 'global' },
|
||||
sourceControlAi: {
|
||||
...current,
|
||||
...(typeof input.customAgentCommand === 'string'
|
||||
? { customAgentCommand: input.customAgentCommand }
|
||||
: {}),
|
||||
actions: setSourceControlActionDefault(current.actions, input.actionId, savedRecipe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentRepoAi = normalizeWritableRepoSourceControlAiOverrides(input.repo?.sourceControlAi)
|
||||
const next = dropLegacyInstructionForAction(
|
||||
{
|
||||
...currentRepoAi,
|
||||
...(typeof input.customAgentCommand === 'string'
|
||||
? { customAgentCommand: input.customAgentCommand }
|
||||
: {}),
|
||||
actionOverrides: {
|
||||
...currentRepoAi?.actionOverrides,
|
||||
[input.actionId]: savedRecipe
|
||||
}
|
||||
},
|
||||
input.actionId
|
||||
)
|
||||
return {
|
||||
target: input.target,
|
||||
update: toSourceControlAiRepoUpdate(next)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ export type SourceControlAiSettingsPatch =
|
||||
| ((current: SourceControlAiSettings) => Partial<SourceControlAiSettings>)
|
||||
|
||||
export type RepoSourceControlAiOverrides = {
|
||||
enabled?: boolean
|
||||
customAgentCommand?: string
|
||||
modelOverridesByOperation?: Partial<Record<SourceControlAiOperation, SourceControlAiModelChoice>>
|
||||
instructionsByOperation?: Partial<Record<SourceControlAiOperation, string | null>>
|
||||
actionOverrides?: Partial<
|
||||
@@ -64,3 +66,18 @@ export type RepoSourceControlAiOverrides = {
|
||||
openAfterCreate?: boolean | null
|
||||
}
|
||||
}
|
||||
|
||||
export type CompleteSourceControlActionRecipe = {
|
||||
agentId: TuiAgent | CustomAgentId | null
|
||||
commandInputTemplate: string
|
||||
agentArgs?: string
|
||||
}
|
||||
|
||||
export type WritableRepoSourceControlAiOverrides = {
|
||||
enabled?: boolean
|
||||
customAgentCommand?: string
|
||||
modelOverridesByOperation?: Partial<Record<SourceControlAiOperation, SourceControlAiModelChoice>>
|
||||
instructionsByOperation?: Partial<Record<SourceControlAiOperation, string>>
|
||||
actionOverrides?: Partial<Record<SourceControlActionId, CompleteSourceControlActionRecipe>>
|
||||
prCreationDefaults?: SourceControlAiPrCreationDefaults
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
mergeLegacyCommitMessageAiIntoSourceControlAi,
|
||||
normalizeRepoSourceControlAiOverrides,
|
||||
projectSourceControlAiToLegacyCommitMessageAi,
|
||||
resolveSourceControlAiEnabled,
|
||||
readSourceControlAiModelChoiceForHost,
|
||||
resolveSourceControlAiForOperation,
|
||||
resolveSourceControlAiPrCreationDefaults,
|
||||
@@ -111,6 +112,30 @@ describe('source-control AI resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lets repo enablement override the global default', () => {
|
||||
const base = settings()
|
||||
base.sourceControlAi = {
|
||||
...base.sourceControlAi!,
|
||||
enabled: false
|
||||
}
|
||||
|
||||
expect(resolveSourceControlAiEnabled({ settings: base, repo: null })).toBe(false)
|
||||
expect(
|
||||
resolveSourceControlAiEnabled({
|
||||
settings: base,
|
||||
repo: { sourceControlAi: { enabled: true } }
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
base.sourceControlAi.enabled = true
|
||||
expect(
|
||||
resolveSourceControlAiEnabled({
|
||||
settings: base,
|
||||
repo: { sourceControlAi: { enabled: false } }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves PR defaults even when generation config is invalid', () => {
|
||||
const base = settings()
|
||||
base.sourceControlAi = {
|
||||
@@ -203,6 +228,34 @@ describe('source-control AI resolution', () => {
|
||||
expect(result.ok && result.value.params.model).toBe('gpt-5.4-mini')
|
||||
})
|
||||
|
||||
it('uses the repo custom command before the global custom command', () => {
|
||||
const base = settings()
|
||||
base.sourceControlAi = {
|
||||
...base.sourceControlAi!,
|
||||
actions: {
|
||||
...base.sourceControlAi!.actions,
|
||||
commitMessage: {
|
||||
agentId: 'custom',
|
||||
commandInputTemplate: '{basePrompt}'
|
||||
}
|
||||
},
|
||||
customAgentCommand: 'global-agent {prompt}'
|
||||
}
|
||||
|
||||
const result = resolveSourceControlAiForOperation({
|
||||
settings: base,
|
||||
repo: {
|
||||
sourceControlAi: {
|
||||
customAgentCommand: 'repo-agent {prompt}'
|
||||
}
|
||||
},
|
||||
operation: 'commitMessage',
|
||||
discoveryHostKey: 'local'
|
||||
})
|
||||
|
||||
expect(result.ok && result.value.params.customAgentCommand).toBe('repo-agent {prompt}')
|
||||
})
|
||||
|
||||
it('resolves thinking effort with override precedence and model default fallback', () => {
|
||||
expect(resolve('commitMessage').params.thinkingLevel).toBe('medium')
|
||||
expect(
|
||||
|
||||
@@ -245,6 +245,15 @@ export function normalizeRepoSourceControlAiOverrides(
|
||||
return undefined
|
||||
}
|
||||
const normalized: RepoSourceControlAiOverrides = {}
|
||||
if (typeof value.enabled === 'boolean') {
|
||||
normalized.enabled = value.enabled
|
||||
}
|
||||
if (typeof value.customAgentCommand === 'string') {
|
||||
const customAgentCommand = value.customAgentCommand.trim()
|
||||
if (customAgentCommand) {
|
||||
normalized.customAgentCommand = customAgentCommand
|
||||
}
|
||||
}
|
||||
const modelOverridesByOperation = normalizeOperationRecord(
|
||||
value.modelOverridesByOperation,
|
||||
normalizeSourceControlAiModelChoice
|
||||
@@ -297,7 +306,7 @@ export function normalizeRepoSourceControlAiOverrides(
|
||||
if (prCreationDefaults) {
|
||||
normalized.prCreationDefaults = prCreationDefaults
|
||||
}
|
||||
return normalized
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined
|
||||
}
|
||||
|
||||
function commandTemplateFromInstruction(instruction: string | null | undefined): string {
|
||||
@@ -1080,6 +1089,18 @@ export function resolveSourceControlAiPrCreationDefaults(
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveSourceControlAiEnabled(input: {
|
||||
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
|
||||
repo?: Pick<Repo, 'sourceControlAi'> | null
|
||||
}): boolean {
|
||||
const source = normalizeSourceControlAiSettings(
|
||||
input.settings?.sourceControlAi,
|
||||
input.settings?.commitMessageAi
|
||||
)
|
||||
const repoOverrides = normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)
|
||||
return repoOverrides?.enabled ?? source.enabled
|
||||
}
|
||||
|
||||
export function resolveSourceControlActionRecipe(input: {
|
||||
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
|
||||
repo?: Pick<Repo, 'sourceControlAi'> | null
|
||||
@@ -1116,14 +1137,14 @@ export function resolveSourceControlAiForOperation(
|
||||
): ResolveSourceControlAiResult {
|
||||
const legacy = input.settings.commitMessageAi
|
||||
const source = normalizeSourceControlAiSettings(input.settings.sourceControlAi, legacy)
|
||||
if (!source.enabled) {
|
||||
const repoOverrides = normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)
|
||||
if (!(repoOverrides?.enabled ?? source.enabled)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Enable Source Control AI in Settings -> Git.'
|
||||
}
|
||||
}
|
||||
|
||||
const repoOverrides = normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)
|
||||
const prCreationDefaults = resolvePrCreationDefaults(
|
||||
source,
|
||||
repoOverrides,
|
||||
@@ -1152,8 +1173,9 @@ export function resolveSourceControlAiForOperation(
|
||||
}
|
||||
}
|
||||
|
||||
const customAgentCommand =
|
||||
repoOverrides?.customAgentCommand?.trim() || source.customAgentCommand.trim()
|
||||
if (isCustomAgentId(agentChoice)) {
|
||||
const customAgentCommand = source.customAgentCommand.trim()
|
||||
if (!customAgentCommand) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1249,6 +1271,7 @@ export function resolveSourceControlAiForOperation(
|
||||
),
|
||||
commandInputTemplate: actionRecipe.commandInputTemplate,
|
||||
...(actionRecipe.agentArgs !== undefined ? { agentArgs: actionRecipe.agentArgs } : {}),
|
||||
...(customAgentCommand ? { customAgentCommand } : {}),
|
||||
...(agentCommandOverride ? { agentCommandOverride } : {})
|
||||
},
|
||||
prCreationDefaults
|
||||
|
||||
Reference in New Issue
Block a user