From bf3e87e98eb162bc86e6038ff8355e2547069c50 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:26:08 -0700 Subject: [PATCH] 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. --- src/main/ipc/repos.ts | 7 +- src/main/persistence.test.ts | 25 +- src/main/persistence.ts | 17 +- src/main/runtime/orca-runtime.ts | 6 +- src/main/runtime/rpc/methods/repo.ts | 6 +- src/preload/api-types.ts | 3 +- .../components/right-sidebar/ChecksPanel.tsx | 51 +- .../right-sidebar/SourceControl.tsx | 178 +++--- .../SourceControlAgentActionDialog.tsx | 51 +- .../SourceControlAgentActionDialogForm.tsx | 42 +- .../SourceControlTextGenerationDialog.tsx | 49 +- .../SourceControlTextGenerationDialogForm.tsx | 83 ++- .../SourceControlTextGenerationParams.ts | 2 + .../useCreatePullRequestDialogFields.ts | 10 +- .../settings/CommitMessageAiPane.tsx | 44 +- .../components/settings/RepositoryPane.tsx | 6 +- .../RepositorySourceControlAiActionRows.tsx | 187 +++++++ ...RepositorySourceControlAiCustomCommand.tsx | 61 ++ .../RepositorySourceControlAiEnablement.tsx | 52 ++ ...orySourceControlAiHostedReviewDefaults.tsx | 66 +++ .../RepositorySourceControlAiSection.tsx | 522 +++++------------- .../repository-source-control-ai-draft.ts | 95 ++++ .../repository-source-control-ai-labels.ts | 96 ++++ .../src/lib/fix-checks-agent-launch.ts | 10 +- .../source-control-launch-platform.test.ts | 33 ++ .../src/lib/source-control-launch-platform.ts | 17 + src/renderer/src/store/slices/repos.ts | 43 +- .../source-control-ai-recipe-save.test.ts | 155 ++++++ src/shared/source-control-ai-recipe-save.ts | 222 ++++++++ src/shared/source-control-ai-types.ts | 17 + src/shared/source-control-ai.test.ts | 53 ++ src/shared/source-control-ai.ts | 31 +- 32 files changed, 1660 insertions(+), 580 deletions(-) create mode 100644 src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx create mode 100644 src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx create mode 100644 src/renderer/src/components/settings/RepositorySourceControlAiEnablement.tsx create mode 100644 src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx create mode 100644 src/renderer/src/components/settings/repository-source-control-ai-draft.ts create mode 100644 src/renderer/src/components/settings/repository-source-control-ai-labels.ts create mode 100644 src/renderer/src/lib/source-control-launch-platform.test.ts create mode 100644 src/renderer/src/lib/source-control-launch-platform.ts create mode 100644 src/shared/source-control-ai-recipe-save.test.ts create mode 100644 src/shared/source-control-ai-recipe-save.ts diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 0e358a0644e..cce2c5be935 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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 ) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 017bda3d78f..aa75ae31afc 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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' + } + } }) }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 37152765749..f6053947e69 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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: { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 329f647decd..e9aa40be176 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6748,9 +6748,8 @@ export class OrcaRuntimeService { | 'externalWorktreeVisibilityPromptDismissedAt' | 'projectGroupId' | 'projectGroupOrder' - | 'sourceControlAi' > - > + > & { sourceControlAi?: Repo['sourceControlAi'] | null } ): Promise { 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') diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 8c6dddcbce6..a2e3f6fea10 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -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 diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index eed4638da26..abfdd4c1b9a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -737,9 +737,8 @@ export type PreloadApi = { | 'externalWorktreeVisibilityPromptDismissedAt' | 'projectGroupId' | 'projectGroupOrder' - | 'sourceControlAi' > - > + > & { sourceControlAi?: Repo['sourceControlAi'] | null } }) => Promise pickFolder: () => Promise pickDirectory: () => Promise diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index b1406333006..166468239e1 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -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 => { - 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('') const refreshRequestKeyRef = useRef(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 diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 5d148c30e66..917de3d5423 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -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 @@ -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 | null | undefined @@ -1369,13 +1379,6 @@ function SourceControlInner(): React.JSX.Element { const prGenerationRequestSeqRef = useRef(0) const prGenerationInFlightRef = useRef>({}) const [prGenerationRecords, setPrGenerationRecords] = useState({}) - 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 => { + 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 => { - 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(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 ): Promise => { - 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 => { - 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 { 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['variant'] @@ -5409,6 +5433,7 @@ type CommitFailureFixSplitButtonProps = { savedCommandInputTemplate?: string | null savedAgentArgs?: string | null onSaveAgentDefault?: ( + target: SourceControlAiWriteTarget, actionId: SourceControlLaunchActionId, recipe: SourceControlActionRecipe ) => void | Promise @@ -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 @@ -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" diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx index e8440408c30..a40786a2581 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx @@ -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 @@ -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()} /> diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx index 9957d89b590..c2e47e4fef8 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx @@ -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({ {canSaveAgentDefault && selectedAgent ? ( - +
+ + +
) : null} {deliveryPlan.status !== 'idle' ? ( diff --git a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx index d64159a684d..2e6ffc6052c 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx @@ -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 | null + repo?: Pick | null discoveryHostKey: string onGenerate: (params: ResolvedSourceControlAiGenerationParams) => void - onSaveDefaults: (params: ResolvedSourceControlAiGenerationParams) => Promise | void + onSaveDefaults: ( + target: SourceControlAiWriteTarget, + params: ResolvedSourceControlAiGenerationParams + ) => Promise | 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} diff --git a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx index 3012a1de3bd..3b28c808148 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx @@ -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 + onSaveDefaults: ( + target: SourceControlAiWriteTarget, + params: ResolvedSourceControlAiGenerationParams + ) => Promise | 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( baseParams?.agentId ?? '' @@ -74,7 +82,7 @@ export function SourceControlTextGenerationDialogForm({ ) const [agentArgs, setAgentArgs] = useState(baseParams?.agentArgs ?? '') const [plan, setPlan] = useState({ status: 'idle' }) - const [saving, setSaving] = useState(false) + const [savingTargetKey, setSavingTargetKey] = useState(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 => { + async ( + saveTarget: SourceControlTextGenerationSaveTarget, + options: { showToast: boolean; showErrors: boolean } + ): Promise => { 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 => { - await saveCurrentDefaults({ showToast: true, showErrors: true }) + const handleSaveDefaults = async ( + saveTarget: SourceControlTextGenerationSaveTarget + ): Promise => { + await saveCurrentDefaults(saveTarget, { showToast: true, showErrors: true }) } return ( @@ -260,16 +272,27 @@ export function SourceControlTextGenerationDialogForm({ Check generation - + {saveTargets.map((saveTarget) => { + const targetKey = + saveTarget.target.type === 'repo' ? `repo:${saveTarget.target.repoId}` : 'global' + return ( + + ) + })}