WIP: Changes before auto-review fixes

This commit is contained in:
Jinjing
2026-06-08 10:30:41 -07:00
parent 3153356d87
commit c7bfb51de4
46 changed files with 4732 additions and 3199 deletions
+38
View File
@@ -962,6 +962,44 @@ describe('registerFilesystemHandlers', () => {
})
})
it('uses one-shot resolved params for local commit message generation', async () => {
const context = {
branch: 'feature/ai',
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
}
const sourceControlAiResolvedParams = {
agentId: 'codex' as const,
model: 'gpt-5.5',
thinkingLevel: 'high',
customPrompt: 'Use Conventional Commits.'
}
getStagedCommitContextMock.mockResolvedValue(context)
generateCommitMessageFromContextMock.mockResolvedValue({
success: true,
message: 'feat: update readme'
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:generateCommitMessage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
sourceControlAiResolvedParams
})
).resolves.toEqual({ success: true, message: 'feat: update readme' })
expect(resolveCommitMessageSettingsMock).not.toHaveBeenCalled()
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
context,
sourceControlAiResolvedParams,
{
kind: 'local',
cwd: WORKTREE_FEATURE_PATH
}
)
})
it('prepares the selected Codex account home before local generation', async () => {
const context = {
branch: 'feature/ai',
+21 -6
View File
@@ -12,6 +12,7 @@ import type {
GitCommitCompareResult,
GitConflictOperation,
GitDiffResult,
GlobalSettings,
GitPushTarget,
GitUpstreamStatus,
GitStatusResult,
@@ -68,6 +69,7 @@ import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } fro
import { checkIgnoredPaths } from '../git/check-ignored-paths'
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai'
import { validateGitPushTarget } from '../git/push-target-validation'
import { getRemoteFileUrl } from '../git/repo'
import {
@@ -819,15 +821,28 @@ export function registerFilesystemHandlers(
worktreePath: string
repoId?: string
connectionId?: string
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
sourceControlAi?: GlobalSettings['sourceControlAi']
agentCmdOverrides?: GlobalSettings['agentCmdOverrides']
}
): Promise<GenerateCommitMessageResult> => {
const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null)
const resolvedSettings = resolveCommitMessageSettings(
store.getSettings(),
discoveryHostKey,
'commitMessage',
await getRepoForSourceControlAi(store, args)
)
const baseSettings = store.getSettings()
const requestSettings = {
...baseSettings,
...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}),
...(args.agentCmdOverrides !== undefined
? { agentCmdOverrides: args.agentCmdOverrides }
: {})
}
const resolvedSettings = args.sourceControlAiResolvedParams
? { ok: true as const, params: args.sourceControlAiResolvedParams }
: resolveCommitMessageSettings(
requestSettings,
discoveryHostKey,
'commitMessage',
await getRepoForSourceControlAi(store, args)
)
if (!resolvedSettings.ok) {
return { success: false, error: resolvedSettings.error }
}
+76
View File
@@ -1372,6 +1372,77 @@ describe('Store', () => {
customPrompt: 'Rollback commit prompt',
customAgentCommand: 'claude'
})
store.flush()
const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8'))
expect(persisted.settings.sourceControlAi.actions.commitMessage).toEqual({
agentId: 'claude',
commandInputTemplate: '{basePrompt}\n\nRollback commit prompt'
})
expect(persisted.settings.sourceControlAi.actions.branchName).toEqual({
agentId: 'claude',
commandInputTemplate: '{basePrompt}\n\nRollback commit prompt'
})
})
it('does not let rollback projection clobber existing source-control action templates on load', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
sourceControlAi: {
enabled: true,
agentId: 'codex',
selectedModelByAgent: {},
selectedModelByAgentByHost: {},
discoveredModelsByAgent: {},
discoveredModelsByAgentByHost: {},
selectedThinkingByModel: {},
customAgentCommand: '',
instructionsByOperation: {
commitMessage: '',
pullRequest: '',
branchName: ''
},
actions: {
commitMessage: {
agentId: 'codex',
commandInputTemplate: 'use $best-commit-msg to write a commit'
},
branchName: {
agentId: 'claude',
commandInputTemplate: 'name this branch from {firstPrompt}'
}
},
prCreationDefaults: {}
},
commitMessageAi: {
enabled: true,
agentId: 'codex',
selectedModelByAgent: {},
selectedModelByAgentByHost: {},
discoveredModelsByAgent: {},
discoveredModelsByAgentByHost: {},
selectedThinkingByModel: {},
customPrompt: 'use $best-commit-msg to write a commit',
customAgentCommand: ''
}
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().sourceControlAi?.actions?.commitMessage).toEqual({
agentId: 'codex',
commandInputTemplate: 'use $best-commit-msg to write a commit'
})
expect(store.getSettings().sourceControlAi?.actions?.branchName).toEqual({
agentId: 'claude',
commandInputTemplate: 'name this branch from {firstPrompt}'
})
})
it('normalizes malformed visible task providers on load', async () => {
@@ -2264,6 +2335,11 @@ describe('Store', () => {
instructionsByOperation: {
commitMessage: 'Repo style'
},
actionOverrides: {
commitMessage: {
commandInputTemplate: '{basePrompt}\n\nRepo style'
}
},
prCreationDefaults: {
draft: true,
useTemplate: null
+5 -2
View File
@@ -1732,8 +1732,11 @@ export class Store {
// Merge with defaults in case new fields were added
const homeDir = homedir()
const defaults = getDefaultPersistedState(homeDir)
const rawSourceControlAiMissing = parsed.settings?.sourceControlAi === undefined
if (rawSourceControlAiMissing) {
const rawSourceControlAi = parsed.settings?.sourceControlAi
const rawSourceControlAiMissing = rawSourceControlAi === undefined
const rawSourceControlAiActionsMissing =
rawSourceControlAi !== undefined && rawSourceControlAi.actions === undefined
if (rawSourceControlAiMissing || rawSourceControlAiActionsMissing) {
this.loadNeedsSave = true
}
const legacyCommitMessageAi = parsed.settings?.commitMessageAi
+135
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: runtime git command coverage shares one mocked command host fixture. */
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
@@ -5,13 +6,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/types'
import type * as GitStatusModule from '../git/status'
import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation'
import type * as PullRequestContextModule from '../text-generation/pull-request-context'
import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git'
const mocks = vi.hoisted(() => ({
abortMerge: vi.fn(),
abortRebase: vi.fn(),
getStagedCommitContext: vi.fn(),
getPullRequestDraftContext: vi.fn(),
generateCommitMessageFromContext: vi.fn(),
generatePullRequestFieldsFromContext: vi.fn(),
resolveCommitMessageSettings: vi.fn(),
getSshGitProvider: vi.fn()
}))
@@ -28,9 +32,17 @@ vi.mock('../text-generation/commit-message-text-generation', async () => ({
'../text-generation/commit-message-text-generation'
)),
generateCommitMessageFromContext: mocks.generateCommitMessageFromContext,
generatePullRequestFieldsFromContext: mocks.generatePullRequestFieldsFromContext,
resolveCommitMessageSettings: mocks.resolveCommitMessageSettings
}))
vi.mock('../text-generation/pull-request-context', async () => ({
...(await vi.importActual<typeof PullRequestContextModule>(
'../text-generation/pull-request-context'
)),
getPullRequestDraftContext: mocks.getPullRequestDraftContext
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: mocks.getSshGitProvider
}))
@@ -64,7 +76,9 @@ describe('RuntimeGitCommands', () => {
mocks.abortMerge.mockReset()
mocks.abortRebase.mockReset()
mocks.getStagedCommitContext.mockReset()
mocks.getPullRequestDraftContext.mockReset()
mocks.generateCommitMessageFromContext.mockReset()
mocks.generatePullRequestFieldsFromContext.mockReset()
mocks.resolveCommitMessageSettings.mockReset()
mocks.getSshGitProvider.mockReset()
})
@@ -196,6 +210,127 @@ describe('RuntimeGitCommands', () => {
)
})
it('uses one-shot resolved params before runtime commit-message defaults', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
const context = {
branch: 'main',
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
}
const sourceControlAiResolvedParams = {
agentId: 'codex' as const,
model: 'gpt-5.5',
thinkingLevel: 'high',
customPrompt: 'Use Conventional Commits.'
}
mocks.getStagedCommitContext.mockResolvedValue(context)
mocks.generateCommitMessageFromContext.mockResolvedValue({
success: true,
message: 'feat: update readme'
})
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
getRuntimeSettings: () =>
({
sourceControlAi: {
commitMessage: {
enabled: true,
agentId: 'cursor',
customPrompt: 'Saved default that should not win.'
}
}
}) as unknown as GlobalSettings
})
await expect(
commands.generateRuntimeCommitMessage('id:wt-1', { sourceControlAiResolvedParams })
).resolves.toEqual({
success: true,
message: 'feat: update readme'
})
expect(mocks.resolveCommitMessageSettings).not.toHaveBeenCalled()
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
context,
sourceControlAiResolvedParams,
expect.objectContaining({
kind: 'local',
cwd: worktreePath
})
)
})
it('uses one-shot resolved params before runtime pull-request defaults', async () => {
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
tempDirs.push(worktreePath)
const context = {
base: 'main',
branch: 'feature/source-control-ai',
commitSummary: 'abc123 feat: test',
changeSummary: 'M README.md',
patch: '+hello',
currentTitle: '',
currentBody: '',
currentDraft: false
}
const sourceControlAiResolvedParams = {
agentId: 'codex' as const,
model: 'gpt-5.5',
thinkingLevel: 'high',
commandInputTemplate: '{basePrompt}\n\nUse release-note style.'
}
mocks.getPullRequestDraftContext.mockResolvedValue(context)
mocks.generatePullRequestFieldsFromContext.mockResolvedValue({
success: true,
fields: {
base: 'main',
title: 'Improve Source Control AI',
body: 'Body',
draft: false
}
})
const commands = new RuntimeGitCommands({
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
getRuntimeSettings: () =>
({
sourceControlAi: {
pullRequest: {
enabled: true,
agentId: 'cursor',
customPrompt: 'Saved default that should not win.'
}
}
}) as unknown as GlobalSettings
})
await expect(
commands.generateRuntimePullRequestFields(
'id:wt-1',
{ base: 'main', title: '', body: '', draft: false },
{ sourceControlAiResolvedParams }
)
).resolves.toEqual({
success: true,
fields: {
base: 'main',
title: 'Improve Source Control AI',
body: 'Body',
draft: false
}
})
expect(mocks.resolveCommitMessageSettings).not.toHaveBeenCalled()
expect(mocks.generatePullRequestFieldsFromContext).toHaveBeenCalledWith(
context,
sourceControlAiResolvedParams,
expect.objectContaining({
kind: 'local',
cwd: worktreePath
})
)
})
it('resolves remote commit-message settings against the SSH host cache', async () => {
const worktreePath = '/remote/repo'
const context = {
+29 -21
View File
@@ -16,7 +16,10 @@ import type {
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import { mergeLegacyCommitMessageAiIntoSourceControlAi } from '../../shared/source-control-ai'
import {
mergeLegacyCommitMessageAiIntoSourceControlAi,
type ResolvedSourceControlAiGenerationParams
} from '../../shared/source-control-ai'
import type { SourceControlAiOperation } from '../../shared/source-control-ai-types'
import { getRemoteFileUrl } from '../git/repo'
import {
@@ -72,6 +75,7 @@ type RuntimeCommitMessageSettingsOverride = Partial<
>
> & {
commitMessageDiscoveryHostKey?: string
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
}
function getRuntimeGitGenerationSettings(
@@ -447,16 +451,18 @@ export class RuntimeGitCommands {
const discoveryHostKey =
settingsOverride?.commitMessageDiscoveryHostKey ??
getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null)
const resolvedSettings = resolveCommitMessageSettings(
getRuntimeGitGenerationSettings(
this.host.getRuntimeSettings(),
settingsOverride,
'commitMessage'
),
discoveryHostKey,
'commitMessage',
target.repo ?? null
)
const resolvedSettings = settingsOverride?.sourceControlAiResolvedParams
? { ok: true as const, params: settingsOverride.sourceControlAiResolvedParams }
: resolveCommitMessageSettings(
getRuntimeGitGenerationSettings(
this.host.getRuntimeSettings(),
settingsOverride,
'commitMessage'
),
discoveryHostKey,
'commitMessage',
target.repo ?? null
)
if (!resolvedSettings.ok) {
return { success: false, error: resolvedSettings.error }
}
@@ -532,16 +538,18 @@ export class RuntimeGitCommands {
const discoveryHostKey =
settingsOverride?.commitMessageDiscoveryHostKey ??
getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null)
const resolvedSettings = resolveCommitMessageSettings(
getRuntimeGitGenerationSettings(
this.host.getRuntimeSettings(),
settingsOverride,
'pullRequest'
),
discoveryHostKey,
'pullRequest',
target.repo ?? null
)
const resolvedSettings = settingsOverride?.sourceControlAiResolvedParams
? { ok: true as const, params: settingsOverride.sourceControlAiResolvedParams }
: resolveCommitMessageSettings(
getRuntimeGitGenerationSettings(
this.host.getRuntimeSettings(),
settingsOverride,
'pullRequest'
),
discoveryHostKey,
'pullRequest',
target.repo ?? null
)
if (!resolvedSettings.ok) {
return { success: false, error: resolvedSettings.error }
}
@@ -100,6 +100,15 @@ const CommitMessageAiSettings = z.object({
})
const SourceControlAiSettings = CommitMessageAiSettings.omit({ customPrompt: true }).extend({
actions: z
.record(
z.string(),
z.object({
agentId: z.string().nullable().optional(),
commandInputTemplate: z.string().optional()
})
)
.optional(),
instructionsByOperation: z.record(z.string(), z.string()).optional(),
modelOverridesByOperation: z
.record(
@@ -120,12 +129,32 @@ const SourceControlAiSettings = CommitMessageAiSettings.omit({ customPrompt: tru
generateDetailsOnOpen: z.boolean().optional(),
openAfterCreate: z.boolean().optional()
})
.optional(),
launchActionDefaults: z
.record(
z.string(),
z.object({
agentId: z.string().nullable().optional(),
commandInputTemplate: z.string().optional()
})
)
.optional()
})
const ResolvedSourceControlAiGenerationParams = z.object({
agentId: z.string(),
model: z.string(),
thinkingLevel: z.string().optional(),
customPrompt: z.string().optional(),
commandInputTemplate: z.string().optional(),
customAgentCommand: z.string().optional(),
agentCommandOverride: z.string().optional()
})
export const GitGenerateCommitMessage = WorktreeSelector.extend({
commitMessageAi: CommitMessageAiSettings.optional(),
sourceControlAi: SourceControlAiSettings.optional(),
sourceControlAiResolvedParams: ResolvedSourceControlAiGenerationParams.optional(),
agentCmdOverrides: z.record(z.string(), z.string()).optional(),
enableGitHubAttribution: z.boolean().optional(),
commitMessageDiscoveryHostKey: z.string().optional()
+58
View File
@@ -365,6 +365,64 @@ describe('git RPC methods', () => {
})
})
it('forwards one-shot commit-message params to the runtime', async () => {
const sourceControlAiResolvedParams = {
agentId: 'codex',
model: 'gpt-5.5',
thinkingLevel: 'high',
customPrompt: 'Use Conventional Commits.'
}
const runtime = {
getRuntimeId: () => 'test-runtime',
generateRuntimeCommitMessage: vi.fn().mockResolvedValue({ success: true, message: 'test' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
await dispatcher.dispatch(
makeRequest('git.generateCommitMessage', {
worktree: 'id:wt-1',
sourceControlAiResolvedParams
})
)
expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1', {
sourceControlAiResolvedParams
})
})
it('forwards one-shot pull-request generation params to the runtime', async () => {
const sourceControlAiResolvedParams = {
agentId: 'codex',
model: 'gpt-5.5',
thinkingLevel: 'high',
commandInputTemplate: '{basePrompt}\n\nUse release-note style.'
}
const runtime = {
getRuntimeId: () => 'test-runtime',
generateRuntimePullRequestFields: vi
.fn()
.mockResolvedValue({ success: true, fields: { title: 'Test', body: '', draft: false } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
await dispatcher.dispatch(
makeRequest('git.generatePullRequestFields', {
worktree: 'id:wt-1',
base: 'main',
title: '',
body: '',
draft: false,
sourceControlAiResolvedParams
})
)
expect(runtime.generateRuntimePullRequestFields).toHaveBeenCalledWith(
'id:wt-1',
{ base: 'main', title: '', body: '', draft: false },
{ sourceControlAiResolvedParams }
)
})
it('rejects malformed commit-message settings before calling the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+15
View File
@@ -1,6 +1,7 @@
/* eslint-disable max-lines -- Why: this table is the runtime git RPC contract; splitting it would make method coverage harder to audit. */
import { defineMethod, type RpcMethod } from '../core'
import type { GlobalSettings } from '../../../../shared/types'
import type { ResolvedSourceControlAiGenerationParams } from '../../../../shared/source-control-ai'
import {
GitBranchCompare,
GitBranchDiff,
@@ -169,6 +170,7 @@ export const GIT_METHODS: RpcMethod[] = [
if (
params.commitMessageAi === undefined &&
params.sourceControlAi === undefined &&
params.sourceControlAiResolvedParams === undefined &&
params.agentCmdOverrides === undefined &&
params.enableGitHubAttribution === undefined &&
params.commitMessageDiscoveryHostKey === undefined
@@ -182,6 +184,12 @@ export const GIT_METHODS: RpcMethod[] = [
...(params.sourceControlAi !== undefined
? { sourceControlAi: params.sourceControlAi as GlobalSettings['sourceControlAi'] }
: {}),
...(params.sourceControlAiResolvedParams !== undefined
? {
sourceControlAiResolvedParams:
params.sourceControlAiResolvedParams as ResolvedSourceControlAiGenerationParams
}
: {}),
...(params.agentCmdOverrides !== undefined
? {
agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides']
@@ -229,6 +237,7 @@ export const GIT_METHODS: RpcMethod[] = [
if (
params.commitMessageAi === undefined &&
params.sourceControlAi === undefined &&
params.sourceControlAiResolvedParams === undefined &&
params.agentCmdOverrides === undefined &&
params.enableGitHubAttribution === undefined &&
params.commitMessageDiscoveryHostKey === undefined
@@ -242,6 +251,12 @@ export const GIT_METHODS: RpcMethod[] = [
...(params.sourceControlAi !== undefined
? { sourceControlAi: params.sourceControlAi as GlobalSettings['sourceControlAi'] }
: {}),
...(params.sourceControlAiResolvedParams !== undefined
? {
sourceControlAiResolvedParams:
params.sourceControlAiResolvedParams as ResolvedSourceControlAiGenerationParams
}
: {}),
...(params.agentCmdOverrides !== undefined
? {
agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides']
@@ -88,7 +88,8 @@ describe('resolveCommitMessageSettings', () => {
agentId: 'codex',
model: 'gpt-5.5',
thinkingLevel: 'low',
customPrompt: 'Use Conventional Commits.'
customPrompt: 'Use Conventional Commits.',
commandInputTemplate: '{basePrompt}\n\nUse Conventional Commits.'
}
})
})
@@ -40,6 +40,7 @@ import {
type ResolvedSourceControlAiGenerationParams
} from '../../shared/source-control-ai'
import type { SourceControlAiOperation } from '../../shared/source-control-ai-types'
import { renderSourceControlActionCommandTemplate } from '../../shared/source-control-ai-actions'
import { resolveCliCommand } from '../codex-cli/command'
import {
getSpawnArgsForWindows,
@@ -738,7 +739,16 @@ export async function generateCommitMessageFromContext(
params: GenerateCommitMessageParams,
target: CommitMessageGenerationTarget
): Promise<GenerateCommitMessageResult> {
const prompt = buildCommitMessagePrompt(context, params.customPrompt ?? '')
const basePrompt = buildCommitMessagePrompt(context, '')
const prompt =
params.commandInputTemplate !== undefined
? renderSourceControlActionCommandTemplate(params.commandInputTemplate, {
basePrompt,
branch: context.branch ?? '(detached)',
stagedFiles: context.stagedSummary,
stagedPatch: context.stagedPatch
})
: buildCommitMessagePrompt(context, params.customPrompt ?? '')
const planned = planCommitMessageGeneration(params, prompt)
if (!planned.ok) {
return { success: false, error: planned.error }
@@ -786,7 +796,20 @@ export async function generatePullRequestFieldsFromContext(
params: GenerateCommitMessageParams,
target: CommitMessageGenerationTarget
): Promise<GeneratePullRequestFieldsResult> {
const prompt = buildPullRequestFieldsPrompt(context, params.customPrompt ?? '')
const basePrompt = buildPullRequestFieldsPrompt(context, '')
const prompt =
params.commandInputTemplate !== undefined
? renderSourceControlActionCommandTemplate(params.commandInputTemplate, {
basePrompt,
branch: context.branch ?? '(detached)',
baseBranch: context.base,
currentTitle: context.currentTitle,
currentBody: context.currentBody,
commitSummary: context.commitSummary,
changedFiles: context.changeSummary,
patch: context.patch
})
: buildPullRequestFieldsPrompt(context, params.customPrompt ?? '')
const planned = planCommitMessageGeneration(params, prompt)
if (!planned.ok) {
return {
@@ -817,7 +840,15 @@ export async function generateBranchNameFromContext(
params: GenerateCommitMessageParams,
target: CommitMessageGenerationTarget
): Promise<GenerateBranchNameResult> {
const prompt = buildBranchNamePrompt(context, params.customPrompt ?? '')
const basePrompt = buildBranchNamePrompt(context)
const prompt =
params.commandInputTemplate !== undefined
? renderSourceControlActionCommandTemplate(params.commandInputTemplate, {
basePrompt,
firstPrompt: context.firstPrompt,
assistantMessage: context.assistantMessage ?? ''
})
: buildBranchNamePrompt(context, params.customPrompt ?? '')
const planned = planCommitMessageGeneration(params, prompt)
if (!planned.ok) {
return { success: false, error: planned.error }
+6
View File
@@ -131,6 +131,7 @@ import type {
SearchResult,
StatsSummary,
MemorySnapshot,
TuiAgent,
UpdateStatus,
Worktree,
WorktreeBaseStatusEvent,
@@ -220,6 +221,8 @@ import type {
CommitMessageAgentCapability,
CommitMessageModelCapability
} from '../shared/commit-message-agent-spec'
import type { ResolvedSourceControlAiGenerationParams } from '../shared/source-control-ai'
import type { SourceControlAiSettings } from '../shared/source-control-ai-types'
import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
import type {
@@ -2014,6 +2017,9 @@ export type PreloadApi = {
worktreePath: string
repoId?: string
connectionId?: string
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
sourceControlAi?: SourceControlAiSettings
agentCmdOverrides?: Partial<Record<TuiAgent, string>>
}) => Promise<
| { success: true; message: string; agentLabel?: string }
| { success: false; error: string; canceled?: boolean }
+4
View File
@@ -2477,7 +2477,11 @@ const api = {
}): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args),
generateCommitMessage: (args: {
worktreePath: string
repoId?: string
connectionId?: string
sourceControlAiResolvedParams?: unknown
sourceControlAi?: unknown
agentCmdOverrides?: Record<string, string>
}): Promise<unknown> => ipcRenderer.invoke('git:generateCommitMessage', args),
discoverCommitMessageModels: (args: {
agentId: string
+187 -158
View File
@@ -1,5 +1,4 @@
/* eslint-disable max-lines -- Why: the GH item dialog keeps its header, conversation, files, and checks tabs co-located so the read-only PR/Issue surface stays in one place while this view evolves. */
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: GitHub item dialogs hydrate provider data, diff sections, snippets, and cache refetches from async provider/virtualizer lifecycles. */
import React, {
Suspense,
lazy,
@@ -145,8 +144,6 @@ import {
GITHUB_PR_MERGE_METHOD_LABELS,
resolveGitHubPRMergeMethods
} from '../../../shared/github-pr-merge-methods'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection'
import { getConnectionId } from '@/lib/connection-context'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import {
@@ -157,6 +154,9 @@ import {
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { buildFixBrokenChecksPrompt, getBrokenChecks } from '@/components/pr-checks-fix-prompt'
import { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog'
import { resolveSourceControlActionRecipe } from '../../../shared/source-control-ai'
import type {
GitHubOwnerRepo,
GitHubPRFile,
@@ -3253,52 +3253,6 @@ function getChecksSummaryLabel(checks: PRCheckDetail[]): string {
return `${counts.passing} of ${checks.length} checks passing`
}
function getBrokenChecks(checks: PRCheckDetail[]): PRCheckDetail[] {
return checks.filter((check) =>
['failure', 'cancelled', 'timed_out'].includes(getCheckConclusion(check))
)
}
function buildFixBrokenChecksPrompt(item: GitHubWorkItem, checks: PRCheckDetail[]): string {
const brokenChecks = getBrokenChecks(checks)
const checkLines =
brokenChecks.length > 0
? brokenChecks.map((check) => {
const details = [
getCheckStatusLabel(check),
check.checkRunId ? `check run ${check.checkRunId}` : null,
check.workflowRunId ? `workflow run ${check.workflowRunId}` : null,
check.url ? `details: ${check.url}` : null
]
.filter(Boolean)
.join(', ')
return `- ${check.name}${details ? ` (${details})` : ''}`
})
: ['- No failing check is currently listed; refresh PR checks first, then inspect CI.']
return [
`Fix the broken checks for PR #${item.number}: ${item.title}`,
`PR: ${item.url}`,
'',
'Broken checks:',
...checkLines,
'',
'Focus only on making the failing checks pass. Inspect the CI output first, make the smallest correct code or test changes, and do not work on unrelated cleanup.'
].join('\n')
}
function pickDefaultAgent(
defaultAgent: TuiAgent | 'blank' | null | undefined,
detectedAgents: TuiAgent[],
disabledAgents?: TuiAgent[]
): TuiAgent | null {
const enabledAgents = filterEnabledTuiAgents(detectedAgents, disabledAgents)
if (defaultAgent && defaultAgent !== 'blank' && enabledAgents.includes(defaultAgent)) {
return defaultAgent
}
return AGENT_CATALOG.find((entry) => enabledAgents.includes(entry.id))?.id ?? null
}
function getCheckDetailsKey(check: PRCheckDetail): string {
return String(check.checkRunId ?? check.workflowRunId ?? check.url ?? check.name)
}
@@ -3342,6 +3296,7 @@ function ChecksTab({
const [rerunning, setRerunning] = useState(false)
const [fixingChecks, setFixingChecks] = useState(false)
const [checksState, setChecksState] = useState(() => createGitHubChecksTabState(checks))
const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState<string | null>(null)
const mountedRef = useMountedRef()
const resolvedChecksState = resolveGitHubChecksTabState(checksState, checks)
if (resolvedChecksState !== checksState) {
@@ -3442,75 +3397,85 @@ function ChecksTab({
return
}
setFixingChecks(true)
try {
const prompt = buildFixBrokenChecksPrompt(item, list)
const store = useAppStore.getState()
const attachedWorkspace = findGithubPrWorkspaceAttachment(
store.allWorktrees(),
targetRepoId,
item.number
)
if (!attachedWorkspace) {
await launchWorkItemDirect({
item: { ...item, pasteContent: prompt },
repoId: targetRepoId,
launchSource: 'task_page',
telemetrySource: 'sidebar',
openModalFallback: () => {
toast.error('Unable to create a fix workspace automatically.')
}
})
return
}
if (!activateAndRevealWorktree(attachedWorkspace.id)) {
toast.error('Unable to open the workspace attached to this pull request.')
return
}
const connectionId = getConnectionId(attachedWorkspace.id)
if (connectionId === undefined) {
toast.error('Unable to resolve the workspace connection.')
return
}
const activeStore = useAppStore.getState()
const detectedAgents =
typeof connectionId === 'string'
? await activeStore.ensureRemoteDetectedAgents(connectionId)
: await activeStore.ensureDetectedAgents()
const agent = pickDefaultAgent(
activeStore.settings?.defaultTuiAgent,
detectedAgents,
activeStore.settings?.disabledTuiAgents
)
if (!agent) {
toast.error('No enabled AI agents. Configure agents in Settings.')
return
}
const result = launchAgentInNewTab({
agent,
worktreeId: attachedWorkspace.id,
prompt,
promptDelivery: 'draft',
launchSource: 'task_page'
setFixChecksComposerPrompt(
buildFixBrokenChecksPrompt({
reviewKind: 'PR',
reviewNumber: item.number,
reviewTitle: item.title,
reviewUrl: item.url,
checks: list
})
if (!result) {
toast.error('Could not build the agent launch command.')
return
)
}, [
failedChecks.length,
fixingChecks,
item.number,
item.repoId,
item.title,
item.url,
list,
repoId
])
const handleStartFixBrokenChecks = useCallback(
async ({
agent,
commandInput
}: {
agent: TuiAgent
commandInput: string
}): Promise<boolean> => {
const targetRepoId = repoId ?? item.repoId
if (!targetRepoId || fixingChecks) {
return false
}
// Why: host-backed web launches can succeed without a local tab id.
if (result.tabId) {
setFixingChecks(true)
try {
const store = useAppStore.getState()
const attachedWorkspace = findGithubPrWorkspaceAttachment(
store.allWorktrees(),
targetRepoId,
item.number
)
if (!attachedWorkspace) {
return await launchWorkItemDirect({
item: { ...item, pasteContent: commandInput },
repoId: targetRepoId,
launchSource: 'task_page',
telemetrySource: 'sidebar',
agentOverride: agent,
openModalFallback: () => {
toast.error('Unable to create a fix workspace automatically.')
}
})
}
if (!activateAndRevealWorktree(attachedWorkspace.id)) {
toast.error('Unable to open the workspace attached to this pull request.')
return false
}
const result = launchAgentInNewTab({
agent,
worktreeId: attachedWorkspace.id,
prompt: commandInput,
promptDelivery: 'draft',
launchSource: 'task_page'
})
if (!result) {
toast.error('Could not build the agent launch command.')
return false
}
focusTerminalTabSurface(result.tabId)
toast.success('Started an AI agent for the broken checks.')
return true
} finally {
setFixingChecks(false)
}
toast.success('Started an AI agent for the broken checks.')
} finally {
setFixingChecks(false)
}
}, [failedChecks.length, fixingChecks, item, list, repoId])
},
[fixingChecks, item, repoId]
)
const handleToggleCheckDetails = useCallback(
(check: PRCheckDetail): void => {
@@ -3904,9 +3869,63 @@ function ChecksTab({
)
}
const composerTargetRepoId = repoId ?? item.repoId
const composerStoreState = useAppStore.getState()
const composerTargetRepo = composerTargetRepoId
? composerStoreState.repos.find((repo) => repo.id === composerTargetRepoId)
: null
const composerAttachedWorkspace =
composerTargetRepoId && fixChecksComposerPrompt
? findGithubPrWorkspaceAttachment(
composerStoreState.allWorktrees(),
composerTargetRepoId,
item.number
)
: null
const composerAttachedConnectionId = composerAttachedWorkspace
? getConnectionId(composerAttachedWorkspace.id)
: undefined
// Why: unattached PR/MR actions need host-specific agent detection before
// launchWorkItemDirect creates and revalidates the workspace.
const composerConnectionId =
composerAttachedConnectionId !== undefined
? composerAttachedConnectionId
: (composerTargetRepo?.connectionId ?? null)
const fixChecksRecipe = resolveSourceControlActionRecipe({
settings: composerStoreState.settings,
repo: composerTargetRepo,
actionId: 'fixChecks'
})
const fixChecksComposerDialog = (
<SourceControlAgentActionDialog
open={fixChecksComposerPrompt !== null}
onOpenChange={(open) => {
if (!open) {
setFixChecksComposerPrompt(null)
}
}}
actionId="fixChecks"
title="Fix Checks With AI"
description="Choose the agent and edit the full command input before launch."
baseCommandInput={fixChecksComposerPrompt ?? ''}
worktreeId={composerAttachedWorkspace?.id ?? null}
groupId={composerAttachedWorkspace?.id ?? null}
connectionId={composerConnectionId}
promptDelivery="draft"
launchSource="task_page"
savedAgentId={fixChecksRecipe.agentId ?? null}
savedCommandInputTemplate={fixChecksRecipe.commandInputTemplate ?? null}
onStart={handleStartFixBrokenChecks}
onLaunched={() => {
setFixChecksComposerPrompt(null)
}}
/>
)
if (loading && list.length === 0) {
return (
<>
{fixChecksComposerDialog}
{variant === 'compact' ? compactHeader : null}
<div className="flex items-center justify-center py-10">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
@@ -3917,24 +3936,28 @@ function ChecksTab({
if (list.length === 0) {
if (variant === 'page') {
return (
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<CircleDashed className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[13px] font-medium text-foreground">
No checks found
</span>
<span className="truncate text-[11px] text-muted-foreground">
This pull request has no reported checks yet.
</span>
<>
{fixChecksComposerDialog}
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<CircleDashed className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[13px] font-medium text-foreground">
No checks found
</span>
<span className="truncate text-[11px] text-muted-foreground">
This pull request has no reported checks yet.
</span>
</div>
{actions}
</div>
{actions}
</div>
</div>
</>
)
}
return (
<>
{fixChecksComposerDialog}
{compactHeader}
<div className="flex flex-col items-center justify-center gap-1 px-4 py-6 text-center">
<CircleDashed className="size-4 text-muted-foreground/60" />
@@ -3961,45 +3984,51 @@ function ChecksTab({
})
}
return (
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<SummaryIcon
className={cn(
'size-4 shrink-0',
summaryColor,
counts.pending > 0 && counts.failing === 0 && 'animate-spin'
)}
/>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-[13px] font-medium text-foreground">{summaryLabel}</span>
{countChips.length > 1 && (
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{countChips.map((chip, i) => (
<React.Fragment key={chip.label}>
{i > 0 && <span className="opacity-40">·</span>}
<span className={chip.className}>{chip.label}</span>
</React.Fragment>
))}
<>
{fixChecksComposerDialog}
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<SummaryIcon
className={cn(
'size-4 shrink-0',
summaryColor,
counts.pending > 0 && counts.failing === 0 && 'animate-spin'
)}
/>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-[13px] font-medium text-foreground">
{summaryLabel}
</span>
)}
</div>
{actions}
</div>
<div className="overflow-hidden rounded-lg border border-border/50 bg-card/50 shadow-xs">
{sorted.map((check, index) => (
<div
key={getCheckDetailsKey(check)}
className={cn(index > 0 && 'border-t border-border/40')}
>
{renderCheckRow(check)}
{countChips.length > 1 && (
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{countChips.map((chip, i) => (
<React.Fragment key={chip.label}>
{i > 0 && <span className="opacity-40">·</span>}
<span className={chip.className}>{chip.label}</span>
</React.Fragment>
))}
</span>
)}
</div>
))}
{actions}
</div>
<div className="overflow-hidden rounded-lg border border-border/50 bg-card/50 shadow-xs">
{sorted.map((check, index) => (
<div
key={getCheckDetailsKey(check)}
className={cn(index > 0 && 'border-t border-border/40')}
>
{renderCheckRow(check)}
</div>
))}
</div>
</div>
</div>
</>
)
}
return (
<>
{fixChecksComposerDialog}
{compactHeader}
<div className="max-h-[280px] overflow-y-auto p-1 scrollbar-sleek">
{sorted.map(renderCheckRow)}
+187 -157
View File
@@ -1,5 +1,4 @@
/* eslint-disable max-lines -- Why: duplicated from GitHubItemDialog so the dedicated PR full-page surface can evolve its Primer-styled header without destabilizing the issue dialog; planned to refactor shared parts out later. */
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: PR pages hydrate provider data, diff sections, snippets, and cache refetches from async provider/virtualizer lifecycles. */
import React, {
Suspense,
lazy,
@@ -142,8 +141,6 @@ import {
GITHUB_PR_MERGE_METHOD_LABELS,
resolveGitHubPRMergeMethods
} from '../../../shared/github-pr-merge-methods'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection'
import { getConnectionId } from '@/lib/connection-context'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import {
@@ -153,6 +150,9 @@ import {
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { buildFixBrokenChecksPrompt, getBrokenChecks } from '@/components/pr-checks-fix-prompt'
import { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog'
import { resolveSourceControlActionRecipe } from '../../../shared/source-control-ai'
import type {
GitHubOwnerRepo,
GitHubPRFile,
@@ -3507,52 +3507,6 @@ function getChecksSummaryLabel(checks: PRCheckDetail[]): string {
return `${counts.passing} of ${checks.length} checks passing`
}
function getBrokenChecks(checks: PRCheckDetail[]): PRCheckDetail[] {
return checks.filter((check) =>
['failure', 'cancelled', 'timed_out'].includes(getCheckConclusion(check))
)
}
function buildFixBrokenChecksPrompt(item: GitHubWorkItem, checks: PRCheckDetail[]): string {
const brokenChecks = getBrokenChecks(checks)
const checkLines =
brokenChecks.length > 0
? brokenChecks.map((check) => {
const details = [
getCheckStatusLabel(check),
check.checkRunId ? `check run ${check.checkRunId}` : null,
check.workflowRunId ? `workflow run ${check.workflowRunId}` : null,
check.url ? `details: ${check.url}` : null
]
.filter(Boolean)
.join(', ')
return `- ${check.name}${details ? ` (${details})` : ''}`
})
: ['- No failing check is currently listed; refresh PR checks first, then inspect CI.']
return [
`Fix the broken checks for PR #${item.number}: ${item.title}`,
`PR: ${item.url}`,
'',
'Broken checks:',
...checkLines,
'',
'Focus only on making the failing checks pass. Inspect the CI output first, make the smallest correct code or test changes, and do not work on unrelated cleanup.'
].join('\n')
}
function pickDefaultAgent(
defaultAgent: TuiAgent | 'blank' | null | undefined,
detectedAgents: TuiAgent[],
disabledAgents?: TuiAgent[]
): TuiAgent | null {
const enabledAgents = filterEnabledTuiAgents(detectedAgents, disabledAgents)
if (defaultAgent && defaultAgent !== 'blank' && enabledAgents.includes(defaultAgent)) {
return defaultAgent
}
return AGENT_CATALOG.find((entry) => enabledAgents.includes(entry.id))?.id ?? null
}
function getCheckDetailsKey(check: PRCheckDetail): string {
return String(check.checkRunId ?? check.workflowRunId ?? check.url ?? check.name)
}
@@ -3596,6 +3550,7 @@ function ChecksTab({
const [rerunning, setRerunning] = useState(false)
const [fixingChecks, setFixingChecks] = useState(false)
const [checksState, setChecksState] = useState(() => createGitHubChecksTabState(checks))
const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState<string | null>(null)
const mountedRef = useMountedRef()
const resolvedChecksState = resolveGitHubChecksTabState(checksState, checks)
if (resolvedChecksState !== checksState) {
@@ -3696,74 +3651,85 @@ function ChecksTab({
return
}
setFixingChecks(true)
try {
const prompt = buildFixBrokenChecksPrompt(item, list)
const store = useAppStore.getState()
const attachedWorkspace = findGithubPrWorkspaceAttachment(
store.allWorktrees(),
targetRepoId,
item.number
)
if (!attachedWorkspace) {
await launchWorkItemDirect({
item: { ...item, pasteContent: prompt },
repoId: targetRepoId,
launchSource: 'task_page',
telemetrySource: 'sidebar',
openModalFallback: () => {
toast.error('Unable to create a fix workspace automatically.')
}
})
return
}
if (!activateAndRevealWorktree(attachedWorkspace.id)) {
toast.error('Unable to open the workspace attached to this pull request.')
return
}
const connectionId = getConnectionId(attachedWorkspace.id)
if (connectionId === undefined) {
toast.error('Unable to resolve the workspace connection.')
return
}
const activeStore = useAppStore.getState()
const detectedAgents =
typeof connectionId === 'string'
? await activeStore.ensureRemoteDetectedAgents(connectionId)
: await activeStore.ensureDetectedAgents()
const agent = pickDefaultAgent(
activeStore.settings?.defaultTuiAgent,
detectedAgents,
activeStore.settings?.disabledTuiAgents
)
if (!agent) {
toast.error('No enabled AI agents. Configure agents in Settings.')
return
}
const result = launchAgentInNewTab({
agent,
worktreeId: attachedWorkspace.id,
prompt,
promptDelivery: 'draft',
launchSource: 'task_page'
setFixChecksComposerPrompt(
buildFixBrokenChecksPrompt({
reviewKind: 'PR',
reviewNumber: item.number,
reviewTitle: item.title,
reviewUrl: item.url,
checks: list
})
if (!result) {
toast.error('Could not build the agent launch command.')
return
)
}, [
failedChecks.length,
fixingChecks,
item.number,
item.repoId,
item.title,
item.url,
list,
repoId
])
const handleStartFixBrokenChecks = useCallback(
async ({
agent,
commandInput
}: {
agent: TuiAgent
commandInput: string
}): Promise<boolean> => {
const targetRepoId = repoId ?? item.repoId
if (!targetRepoId || fixingChecks) {
return false
}
if (result.tabId) {
setFixingChecks(true)
try {
const store = useAppStore.getState()
const attachedWorkspace = findGithubPrWorkspaceAttachment(
store.allWorktrees(),
targetRepoId,
item.number
)
if (!attachedWorkspace) {
return await launchWorkItemDirect({
item: { ...item, pasteContent: commandInput },
repoId: targetRepoId,
launchSource: 'task_page',
telemetrySource: 'sidebar',
agentOverride: agent,
openModalFallback: () => {
toast.error('Unable to create a fix workspace automatically.')
}
})
}
if (!activateAndRevealWorktree(attachedWorkspace.id)) {
toast.error('Unable to open the workspace attached to this pull request.')
return false
}
const result = launchAgentInNewTab({
agent,
worktreeId: attachedWorkspace.id,
prompt: commandInput,
promptDelivery: 'draft',
launchSource: 'task_page'
})
if (!result) {
toast.error('Could not build the agent launch command.')
return false
}
focusTerminalTabSurface(result.tabId)
toast.success('Started an AI agent for the broken checks.')
return true
} finally {
setFixingChecks(false)
}
toast.success('Started an AI agent for the broken checks.')
} finally {
setFixingChecks(false)
}
}, [failedChecks.length, fixingChecks, item, list, repoId])
},
[fixingChecks, item, repoId]
)
const handleToggleCheckDetails = useCallback(
(check: PRCheckDetail): void => {
@@ -4157,9 +4123,63 @@ function ChecksTab({
)
}
const composerTargetRepoId = repoId ?? item.repoId
const composerStoreState = useAppStore.getState()
const composerTargetRepo = composerTargetRepoId
? composerStoreState.repos.find((repo) => repo.id === composerTargetRepoId)
: null
const composerAttachedWorkspace =
composerTargetRepoId && fixChecksComposerPrompt
? findGithubPrWorkspaceAttachment(
composerStoreState.allWorktrees(),
composerTargetRepoId,
item.number
)
: null
const composerAttachedConnectionId = composerAttachedWorkspace
? getConnectionId(composerAttachedWorkspace.id)
: undefined
// Why: unattached PR/MR actions need host-specific agent detection before
// launchWorkItemDirect creates and revalidates the workspace.
const composerConnectionId =
composerAttachedConnectionId !== undefined
? composerAttachedConnectionId
: (composerTargetRepo?.connectionId ?? null)
const fixChecksRecipe = resolveSourceControlActionRecipe({
settings: composerStoreState.settings,
repo: composerTargetRepo,
actionId: 'fixChecks'
})
const fixChecksComposerDialog = (
<SourceControlAgentActionDialog
open={fixChecksComposerPrompt !== null}
onOpenChange={(open) => {
if (!open) {
setFixChecksComposerPrompt(null)
}
}}
actionId="fixChecks"
title="Fix Checks With AI"
description="Choose the agent and edit the full command input before launch."
baseCommandInput={fixChecksComposerPrompt ?? ''}
worktreeId={composerAttachedWorkspace?.id ?? null}
groupId={composerAttachedWorkspace?.id ?? null}
connectionId={composerConnectionId}
promptDelivery="draft"
launchSource="task_page"
savedAgentId={fixChecksRecipe.agentId ?? null}
savedCommandInputTemplate={fixChecksRecipe.commandInputTemplate ?? null}
onStart={handleStartFixBrokenChecks}
onLaunched={() => {
setFixChecksComposerPrompt(null)
}}
/>
)
if (loading && list.length === 0) {
return (
<>
{fixChecksComposerDialog}
{variant === 'compact' ? compactHeader : null}
<div className="flex items-center justify-center py-10">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
@@ -4170,24 +4190,28 @@ function ChecksTab({
if (list.length === 0) {
if (variant === 'page') {
return (
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<CircleDashed className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[13px] font-medium text-foreground">
No checks found
</span>
<span className="truncate text-[11px] text-muted-foreground">
This pull request has no reported checks yet.
</span>
<>
{fixChecksComposerDialog}
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<CircleDashed className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[13px] font-medium text-foreground">
No checks found
</span>
<span className="truncate text-[11px] text-muted-foreground">
This pull request has no reported checks yet.
</span>
</div>
{actions}
</div>
{actions}
</div>
</div>
</>
)
}
return (
<>
{fixChecksComposerDialog}
{compactHeader}
<div className="flex flex-col items-center justify-center gap-1 px-4 py-6 text-center">
<CircleDashed className="size-4 text-muted-foreground/60" />
@@ -4214,45 +4238,51 @@ function ChecksTab({
})
}
return (
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<SummaryIcon
className={cn(
'size-4 shrink-0',
summaryColor,
counts.pending > 0 && counts.failing === 0 && 'animate-spin'
)}
/>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-[13px] font-medium text-foreground">{summaryLabel}</span>
{countChips.length > 1 && (
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{countChips.map((chip, i) => (
<React.Fragment key={chip.label}>
{i > 0 && <span className="opacity-40">·</span>}
<span className={chip.className}>{chip.label}</span>
</React.Fragment>
))}
<>
{fixChecksComposerDialog}
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<SummaryIcon
className={cn(
'size-4 shrink-0',
summaryColor,
counts.pending > 0 && counts.failing === 0 && 'animate-spin'
)}
/>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-[13px] font-medium text-foreground">
{summaryLabel}
</span>
)}
</div>
{actions}
</div>
<div className="overflow-hidden rounded-lg border border-border/50 bg-card shadow-xs">
{sorted.map((check, index) => (
<div
key={getCheckDetailsKey(check)}
className={cn(index > 0 && 'border-t border-border/40')}
>
{renderCheckRow(check)}
{countChips.length > 1 && (
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{countChips.map((chip, i) => (
<React.Fragment key={chip.label}>
{i > 0 && <span className="opacity-40">·</span>}
<span className={chip.className}>{chip.label}</span>
</React.Fragment>
))}
</span>
)}
</div>
))}
{actions}
</div>
<div className="overflow-hidden rounded-lg border border-border/50 bg-card shadow-xs">
{sorted.map((check, index) => (
<div
key={getCheckDetailsKey(check)}
className={cn(index > 0 && 'border-t border-border/40')}
>
{renderCheckRow(check)}
</div>
))}
</div>
</div>
</div>
</>
)
}
return (
<>
{fixChecksComposerDialog}
{compactHeader}
<div className="max-h-[280px] overflow-y-auto p-1 scrollbar-sleek">
{sorted.map(renderCheckRow)}
@@ -37,7 +37,6 @@ import {
ConflictingFilesSection,
MergeConflictNotice,
ChecksList,
isMutablePRConversationComment,
PRCommentsList,
PRTriageStrip
} from './checks-panel-content'
@@ -51,9 +50,6 @@ import type {
PRComment
} from '../../../../shared/types'
import { getConnectionId } from '@/lib/connection-context'
import { openHttpLink } from '@/lib/http-link-routing'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import {
buildResolvePullRequestConflictsPrompt,
pickDefaultSourceControlAgent
@@ -70,8 +66,13 @@ import type {
} from '../../../../shared/hosted-review'
import { getHostedReviewCacheKey, refreshHostedReviewCard } from '@/store/slices/hosted-review'
import { toast } from 'sonner'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import {
classifyHostedReview,
type HostedReviewClassificationOptions
} from '../../../../shared/hosted-review-queue'
import { hostedReviewSummaryFromGitHubPRInfo } from '../../../../shared/hosted-review-github'
import { type ChecksPanelReview, gitHubPRToChecksPanelReview } from './checks-panel-review'
import { hostedReviewSummaryFromGitLabInfo } from '../../../../shared/hosted-review-gitlab'
import {
checksPanelAsyncResultKey,
checksPanelHostedReviewAsyncResultKey,
@@ -98,6 +99,16 @@ import { useMountedRef } from '@/hooks/useMountedRef'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { gitLabPipelineJobsToPRChecks } from '../../../../shared/gitlab-pipeline-checks'
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog'
import {
normalizeSourceControlAiSettings,
resolveSourceControlActionRecipe
} from '../../../../shared/source-control-ai'
import {
setSourceControlActionDefault,
type SourceControlActionRecipe,
type SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
const RUNTIME_SSH_STATUS_REFRESH_MS = 3000
const GIT_STATUS_FAILURE_RETRY_MS = 3000
@@ -110,6 +121,13 @@ type HostedReviewCreationSnapshot = {
data: HostedReviewCreationEligibility
}
type ChecksAgentComposerState = {
actionId: SourceControlLaunchActionId
title: string
description: string
prompt: string
launchSource: 'conflict_resolution' | 'task_page'
}
type ChecksPanelReviewHeaderProps = {
review: ChecksPanelReview
isRefreshing: boolean
@@ -270,6 +288,7 @@ export default function ChecksPanel(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const repo = useRepoById(activeWorktree?.repoId ?? null)
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const prCache = useAppStore((s) => s.prCache)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
@@ -319,14 +338,18 @@ export default function ChecksPanel(): React.JSX.Element {
const [checksLoading, setChecksLoading] = useState(false)
const [comments, setComments] = useState<PRComment[]>([])
const [commentsLoading, setCommentsLoading] = useState(false)
const [gitLabDetailsFetchedAt, setGitLabDetailsFetchedAt] = useState<number | null>(null)
const [emptyRefreshing, setEmptyRefreshing] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [conflictDetailsRefreshing, setConflictDetailsRefreshing] = useState(false)
const [createPrDialogOpen, setCreatePrDialogOpen] = useState(false)
const [createPrPushFirst, setCreatePrPushFirst] = useState(false)
const [isPublishingBranch, setIsPublishingBranch] = useState(false)
const [isResolvingConflictsWithAI, setIsResolvingConflictsWithAI] = useState(false)
const isResolvingConflictsWithAI = false
const [isFixingChecksWithAI, setIsFixingChecksWithAI] = useState(false)
const [agentComposerState, setAgentComposerState] = useState<ChecksAgentComposerState | null>(
null
)
const [hostedReviewCreationSnapshot, setHostedReviewCreationSnapshot] =
useState<HostedReviewCreationSnapshot | null>(null)
const [gitStatusSnapshot, setGitStatusSnapshot] = useState<ChecksPanelGitStatusSnapshot | null>(
@@ -340,9 +363,28 @@ export default function ChecksPanel(): React.JSX.Element {
const titleInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pollIntervalRef = useRef(30_000) // start at 30s, backs off to 120s
const mountedRef = useMountedRef()
const confirm = useConfirmationDialog()
const prevChecksRef = useRef<string>('')
const conflictSummaryRefreshKeyRef = useRef<string | null>(null)
const saveLaunchActionDefault = useCallback(
async (
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)
}
})
},
[updateSettings]
)
const asyncResultKeyRef = useRef<string>('')
const refreshRequestKeyRef = useRef<string | null>(null)
const refreshContextKeyRef = useRef<string | null>(null)
@@ -404,14 +446,14 @@ export default function ChecksPanel(): React.JSX.Element {
setChecksLoading(false)
setComments([])
setCommentsLoading(false)
setGitLabDetailsFetchedAt(null)
setIsRefreshing(false)
setEmptyRefreshing(false)
setConflictDetailsRefreshing(false)
setCreatePrDialogOpen(false)
setCreatePrPushFirst(false)
setIsPublishingBranch(false)
setIsResolvingConflictsWithAI(false)
setIsFixingChecksWithAI(false)
setAgentComposerState(null)
setHostedReviewCreationSnapshot(null)
setGitStatusSnapshot(null)
setGitStatusRefreshNonce((value) => value + 1)
@@ -987,6 +1029,7 @@ export default function ChecksPanel(): React.JSX.Element {
const result = gitLabPipelineJobsToPRChecks(details?.pipelineJobs ?? [])
setChecks(result)
setComments(gitLabMRCommentsToPRComments(details?.comments))
setGitLabDetailsFetchedAt(Date.now())
const signature = JSON.stringify(result.map((c) => `${c.name}:${c.status}:${c.conclusion}`))
pollIntervalRef.current =
signature === prevChecksRef.current
@@ -1000,6 +1043,7 @@ export default function ChecksPanel(): React.JSX.Element {
console.warn('Failed to fetch GitLab MR checks:', err)
setChecks([])
setComments([])
setGitLabDetailsFetchedAt(null)
} finally {
if (isCurrentAsyncResult(requestKey)) {
setChecksLoading(false)
@@ -1731,57 +1775,6 @@ export default function ChecksPanel(): React.JSX.Element {
]
)
const handleEditComment = useCallback(
async (comment: PRComment, body: string): Promise<boolean> => {
if (!pr?.prRepo || !isMutablePRConversationComment(comment)) {
return false
}
const result = await window.api.gh.updateIssueCommentBySlug({
owner: pr.prRepo.owner,
repo: pr.prRepo.repo,
commentId: comment.id,
body
})
if (!result.ok) {
toast.error(result.error.message)
return false
}
setComments((prev) =>
prev.map((entry) => (entry.id === comment.id ? { ...entry, body } : entry))
)
return true
},
[pr?.prRepo]
)
const handleDeleteComment = useCallback(
async (comment: PRComment): Promise<void> => {
if (!pr?.prRepo || !isMutablePRConversationComment(comment)) {
return
}
const confirmed = await confirm({
title: 'Delete comment?',
description: 'This will permanently remove the comment from the PR.',
confirmLabel: 'Delete',
confirmVariant: 'destructive'
})
if (!confirmed) {
return
}
const result = await window.api.gh.deleteIssueCommentBySlug({
owner: pr.prRepo.owner,
repo: pr.prRepo.repo,
commentId: comment.id
})
if (!result.ok) {
toast.error(result.error.message)
return
}
setComments((prev) => prev.filter((entry) => entry.id !== comment.id))
},
[pr?.prRepo, confirm]
)
const handleReplyToComment = useCallback(
async (comment: PRComment, body: string) => {
if (!repo || !prNumber || !pr?.prRepo) {
@@ -1835,68 +1828,23 @@ export default function ChecksPanel(): React.JSX.Element {
// not a local MERGE_HEAD, so the prompt must tell the agent how to reproduce
// the merge locally instead of reusing the live Source Control conflict prompt.
const handleResolveConflictsWithAI = useCallback(async (): Promise<void> => {
if (isResolvingConflictsWithAI || !activeWorktreeId || !activeConflictReview) {
if (!activeWorktreeId || !activeConflictReview) {
return
}
const requestKey = stateRequestKey
const conflictFiles = activeConflictReview.conflictSummary?.files ?? []
setIsResolvingConflictsWithAI(true)
try {
const connectionId = getConnectionId(activeWorktreeId)
if (connectionId === undefined) {
toast.error('Unable to resolve the workspace connection.')
return
}
const store = useAppStore.getState()
const detectedAgents =
typeof connectionId === 'string'
? await store.ensureRemoteDetectedAgents(connectionId)
: await store.ensureDetectedAgents()
const agent = pickDefaultSourceControlAgent(
store.settings?.defaultTuiAgent,
detectedAgents,
store.settings?.disabledTuiAgents
)
if (!agent) {
toast.error('No enabled AI agents. Configure agents in Settings.')
return
}
if (!isCurrentAsyncResult(requestKey)) {
return
}
const prompt = buildResolvePullRequestConflictsPrompt({
setAgentComposerState({
actionId: 'resolveConflicts',
title: 'Resolve Review Conflicts With AI',
description: 'Review and edit the full command input before starting an agent.',
prompt: buildResolvePullRequestConflictsPrompt({
reviewKind: activeConflictReview.provider === 'gitlab' ? 'MR' : 'PR',
baseRef: activeConflictReview.conflictSummary?.baseRef,
entries: conflictFiles.map((path) => ({ path })),
worktreePath: activeWorktreePath ?? null,
reviewKind: activeGitLabReview ? 'merge request' : 'pull request'
})
const result = launchAgentInNewTab({
agent,
worktreeId: activeWorktreeId,
prompt,
promptDelivery: 'submit-after-ready',
launchSource: 'conflict_resolution'
})
if (!result) {
toast.error('Could not build the agent launch command.')
return
}
if (result.tabId) {
focusTerminalTabSurface(result.tabId)
}
toast.success('Started an AI agent for the conflicts.')
} finally {
setIsResolvingConflictsWithAI(false)
}
}, [
activeConflictReview,
activeGitLabReview,
activeWorktreeId,
activeWorktreePath,
isCurrentAsyncResult,
isResolvingConflictsWithAI,
stateRequestKey
])
worktreePath: activeWorktreePath ?? null
}),
launchSource: 'conflict_resolution'
})
}, [activeConflictReview, activeWorktreeId, activeWorktreePath])
const handleFixChecksWithAI = useCallback(async (): Promise<void> => {
if (isFixingChecksWithAI || !activeWorktreeId || !activeReview) {
@@ -1910,28 +1858,6 @@ export default function ChecksPanel(): React.JSX.Element {
const requestKey = stateRequestKey
setIsFixingChecksWithAI(true)
try {
const connectionId = getConnectionId(activeWorktreeId)
if (connectionId === undefined) {
toast.error('Unable to resolve the workspace connection.')
return
}
const store = useAppStore.getState()
const detectedAgents =
typeof connectionId === 'string'
? await store.ensureRemoteDetectedAgents(connectionId)
: await store.ensureDetectedAgents()
const agent = pickDefaultSourceControlAgent(
store.settings?.defaultTuiAgent,
detectedAgents,
store.settings?.disabledTuiAgents
)
if (!agent) {
toast.error('No enabled AI agents. Configure agents in Settings.')
return
}
if (!isCurrentAsyncResult(requestKey)) {
return
}
const checkRunDetailsByCheckKey: Record<string, PRCheckRunDetails> = {}
if (activeReview.provider !== 'gitlab' && repo) {
await Promise.all(
@@ -1963,31 +1889,20 @@ export default function ChecksPanel(): React.JSX.Element {
if (!isCurrentAsyncResult(requestKey)) {
return
}
const prompt = buildFixBrokenChecksPrompt({
reviewKind: activeReview.provider === 'gitlab' ? 'MR' : 'PR',
reviewNumber: activeReview.number,
reviewTitle: activeReview.title,
reviewUrl: activeReview.url,
checks,
checkRunDetailsByCheckKey
setAgentComposerState({
actionId: 'fixChecks',
title: 'Fix Checks With AI',
description: 'Review and edit the full command input before starting an agent.',
prompt: buildFixBrokenChecksPrompt({
reviewKind: activeReview.provider === 'gitlab' ? 'MR' : 'PR',
reviewNumber: activeReview.number,
reviewTitle: activeReview.title,
reviewUrl: activeReview.url,
checks,
checkRunDetailsByCheckKey
}),
launchSource: 'task_page'
})
const result = launchAgentInNewTab({
agent,
worktreeId: activeWorktreeId,
prompt,
promptDelivery: 'submit-after-ready',
launchSource: 'task_page',
onPromptDelivered: () => {
toast.success('Started an AI agent for the broken checks.')
}
})
if (!result) {
toast.error('Could not build the agent launch command.')
return
}
if (result.tabId) {
focusTerminalTabSurface(result.tabId)
}
} finally {
setIsFixingChecksWithAI(false)
}
@@ -2149,12 +2064,9 @@ export default function ChecksPanel(): React.JSX.Element {
// Open hosted review in browser
const handleOpenPR = useCallback(() => {
if (activeReview?.url) {
// Why: route through openHttpLink so the PR/MR link honors the "open links
// in app" setting and lands in the Orca browser, matching terminal/editor
// links — instead of always launching the system browser.
openHttpLink(activeReview.url, { worktreeId: activeWorktreeId })
window.api.shell.openUrl(activeReview.url)
}
}, [activeReview, activeWorktreeId])
}, [activeReview])
const handleUnlinkPullRequest = useCallback(() => {
if (!activeWorktreeId || activeReview?.provider !== 'github' || linkedPR === null) {
@@ -2313,6 +2225,82 @@ export default function ChecksPanel(): React.JSX.Element {
]
)
const activeReviewClassification = React.useMemo(() => {
if (!repo) {
return null
}
const options: HostedReviewClassificationOptions = {
agentAuthorLogins: [],
viewer: null
}
if (activeGitLabReview) {
const commentsForClassification =
gitLabDetailsFetchedAt !== null && !commentsLoading ? comments : undefined
const summary = hostedReviewSummaryFromGitLabInfo({
review: activeGitLabReview,
comments: commentsForClassification,
checks
})
return classifyHostedReview(summary, options)
}
if (!pr) {
return null
}
let host = 'github.com'
let owner = 'unknown'
let repoName = 'unknown'
try {
const parsed = new URL(pr.url)
host = parsed.host || host
const segments = parsed.pathname.split('/').filter(Boolean)
if (segments.length >= 2) {
owner = segments[0]
repoName = segments[1]
}
} catch {
// Why: malformed URLs should not block queue-state classification.
}
// Why: unresolved thread data is paginated and fetched separately. Until
// comments have loaded for this PR, do not let queue badges imply a clean review.
const commentsForClassification =
commentsFetchedAt !== undefined && !commentsLoading ? comments : undefined
const summary = hostedReviewSummaryFromGitHubPRInfo({
pr,
owner,
repo: repoName,
host,
comments: commentsForClassification,
checks
})
return classifyHostedReview(summary, options)
}, [
activeGitLabReview,
repo,
gitLabDetailsFetchedAt,
commentsLoading,
comments,
checks,
pr,
commentsFetchedAt
])
const queueBadges = React.useMemo(() => {
if (!activeReviewClassification) {
return [] as string[]
}
const badges: string[] = []
if (activeReviewClassification.needsResponse) {
badges.push('Needs response')
}
// Why: viewer/author/requestedReviewer signals are not wired into the
// ChecksPanel call site yet, so `state` and `requested` would mis-classify
// every PR (collapsing to 'teammate'). Suppress those badges until the
// inputs are available; needs-response works from PR metadata alone and
// remains accurate.
return badges
}, [activeReviewClassification])
// ── Empty state ──
if (!activeWorktree) {
return (
@@ -2514,6 +2502,18 @@ export default function ChecksPanel(): React.JSX.Element {
{reviewShortLabel} updated {new Date(activeReview.updatedAt).toLocaleString()}
</div>
)}
{queueBadges.length > 0 ? (
<div className="flex flex-wrap gap-1">
{queueBadges.map((badge) => (
<span
key={badge}
className="rounded-full border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{badge}
</span>
))}
</div>
) : null}
{/* Merge / Delete Workspace actions */}
{activeReview && activeWorktree && repo && (
@@ -2572,8 +2572,49 @@ export default function ChecksPanel(): React.JSX.Element {
onAddComment={pr ? handleAddPRComment : undefined}
onReply={pr ? handleReplyToComment : undefined}
onResolve={pr || activeGitLabReview ? handleResolve : undefined}
onEditComment={pr ? handleEditComment : undefined}
onDeleteComment={pr ? handleDeleteComment : undefined}
/>
<SourceControlAgentActionDialog
open={agentComposerState !== null}
onOpenChange={(open) => {
if (!open) {
setAgentComposerState(null)
}
}}
actionId={agentComposerState?.actionId ?? 'fixChecks'}
title={agentComposerState?.title ?? 'Fix With AI'}
description={agentComposerState?.description ?? ''}
baseCommandInput={agentComposerState?.prompt ?? ''}
worktreeId={activeWorktreeId}
groupId={activeWorktreeId}
connectionId={activeWorktreeId ? getConnectionId(activeWorktreeId) : null}
promptDelivery="submit-after-ready"
launchSource={agentComposerState?.launchSource ?? 'task_page'}
savedAgentId={
agentComposerState
? (resolveSourceControlActionRecipe({
settings,
repo,
actionId: agentComposerState.actionId
}).agentId ?? null)
: null
}
savedCommandInputTemplate={
agentComposerState
? (resolveSourceControlActionRecipe({
settings,
repo,
actionId: agentComposerState.actionId
}).commandInputTemplate ?? null)
: null
}
onSaveAgentDefault={saveLaunchActionDefault}
onLaunched={() => {
if (agentComposerState?.actionId === 'resolveConflicts') {
toast.success('Started an AI agent for the conflicts.')
} else {
toast.success('Started an AI agent for the broken checks.')
}
}}
/>
</div>
)
@@ -157,6 +157,20 @@ describe('CommitArea AI generation', () => {
expect(markup).toContain('aria-label="Generate commit message with AI"')
})
it('renders a single commit-message AI entry point in the composer', () => {
const props = baseProps({ hasMessage: false })
const markup = renderCommitArea({
...props,
commitMessage: '',
aiEnabled: true,
aiAgentConfigured: true
})
expect(markup.match(/aria-label="Generate commit message with AI"/g)).toHaveLength(1)
expect(markup).not.toContain('aria-label="Customize commit-message generation"')
expect(markup).not.toContain('aria-label="Add commit message instructions"')
})
it('can hide only the composer while keeping the split action surface visible', () => {
const markup = renderCommitArea({
...baseProps({ hasMessage: false, stagedCount: 0 }),
@@ -157,6 +157,7 @@ export function CreatePullRequestDialog({
worktreePath,
branch,
eligibility,
repo,
settings,
submitting,
prCreationDefaults,
@@ -13,7 +13,7 @@ describe('buildResolvePullRequestConflictsPrompt', () => {
expect(prompt).toContain(
'- Conflict source: pull request mergeability check (the local worktree may not have MERGE_HEAD yet).'
)
expect(prompt).toContain('- Pull request base branch: "main"')
expect(prompt).toContain('- PR base branch: "main"')
expect(prompt).toContain('- Operation to create locally: merge')
expect(prompt).toContain('do not treat the handoff as stale')
expect(prompt).toContain('git fetch origin main')
@@ -29,10 +29,24 @@ describe('buildResolvePullRequestConflictsPrompt', () => {
entries: [{ path: 'src/conflict.ts' }]
})
expect(prompt).toContain('- Pull request base branch: "-upload-pack=sh"')
expect(prompt).toContain('- PR base branch: "-upload-pack=sh"')
expect(prompt).toContain('quoting the ref exactly for the current shell')
expect(prompt).toContain('after verifying the fetched ref exists')
expect(prompt).not.toContain('git fetch origin -upload-pack=sh')
expect(prompt).not.toContain('origin/-upload-pack=sh')
})
it('uses merge request wording for GitLab conflict prompts', () => {
const prompt = buildResolvePullRequestConflictsPrompt({
reviewKind: 'MR',
worktreePath: '/repo/worktree',
baseRef: 'main',
entries: [{ path: 'src/conflict.ts' }]
})
expect(prompt).toContain('reported for this merge request')
expect(prompt).toContain('- Conflict source: merge request mergeability check')
expect(prompt).toContain('- MR base branch: "main"')
expect(prompt).not.toContain('pull request')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,454 @@
/* eslint-disable max-lines -- Why: action launch customization keeps agent
selection, dry-run planning, save defaults, and execution in one modal. */
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { CheckCircle2, RefreshCw, RotateCcw, Settings, Sparkles, TriangleAlert } from 'lucide-react'
import AgentCombobox from '@/components/agent/AgentCombobox'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { AGENT_CATALOG, getAgentLabel } from '@/lib/agent-catalog'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import {
renderSourceControlActionCommandTemplate,
type SourceControlActionRecipe,
type SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import type { TuiAgent } from '../../../../shared/types'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import { planSourceControlAgentActionLaunch } from '@/lib/source-control-agent-action-plan'
import { pickSourceControlLaunchAgent } from '@/lib/source-control-launch-agent-selection'
import { toast } from 'sonner'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
type DeliveryPlanState =
| { status: 'idle' }
| { status: 'checking' }
| { status: 'success'; summary: string; commandLabel: string; caveat: string }
| { status: 'error'; error: string }
export type SourceControlAgentActionDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
actionId: SourceControlLaunchActionId
title: string
description: string
baseCommandInput: string
savedCommandInputTemplate?: string | null
worktreeId?: string | null
groupId?: string | null
connectionId?: string | null
promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready'
launchSource: LaunchSource
savedAgentId?: TuiAgent | null
onSaveAgentDefault?: (
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => void | Promise<void>
onOpenSettings?: () => void
onLaunched?: () => void
startLabel?: string
onStart?: (args: { agent: TuiAgent; commandInput: string }) => boolean | Promise<boolean>
}
function isAgentDetectedAndEnabled(
agent: TuiAgent | null,
detectedAgents: TuiAgent[],
disabledAgents: TuiAgent[] | undefined
): boolean {
return Boolean(
agent && detectedAgents.includes(agent) && isTuiAgentEnabled(agent, disabledAgents)
)
}
export function SourceControlAgentActionDialog({
open,
onOpenChange,
actionId,
title,
description,
baseCommandInput,
savedCommandInputTemplate,
worktreeId,
groupId,
connectionId,
promptDelivery = 'submit-after-ready',
launchSource,
savedAgentId,
onSaveAgentDefault,
onOpenSettings,
onLaunched,
startLabel = 'Start agent',
onStart
}: SourceControlAgentActionDialogProps): React.JSX.Element {
const settings = useAppStore((state) => state.settings)
const ensureDetectedAgents = useAppStore((state) => state.ensureDetectedAgents)
const ensureRemoteDetectedAgents = useAppStore((state) => state.ensureRemoteDetectedAgents)
const [commandTemplate, setCommandTemplate] = useState(
savedCommandInputTemplate ?? '{basePrompt}'
)
const [selectedAgent, setSelectedAgent] = useState<TuiAgent | null>(savedAgentId ?? null)
const [detectedAgents, setDetectedAgents] = useState<TuiAgent[]>([])
const [detecting, setDetecting] = useState(false)
const [deliveryPlan, setDeliveryPlan] = useState<DeliveryPlanState>({ status: 'idle' })
const [isStarting, setIsStarting] = useState(false)
const [saveAgentDefault, setSaveAgentDefault] = useState(false)
const disabledAgents = settings?.disabledTuiAgents
const connectionUnavailable = Boolean(worktreeId && connectionId === undefined)
const refreshDetectedAgents = useCallback(async (): Promise<TuiAgent[]> => {
if (connectionUnavailable) {
setDetectedAgents([])
setDetecting(false)
return []
}
setDetecting(true)
try {
const nextAgents =
typeof connectionId === 'string'
? await ensureRemoteDetectedAgents(connectionId)
: await ensureDetectedAgents()
setDetectedAgents(nextAgents)
return nextAgents
} finally {
setDetecting(false)
}
}, [connectionId, connectionUnavailable, ensureDetectedAgents, ensureRemoteDetectedAgents])
useEffect(() => {
if (!open) {
return
}
setCommandTemplate(savedCommandInputTemplate ?? '{basePrompt}')
setDeliveryPlan({ status: 'idle' })
setSaveAgentDefault(false)
setSelectedAgent(savedAgentId ?? null)
let stale = false
void refreshDetectedAgents().then((nextAgents) => {
if (stale) {
return
}
setSelectedAgent((current) =>
isAgentDetectedAndEnabled(current, nextAgents, disabledAgents)
? current
: current
? current
: pickSourceControlLaunchAgent({
savedAgent: savedAgentId,
defaultAgent: settings?.defaultTuiAgent,
detectedAgents: nextAgents,
disabledAgents
})
)
})
return () => {
stale = true
}
}, [
baseCommandInput,
disabledAgents,
open,
refreshDetectedAgents,
savedAgentId,
savedCommandInputTemplate,
settings?.defaultTuiAgent
])
const enabledDetectedAgents = useMemo(
() => detectedAgents.filter((agent) => isTuiAgentEnabled(agent, disabledAgents)),
[detectedAgents, disabledAgents]
)
const agentOptions = useMemo(
() =>
AGENT_CATALOG.filter(
(entry) => enabledDetectedAgents.includes(entry.id) || entry.id === selectedAgent
),
[enabledDetectedAgents, selectedAgent]
)
const selectedAgentUnavailable = Boolean(
selectedAgent && !isAgentDetectedAndEnabled(selectedAgent, detectedAgents, disabledAgents)
)
const hasEnabledAgents = enabledDetectedAgents.length > 0
const commandInput = renderSourceControlActionCommandTemplate(commandTemplate, {
basePrompt: baseCommandInput
})
const trimmedCommandInput = commandInput.trim()
const canStart =
Boolean(trimmedCommandInput) &&
Boolean(selectedAgent) &&
!selectedAgentUnavailable &&
!connectionUnavailable &&
!detecting &&
!isStarting
const buildPlan = useCallback(
async (agentsOverride?: TuiAgent[]): Promise<DeliveryPlanState> => {
const currentDetectedAgents = agentsOverride ?? (await refreshDetectedAgents())
if (connectionUnavailable) {
return { status: 'error', error: 'Unable to resolve the workspace connection.' }
}
const result = planSourceControlAgentActionLaunch({
agent: selectedAgent,
commandInput,
promptDelivery,
detectedAgents: currentDetectedAgents,
disabledAgents: useAppStore.getState().settings?.disabledTuiAgents,
cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides
})
if (!result.ok) {
return { status: 'error', error: result.error }
}
return {
status: 'success',
summary: result.summary,
commandLabel: result.commandLabel,
caveat: result.caveat
}
},
[commandInput, connectionUnavailable, promptDelivery, refreshDetectedAgents, selectedAgent]
)
const handleCheckDelivery = useCallback(async () => {
setDeliveryPlan({ status: 'checking' })
setDeliveryPlan(await buildPlan())
}, [buildPlan])
const handleStart = useCallback(async () => {
if (!selectedAgent || isStarting) {
return
}
if (connectionUnavailable) {
setDeliveryPlan({ status: 'error', error: 'Unable to resolve the workspace connection.' })
return
}
setIsStarting(true)
try {
const nextAgents = await refreshDetectedAgents()
const nextPlan = await buildPlan(nextAgents)
if (nextPlan.status === 'error') {
setDeliveryPlan(nextPlan)
return
}
setDeliveryPlan(nextPlan)
let launched = false
if (onStart) {
launched = await onStart({ agent: selectedAgent, commandInput: trimmedCommandInput })
} else if (worktreeId) {
const result = launchAgentInNewTab({
agent: selectedAgent,
worktreeId,
groupId: groupId ?? worktreeId,
prompt: trimmedCommandInput,
promptDelivery,
launchSource
})
launched = Boolean(result)
if (result) {
focusTerminalTabSurface(result.tabId)
}
}
if (!launched) {
toast.error('Could not start the selected agent.')
return
}
if (saveAgentDefault && onSaveAgentDefault) {
await onSaveAgentDefault(actionId, {
agentId: selectedAgent,
commandInputTemplate: commandTemplate
})
}
onLaunched?.()
onOpenChange(false)
} finally {
setIsStarting(false)
}
}, [
actionId,
buildPlan,
commandTemplate,
connectionUnavailable,
groupId,
isStarting,
launchSource,
onLaunched,
onOpenChange,
onSaveAgentDefault,
onStart,
promptDelivery,
refreshDetectedAgents,
saveAgentDefault,
selectedAgent,
trimmedCommandInput,
worktreeId
])
const statusCopy = selectedAgentUnavailable
? `${getAgentLabel(selectedAgent!)} is not enabled or was not detected on this workspace host.`
: connectionUnavailable
? 'Unable to resolve the workspace connection.'
: !hasEnabledAgents && !detecting
? 'No enabled agents were detected on this workspace host.'
: null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">{description}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs">Agent</Label>
{hasEnabledAgents || selectedAgent ? (
<AgentCombobox
agents={agentOptions}
value={selectedAgent}
onValueChange={(agent) => {
setSelectedAgent(agent)
setDeliveryPlan({ status: 'idle' })
}}
allowNarrowTrigger
triggerClassName="w-full"
/>
) : (
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<span>{detecting ? 'Detecting agents…' : 'No enabled agents'}</span>
{onOpenSettings ? (
<Button type="button" variant="ghost" size="xs" onClick={onOpenSettings}>
<Settings className="size-3.5" />
Settings
</Button>
) : null}
</div>
)}
{statusCopy ? (
<p className="flex items-start gap-1.5 text-[11px] text-destructive">
<TriangleAlert className="mt-px size-3 shrink-0" />
<span>{statusCopy}</span>
</p>
) : null}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="source-control-agent-command-input" className="text-xs">
Command template
</Label>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => {
setCommandTemplate(savedCommandInputTemplate ?? '{basePrompt}')
setDeliveryPlan({ status: 'idle' })
}}
>
<RotateCcw className="size-3.5" />
Reset
</Button>
</div>
<textarea
id="source-control-agent-command-input"
rows={12}
value={commandTemplate}
onChange={(event) => {
setCommandTemplate(event.target.value)
setDeliveryPlan({ status: 'idle' })
}}
className="min-h-[14rem] w-full resize-y rounded-md border border-border bg-background px-2.5 py-2 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
/>
<SourceControlActionVariableChips
actionId={actionId}
onInsert={(variable) => {
const separator =
commandTemplate.endsWith('\n') || commandTemplate.length === 0 ? '' : ' '
setCommandTemplate(`${commandTemplate}${separator}{${variable}}`)
setDeliveryPlan({ status: 'idle' })
}}
/>
</div>
{onSaveAgentDefault && selectedAgent ? (
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={saveAgentDefault}
onChange={(event) => setSaveAgentDefault(event.target.checked)}
className="size-3.5 rounded border-border"
/>
Save {getAgentLabel(selectedAgent)} and this command template as the default for this
action
</label>
) : null}
{deliveryPlan.status !== 'idle' ? (
<div
className={cn(
'rounded-md border px-3 py-2 text-xs',
deliveryPlan.status === 'error'
? 'border-destructive/30 bg-destructive/5 text-destructive'
: 'border-border bg-muted/30 text-muted-foreground'
)}
>
{deliveryPlan.status === 'checking' ? (
<span className="inline-flex items-center gap-2">
<RefreshCw className="size-3.5 animate-spin" />
Checking delivery…
</span>
) : deliveryPlan.status === 'error' ? (
<span className="inline-flex items-start gap-2">
<TriangleAlert className="mt-px size-3.5 shrink-0" />
{deliveryPlan.error}
</span>
) : (
<div className="space-y-1.5">
<div className="flex items-start gap-2 text-foreground">
<CheckCircle2 className="mt-px size-3.5 shrink-0 text-emerald-500" />
<span>{deliveryPlan.summary}</span>
</div>
<div className="truncate font-mono text-[11px]">
Launch: {deliveryPlan.commandLabel}
</div>
<div className="text-[11px]">{deliveryPlan.caveat}</div>
</div>
)}
</div>
) : null}
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleCheckDelivery}>
{deliveryPlan.status === 'checking' ? (
<RefreshCw className="size-4 animate-spin" />
) : (
<CheckCircle2 className="size-4" />
)}
Check delivery
</Button>
<Button type="button" size="sm" disabled={!canStart} onClick={() => void handleStart()}>
{isStarting ? (
<RefreshCw className="size-4 animate-spin" />
) : (
<Sparkles className="size-4" />
)}
{startLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import {
applyCommitMessageGenerationDefaults,
buildCommitMessageGenerationParams
} from './SourceControlCommitMessageGenerationDialog'
describe('buildCommitMessageGenerationParams', () => {
it('preserves the resolved model and thinking level for the selected agent', () => {
expect(
buildCommitMessageGenerationParams({
agentId: 'codex',
commandTemplate: '{basePrompt}\n\nUse Conventional Commits.',
baseParams: {
agentId: 'codex',
model: 'gpt-5.4-mini',
thinkingLevel: 'xhigh',
commandInputTemplate: '{basePrompt}',
agentCommandOverride: 'codex'
},
settings: { agentCmdOverrides: { codex: 'codex --profile work' } }
})
).toEqual({
agentId: 'codex',
model: 'gpt-5.4-mini',
thinkingLevel: 'xhigh',
commandInputTemplate: '{basePrompt}\n\nUse Conventional Commits.',
agentCommandOverride: 'codex --profile work'
})
})
it('keeps existing custom-command generation usable from the dialog', () => {
expect(
buildCommitMessageGenerationParams({
agentId: 'custom',
commandTemplate: '{basePrompt}\n\nPrefer ticket IDs.',
baseParams: {
agentId: 'custom',
model: '',
customPrompt: 'Prefer ticket IDs.',
commandInputTemplate: '{basePrompt}\n\nPrefer ticket IDs.',
customAgentCommand: 'my-commit-writer --prompt {prompt}'
},
settings: null
})
).toEqual({
agentId: 'custom',
model: '',
customPrompt: 'Prefer ticket IDs.',
commandInputTemplate: '{basePrompt}\n\nPrefer ticket IDs.',
customAgentCommand: 'my-commit-writer --prompt {prompt}'
})
})
it('saves custom-command templates without replacing the inherited custom agent', () => {
expect(
applyCommitMessageGenerationDefaults(
{
enabled: true,
agentId: 'custom',
selectedModelByAgent: {},
selectedThinkingByModel: {},
customAgentCommand: 'my-commit-writer',
instructionsByOperation: {},
actions: {
commitMessage: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}'
}
}
},
'local',
{
agentId: 'custom',
model: '',
commandInputTemplate: '{basePrompt}\n\nPrefer ticket IDs.',
customAgentCommand: 'my-commit-writer'
}
).actions?.commitMessage
).toEqual({
commandInputTemplate: '{basePrompt}\n\nPrefer ticket IDs.'
})
})
})
@@ -0,0 +1,354 @@
import React, { useEffect, useMemo, useState } from 'react'
import { CheckCircle2, RefreshCw, Save, Sparkles, Terminal, TriangleAlert } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
import { planSourceControlCommitMessageGeneration } from '@/lib/source-control-generation-plan'
import {
CUSTOM_AGENT_ID,
getCommitMessageAgentCapability,
isCustomAgentId,
listCommitMessageAgentCapabilities
} from '../../../../shared/commit-message-agent-spec'
import {
resolveSourceControlAiForOperation,
type ResolvedSourceControlAiGenerationParams
} from '../../../../shared/source-control-ai'
import { setSourceControlActionDefault } from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
import { toast } from 'sonner'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
type PlanState =
| { status: 'idle' }
| { status: 'success'; commandLabel: string; delivery: string; caveat: string }
| { status: 'error'; error: string }
type SourceControlCommitMessageGenerationDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
settings: GlobalSettings | null
repo?: Pick<Repo, 'sourceControlAi'> | null
discoveryHostKey: string
onGenerate: (params: ResolvedSourceControlAiGenerationParams) => void
onSaveDefaults: (params: ResolvedSourceControlAiGenerationParams) => Promise<void> | void
}
const UNCONFIGURED_AGENT_SELECT_VALUE = ''
type CommitMessageGenerationAgentChoice = ResolvedSourceControlAiGenerationParams['agentId'] | ''
function agentLabel(agentId: TuiAgent): string {
return AGENT_CATALOG.find((agent) => agent.id === agentId)?.label ?? agentId
}
export function buildCommitMessageGenerationParams(args: {
agentId: CommitMessageGenerationAgentChoice
commandTemplate: string
baseParams: ResolvedSourceControlAiGenerationParams | null
settings: Pick<GlobalSettings, 'agentCmdOverrides'> | null | undefined
}): ResolvedSourceControlAiGenerationParams | null {
if (!args.agentId) {
return null
}
if (isCustomAgentId(args.agentId)) {
return {
agentId: CUSTOM_AGENT_ID,
model: '',
customPrompt: args.baseParams?.customPrompt,
commandInputTemplate: args.commandTemplate,
customAgentCommand: args.baseParams?.customAgentCommand ?? ''
}
}
const capability = getCommitMessageAgentCapability(args.agentId)
if (!capability) {
return null
}
const sameResolvedAgent = args.baseParams?.agentId === args.agentId
const modelId =
sameResolvedAgent && args.baseParams?.model
? args.baseParams.model
: (capability.models.find((model) => model.id === capability.defaultModelId)?.id ??
capability.defaultModelId)
const model = capability.models.find((candidate) => candidate.id === modelId)
const thinkingLevel =
sameResolvedAgent && args.baseParams?.thinkingLevel
? args.baseParams.thinkingLevel
: model?.defaultThinkingLevel
const agentCommandOverride = args.settings?.agentCmdOverrides?.[args.agentId]?.trim()
return {
agentId: args.agentId,
model: modelId,
...(thinkingLevel ? { thinkingLevel } : {}),
commandInputTemplate: args.commandTemplate,
...(agentCommandOverride ? { agentCommandOverride } : {})
}
}
export function SourceControlCommitMessageGenerationDialog({
open,
onOpenChange,
settings,
repo,
discoveryHostKey,
onGenerate,
onSaveDefaults
}: SourceControlCommitMessageGenerationDialogProps): React.JSX.Element {
const resolved = useMemo(
() =>
settings
? resolveSourceControlAiForOperation({
settings,
repo: repo ?? null,
operation: 'commitMessage',
discoveryHostKey
})
: { ok: false as const, error: 'Settings are not loaded.' },
[discoveryHostKey, repo, settings]
)
const baseParams = resolved.ok ? resolved.value.params : null
const capabilities = useMemo(listCommitMessageAgentCapabilities, [])
const showCustomAgent = Boolean(
baseParams && (isCustomAgentId(baseParams.agentId) || baseParams.customAgentCommand?.trim())
)
const [agentId, setAgentId] = useState<CommitMessageGenerationAgentChoice>('')
const [commandTemplate, setCommandTemplate] = useState('{basePrompt}')
const [plan, setPlan] = useState<PlanState>({ status: 'idle' })
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open || !baseParams) {
return
}
setAgentId(baseParams.agentId)
setCommandTemplate(baseParams.commandInputTemplate ?? '{basePrompt}')
setPlan({ status: 'idle' })
}, [baseParams, open])
const params = buildCommitMessageGenerationParams({
agentId,
commandTemplate,
baseParams,
settings
})
const paramsPlanResult = params ? planSourceControlCommitMessageGeneration(params) : null
const canRunGeneration = Boolean(params && paramsPlanResult?.ok)
const handlePlan = (): void => {
if (!params || !paramsPlanResult) {
setPlan({ status: 'error', error: 'Choose an agent before checking generation.' })
return
}
setPlan(
paramsPlanResult.ok
? {
status: 'success',
commandLabel: paramsPlanResult.commandLabel,
delivery: paramsPlanResult.delivery,
caveat: paramsPlanResult.caveat
}
: { status: 'error', error: paramsPlanResult.error }
)
}
const handleGenerate = (): void => {
if (!params || !paramsPlanResult?.ok) {
if (paramsPlanResult && !paramsPlanResult.ok) {
setPlan({ status: 'error', error: paramsPlanResult.error })
}
return
}
onGenerate(params)
onOpenChange(false)
}
const handleSaveDefaults = async (): Promise<void> => {
if (!params || saving || !paramsPlanResult?.ok) {
if (paramsPlanResult && !paramsPlanResult.ok) {
setPlan({ status: 'error', error: paramsPlanResult.error })
}
return
}
setSaving(true)
try {
await onSaveDefaults(params)
toast.success('Saved commit-message recipe.')
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-sm">Generate Commit Message</DialogTitle>
<DialogDescription className="text-xs">
Choose the agent and command template for this run.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{!resolved.ok ? (
<p className="flex items-start gap-1.5 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<TriangleAlert className="mt-px size-3.5 shrink-0" />
{resolved.error}
</p>
) : null}
<div className="space-y-2">
<Label className="text-xs">Agent</Label>
<Select
value={agentId || UNCONFIGURED_AGENT_SELECT_VALUE}
onValueChange={(value) => {
if (value === UNCONFIGURED_AGENT_SELECT_VALUE) {
return
}
setAgentId(value === CUSTOM_AGENT_ID ? CUSTOM_AGENT_ID : (value as TuiAgent))
setPlan({ status: 'idle' })
}}
>
<SelectTrigger size="sm" className="h-8 text-xs">
<SelectValue placeholder="Choose agent" />
</SelectTrigger>
<SelectContent>
{capabilities.map((capability) => (
<SelectItem key={capability.id} value={capability.id}>
<span className="flex items-center gap-2">
<AgentIcon agent={capability.id} size={14} />
{agentLabel(capability.id)}
</span>
</SelectItem>
))}
{showCustomAgent ? (
<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}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="commit-message-command-template" className="text-xs">
Command template
</Label>
<textarea
id="commit-message-command-template"
rows={8}
value={commandTemplate}
spellCheck={false}
onChange={(event) => {
setCommandTemplate(event.target.value)
setPlan({ status: 'idle' })
}}
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"
/>
<SourceControlActionVariableChips
actionId="commitMessage"
onInsert={(variable) => {
const separator =
commandTemplate.endsWith('\n') || commandTemplate.length === 0 ? '' : ' '
setCommandTemplate(`${commandTemplate}${separator}{${variable}}`)
setPlan({ status: 'idle' })
}}
/>
</div>
{plan.status !== 'idle' ? (
<div
className={
plan.status === 'error'
? 'rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive'
: 'space-y-1 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground'
}
>
{plan.status === 'error' ? (
<span className="flex items-start gap-2">
<TriangleAlert className="mt-px size-3.5 shrink-0" />
{plan.error}
</span>
) : (
<>
<div className="flex items-start gap-2 text-foreground">
<CheckCircle2 className="mt-px size-3.5 shrink-0 text-emerald-500" />
{plan.delivery}
</div>
<div className="truncate font-mono text-[11px]">Launch: {plan.commandLabel}</div>
<div className="text-[11px]">{plan.caveat}</div>
</>
)}
</div>
) : null}
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" size="sm" onClick={handlePlan}>
<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>
<Button type="button" size="sm" disabled={!canRunGeneration} onClick={handleGenerate}>
<Sparkles className="size-4" />
Generate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function applyCommitMessageGenerationDefaults(
current: SourceControlAiSettings,
_hostKey: string,
params: ResolvedSourceControlAiGenerationParams
): SourceControlAiSettings {
if (params.agentId === CUSTOM_AGENT_ID) {
const currentCommitRecipe = current.actions?.commitMessage ?? {}
const { agentId: _agentId, ...recipeWithoutAgent } = currentCommitRecipe
return {
...current,
actions: {
...current.actions,
commitMessage: {
...recipeWithoutAgent,
commandInputTemplate: params.commandInputTemplate ?? '{basePrompt}'
}
}
}
}
return {
...current,
actions: setSourceControlActionDefault(current.actions, 'commitMessage', {
agentId: params.agentId,
commandInputTemplate: params.commandInputTemplate ?? '{basePrompt}'
})
}
}
@@ -1,6 +1,5 @@
/* eslint-disable max-lines -- Why: field state, base search, AI generation,
and cancellation share request guards that need to stay in one hook. */
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: PR defaults, base-ref search, and generated fields are synchronized with runtime git IPC and cancellation tokens. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { getConnectionId } from '@/lib/connection-context'
import { useAppStore, type AppState } from '@/store'
@@ -13,16 +12,14 @@ import {
getRuntimeRepoBaseRefDefault,
searchRuntimeRepoBaseRefDetails
} from '@/runtime/runtime-repo-client'
import {
isCustomAgentId,
resolveCommitMessageAgentChoice
} from '../../../../shared/commit-message-agent-spec'
import type { Repo } from '../../../../shared/types'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs'
import type { BaseRefSearchResult } from '../../../../shared/types'
import {
DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
normalizeSourceControlAiSettings
normalizeSourceControlAiSettings,
resolveSourceControlAiForOperation
} from '../../../../shared/source-control-ai'
import type { SourceControlAiPrCreationDefaults } from '../../../../shared/source-control-ai-types'
@@ -42,6 +39,7 @@ type UseCreatePullRequestDialogFieldsOptions = {
worktreePath: string
branch: string
eligibility: HostedReviewCreationEligibility | null
repo?: Pick<Repo, 'sourceControlAi'> | null
settings: AppState['settings']
submitting: boolean
prCreationDefaults?: SourceControlAiPrCreationDefaults
@@ -98,6 +96,7 @@ export function useCreatePullRequestDialogFields({
worktreePath,
branch,
eligibility,
repo,
settings,
submitting,
prCreationDefaults,
@@ -111,11 +110,13 @@ export function useCreatePullRequestDialogFields({
const sourceControlAi = settings
? normalizedSourceControlAi
: { ...normalizedSourceControlAi, enabled: false }
const effectiveCommitMessageAgentId = resolveCommitMessageAgentChoice(
sourceControlAi.agentId,
settings?.defaultTuiAgent,
settings?.disabledTuiAgents
)
const resolvedPullRequestAi = settings
? resolveSourceControlAiForOperation({
settings,
repo,
operation: 'pullRequest'
})
: null
const resolvedPrDefaults = {
...DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
...prCreationDefaults
@@ -309,16 +310,9 @@ export function useCreatePullRequestDialogFields({
let generateDisabledReason: string | undefined
if (submitting) {
generateDisabledReason = 'Create PR in progress...'
} else if (!sourceControlAi.enabled) {
generateDisabledReason = 'Enable Git AI Author in Settings -> Git.'
} else if (!effectiveCommitMessageAgentId) {
generateDisabledReason = 'Pick an agent in Settings -> Git -> Git AI Author.'
} else if (isCustomAgentId(effectiveCommitMessageAgentId)) {
const command = sourceControlAi.customAgentCommand?.trim() ?? ''
if (!command) {
generateDisabledReason =
'Custom command is empty. Add one in Settings -> Git -> Git AI Author.'
}
} else if (!resolvedPullRequestAi?.ok) {
generateDisabledReason =
resolvedPullRequestAi?.error ?? 'Enable Source Control AI in Settings -> Git.'
} else if (!base.trim()) {
generateDisabledReason = 'Choose a base branch before generating.'
}
@@ -0,0 +1,276 @@
/* eslint-disable max-lines -- Why: the setting owns one collapsed form with
queued writes and command-template draft state. Splitting the tiny
subcontrols would make the settings write flow harder to audit. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/types'
import type {
SourceControlAiSettingsPatch,
SourceControlAiSettings
} from '../../../../shared/source-control-ai-types'
import { buildBranchNamePrompt } from '../../../../shared/branch-name-from-work'
import { normalizeSourceControlAiSettings } from '../../../../shared/source-control-ai'
import {
resolveSourceControlActionCommandTemplate,
setSourceControlActionDefault
} from '../../../../shared/source-control-ai-actions'
import { cn } from '@/lib/utils'
import { useAppStore } from '../../store'
import { Button } from '../ui/button'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'
import { Label } from '../ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { AUTO_RENAME_BRANCH_ADVANCED_SEARCH_ENTRIES } from './auto-rename-branch-search'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
type AutoRenameBranchFromWorkSettingProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void | Promise<void>
writeSourceControlAiSettings: (patch: SourceControlAiSettingsPatch) => Promise<void>
forceVisible?: boolean
onBranchPromptDirtyChange?: (dirty: boolean) => void
branchPromptDiscardSignal?: number
settingsSearchQuery?: string
}
const BUILT_IN_BRANCH_NAME_PROMPT = buildBranchNamePrompt({
firstPrompt: '{first agent prompt}',
assistantMessage: '{agent initial response, when available}'
})
export function shouldOpenAutoRenameBranchAdvanced(searchQuery: string): boolean {
return (
normalizeSettingsSearchQuery(searchQuery) !== '' &&
matchesSettingsSearch(searchQuery, AUTO_RENAME_BRANCH_ADVANCED_SEARCH_ENTRIES)
)
}
function readSourceControlSettings(settings: GlobalSettings): SourceControlAiSettings {
return normalizeSourceControlAiSettings(settings.sourceControlAi, settings.commitMessageAi)
}
export function AutoRenameBranchFromWorkSetting({
settings,
updateSettings,
writeSourceControlAiSettings,
forceVisible = false,
onBranchPromptDirtyChange,
branchPromptDiscardSignal,
settingsSearchQuery
}: AutoRenameBranchFromWorkSettingProps): React.JSX.Element {
const storeSearchQuery = useAppStore((state) => state.settingsSearchQuery)
const searchQuery = settingsSearchQuery ?? storeSearchQuery
const config = readSourceControlSettings(settings)
const [optionsOpen, setOptionsOpen] = useState(false)
const advancedSearchOpen = shouldOpenAutoRenameBranchAdvanced(searchQuery)
const advancedOpen = optionsOpen || advancedSearchOpen
const persistedBranchNameTemplate = resolveSourceControlActionCommandTemplate(
config.actions,
'branchName'
)
const persistedBranchNameTemplateRef = useRef(persistedBranchNameTemplate)
persistedBranchNameTemplateRef.current = persistedBranchNameTemplate
const [branchNameTemplateDraft, setBranchNameTemplateDraft] = useState(
persistedBranchNameTemplate
)
const [isSavingPrompt, setIsSavingPrompt] = useState(false)
const branchNamePromptDirty = branchNameTemplateDraft !== persistedBranchNameTemplate
useEffect(() => {
if (!branchNamePromptDirty) {
setBranchNameTemplateDraft(persistedBranchNameTemplate)
}
}, [branchNamePromptDirty, persistedBranchNameTemplate])
useEffect(() => {
setBranchNameTemplateDraft(persistedBranchNameTemplateRef.current)
// Why: Settings owns the discard confirmation, but the draft lives here so
// the row can keep its prompt-specific save/discard affordances.
}, [branchPromptDiscardSignal])
useEffect(() => {
onBranchPromptDirtyChange?.(branchNamePromptDirty)
}, [branchNamePromptDirty, onBranchPromptDirtyChange])
const onBranchPromptDirtyChangeRef = useRef(onBranchPromptDirtyChange)
onBranchPromptDirtyChangeRef.current = onBranchPromptDirtyChange
const setSettingRootRef = useCallback((node: HTMLDivElement | null): void => {
if (node !== null) {
return
}
// Why: Settings owns the global unsaved-branch-prompt guard; reset it
// when this setting detaches without a passive cleanup-only Effect.
onBranchPromptDirtyChangeRef.current?.(false)
}, [])
const onSavePrompt = async (): Promise<void> => {
if (!branchNamePromptDirty || isSavingPrompt) {
return
}
setIsSavingPrompt(true)
try {
await writeSourceControlAiSettings((current) => ({
actions: setSourceControlActionDefault(current.actions, 'branchName', {
commandInputTemplate: branchNameTemplateDraft
})
}))
} finally {
setIsSavingPrompt(false)
}
}
const onDiscardPrompt = (): void => {
setBranchNameTemplateDraft(persistedBranchNameTemplate)
}
return (
<SearchableSetting
title="Auto-Rename Branch"
description="Rename the auto-generated branch based on the work once an agent starts."
keywords={[
'branch',
'rename',
'auto',
'creature name',
'agent',
'prompt',
'command',
'template',
'worktree',
'slug'
]}
forceVisible={forceVisible || branchNamePromptDirty || advancedSearchOpen}
className="space-y-3 py-2"
>
<div ref={setSettingRootRef} className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label>Auto-Rename Branch</Label>
<p className="text-xs text-muted-foreground">
When an agent starts working in a new workspace, Orca renames its auto-generated branch
(e.g. <code>Nautilus</code>) to a short name summarizing the task. Only branches Orca
named itself are renamed, and never after they have been pushed.
</p>
</div>
<button
role="switch"
aria-checked={settings.autoRenameBranchFromWork}
onClick={() =>
updateSettings({
autoRenameBranchFromWork: !settings.autoRenameBranchFromWork
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.autoRenameBranchFromWork ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.autoRenameBranchFromWork ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<Collapsible open={advancedOpen} onOpenChange={setOptionsOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="-ml-2 h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
Advanced
<ChevronDown
className={cn('size-3.5 transition-transform', advancedOpen && 'rotate-180')}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 space-y-3 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
<div className="space-y-2">
<div className="space-y-0.5">
<Label htmlFor="git-auto-rename-branch-name-template">
Branch name command template
</Label>
<p className="text-xs text-muted-foreground">
Use <code className="font-mono">{'{basePrompt}'}</code> to include Orca&apos;s{' '}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline rounded-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
built-in branch-name prompt
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
className="w-[520px] max-w-[calc(100vw-2rem)] p-3"
>
<div>
<pre className="scrollbar-sleek max-h-72 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
{BUILT_IN_BRANCH_NAME_PROMPT}
</pre>
</div>
</PopoverContent>
</Popover>
. You can also reference <code className="font-mono">{'{firstPrompt}'}</code> and{' '}
<code className="font-mono">{'{assistantMessage}'}</code>. Orca generates only the
final segment, like <code className="font-mono">fix-login-flow</code>; your branch
prefix setting still applies.
</p>
</div>
<textarea
id="git-auto-rename-branch-name-template"
rows={4}
value={branchNameTemplateDraft}
onChange={(event) => setBranchNameTemplateDraft(event.target.value)}
placeholder="{basePrompt}"
className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
/>
<SourceControlActionVariableChips
actionId="branchName"
onInsert={(variable) => {
const separator =
branchNameTemplateDraft.endsWith('\n') || branchNameTemplateDraft.length === 0
? ''
: ' '
setBranchNameTemplateDraft(`${branchNameTemplateDraft}${separator}{${variable}}`)
}}
/>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{branchNamePromptDirty ? 'Unsaved changes' : 'Saved'}
</p>
<div className="flex items-center gap-2">
{branchNamePromptDirty ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={onDiscardPrompt}
disabled={isSavingPrompt}
>
Discard
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void onSavePrompt()}
disabled={!branchNamePromptDirty || isSavingPrompt}
>
{isSavingPrompt ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</SearchableSetting>
)
}
@@ -10,19 +10,17 @@ import {
import { useAppStore } from '../../store'
import {
CommitMessageAiPane,
createCommitMessageInstructionDraftState,
getAgentCatalogForAction,
getCommitMessageSettingsPaneDiscoveryHostKey,
mergeDiscoveredModelsIntoCommitMessageConfig,
resolveCommitMessageInstructionDraftState
mergeDiscoveredModelsIntoCommitMessageConfig
} from './CommitMessageAiPane'
import { COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES } from './commit-message-ai-search'
function renderPane(settings: GlobalSettings, settingsSearchQuery = ''): string {
function renderPane(settings: GlobalSettings): string {
return renderToStaticMarkup(
React.createElement(CommitMessageAiPane, {
settings,
updateSettings: () => {},
settingsSearchQuery
updateSettings: () => {}
})
)
}
@@ -46,117 +44,19 @@ describe('CommitMessageAiPane', () => {
useAppStore.setState({ settingsSearchQuery: '' })
})
it('updates clean instruction drafts when persisted instructions change', () => {
const state = createCommitMessageInstructionDraftState(
{
commitMessage: 'commit-a',
pullRequest: 'pr-a',
branchName: 'branch-a'
},
1
)
const resolved = resolveCommitMessageInstructionDraftState(
state,
{
commitMessage: 'commit-b',
pullRequest: 'pr-a',
branchName: 'branch-b'
},
1
)
expect(resolved.draft).toEqual({
commitMessage: 'commit-b',
pullRequest: 'pr-a',
branchName: 'branch-b'
})
})
it('preserves dirty instruction drafts until the discard signal changes', () => {
const state = createCommitMessageInstructionDraftState(
{
commitMessage: 'commit-a',
pullRequest: 'pr-a',
branchName: 'branch-a'
},
1
)
state.draft.commitMessage = 'local edit'
const withExternalChange = resolveCommitMessageInstructionDraftState(
state,
{
commitMessage: 'commit-b',
pullRequest: 'pr-b',
branchName: 'branch-b'
},
1
)
expect(withExternalChange.draft).toEqual({
commitMessage: 'local edit',
pullRequest: 'pr-b',
branchName: 'branch-b'
})
const afterDiscard = resolveCommitMessageInstructionDraftState(
withExternalChange,
{
commitMessage: 'commit-b',
pullRequest: 'pr-b',
branchName: 'branch-b'
},
2
)
expect(afterDiscard.draft).toEqual({
commitMessage: 'commit-b',
pullRequest: 'pr-b',
branchName: 'branch-b'
})
})
it('renders only the opt-in control before the feature is enabled', () => {
const markup = renderPane(buildSettings())
expect(markup).toContain('Git AI Author')
expect(markup).toContain('Enable Git AI Author')
expect(markup).toContain('Source Control AI')
expect(markup).toContain('Enable Source Control AI')
expect(markup).toContain('aria-checked="false"')
expect(markup).not.toContain('Orca invokes this CLI')
expect(markup).not.toContain('Thinking Effort')
// The auto-name toggle depends on Git AI Author, so it is hidden while off.
expect(markup).not.toContain('Auto-name new workspaces from first message')
expect(markup).not.toContain('Action recipes')
expect(markup).not.toContain('Command template')
expect(markup).not.toContain('Default model')
expect(markup).not.toContain('Thinking effort')
})
it('renders the auto-name toggle once Git AI Author is enabled', () => {
const markup = renderPane(
buildSettings({
autoRenameBranchFromWork: true,
commitMessageAi: {
enabled: true,
agentId: 'codex',
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: { 'gpt-5.5': 'medium' },
customPrompt: '',
customAgentCommand: ''
}
})
)
expect(markup).toContain('Auto-name new workspaces from first message')
// Tuning lives in the Advanced -> Branch Names group, not on the toggle row.
expect(markup).toContain('Tune the model and prompt under Advanced')
})
it('surfaces the enable row when searching for auto-name while the feature is off', () => {
const markup = renderPane(buildSettings(), 'auto-name')
// Why: the toggle can't render while disabled, so an auto-name search should
// still guide the user to the Enable Git AI Author row.
expect(markup).toContain('Enable Git AI Author')
expect(markup).not.toContain('Auto-name new workspaces from first message')
})
it('renders model, thinking, and collapsed advanced customization for enabled preset agents', () => {
it('renders action recipes for every Source Control AI action', () => {
const markup = renderPane(
buildSettings({
commitMessageAi: {
@@ -171,139 +71,32 @@ describe('CommitMessageAiPane', () => {
)
expect(markup).toContain('aria-checked="true"')
expect(markup).toContain('Orca invokes this CLI')
expect(markup).toContain('Model')
expect(markup).toContain('Thinking Effort')
expect(markup).toContain('Advanced')
expect(markup).toContain('aria-expanded="false"')
// Match the group headings specifically: the auto-name toggle copy mentions
// "Branch Names", but the collapsed group heading must not be rendered.
expect(markup).not.toContain('>Commit Messages</h4>')
expect(markup).not.toContain('>Pull Requests</h4>')
expect(markup).not.toContain('>Branch Names</h4>')
expect(markup).not.toContain('Use a different model for commit message generation.')
expect(markup).not.toContain('Creation defaults')
expect(markup).not.toContain('Use a different model for branch name generation.')
expect(markup).not.toContain('Higher effort produces more careful messages')
expect(markup).not.toContain('Use Conventional Commits.')
expect(markup).not.toContain('Saved')
expect(markup).toContain('Action recipes')
expect(markup).toContain('Commit message')
expect(markup).toContain('Pull request details')
expect(markup).toContain('Branch name')
expect(markup).toContain('Commit failure fixes')
expect(markup).toContain('Broken checks fixes')
expect(markup).toContain('Conflict resolution')
expect(markup).toContain('Command template')
expect(markup).toContain('{basePrompt}')
expect(markup).toContain('{stagedPatch}')
expect(markup).toContain('Use Conventional Commits.')
expect(markup).not.toContain('Default model')
expect(markup).not.toContain('Thinking effort')
})
it('shows the enable row for Git AI Author search matches before the feature is enabled', () => {
const markup = renderPane(buildSettings(), 'customization')
expect(markup).toContain('Git AI Author')
expect(markup).toContain('Enable Git AI Author')
expect(markup).toContain('aria-checked="false"')
expect(markup).not.toContain('aria-expanded="false"')
expect(markup).not.toContain('Branch Names')
})
it('opens advanced customization for matching settings search terms', () => {
const markup = renderPane(
buildSettings({
commitMessageAi: {
enabled: true,
agentId: 'codex',
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: { 'gpt-5.5': 'medium' },
customPrompt: '',
customAgentCommand: ''
}
}),
'customization'
it('only offers non-interactive generation agents for text generation actions', () => {
expect(getAgentCatalogForAction('commitMessage', null).map((agent) => agent.id)).not.toContain(
'aider'
)
expect(markup).toContain('aria-expanded="true"')
expect(markup).toContain('Commit Messages')
expect(markup).toContain('Pull Requests')
expect(markup).toContain('Branch Names')
expect(markup).toContain('Creation defaults')
expect(getAgentCatalogForAction('pullRequest', null).map((agent) => agent.id)).not.toContain(
'aider'
)
expect(getAgentCatalogForAction('fixChecks', null).map((agent) => agent.id)).toContain('aider')
})
it('shows the nested branch name model control for branch name model search', () => {
const markup = renderPane(
buildSettings({
commitMessageAi: {
enabled: true,
agentId: 'codex',
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: { 'gpt-5.5': 'medium' },
customPrompt: '',
customAgentCommand: ''
}
}),
'branch name model'
)
expect(markup).toContain('aria-expanded="true"')
expect(markup).toContain('Branch Names')
expect(markup).toContain('Use a different model for branch name generation.')
})
it('shows the nested commit model control for commit message model search', () => {
const markup = renderPane(
buildSettings({
commitMessageAi: {
enabled: true,
agentId: 'codex',
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: { 'gpt-5.5': 'medium' },
customPrompt: '',
customAgentCommand: ''
}
}),
'commit message model'
)
expect(markup).toContain('aria-expanded="true"')
expect(markup).toContain('Commit Messages')
expect(markup).toContain('Use a different model for commit message generation.')
})
it('shows the nested commit model control for commit model search', () => {
const markup = renderPane(
buildSettings({
commitMessageAi: {
enabled: true,
agentId: 'codex',
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: { 'gpt-5.5': 'medium' },
customPrompt: '',
customAgentCommand: ''
}
}),
'commit model'
)
expect(markup).toContain('aria-expanded="true"')
expect(markup).toContain('Commit Messages')
expect(markup).toContain('Use a different model for commit message generation.')
})
it('shows the nested pull request model control for pr model search', () => {
const markup = renderPane(
buildSettings({
commitMessageAi: {
enabled: true,
agentId: 'codex',
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: { 'gpt-5.5': 'medium' },
customPrompt: '',
customAgentCommand: ''
}
}),
'pr model'
)
expect(markup).toContain('aria-expanded="true"')
expect(markup).toContain('Pull Requests')
expect(markup).toContain(
'Use a different model for pull request title and description generation.'
)
})
it('keeps the agent and model selectors aligned for long labels', () => {
it('keeps action agent selectors constrained for long labels', () => {
const markup = renderPane(
buildSettings({
commitMessageAi: {
@@ -317,30 +110,46 @@ describe('CommitMessageAiPane', () => {
})
)
expect(markup.match(/w-\[260px\]/g)).toHaveLength(2)
expect(markup.match(/shrink-0/g)?.length ?? 0).toBeGreaterThanOrEqual(2)
expect(markup.match(/sm:w-\[220px\]/g)?.length ?? 0).toBeGreaterThanOrEqual(6)
expect(markup.match(/shrink-0/g)?.length ?? 0).toBeGreaterThanOrEqual(6)
})
it('renders custom command settings for custom agents', () => {
it('renders saved custom action templates without the old custom command schema', () => {
const markup = renderPane(
buildSettings({
commitMessageAi: {
sourceControlAi: {
enabled: true,
agentId: 'custom',
agentId: null,
selectedModelByAgent: {},
selectedModelByAgentByHost: {},
discoveredModelsByAgent: {},
discoveredModelsByAgentByHost: {},
selectedThinkingByModel: {},
customPrompt: '',
customAgentCommand: 'ollama run llama3.1 {prompt}'
instructionsByOperation: { commitMessage: '', pullRequest: '', branchName: '' },
customAgentCommand: '',
actions: {
commitMessage: {
agentId: 'codex',
commandInputTemplate: 'use $best-commit-msg to write a commit'
},
fixChecks: {
agentId: 'claude',
commandInputTemplate: 'use /fix-ci-issue to fix the linked CI bug'
}
},
prCreationDefaults: {},
launchActionDefaults: {}
}
})
)
expect(markup).toContain('Git AI Author')
expect(markup).toContain('Custom command')
expect(markup).toContain('ollama run llama3.1 {prompt}')
expect(markup).toContain('Source Control AI')
expect(markup).toContain('use $best-commit-msg to write a commit')
expect(markup).toContain('use /fix-ci-issue to fix the linked CI bug')
expect(markup).not.toContain('Custom command')
})
it('shows an unconfigured state when the default agent is unsupported', () => {
it('allows default-agent recipes even when the old default generator is unsupported', () => {
const markup = renderPane(
buildSettings({
defaultTuiAgent: 'aider',
@@ -355,14 +164,13 @@ describe('CommitMessageAiPane', () => {
})
)
expect(markup).toContain('Not configured')
expect(markup).toContain('Your default agent is Aider')
expect(markup).toContain('Choose a supported agent or Custom')
expect(markup).not.toContain('Which model the selected agent uses')
expect(markup).not.toContain('Thinking Effort')
expect(markup).toContain('Action recipes')
expect(markup).toContain('{basePrompt}')
expect(markup).not.toContain('Not configured')
expect(markup).not.toContain('Thinking effort')
})
it('shows Gemini as coming soon instead of a selectable generator', () => {
it('removes the old Gemini text-generation lockout from the settings pane', () => {
const markup = renderPane(
buildSettings({
commitMessageAi: {
@@ -376,18 +184,18 @@ describe('CommitMessageAiPane', () => {
})
)
expect(markup).toContain('Gemini')
expect(markup).toContain('Gemini Git AI Author is coming soon')
expect(markup).not.toContain('Which model Git AI Author uses')
expect(markup).toContain('Action recipes')
expect(markup).not.toContain('Gemini Source Control AI is coming soon')
expect(markup).not.toContain('Which model Source Control AI uses')
})
it('keeps custom command discoverable in settings search metadata', () => {
const customCommandEntry = COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES.find(
(entry) => entry.title === 'Custom command'
it('keeps action recipes discoverable in settings search metadata', () => {
const actionRecipesEntry = COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES.find(
(entry) => entry.title === 'Action recipes'
)
expect(customCommandEntry?.keywords).toEqual(
expect.arrayContaining(['custom', 'command', 'ollama'])
expect(actionRecipesEntry?.keywords).toEqual(
expect.arrayContaining(['agent', 'command', 'template', 'ci'])
)
})
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,8 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { useAppStore } from '../../store'
import { GitPane } from './GitPane'
import { shouldOpenAutoRenameBranchAdvanced } from './AutoRenameBranchFromWorkSetting'
import { GitPane, shouldShowAutoRenameBranchSetting } from './GitPane'
function renderGitPane(searchQuery: string): string {
useAppStore.setState({ settingsSearchQuery: searchQuery })
@@ -11,6 +12,7 @@ function renderGitPane(searchQuery: string): string {
React.createElement(GitPane, {
settings: getDefaultSettings('/tmp'),
updateSettings: () => {},
writeSourceControlAiSettings: async () => {},
displayedGitUsername: 'brennan',
settingsSearchQuery: searchQuery
})
@@ -18,16 +20,38 @@ function renderGitPane(searchQuery: string): string {
}
describe('GitPane', () => {
it('still renders its own git settings (e.g. Branch Prefix) on a matching search', () => {
expect(renderGitPane('branch prefix')).toContain('Branch Prefix')
it('keeps the auto-rename branch setting visible while its prompt draft is dirty', () => {
expect(shouldShowAutoRenameBranchSetting('zz-no-match', true)).toBe(true)
})
it('no longer renders the auto-name toggle or the relocated branch-name controls', () => {
// Why: the auto-name toggle moved to the Git AI Author pane (it depends on
// that feature), and its model/prompt tuning lives under Advanced -> Branch Names.
const markup = renderGitPane('rename')
expect(markup).not.toContain('Auto-name new workspaces from first message')
expect(markup).not.toContain('Branch name prompt')
expect(markup).not.toContain('Branch name model')
it('shows the auto-rename branch setting for advanced command-template searches', () => {
expect(shouldShowAutoRenameBranchSetting('instructions', false)).toBe(true)
expect(shouldShowAutoRenameBranchSetting('built-in prompt', false)).toBe(true)
expect(shouldShowAutoRenameBranchSetting('command template', false)).toBe(true)
expect(shouldShowAutoRenameBranchSetting('kebab-case', false)).toBe(true)
})
it('hides the auto-rename branch setting when search misses and the prompt draft is clean', () => {
expect(shouldShowAutoRenameBranchSetting('zz-no-match', false)).toBe(false)
})
it('opens auto-rename advanced controls when search matches hidden command-template fields', () => {
expect(shouldOpenAutoRenameBranchAdvanced('prompt')).toBe(true)
expect(shouldOpenAutoRenameBranchAdvanced('instructions')).toBe(true)
expect(shouldOpenAutoRenameBranchAdvanced('built-in prompt')).toBe(true)
expect(shouldOpenAutoRenameBranchAdvanced('command template')).toBe(true)
expect(shouldOpenAutoRenameBranchAdvanced('kebab-case')).toBe(true)
expect(shouldOpenAutoRenameBranchAdvanced('model')).toBe(false)
expect(shouldOpenAutoRenameBranchAdvanced('thinking')).toBe(false)
})
it('renders auto-rename advanced controls for advanced-only search terms', () => {
expect(renderGitPane('instructions')).toContain('Branch name command template')
expect(renderGitPane('command template')).toContain('Branch name command template')
})
it('keeps auto-rename advanced controls collapsed without an advanced search match', () => {
expect(shouldOpenAutoRenameBranchAdvanced('')).toBe(false)
expect(shouldOpenAutoRenameBranchAdvanced('creature name')).toBe(false)
})
})
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
import { createRepoAiDraftState, resolveRepoAiDraftState } from './RepositorySourceControlAiSection'
import {
createRepoAiDraftState,
dropRepoLegacyInstructionForAction,
resolveRepoAiDraftState
} from './RepositorySourceControlAiSection'
describe('RepositorySourceControlAiSection draft state', () => {
it('refreshes clean drafts when persisted repo overrides change', () => {
@@ -68,3 +73,36 @@ describe('RepositorySourceControlAiSection draft state', () => {
})
})
})
describe('dropRepoLegacyInstructionForAction', () => {
it('prevents legacy text instructions from remigrating after an action override is cleared', () => {
const next = dropRepoLegacyInstructionForAction(
{
instructionsByOperation: {
commitMessage: 'Use repo style.',
pullRequest: 'Use PR style.'
},
actionOverrides: {}
},
'commitMessage'
)
expect(next.instructionsByOperation).toEqual({ pullRequest: 'Use PR style.' })
const normalized = normalizeRepoSourceControlAiOverrides(next)
expect(normalized?.actionOverrides?.commitMessage).toBeUndefined()
expect(normalized?.actionOverrides?.pullRequest).toEqual({
commandInputTemplate: '{basePrompt}\n\nUse PR style.'
})
})
it('leaves launch action recipes alone because they have no legacy instruction key', () => {
const value = {
instructionsByOperation: { commitMessage: 'Use repo style.' },
actionOverrides: {
fixChecks: { commandInputTemplate: '{basePrompt}' }
}
}
expect(dropRepoLegacyInstructionForAction(value, 'fixChecks')).toBe(value)
})
})
@@ -1,69 +1,55 @@
/* eslint-disable max-lines -- Why: repo Source Control AI settings keep one
draft/save flow across model, instruction, and PR-default override groups. */
draft/save flow across action recipes and PR-default override groups. */
import { useMemo, useState } from 'react'
import type { Repo } from '../../../../shared/types'
import { Terminal } from 'lucide-react'
import type { Repo, TuiAgent } from '../../../../shared/types'
import type {
RepoSourceControlAiOverrides,
SourceControlAiOperation
SourceControlAiSettings
} from '../../../../shared/source-control-ai-types'
import {
clearSourceControlAiModelChoiceForHost,
normalizeRepoSourceControlAiOverrides,
normalizeSourceControlAiSettings,
readSourceControlAiModelChoiceForHost,
selectSourceControlAiModelChoiceForHost
normalizeSourceControlAiSettings
} from '../../../../shared/source-control-ai'
import {
getCommitMessageAgentCapability,
isCustomAgentId,
resolveCommitMessageAgentChoice
} from '../../../../shared/commit-message-agent-spec'
import {
getCommitMessageModelDiscoveryHostKeyForScope,
LOCAL_COMMIT_MESSAGE_HOST_KEY
} from '../../../../shared/commit-message-host-key'
SOURCE_CONTROL_ACTION_IDS,
SOURCE_CONTROL_ACTION_LABELS,
SOURCE_CONTROL_TEXT_ACTION_IDS,
resolveSourceControlActionCommandTemplate,
type SourceControlActionId
} from '../../../../shared/source-control-ai-actions'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { useAppStore } from '../../store'
import { getRuntimeGitScope } from '../../runtime/runtime-git-client'
import { getRepositorySourceControlAiSectionId } from './repository-settings-targets'
import { Button } from '../ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
import { listCommitMessageAgentCapabilities } from '../../../../shared/commit-message-agent-spec'
type RepositorySourceControlAiSectionProps = {
repo: Repo
updateRepo: (repoId: string, updates: Partial<Repo>) => void | Promise<boolean>
}
const INHERIT_MODEL_VALUE = '__inherit__'
const PROMPT_MODE_INHERIT = 'inherit'
const PROMPT_MODE_OVERRIDE = 'override'
const ACTION_MODE_INHERIT = 'inherit'
const ACTION_MODE_OVERRIDE = 'override'
const INHERIT_AGENT_VALUE = '__inherit_agent__'
const DEFAULT_AGENT_VALUE = '__default_agent__'
const SOURCE_CONTROL_TEXT_ACTION_ID_SET = new Set<string>(SOURCE_CONTROL_TEXT_ACTION_IDS)
const TEXT_GENERATION_AGENT_ID_SET = new Set(
listCommitMessageAgentCapabilities().map((capability) => capability.id)
)
const OPERATIONS: {
operation: SourceControlAiOperation
modelLabel: string
instructionLabel: string
globalPlaceholder: string
}[] = [
{
operation: 'commitMessage',
modelLabel: 'Commit message model',
instructionLabel: 'Commit message prompt',
globalPlaceholder: 'Global commit message prompt is empty.'
},
{
operation: 'pullRequest',
modelLabel: 'PR details model',
instructionLabel: 'Pull request prompt',
globalPlaceholder: 'Global pull request prompt is empty.'
},
{
operation: 'branchName',
modelLabel: 'Branch name model',
instructionLabel: 'Branch name prompt',
globalPlaceholder: 'Global branch name prompt is empty.'
}
]
const ACTION_DESCRIPTIONS: Record<SourceControlActionId, string> = {
commitMessage: 'Generate the commit message from staged changes.',
pullRequest: 'Generate the hosted review title and description.',
branchName: 'Rename Orca-created branches from the initial agent task.',
fixCommitFailure: 'Start an agent when a commit hook or git commit fails.',
fixChecks: 'Start an agent from failed hosted-review checks.',
resolveConflicts: 'Start an agent for local or hosted-review merge conflicts.'
}
type PrDefaultKey = keyof NonNullable<RepoSourceControlAiOverrides['prCreationDefaults']>
type RepoAiDraftState = {
@@ -72,11 +58,11 @@ type RepoAiDraftState = {
baseSerialized: string
}
function hasOwnPrompt(
prompts: RepoSourceControlAiOverrides['instructionsByOperation'],
operation: SourceControlAiOperation
function hasOwnActionOverride(
overrides: RepoSourceControlAiOverrides['actionOverrides'],
actionId: SourceControlActionId
): boolean {
return typeof prompts?.[operation] === 'string'
return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId)
}
function triStateValue(value: boolean | null | undefined): 'inherit' | 'on' | 'off' {
@@ -141,6 +127,48 @@ export function resolveRepoAiDraftState(
return current
}
function readInheritedCommandTemplate(
source: SourceControlAiSettings,
actionId: SourceControlActionId
): string {
return resolveSourceControlActionCommandTemplate(source.actions, actionId)
}
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 | null | undefined): string {
if (agentId === undefined) {
return INHERIT_AGENT_VALUE
}
return agentId ?? DEFAULT_AGENT_VALUE
}
function getAgentCatalogForAction(
actionId: SourceControlActionId,
selectedAgent: TuiAgent | null | undefined
): typeof AGENT_CATALOG {
if (!SOURCE_CONTROL_TEXT_ACTION_ID_SET.has(actionId)) {
return AGENT_CATALOG
}
return AGENT_CATALOG.filter(
(agent) => TEXT_GENERATION_AGENT_ID_SET.has(agent.id) || agent.id === selectedAgent
)
}
export function RepositorySourceControlAiSection({
repo,
updateRepo
@@ -151,26 +179,6 @@ export function RepositorySourceControlAiSection({
settings?.sourceControlAi,
settings?.commitMessageAi
)
const hostScope = getRuntimeGitScope(settings, repo.connectionId)
const hostKey = getCommitMessageModelDiscoveryHostKeyForScope(hostScope)
const agentId = resolveCommitMessageAgentChoice(
source.agentId,
settings?.defaultTuiAgent,
settings?.disabledTuiAgents
)
const baseCapability =
agentId && !isCustomAgentId(agentId) ? getCommitMessageAgentCapability(agentId) : null
const discoveredModels =
agentId && !isCustomAgentId(agentId)
? (source.discoveredModelsByAgentByHost?.[hostKey]?.[agentId] ??
(hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY
? source.discoveredModelsByAgent?.[agentId]
: undefined))
: undefined
const capability =
baseCapability && discoveredModels?.length
? { ...baseCapability, models: discoveredModels }
: baseCapability
const persistedRepoAi = useMemo(
() => normalizeRepoAiDraft(repo.sourceControlAi),
[repo.sourceControlAi]
@@ -239,7 +247,7 @@ export function RepositorySourceControlAiSection({
return
}
if (result === false) {
setSaveError('Failed to save Git AI Author settings.')
setSaveError('Failed to save Source Control AI settings.')
return
}
setDraftState((current) => {
@@ -255,7 +263,7 @@ export function RepositorySourceControlAiSection({
})
} catch {
if (mountedRef.current) {
setSaveError('Failed to save Git AI Author settings.')
setSaveError('Failed to save Source Control AI settings.')
}
} finally {
if (mountedRef.current) {
@@ -269,98 +277,71 @@ export function RepositorySourceControlAiSection({
setSaveError(null)
}
const updateModelOverride = (operation: SourceControlAiOperation, modelId: string): void => {
if (!capability) {
return
}
const updateActionMode = (actionId: SourceControlActionId, mode: string): void => {
updateDraftRepoAi((current) => {
const nextModelOverrides = { ...current.modelOverridesByOperation }
if (modelId === INHERIT_MODEL_VALUE) {
const nextChoice = clearSourceControlAiModelChoiceForHost(
nextModelOverrides[operation],
hostKey,
capability.id
)
if (nextChoice) {
nextModelOverrides[operation] = nextChoice
} else {
delete nextModelOverrides[operation]
const nextActionOverrides = { ...current.actionOverrides }
if (mode === ACTION_MODE_INHERIT) {
delete nextActionOverrides[actionId]
} else if (!hasOwnActionOverride(nextActionOverrides, actionId)) {
nextActionOverrides[actionId] = {
commandInputTemplate: readInheritedCommandTemplate(source, actionId)
}
return { ...current, modelOverridesByOperation: nextModelOverrides }
}
const model = capability.models.find((candidate) => candidate.id === modelId)
if (!model) {
return current
}
const nextChoice = selectSourceControlAiModelChoiceForHost(
current.modelOverridesByOperation?.[operation],
hostKey,
capability.id,
model.id
return dropRepoLegacyInstructionForAction(
{ ...current, actionOverrides: nextActionOverrides },
actionId
)
if (model.thinkingLevels && model.defaultThinkingLevel) {
nextChoice.selectedThinkingByModel = {
...nextChoice.selectedThinkingByModel,
[model.id]: nextChoice.selectedThinkingByModel?.[model.id] ?? model.defaultThinkingLevel
}
}
return {
...current,
modelOverridesByOperation: {
...nextModelOverrides,
[operation]: nextChoice
}
}
})
}
const updatePromptMode = (
operation: SourceControlAiOperation,
mode: string,
inheritedValue: string
): void => {
const updateActionAgent = (actionId: SourceControlActionId, value: string): void => {
updateDraftRepoAi((current) => {
const nextPrompts = { ...current.instructionsByOperation }
if (mode === PROMPT_MODE_INHERIT) {
delete nextPrompts[operation]
} else if (!hasOwnPrompt(nextPrompts, operation)) {
nextPrompts[operation] = inheritedValue
const nextActionOverrides = { ...current.actionOverrides }
const currentOverride = nextActionOverrides[actionId] ?? {
commandInputTemplate: readInheritedCommandTemplate(source, actionId)
}
return { ...current, instructionsByOperation: nextPrompts }
if (value === INHERIT_AGENT_VALUE) {
const { agentId: _agentId, ...rest } = currentOverride
nextActionOverrides[actionId] = rest
} else {
nextActionOverrides[actionId] = {
...currentOverride,
agentId: value === DEFAULT_AGENT_VALUE ? null : (value as TuiAgent)
}
}
return dropRepoLegacyInstructionForAction(
{ ...current, actionOverrides: nextActionOverrides },
actionId
)
})
}
const updatePromptOverride = (operation: SourceControlAiOperation, value: string): void => {
updateDraftRepoAi((current) => ({
...current,
instructionsByOperation: {
...current.instructionsByOperation,
[operation]: value
}
}))
}
const updateOperationThinking = (
operation: SourceControlAiOperation,
modelId: string,
value: string
): void => {
updateDraftRepoAi((current) => {
const choice = current.modelOverridesByOperation?.[operation]
return {
...current,
modelOverridesByOperation: {
...current.modelOverridesByOperation,
[operation]: {
...choice,
selectedThinkingByModel: {
...choice?.selectedThinkingByModel,
[modelId]: value
const updateActionTemplate = (actionId: SourceControlActionId, value: string): void => {
updateDraftRepoAi((current) =>
dropRepoLegacyInstructionForAction(
{
...current,
actionOverrides: {
...current.actionOverrides,
[actionId]: {
...current.actionOverrides?.[actionId],
commandInputTemplate: value
}
}
}
}
})
},
actionId
)
)
}
const appendVariable = (actionId: SourceControlActionId, variable: string): void => {
const override = repoAi.actionOverrides?.[actionId]
const currentTemplate =
typeof override?.commandInputTemplate === 'string'
? override.commandInputTemplate
: readInheritedCommandTemplate(source, actionId)
const separator = currentTemplate.endsWith('\n') || currentTemplate.length === 0 ? '' : ' '
updateActionTemplate(actionId, `${currentTemplate}${separator}{${variable}}`)
}
const updatePrDefault = (key: PrDefaultKey, value: string): void => {
@@ -390,9 +371,10 @@ export function RepositorySourceControlAiSection({
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<h3 className="text-sm font-semibold">Git AI Author</h3>
<h3 className="text-sm font-semibold">Source Control AI</h3>
<p className="text-xs text-muted-foreground">
Repo-specific overrides. Each field uses global settings until you set it here.
Repo-specific action recipes. Each action inherits global settings until you customize
it here.
</p>
{saveError ? <p className="text-xs text-destructive">{saveError}</p> : null}
</div>
@@ -423,109 +405,94 @@ export function RepositorySourceControlAiSection({
</div>
</div>
{capability ? (
<div className="space-y-3">
{OPERATIONS.map((row) => {
const choice = repoAi.modelOverridesByOperation?.[row.operation]
const selectedModelId = readSourceControlAiModelChoiceForHost(
choice,
hostKey,
capability.id
)
const selectedModel = selectedModelId
? capability.models.find((model) => model.id === selectedModelId)
: null
const selectedThinking =
selectedModel?.thinkingLevels && selectedModel.defaultThinkingLevel
? (choice?.selectedThinkingByModel?.[selectedModel.id] ??
selectedModel.defaultThinkingLevel)
: null
return (
<div
key={row.operation}
className="space-y-2 rounded-md border border-border px-3 py-2"
>
<div className="flex items-center justify-between gap-4">
<Label className="text-xs font-medium">{row.modelLabel}</Label>
<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 templateValue =
hasOverride && typeof override?.commandInputTemplate === 'string'
? override.commandInputTemplate
: ''
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={selectedModelId ?? INHERIT_MODEL_VALUE}
onValueChange={(value) => updateModelOverride(row.operation, value)}
value={actionAgentSelectValue(override?.agentId)}
onValueChange={(value) => updateActionAgent(actionId, value)}
disabled={!hasOverride}
>
<SelectTrigger size="sm" className="h-8 w-[240px] text-xs">
<SelectTrigger size="sm" className="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={INHERIT_MODEL_VALUE}>Use global model</SelectItem>
{capability.models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
<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>
{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>
</div>
{selectedModel?.thinkingLevels && selectedThinking ? (
<div className="flex items-center justify-end gap-2">
<span className="text-[11px] text-muted-foreground">Thinking</span>
<Select
value={selectedThinking}
onValueChange={(value) =>
updateOperationThinking(row.operation, selectedModel.id, value)
}
>
<SelectTrigger size="sm" className="h-7 w-[150px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{selectedModel.thinkingLevels.map((level) => (
<SelectItem key={level.id} value={level.id}>
{level.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<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>
) : (
<p className="rounded-md border border-border px-3 py-2 text-xs text-muted-foreground">
Model overrides are available after a supported global Git AI Author agent is selected.
</p>
)}
<div className="space-y-3">
{OPERATIONS.map((row) => {
const inherited = source.instructionsByOperation[row.operation]?.trim() ?? ''
const hasOverride = hasOwnPrompt(repoAi.instructionsByOperation, row.operation)
const value = hasOverride ? (repoAi.instructionsByOperation?.[row.operation] ?? '') : ''
return (
<div key={row.instructionLabel} className="space-y-2">
<div className="flex items-center justify-between gap-4">
<Label className="text-xs font-medium">{row.instructionLabel}</Label>
<Select
value={hasOverride ? PROMPT_MODE_OVERRIDE : PROMPT_MODE_INHERIT}
onValueChange={(mode) => updatePromptMode(row.operation, mode, inherited)}
>
<SelectTrigger size="sm" className="h-8 w-[150px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={PROMPT_MODE_INHERIT}>Use global</SelectItem>
<SelectItem value={PROMPT_MODE_OVERRIDE}>Customize</SelectItem>
</SelectContent>
</Select>
</div>
<textarea
rows={3}
value={hasOverride ? value : ''}
onChange={(event) => updatePromptOverride(row.operation, event.target.value)}
disabled={!hasOverride}
placeholder={hasOverride ? '' : inherited || row.globalPlaceholder}
className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 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"
/>
</div>
)
})}
@@ -1,24 +1,39 @@
import type { SettingsSearchEntry } from './settings-search'
// The auto-name toggle lives in the Git AI Author pane (it depends on that
// feature being enabled); its model/prompt tuning is under Advanced → Branch
// Names. This identity entry is searched as part of that pane's search set.
export const AUTO_RENAME_BRANCH_PARENT_SEARCH_ENTRY: SettingsSearchEntry = {
title: 'Auto-Name From First Message',
description: 'Use the first task to name blank new workspaces and their unpublished branches.',
title: 'Auto-Rename Branch',
description: 'Rename the auto-generated branch based on the work once an agent starts.',
keywords: [
'workspace',
'title',
'branch',
'rename',
'name',
'auto',
'auto-name',
'creature name',
'agent',
'prompt',
'command',
'template',
'worktree',
'model',
'slug'
]
}
export const AUTO_RENAME_BRANCH_ADVANCED_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Branch name command template',
description: 'Agent command template used when generating branch names.',
keywords: [
'prompt',
'instructions',
'built-in prompt',
'command',
'template',
'slug',
'kebab-case'
]
}
]
export const AUTO_RENAME_BRANCH_SEARCH_ENTRIES: SettingsSearchEntry[] = [
AUTO_RENAME_BRANCH_PARENT_SEARCH_ENTRY,
...AUTO_RENAME_BRANCH_ADVANCED_SEARCH_ENTRIES
]
@@ -1,14 +1,10 @@
import type { SettingsSearchEntry } from './settings-search'
import { AUTO_RENAME_BRANCH_PARENT_SEARCH_ENTRY } from './auto-rename-branch-search'
export const COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
// Why: the auto-name toggle now lives in this pane (it depends on Git AI
// Author), so its search identity belongs here — matching it surfaces the
// Enable row when the feature is off, guiding the user to turn it on.
AUTO_RENAME_BRANCH_PARENT_SEARCH_ENTRY,
{
title: 'Enable Git AI Author',
description: 'Adds AI generation to git commit, pull request, and branch-name flows.',
title: 'Enable Source Control AI defaults',
description:
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.',
keywords: [
'ai',
'commit',
@@ -22,89 +18,25 @@ export const COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
]
},
{
title: 'Agent',
description: 'Which agent to invoke for git text generation.',
keywords: ['agent', 'claude', 'codex', 'source control', 'git ai author']
},
{
title: 'Model',
description: 'Which model Git AI Author uses unless a per-action model is set.',
keywords: ['model', 'haiku', 'sonnet', 'opus', 'gpt']
},
{
title: 'Thinking Effort',
description: 'Reasoning effort level for the selected model. Higher levels are slower.',
keywords: ['thinking', 'effort', 'reasoning']
},
{
title: 'Advanced',
description:
'Override the model and prompt for commit messages, pull requests, and branch names.',
title: 'Action recipes',
description: 'Agent and command template used by each Source Control AI button.',
keywords: [
'customization',
'advanced',
'agent',
'command',
'template',
'prompt',
'fix',
'checks',
'ci',
'conflicts',
'commit',
'pull request',
'pr',
'branch',
'name',
'model',
'prompt'
'branch'
]
},
{
title: 'Commit Messages',
description: 'Commit message generation settings.',
keywords: ['commit', 'message', 'model', 'prompt', 'conventional commits']
},
{
title: 'Commit message model',
description: 'Optional model choice for commit message generation.',
keywords: ['model', 'override', 'commit', 'message', 'commit model', 'thinking']
},
{
title: 'Commit message prompt',
description: 'Additional prompt text appended only when generating commit messages.',
keywords: ['prompt', 'conventional commits', 'gitmoji', 'style']
},
{
title: 'Pull Requests',
description: 'Pull request authoring and creation settings.',
keywords: ['pull request', 'pr', 'model', 'prompt', 'draft', 'template', 'authoring']
},
{
title: 'Pull request model',
description: 'Optional model choice for pull request detail generation.',
keywords: ['model', 'override', 'pull request', 'pr', 'pr model', 'thinking']
},
{
title: 'Pull request prompt',
description: 'Additional prompt text appended only when generating pull request details.',
keywords: ['prompt', 'pull request', 'pr', 'description', 'template']
},
{
title: 'PR creation defaults',
description: 'Defaults used when the Create PR composer opens.',
keywords: ['pull request', 'pr', 'draft', 'template', 'generate', 'open']
},
{
title: 'Branch Names',
description: 'Branch name generation settings for auto-named workspaces.',
keywords: ['branch', 'name', 'rename', 'model', 'prompt', 'slug', 'workspace']
},
{
title: 'Branch name model',
description: 'Optional model choice for branch name generation.',
keywords: ['model', 'override', 'branch', 'name', 'branch name model', 'slug', 'thinking']
},
{
title: 'Branch name prompt',
description: 'Additional prompt text appended only when generating branch names.',
keywords: ['prompt', 'instructions', 'built-in prompt', 'slug', 'kebab-case']
},
{
title: 'Custom command',
description: 'Command line Orca runs to generate the commit message.',
keywords: ['custom', 'command', 'cli', 'binary', 'prompt', 'placeholder', 'ollama']
}
]
@@ -0,0 +1,63 @@
import type React from 'react'
import { Braces } from 'lucide-react'
import {
SOURCE_CONTROL_ACTION_VARIABLE_INFO,
SOURCE_CONTROL_ACTION_VARIABLES,
type SourceControlActionId
} from '../../../../shared/source-control-ai-actions'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
type SourceControlActionVariableChipsProps = {
actionId: SourceControlActionId
disabled?: boolean
onInsert: (variable: string) => void
}
export function SourceControlActionVariableChips({
actionId,
disabled = false,
onInsert
}: SourceControlActionVariableChipsProps): React.JSX.Element {
return (
<TooltipProvider>
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Braces className="size-3" />
Variables
</span>
{SOURCE_CONTROL_ACTION_VARIABLES[actionId].map((variable) => {
const info = SOURCE_CONTROL_ACTION_VARIABLE_INFO[variable]
return (
<Tooltip key={variable}>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
type="button"
variant="ghost"
size="xs"
disabled={disabled}
className="h-5 rounded px-1.5 font-mono text-[10px]"
onClick={() => onInsert(variable)}
>
{`{${variable}}`}
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-80 text-left">
<div className="space-y-1">
<div className="font-mono text-[11px]">{`{${variable}}`}</div>
<div>{info.description}</div>
<div className="font-mono text-[11px] opacity-80 whitespace-pre-wrap">
{info.example}
</div>
<div className="text-[11px] opacity-70">Click to insert this variable.</div>
</div>
</TooltipContent>
</Tooltip>
)
})}
</div>
</TooltipProvider>
)
}
+53 -17
View File
@@ -17,6 +17,7 @@ import {
type LinkedWorkItemContext
} from '@/lib/linked-work-item-context'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { getConnectionId } from '@/lib/connection-context'
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
import { track, tuiAgentToAgentKind } from '@/lib/telemetry'
import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform'
@@ -74,6 +75,9 @@ export type LaunchWorkItemDirectArgs = {
* entry point (Tasks page row → `sidebar`, Create-from modal →
* `command_palette`). Omitted callers default to `unknown`. */
telemetrySource?: WorkspaceCreateTelemetrySource
/** Explicit agent chosen by an action-time composer. When unavailable after
* workspace creation, Orca must not fall back to a different agent. */
agentOverride?: TuiAgent
}
async function resolveDirectPrStartPoint(
@@ -190,26 +194,36 @@ async function pasteWorkItemDraftWhenAgentReady(args: {
* the agent-readiness or paste steps only toast a notice — the user still
* has a usable workspace and can paste the work item context themselves.
*/
export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Promise<void> {
const { item, repoId, openModalFallback, baseBranch, telemetrySource, launchSource } = args
export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Promise<boolean> {
const {
item,
repoId,
openModalFallback,
baseBranch,
telemetrySource,
launchSource,
agentOverride
} = args
const store = useAppStore.getState()
const repo = store.repos.find((r) => r.id === repoId)
if (!repo) {
openModalFallback()
return
return false
}
const settings = store.settings
// Why: agent detection shells out and can be cold/slow. Start it now, but
// don't let it serialize setup-policy resolution or git worktree creation.
const detectedAgentsPromise = repo.connectionId
? store.ensureRemoteDetectedAgents(repo.connectionId)
: store.ensureDetectedAgents()
const detectedAgentsPromise = agentOverride
? null
: repo.connectionId
? store.ensureRemoteDetectedAgents(repo.connectionId)
: store.ensureDetectedAgents()
const setupResolution = await resolveSetupDecision(repoId, repo)
if (setupResolution.kind === 'needs-modal') {
openModalFallback()
return
return false
}
const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
@@ -245,7 +259,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to resolve PR head.')
openModalFallback()
return
return false
}
}
@@ -278,12 +292,33 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
const worktreePath = result.worktree.path
const agentLaunchPlatform = getAgentLaunchPlatformForRepo(repo)
const detectedIds = new Set(await detectedAgentsPromise)
effectiveAgent = pickTuiAgent(
settings?.defaultTuiAgent,
detectedIds,
settings?.disabledTuiAgents
)
if (agentOverride) {
const createdConnectionId = getConnectionId(worktreeId)
const latestStore = useAppStore.getState()
const detectedAgents =
typeof createdConnectionId === 'string'
? await latestStore.ensureRemoteDetectedAgents(createdConnectionId)
: await latestStore.ensureDetectedAgents()
if (
!detectedAgents.includes(agentOverride) ||
!pickTuiAgent(agentOverride, detectedAgents, latestStore.settings?.disabledTuiAgents)
) {
activateAndRevealWorktree(worktreeId, {
sidebarRevealBehavior: 'auto',
setup: result.setup
})
toast.error('Selected agent is not available in the created workspace.')
return false
}
effectiveAgent = agentOverride
} else {
const detectedIds = new Set(await detectedAgentsPromise!)
effectiveAgent = pickTuiAgent(
settings?.defaultTuiAgent,
detectedIds,
settings?.disabledTuiAgents
)
}
if (effectiveAgent) {
// Why: direct task launch creates and starts the workspace in separate
// steps so agent detection can overlap git worktree creation. Persist
@@ -359,13 +394,13 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
// Worktree vanished between create and activate — extremely unlikely but
// worth handling explicitly rather than silently dropping the draft.
toast.error('Workspace created but could not be activated.')
return
return false
}
primaryTabId = activation.primaryTabId
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create workspace.'
toast.error(message)
return
return false
}
store.setSidebarOpen(true)
@@ -376,7 +411,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
// were launched with the draft already on argv (Claude --prefill today),
// the context is in the input box already — pasting again would duplicate it.
if (!primaryTabId || !startupPlan || draftLaunchedNatively) {
return
return true
}
// Why: the workspace is already created and visible; do not block selection
@@ -389,4 +424,5 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
content: draftContent,
...(effectiveAgent ? { agentKind: tuiAgentToAgentKind(effectiveAgent) } : {})
})
return true
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { planSourceControlAgentActionLaunch } from './source-control-agent-action-plan'
describe('planSourceControlAgentActionLaunch', () => {
it('rejects disabled agents', () => {
expect(
planSourceControlAgentActionLaunch({
agent: 'codex',
commandInput: 'Fix checks',
promptDelivery: 'submit-after-ready',
detectedAgents: ['codex'],
disabledAgents: ['codex'],
platform: 'darwin'
})
).toEqual({ ok: false, error: 'The selected agent is disabled in Settings.' })
})
it('rejects agents not detected on the current host', () => {
expect(
planSourceControlAgentActionLaunch({
agent: 'claude',
commandInput: 'Fix checks',
promptDelivery: 'submit-after-ready',
detectedAgents: ['codex'],
platform: 'linux'
})
).toEqual({ ok: false, error: 'The selected agent was not detected on this workspace host.' })
})
it('mirrors submit-after-ready delivery without embedding the prompt in the command', () => {
const result = planSourceControlAgentActionLaunch({
agent: 'codex',
commandInput: 'Fix checks',
promptDelivery: 'submit-after-ready',
detectedAgents: ['codex'],
platform: 'linux'
})
expect(result.ok && result.delivery).toBe('paste-submit')
expect(result.ok && result.commandLabel).toBe('codex')
expect(result.ok && result.summary).toContain('pastes and submits')
expect(result.ok && result.caveat).toContain('PATH')
})
it('uses native draft launch when the selected agent supports it', () => {
const result = planSourceControlAgentActionLaunch({
agent: 'claude',
commandInput: 'Fix checks',
promptDelivery: 'draft',
detectedAgents: ['claude'],
platform: 'darwin'
})
expect(result.ok && result.delivery).toBe('draft-native')
expect(result.ok && result.commandLabel).toContain('--prefill')
})
})
@@ -0,0 +1,135 @@
import {
buildAgentDraftLaunchPlan,
buildAgentStartupPlan,
type AgentStartupPlan
} from '@/lib/tui-agent-startup'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
import type { TuiAgent } from '../../../shared/types'
export type SourceControlLaunchPlanDelivery =
| 'argv'
| 'draft-native'
| 'draft-paste'
| 'paste-submit'
export type SourceControlLaunchPlanResult =
| {
ok: true
plan: AgentStartupPlan
delivery: SourceControlLaunchPlanDelivery
commandLabel: string
summary: string
caveat: string
}
| { ok: false; error: string }
export function planSourceControlAgentActionLaunch(args: {
agent: TuiAgent | null
commandInput: string
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
detectedAgents: TuiAgent[]
disabledAgents?: TuiAgent[]
cmdOverrides?: Partial<Record<TuiAgent, string>>
platform?: NodeJS.Platform
}): SourceControlLaunchPlanResult {
const agent = args.agent
if (!agent) {
return { ok: false, error: 'Choose an agent before checking delivery.' }
}
if (!isTuiAgentEnabled(agent, args.disabledAgents)) {
return { ok: false, error: 'The selected agent is disabled in Settings.' }
}
if (!args.detectedAgents.includes(agent)) {
return { ok: false, error: 'The selected agent was not detected on this workspace host.' }
}
const trimmedInput = args.commandInput.trim()
if (!trimmedInput) {
return { ok: false, error: 'Command input is empty.' }
}
const cmdOverrides = args.cmdOverrides ?? {}
const platform = args.platform ?? CLIENT_PLATFORM
let startupPlan: AgentStartupPlan | null = null
let delivery: SourceControlLaunchPlanDelivery
if (args.promptDelivery === 'submit-after-ready') {
startupPlan = buildAgentStartupPlan({
agent,
prompt: '',
cmdOverrides,
platform,
allowEmptyPromptLaunch: true
})
delivery = 'paste-submit'
} else if (args.promptDelivery === 'draft') {
const draftLaunchPlan = buildAgentDraftLaunchPlan({
agent,
draft: trimmedInput,
cmdOverrides,
platform
})
if (draftLaunchPlan) {
startupPlan = {
agent: draftLaunchPlan.agent,
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null,
...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {})
}
delivery = 'draft-native'
} else {
startupPlan = buildAgentStartupPlan({
agent,
prompt: '',
cmdOverrides,
platform,
allowEmptyPromptLaunch: true
})
delivery = 'draft-paste'
}
} else if (TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start') {
startupPlan = buildAgentStartupPlan({
agent,
prompt: '',
cmdOverrides,
platform,
allowEmptyPromptLaunch: true
})
delivery = 'draft-paste'
} else {
startupPlan = buildAgentStartupPlan({
agent,
prompt: trimmedInput,
cmdOverrides,
platform,
allowEmptyPromptLaunch: false
})
delivery = 'argv'
}
if (!startupPlan) {
return { ok: false, error: 'Could not build the agent launch command.' }
}
const summary =
delivery === 'paste-submit'
? 'The agent starts with no prompt, then Orca pastes and submits the command input after the TUI is ready.'
: delivery === 'draft-native'
? 'The command input is prefilled as an editable draft by the agent launch command.'
: delivery === 'draft-paste'
? 'The agent starts with no prompt, then Orca pastes the command input as an editable draft after the TUI is ready.'
: 'The command input is included in the launch command and submitted as the first turn.'
return {
ok: true,
plan: startupPlan,
delivery,
commandLabel: startupPlan.launchCommand,
summary,
caveat:
'This check builds Orca’s launch plan only. PATH, binary availability, account setup, and terminal startup failures are still caught by the real launch watchdog.'
}
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { planSourceControlCommitMessageGeneration } from './source-control-generation-plan'
describe('planSourceControlCommitMessageGeneration', () => {
it('catches empty custom commands without invoking an agent', () => {
expect(
planSourceControlCommitMessageGeneration({
agentId: 'custom',
model: '',
customAgentCommand: ''
})
).toEqual({
ok: false,
error: 'Custom command is empty. Add one in Settings → Git → AI Commit Messages.'
})
})
it('rejects command templates that render empty input', () => {
expect(
planSourceControlCommitMessageGeneration({
agentId: 'codex',
model: 'gpt-5.5',
commandInputTemplate: ''
})
).toEqual({ ok: false, error: 'Command input is empty.' })
})
it('plans known agents and includes renderer-only caveats', () => {
const result = planSourceControlCommitMessageGeneration({
agentId: 'codex',
model: 'gpt-5.5',
thinkingLevel: 'low'
})
expect(result.ok && result.commandLabel).toContain('codex exec')
expect(result.ok && result.delivery).toContain('stdin')
expect(result.ok && result.caveat).toContain('Windows .cmd')
})
})
@@ -0,0 +1,42 @@
import { planCommitMessageGeneration } from '../../../shared/commit-message-plan'
import { renderSourceControlActionCommandTemplate } from '../../../shared/source-control-ai-actions'
import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai'
export type SourceControlGenerationPlanResult =
| { ok: true; commandLabel: string; delivery: string; caveat: string }
| { ok: false; error: string }
const SYNTHETIC_COMMIT_PROMPT =
'Generate a concise git commit message for a synthetic dry-run diff. Return only the commit message.'
export function planSourceControlCommitMessageGeneration(
params: ResolvedSourceControlAiGenerationParams
): SourceControlGenerationPlanResult {
const prompt =
params.commandInputTemplate !== undefined
? renderSourceControlActionCommandTemplate(params.commandInputTemplate, {
basePrompt: SYNTHETIC_COMMIT_PROMPT,
branch: 'feature/example',
stagedFiles: 'M src/example.ts',
stagedPatch: 'diff --git a/src/example.ts b/src/example.ts'
})
: SYNTHETIC_COMMIT_PROMPT
if (!prompt.trim()) {
return { ok: false, error: 'Command input is empty.' }
}
const planned = planCommitMessageGeneration(params, prompt)
if (!planned.ok) {
return { ok: false, error: planned.error }
}
const delivery =
planned.plan.stdinPayload === null
? 'Prompt is delivered as command arguments.'
: 'Prompt is piped to the agent over stdin.'
return {
ok: true,
commandLabel: [planned.plan.binary, ...planned.plan.args].join(' '),
delivery,
caveat:
'This checks Orca’s planner only. It does not invoke the CLI, prove PATH or binary availability, or reproduce main-process Windows .cmd resolution.'
}
}
@@ -0,0 +1,23 @@
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection'
import type { TuiAgent } from '../../../shared/types'
export function pickSourceControlLaunchAgent(args: {
savedAgent?: TuiAgent | null
defaultAgent: TuiAgent | 'blank' | null | undefined
detectedAgents: TuiAgent[]
disabledAgents?: TuiAgent[]
}): TuiAgent | null {
const enabledAgents = filterEnabledTuiAgents(args.detectedAgents, args.disabledAgents)
if (args.savedAgent && enabledAgents.includes(args.savedAgent)) {
return args.savedAgent
}
if (
args.defaultAgent &&
args.defaultAgent !== 'blank' &&
enabledAgents.includes(args.defaultAgent)
) {
return args.defaultAgent
}
return AGENT_CATALOG.find((entry) => enabledAgents.includes(entry.id))?.id ?? null
}
@@ -422,6 +422,55 @@ describe('runtime git client', () => {
})
})
it('passes one-shot commit-message params to local and runtime generation', async () => {
const sourceControlAiResolvedParams = {
agentId: 'codex' as const,
model: 'gpt-5.5',
thinkingLevel: 'high',
customPrompt: 'Use Conventional Commits.'
}
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: { success: true, message: 'feat: test' },
_meta: { runtimeId: 'remote-runtime' }
})
await generateRuntimeCommitMessage(
{
settings: { activeRuntimeEnvironmentId: null },
worktreeId: 'repo-1::/repo',
worktreePath: '/repo'
},
{ sourceControlAiResolvedParams }
)
await generateRuntimeCommitMessage(
{
settings: { activeRuntimeEnvironmentId: 'env-1' },
worktreeId: 'wt-1',
worktreePath: '/repo'
},
{ sourceControlAiResolvedParams }
)
expect(gitGenerateCommitMessage).toHaveBeenCalledWith({
worktreePath: '/repo',
repoId: 'repo-1',
connectionId: undefined,
sourceControlAiResolvedParams
})
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'git.generateCommitMessage',
params: {
worktree: 'wt-1',
commitMessageDiscoveryHostKey: 'runtime:env-1',
sourceControlAiResolvedParams
},
timeoutMs: 75_000
})
})
it('discovers commit-message models through the active runtime', async () => {
const agentCmdOverrides = { cursor: 'cursor-agent' }
runtimeEnvironmentCall.mockResolvedValue({
+51 -50
View File
@@ -15,11 +15,11 @@ import type {
CommitMessageAgentCapability,
CommitMessageModelCapability
} from '../../../shared/commit-message-agent-spec'
import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai'
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../shared/commit-message-host-key'
import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
export type RuntimeGenerateCommitMessageResult =
| { success: true; message: string; agentLabel?: string }
@@ -58,6 +58,12 @@ export type RuntimeGitContext = {
connectionId?: string
}
export type RuntimeGenerateCommitMessageOverrides = {
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
sourceControlAi?: GlobalSettings['sourceControlAi']
agentCmdOverrides?: GlobalSettings['agentCmdOverrides']
}
function getRuntimeCommitMessageSettings(
settings: RuntimeGitSettings | null | undefined,
connectionId?: string
@@ -114,7 +120,7 @@ export async function getRuntimeGitStatus(
return callRuntimeRpc<GitStatusResult>(
target,
'git.status',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), ...includeIgnoredArgs },
{ worktree: context.worktreeId, ...includeIgnoredArgs },
{ timeoutMs: 15_000 }
)
}
@@ -137,7 +143,7 @@ export async function getRuntimeGitIgnoredPaths(
return callRuntimeRpc<string[]>(
target,
'git.checkIgnored',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), paths },
{ worktree: context.worktreeId, paths },
{ timeoutMs: 15_000 }
)
}
@@ -157,7 +163,7 @@ export async function getRuntimeGitHistory(
return callRuntimeRpc<GitHistoryResult>(
target,
'git.history',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), ...options },
{ worktree: context.worktreeId, ...options },
{ timeoutMs: 15_000 }
)
}
@@ -175,7 +181,7 @@ export async function getRuntimeGitConflictOperation(
return callRuntimeRpc<GitConflictOperation>(
target,
'git.conflictOperation',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId) },
{ worktree: context.worktreeId },
{ timeoutMs: 15_000 }
)
}
@@ -192,7 +198,7 @@ export async function abortRuntimeGitMerge(context: RuntimeGitContext): Promise<
await callRuntimeRpc(
target,
'git.abortMerge',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId) },
{ worktree: context.worktreeId },
{ timeoutMs: 30_000 }
)
}
@@ -209,7 +215,7 @@ export async function abortRuntimeGitRebase(context: RuntimeGitContext): Promise
await callRuntimeRpc(
target,
'git.abortRebase',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId) },
{ worktree: context.worktreeId },
{ timeoutMs: 30_000 }
)
}
@@ -231,7 +237,7 @@ export async function getRuntimeGitDiff(
return callRuntimeRpc<GitDiffResult>(
target,
'git.diff',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), ...args },
{ worktree: context.worktreeId, ...args },
{ timeoutMs: 15_000 }
)
}
@@ -251,7 +257,7 @@ export async function getRuntimeGitBranchCompare(
return callRuntimeRpc<GitBranchCompareResult>(
target,
'git.branchCompare',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), baseRef },
{ worktree: context.worktreeId, baseRef },
{ timeoutMs: 15_000 }
)
}
@@ -271,7 +277,7 @@ export async function getRuntimeGitCommitCompare(
return callRuntimeRpc<GitCommitCompareResult>(
target,
'git.commitCompare',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), commitId },
{ worktree: context.worktreeId, commitId },
{ timeoutMs: 15_000 }
)
}
@@ -291,10 +297,7 @@ export async function getRuntimeGitUpstreamStatus(
return callRuntimeRpc<GitUpstreamStatus>(
target,
'git.upstreamStatus',
{
worktree: toRuntimeWorktreeSelector(context.worktreeId),
...(pushTarget ? { pushTarget } : {})
},
{ worktree: context.worktreeId, ...(pushTarget ? { pushTarget } : {}) },
{ timeoutMs: 15_000 }
)
}
@@ -315,10 +318,7 @@ export async function fetchRuntimeGit(
await callRuntimeRpc(
target,
'git.fetch',
{
worktree: toRuntimeWorktreeSelector(context.worktreeId),
...(pushTarget ? { pushTarget } : {})
},
{ worktree: context.worktreeId, ...(pushTarget ? { pushTarget } : {}) },
{ timeoutMs: 30_000 }
)
}
@@ -339,10 +339,7 @@ export async function pullRuntimeGit(
await callRuntimeRpc(
target,
'git.pull',
{
worktree: toRuntimeWorktreeSelector(context.worktreeId),
...(pushTarget ? { pushTarget } : {})
},
{ worktree: context.worktreeId, ...(pushTarget ? { pushTarget } : {}) },
{ timeoutMs: 30_000 }
)
}
@@ -363,10 +360,7 @@ export async function fastForwardRuntimeGit(
await callRuntimeRpc(
target,
'git.fastForward',
{
worktree: toRuntimeWorktreeSelector(context.worktreeId),
...(pushTarget ? { pushTarget } : {})
},
{ worktree: context.worktreeId, ...(pushTarget ? { pushTarget } : {}) },
{ timeoutMs: 30_000 }
)
}
@@ -387,7 +381,7 @@ export async function rebaseRuntimeGitFromBase(
await callRuntimeRpc(
target,
'git.rebaseFromBase',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), baseRef },
{ worktree: context.worktreeId, baseRef },
{ timeoutMs: 30_000 }
)
}
@@ -411,7 +405,7 @@ export async function pushRuntimeGit(
target,
'git.push',
{
worktree: toRuntimeWorktreeSelector(context.worktreeId),
worktree: context.worktreeId,
...(args.publish !== undefined ? { publish: args.publish } : {}),
...(args.pushTarget !== undefined ? { pushTarget: args.pushTarget } : {}),
...(args.forceWithLease !== undefined ? { forceWithLease: args.forceWithLease } : {})
@@ -441,7 +435,7 @@ export async function getRuntimeGitBranchDiff(
return callRuntimeRpc<GitDiffResult>(
target,
'git.branchDiff',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), ...args },
{ worktree: context.worktreeId, ...args },
{ timeoutMs: 15_000 }
)
}
@@ -469,7 +463,7 @@ export async function getRuntimeGitCommitDiff(
return callRuntimeRpc<GitDiffResult>(
target,
'git.commitDiff',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), ...args },
{ worktree: context.worktreeId, ...args },
{ timeoutMs: 15_000 }
)
}
@@ -489,28 +483,39 @@ export async function commitRuntimeGit(
return callRuntimeRpc<{ success: boolean; error?: string }>(
target,
'git.commit',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), message },
{ worktree: context.worktreeId, message },
{ timeoutMs: 30_000 }
)
}
export async function generateRuntimeCommitMessage(
context: RuntimeGitContext
context: RuntimeGitContext,
overrides?: RuntimeGenerateCommitMessageOverrides
): Promise<RuntimeGenerateCommitMessageResult> {
const target = getActiveRuntimeTarget(context.settings)
if (target.kind === 'local' || !context.worktreeId) {
return window.api.git.generateCommitMessage({
worktreePath: context.worktreePath,
repoId: context.worktreeId ? getRepoIdFromWorktreeId(context.worktreeId) : undefined,
connectionId: context.connectionId
connectionId: context.connectionId,
...(overrides?.sourceControlAiResolvedParams
? { sourceControlAiResolvedParams: overrides.sourceControlAiResolvedParams }
: {}),
...(overrides?.sourceControlAi ? { sourceControlAi: overrides.sourceControlAi } : {}),
...(overrides?.agentCmdOverrides ? { agentCmdOverrides: overrides.agentCmdOverrides } : {})
}) as Promise<RuntimeGenerateCommitMessageResult>
}
return callRuntimeRpc<RuntimeGenerateCommitMessageResult>(
target,
'git.generateCommitMessage',
{
worktree: toRuntimeWorktreeSelector(context.worktreeId),
...getRuntimeCommitMessageSettings(context.settings, context.connectionId)
worktree: context.worktreeId,
...getRuntimeCommitMessageSettings(context.settings, context.connectionId),
...(overrides?.sourceControlAiResolvedParams
? { sourceControlAiResolvedParams: overrides.sourceControlAiResolvedParams }
: {}),
...(overrides?.sourceControlAi ? { sourceControlAi: overrides.sourceControlAi } : {}),
...(overrides?.agentCmdOverrides ? { agentCmdOverrides: overrides.agentCmdOverrides } : {})
},
{ timeoutMs: 75_000 }
)
@@ -532,7 +537,7 @@ export async function discoverRuntimeCommitMessageModels(
target,
'git.discoverCommitMessageModels',
{
worktree: toRuntimeWorktreeSelector(context.worktreeId),
worktree: context.worktreeId,
agentId,
...(context.settings?.agentCmdOverrides
? { agentCmdOverrides: context.settings.agentCmdOverrides }
@@ -556,7 +561,7 @@ export async function cancelRuntimeGenerateCommitMessage(
await callRuntimeRpc(
target,
'git.cancelGenerateCommitMessage',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId) },
{ worktree: context.worktreeId },
{ timeoutMs: 5_000 }
)
}
@@ -578,7 +583,7 @@ export async function generateRuntimePullRequestFields(
target,
'git.generatePullRequestFields',
{
worktree: toRuntimeWorktreeSelector(context.worktreeId),
worktree: context.worktreeId,
...input,
...getRuntimeCommitMessageSettings(context.settings, context.connectionId)
},
@@ -600,7 +605,7 @@ export async function cancelRuntimeGeneratePullRequestFields(
await callRuntimeRpc(
target,
'git.cancelGeneratePullRequestFields',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId) },
{ worktree: context.worktreeId },
{ timeoutMs: 5_000 }
)
}
@@ -621,7 +626,7 @@ export async function stageRuntimeGitPath(
await callRuntimeRpc(
target,
'git.stage',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), filePath },
{ worktree: context.worktreeId, filePath },
{ timeoutMs: 15_000 }
)
}
@@ -642,7 +647,7 @@ export async function bulkStageRuntimeGitPaths(
await callRuntimeRpc(
target,
'git.bulkStage',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), filePaths },
{ worktree: context.worktreeId, filePaths },
{ timeoutMs: 15_000 }
)
}
@@ -663,7 +668,7 @@ export async function unstageRuntimeGitPath(
await callRuntimeRpc(
target,
'git.unstage',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), filePath },
{ worktree: context.worktreeId, filePath },
{ timeoutMs: 15_000 }
)
}
@@ -684,7 +689,7 @@ export async function bulkUnstageRuntimeGitPaths(
await callRuntimeRpc(
target,
'git.bulkUnstage',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), filePaths },
{ worktree: context.worktreeId, filePaths },
{ timeoutMs: 15_000 }
)
}
@@ -705,7 +710,7 @@ export async function bulkDiscardRuntimeGitPaths(
await callRuntimeRpc(
target,
'git.bulkDiscard',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), filePaths },
{ worktree: context.worktreeId, filePaths },
{ timeoutMs: 15_000 }
)
}
@@ -726,7 +731,7 @@ export async function discardRuntimeGitPath(
await callRuntimeRpc(
target,
'git.discard',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), filePath },
{ worktree: context.worktreeId, filePath },
{ timeoutMs: 15_000 }
)
}
@@ -747,11 +752,7 @@ export async function getRuntimeGitRemoteFileUrl(
return callRuntimeRpc<string | null>(
target,
'git.remoteFileUrl',
{
worktree: toRuntimeWorktreeSelector(context.worktreeId),
relativePath: args.relativePath,
line: args.line
},
{ worktree: context.worktreeId, relativePath: args.relativePath, line: args.line },
{ timeoutMs: 15_000 }
)
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import {
normalizeSourceControlAiActionDefaults,
readSourceControlActionDefault,
renderSourceControlActionCommandTemplate,
resolveSourceControlActionCommandTemplate,
setSourceControlActionAgentDefault
} from './source-control-ai-actions'
describe('source-control AI launch action defaults', () => {
it('normalizes safe launch action defaults', () => {
expect(
normalizeSourceControlAiActionDefaults({
fixChecks: { agentId: 'codex', commandInputTemplate: ' {basePrompt} ' },
resolveConflicts: { agentId: null },
pullRequest: { agentId: 'claude' }
})
).toEqual({
fixChecks: { agentId: 'codex', commandInputTemplate: ' {basePrompt} ' },
resolveConflicts: { agentId: null },
pullRequest: { agentId: 'claude' }
})
})
it('rejects unsafe prototype keys and invalid agent ids', () => {
expect(
normalizeSourceControlAiActionDefaults({
__proto__: { agentId: 'codex' },
constructor: { agentId: 'codex' },
prototype: { agentId: 'codex' },
fixCommitFailure: { agentId: 'not-real', commandInputTemplate: 42 }
})
).toBeUndefined()
})
it('trims command templates only when reading them', () => {
const defaults = normalizeSourceControlAiActionDefaults({
fixCommitFailure: { agentId: 'claude', commandInputTemplate: ' {basePrompt} ' }
})
expect(defaults?.fixCommitFailure?.commandInputTemplate).toBe(' {basePrompt} ')
expect(readSourceControlActionDefault(defaults, 'fixCommitFailure')).toEqual({
agentId: 'claude',
commandInputTemplate: '{basePrompt}'
})
})
it('preserves explicitly empty command templates when resolving defaults', () => {
expect(
resolveSourceControlActionCommandTemplate(
{ fixCommitFailure: { commandInputTemplate: '' } },
'fixCommitFailure'
)
).toBe('')
expect(resolveSourceControlActionCommandTemplate(undefined, 'fixCommitFailure')).toBe(
'{basePrompt}'
)
})
it('sets agent defaults without dropping neighboring action defaults', () => {
expect(
setSourceControlActionAgentDefault(
{ fixChecks: { agentId: 'codex' } },
'resolveConflicts',
'claude'
)
).toEqual({
fixChecks: { agentId: 'codex' },
resolveConflicts: { agentId: 'claude' }
})
})
it('renders known template variables and leaves unknown variables visible', () => {
expect(
renderSourceControlActionCommandTemplate('fix {thing} with {missing}', {
thing: 'CI'
})
).toBe('fix CI with {missing}')
})
})
+229
View File
@@ -0,0 +1,229 @@
import { isTuiAgent } from './tui-agent-config'
import type { TuiAgent } from './types'
export type SourceControlTextActionId = 'commitMessage' | 'pullRequest' | 'branchName'
export type SourceControlLaunchActionId = 'fixCommitFailure' | 'fixChecks' | 'resolveConflicts'
export type SourceControlActionId = SourceControlTextActionId | SourceControlLaunchActionId
export type SourceControlActionRecipe = {
agentId?: TuiAgent | null
commandInputTemplate?: string
}
export type SourceControlAiActionDefaults = Partial<
Record<SourceControlActionId, SourceControlActionRecipe>
>
export const SOURCE_CONTROL_TEXT_ACTION_IDS = [
'commitMessage',
'pullRequest',
'branchName'
] as const satisfies readonly SourceControlTextActionId[]
export const SOURCE_CONTROL_LAUNCH_ACTION_IDS = [
'fixCommitFailure',
'fixChecks',
'resolveConflicts'
] as const satisfies readonly SourceControlLaunchActionId[]
export const SOURCE_CONTROL_ACTION_IDS = [
...SOURCE_CONTROL_TEXT_ACTION_IDS,
...SOURCE_CONTROL_LAUNCH_ACTION_IDS
] as const satisfies readonly SourceControlActionId[]
export const SOURCE_CONTROL_TEXT_ACTION_LABELS: Record<SourceControlTextActionId, string> = {
commitMessage: 'Commit message',
pullRequest: 'Pull request details',
branchName: 'Branch name'
}
export const SOURCE_CONTROL_LAUNCH_ACTION_LABELS: Record<SourceControlLaunchActionId, string> = {
fixCommitFailure: 'Commit failure fixes',
fixChecks: 'Broken checks fixes',
resolveConflicts: 'Conflict resolution'
}
export const SOURCE_CONTROL_ACTION_LABELS: Record<SourceControlActionId, string> = {
...SOURCE_CONTROL_TEXT_ACTION_LABELS,
...SOURCE_CONTROL_LAUNCH_ACTION_LABELS
}
export const DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES: Record<
SourceControlActionId,
string
> = {
commitMessage: '{basePrompt}',
pullRequest: '{basePrompt}',
branchName: '{basePrompt}',
fixCommitFailure: '{basePrompt}',
fixChecks: '{basePrompt}',
resolveConflicts: '{basePrompt}'
}
export const SOURCE_CONTROL_ACTION_VARIABLES: Record<SourceControlActionId, string[]> = {
commitMessage: ['basePrompt', 'branch', 'stagedFiles', 'stagedPatch'],
pullRequest: ['basePrompt', 'branch', 'baseBranch', 'currentTitle', 'currentBody'],
branchName: ['basePrompt', 'firstPrompt', 'assistantMessage'],
fixCommitFailure: ['basePrompt'],
fixChecks: ['basePrompt'],
resolveConflicts: ['basePrompt']
}
export type SourceControlActionVariableInfo = {
description: string
example: string
}
export const SOURCE_CONTROL_ACTION_VARIABLE_INFO: Record<string, SourceControlActionVariableInfo> =
{
basePrompt: {
description:
'Orca’s built-in prompt for this action, including the context Orca knows how to gather safely.',
example:
'Commit messages include staged diff guidance; PR details include branch comparison guidance; fix actions include the failure summary.'
},
branch: {
description: 'The current source-control branch name.',
example: 'feature/source-control-ai-recipes'
},
stagedFiles: {
description: 'A newline-separated list of staged files for commit-message generation.',
example: 'M src/shared/source-control-ai.ts\nA src/shared/source-control-ai-actions.ts'
},
stagedPatch: {
description: 'The staged git patch used for commit-message generation.',
example: 'diff --git a/src/app.ts b/src/app.ts\n+addActionRecipeDefaults()'
},
baseBranch: {
description: 'The target branch selected in the Create PR composer.',
example: 'main'
},
currentTitle: {
description: 'The PR title currently typed in the composer before generation starts.',
example: 'Improve Source Control AI customization'
},
currentBody: {
description: 'The PR description currently typed in the composer before generation starts.',
example: 'Adds configurable agents and command templates for Source Control actions.'
},
firstPrompt: {
description: 'The first user request that created the Orca workspace.',
example: 'Fix CI and commit the result'
},
assistantMessage: {
description: 'The initial agent response, when Orca has one available.',
example: 'I will inspect the failing check, patch the issue, and run tests.'
}
}
const ACTION_ID_SET = new Set<string>(SOURCE_CONTROL_ACTION_IDS)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isSafeRecordKey(key: string): boolean {
return key !== '' && key !== '__proto__' && key !== 'constructor' && key !== 'prototype'
}
function isSourceControlActionId(value: string): value is SourceControlActionId {
return ACTION_ID_SET.has(value)
}
export function normalizeSourceControlActionRecipe(
value: unknown
): SourceControlActionRecipe | undefined {
if (!isRecord(value)) {
return undefined
}
const normalized: SourceControlActionRecipe = {}
if (value.agentId === null || isTuiAgent(value.agentId)) {
normalized.agentId = value.agentId
}
if (typeof value.commandInputTemplate === 'string') {
normalized.commandInputTemplate = value.commandInputTemplate
}
return Object.keys(normalized).length > 0 ? normalized : undefined
}
export function normalizeSourceControlAiActionDefaults(
value: unknown
): SourceControlAiActionDefaults | undefined {
if (!isRecord(value)) {
return undefined
}
const normalized: SourceControlAiActionDefaults = {}
for (const [key, item] of Object.entries(value)) {
if (!isSafeRecordKey(key) || !isSourceControlActionId(key)) {
continue
}
const defaultValue = normalizeSourceControlActionRecipe(item)
if (defaultValue) {
normalized[key] = defaultValue
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined
}
export function readSourceControlActionDefault(
defaults: SourceControlAiActionDefaults | null | undefined,
actionId: SourceControlActionId
): SourceControlActionRecipe {
const value = defaults?.[actionId]
return {
...(value?.agentId !== undefined ? { agentId: value.agentId } : {}),
...(typeof value?.commandInputTemplate === 'string'
? { commandInputTemplate: value.commandInputTemplate.trim() }
: {})
}
}
export function resolveSourceControlActionCommandTemplate(
defaults: SourceControlAiActionDefaults | null | undefined,
actionId: SourceControlActionId
): string {
const template = readSourceControlActionDefault(defaults, actionId).commandInputTemplate
return template !== undefined
? template
: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId]
}
export function setSourceControlActionDefault(
defaults: SourceControlAiActionDefaults | null | undefined,
actionId: SourceControlActionId,
value: SourceControlActionRecipe
): SourceControlAiActionDefaults {
return {
...defaults,
[actionId]: {
...defaults?.[actionId],
...value
}
}
}
export function setSourceControlActionAgentDefault(
defaults: SourceControlAiActionDefaults | null | undefined,
actionId: SourceControlActionId,
agentId: TuiAgent | null
): SourceControlAiActionDefaults {
return setSourceControlActionDefault(defaults, actionId, { agentId })
}
export function renderSourceControlActionCommandTemplate(
template: string,
variables: Record<string, string | null | undefined>
): string {
return template.replace(
/\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}|\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}/g,
(match, doubleName, singleName) => {
const name = (doubleName ?? singleName) as string
const value = variables[name]
return value === undefined || value === null ? match : value
}
)
}
+18 -1
View File
@@ -1,6 +1,11 @@
import type { CommitMessageAiModelCapability, TuiAgent } from './types'
import type {
SourceControlAiActionDefaults,
SourceControlActionId,
SourceControlTextActionId
} from './source-control-ai-actions'
export type SourceControlAiOperation = 'commitMessage' | 'pullRequest' | 'branchName'
export type SourceControlAiOperation = SourceControlTextActionId
export type SourceControlAiModelChoice = {
selectedModelByAgent?: Partial<Record<TuiAgent, string>>
@@ -17,6 +22,7 @@ export type SourceControlAiPrCreationDefaults = {
export type SourceControlAiSettings = {
enabled: boolean
actions?: SourceControlAiActionDefaults
agentId: TuiAgent | 'custom' | null
selectedModelByAgent: Partial<Record<TuiAgent, string>>
selectedModelByAgentByHost?: Partial<Record<string, Partial<Record<TuiAgent, string>>>>
@@ -29,6 +35,8 @@ export type SourceControlAiSettings = {
instructionsByOperation: Partial<Record<SourceControlAiOperation, string>>
modelOverridesByOperation?: Partial<Record<SourceControlAiOperation, SourceControlAiModelChoice>>
prCreationDefaults?: SourceControlAiPrCreationDefaults
/** @deprecated use actions instead. Kept for automatic migration and rollback compatibility. */
launchActionDefaults?: SourceControlAiActionDefaults
}
export type SourceControlAiSettingsPatch =
@@ -38,6 +46,15 @@ export type SourceControlAiSettingsPatch =
export type RepoSourceControlAiOverrides = {
modelOverridesByOperation?: Partial<Record<SourceControlAiOperation, SourceControlAiModelChoice>>
instructionsByOperation?: Partial<Record<SourceControlAiOperation, string | null>>
actionOverrides?: Partial<
Record<
SourceControlActionId,
{
agentId?: TuiAgent | null
commandInputTemplate?: string | null
}
>
>
prCreationDefaults?: {
draft?: boolean | null
useTemplate?: boolean | null
+270 -1
View File
@@ -9,6 +9,7 @@ import {
normalizeRepoSourceControlAiOverrides,
projectSourceControlAiToLegacyCommitMessageAi,
readSourceControlAiModelChoiceForHost,
resolveSourceControlActionRecipe,
resolveSourceControlAiForOperation,
resolveSourceControlAiPrCreationDefaults,
selectSourceControlAiModelChoiceForHost,
@@ -171,6 +172,56 @@ describe('source-control AI resolution', () => {
expect(result.ok && result.value.params.model).toBe('gpt-5.4')
})
it('treats an explicit action null agent as the default agent, not the global legacy agent', () => {
const base = settings()
base.defaultTuiAgent = 'codex'
base.sourceControlAi = {
...base.sourceControlAi!,
agentId: 'claude',
selectedModelByAgent: { codex: 'gpt-5.5', claude: 'sonnet' },
actions: {
...base.sourceControlAi!.actions,
commitMessage: { agentId: null, commandInputTemplate: '{basePrompt}' }
}
}
const result = resolveSourceControlAiForOperation({
settings: base,
repo: null,
operation: 'commitMessage',
discoveryHostKey: 'local'
})
expect(result.ok && result.value.params.agentId).toBe('codex')
expect(result.ok && result.value.params.model).toBe('gpt-5.5')
})
it('treats a repo action null agent as the default agent, not the global action agent', () => {
const base = settings()
base.defaultTuiAgent = 'codex'
base.sourceControlAi = {
...base.sourceControlAi!,
agentId: 'claude',
selectedModelByAgent: { codex: 'gpt-5.5', claude: 'sonnet' }
}
const result = resolveSourceControlAiForOperation({
settings: base,
repo: {
sourceControlAi: {
actionOverrides: {
commitMessage: {
agentId: null,
commandInputTemplate: '{basePrompt}'
}
}
}
},
operation: 'commitMessage',
discoveryHostKey: 'local'
})
expect(result.ok && result.value.params.agentId).toBe('codex')
})
it('lets a global operation model override win over the global default', () => {
const base = settings()
base.sourceControlAi!.modelOverridesByOperation = {
@@ -292,6 +343,192 @@ describe('source-control AI resolution', () => {
).toMatchObject({ draft: true, openAfterCreate: false })
})
it('resolves launch action recipes from repo overrides over global defaults', () => {
const base = settings()
base.sourceControlAi = {
...base.sourceControlAi!,
actions: {
...base.sourceControlAi!.actions,
fixChecks: {
agentId: 'claude',
commandInputTemplate: '{basePrompt}\n\nglobal'
}
}
}
expect(
resolveSourceControlActionRecipe({
settings: base,
repo: {
sourceControlAi: {
actionOverrides: {
fixChecks: {
agentId: 'codex',
commandInputTemplate: ' {basePrompt}\n\nrepo '
}
}
}
},
actionId: 'fixChecks'
})
).toEqual({
agentId: 'codex',
commandInputTemplate: '{basePrompt}\n\nrepo'
})
})
it('preserves existing action templates when legacy settings are only the rollback projection', () => {
const source = {
...settings().sourceControlAi!,
actions: {
...settings().sourceControlAi!.actions,
commitMessage: {
agentId: 'codex' as const,
commandInputTemplate: 'use $best-commit-msg to write a commit'
},
branchName: {
agentId: 'claude' as const,
commandInputTemplate: 'name this branch from {firstPrompt}'
}
}
}
const legacy = projectSourceControlAiToLegacyCommitMessageAi(source)
const merged = mergeLegacyCommitMessageAiIntoSourceControlAi(source, legacy)
expect(merged.actions?.commitMessage).toEqual({
agentId: 'codex',
commandInputTemplate: 'use $best-commit-msg to write a commit'
})
expect(merged.actions?.branchName).toEqual({
agentId: 'claude',
commandInputTemplate: 'name this branch from {firstPrompt}'
})
})
it('does not let rollback enabled changes clobber independent branch action templates', () => {
const source = {
...settings().sourceControlAi!,
enabled: true,
actions: {
...settings().sourceControlAi!.actions,
commitMessage: {
agentId: 'codex' as const,
commandInputTemplate: 'use $best-commit-msg to write a commit'
},
branchName: {
agentId: 'claude' as const,
commandInputTemplate: 'name this branch from {firstPrompt}'
}
}
}
const legacy = {
...projectSourceControlAiToLegacyCommitMessageAi(source),
enabled: false
}
const merged = mergeLegacyCommitMessageAiIntoSourceControlAi(source, legacy)
expect(merged.enabled).toBe(false)
expect(merged.actions?.commitMessage).toEqual({
agentId: 'codex',
commandInputTemplate: 'use $best-commit-msg to write a commit'
})
expect(merged.actions?.branchName).toEqual({
agentId: 'claude',
commandInputTemplate: 'name this branch from {firstPrompt}'
})
})
it('does not let stale legacy branch instructions clobber independent branch templates', () => {
const source = {
...settings().sourceControlAi!,
instructionsByOperation: {
...settings().sourceControlAi!.instructionsByOperation,
commitMessage: 'old shared prompt',
branchName: 'old shared prompt'
},
actions: {
...settings().sourceControlAi!.actions,
commitMessage: {
agentId: 'codex' as const,
commandInputTemplate: '{basePrompt}\n\nold shared prompt'
},
branchName: {
agentId: 'claude' as const,
commandInputTemplate: 'name this branch from {firstPrompt}'
}
}
}
const legacy = {
...projectSourceControlAiToLegacyCommitMessageAi(source),
customPrompt: 'rollback changed commit prompt'
}
const merged = mergeLegacyCommitMessageAiIntoSourceControlAi(source, legacy)
expect(merged.actions?.commitMessage).toEqual({
agentId: 'codex',
commandInputTemplate: '{basePrompt}\n\nrollback changed commit prompt'
})
expect(merged.instructionsByOperation.branchName).toBe('old shared prompt')
expect(merged.actions?.branchName).toEqual({
agentId: 'claude',
commandInputTemplate: 'name this branch from {firstPrompt}'
})
})
it('lets rollback custom-agent changes clear a commit action agent override', () => {
const source = {
...settings().sourceControlAi!,
agentId: 'codex' as const,
customAgentCommand: '',
actions: {
...settings().sourceControlAi!.actions,
commitMessage: {
agentId: 'codex' as const,
commandInputTemplate: 'use $best-commit-msg to write a commit'
}
}
}
const legacy = {
...projectSourceControlAiToLegacyCommitMessageAi(source),
agentId: 'custom' as const,
customAgentCommand: 'custom-agent {prompt}'
}
const merged = mergeLegacyCommitMessageAiIntoSourceControlAi(source, legacy)
expect(merged.agentId).toBe('custom')
expect(merged.customAgentCommand).toBe('custom-agent {prompt}')
expect(merged.actions?.commitMessage).toEqual({
commandInputTemplate: 'use $best-commit-msg to write a commit'
})
})
it('preserves an explicitly empty global text action template as invalid config', () => {
const base = settings()
base.sourceControlAi = {
...base.sourceControlAi!,
actions: {
...base.sourceControlAi!.actions,
commitMessage: { commandInputTemplate: '' }
}
}
expect(
resolveSourceControlAiForOperation({
settings: base,
repo: null,
operation: 'commitMessage',
discoveryHostKey: 'local'
})
).toEqual({
ok: false,
error: 'Command template is empty for commit messages.'
})
})
it('maps legacy custom prompt to released split instructions', () => {
const migrated = sourceControlAiSettingsFromLegacy({
enabled: true,
@@ -326,7 +563,7 @@ describe('source-control AI resolution', () => {
instructionsByOperation: {
commitMessage: 'Legacy commit prompt',
pullRequest: 'Global PR style',
branchName: 'Legacy commit prompt'
branchName: 'Global branch style'
}
})
expect(merged.modelOverridesByOperation?.commitMessage).toEqual({
@@ -633,6 +870,14 @@ describe('source-control AI resolution', () => {
pullRequest: '',
branchName: 'branch style'
},
actionOverrides: {
pullRequest: {
commandInputTemplate: '{basePrompt}'
},
branchName: {
commandInputTemplate: '{basePrompt}\n\nbranch style'
}
},
prCreationDefaults: {
draft: true,
useTemplate: null,
@@ -642,4 +887,28 @@ describe('source-control AI resolution', () => {
expect(normalizeRepoSourceControlAiOverrides(null)).toBeUndefined()
expect(normalizeRepoSourceControlAiOverrides([])).toBeUndefined()
})
it('preserves repo null command templates without requiring another override field', () => {
expect(
normalizeRepoSourceControlAiOverrides({
instructionsByOperation: {
commitMessage: 'legacy repo style'
},
actionOverrides: {
commitMessage: {
commandInputTemplate: null
}
}
})
).toEqual({
instructionsByOperation: {
commitMessage: 'legacy repo style'
},
actionOverrides: {
commitMessage: {
commandInputTemplate: null
}
}
})
})
})
+381 -42
View File
@@ -9,6 +9,17 @@ import {
resolveCommitMessageAgentChoice
} from './commit-message-agent-spec'
import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from './commit-message-host-key'
import {
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
normalizeSourceControlActionRecipe,
normalizeSourceControlAiActionDefaults,
readSourceControlActionDefault,
resolveSourceControlActionCommandTemplate,
SOURCE_CONTROL_ACTION_IDS,
SOURCE_CONTROL_TEXT_ACTION_IDS,
type SourceControlActionId,
type SourceControlActionRecipe
} from './source-control-ai-actions'
import type {
CommitMessageAiModelCapability,
CommitMessageAiSettings,
@@ -37,6 +48,7 @@ export type ResolvedSourceControlAiGenerationParams = {
model: string
thinkingLevel?: string
customPrompt?: string
commandInputTemplate?: string
customAgentCommand?: string
agentCommandOverride?: string
}
@@ -69,6 +81,10 @@ export type ResolveSourceControlAiPrCreationDefaultsInput = {
prCreationProductDefaults?: SourceControlAiPrCreationDefaults
}
type RepoSourceControlActionOverride = NonNullable<
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
>
const OPERATION_LABEL: Record<SourceControlAiOperation, string> = {
commitMessage: 'commit messages',
pullRequest: 'pull request details',
@@ -177,6 +193,26 @@ function normalizeOperationRecord<T>(
return Object.keys(normalized).length > 0 ? normalized : undefined
}
function normalizeActionRecord<T>(
value: unknown,
normalizeValue: (value: unknown) => T | undefined
): Partial<Record<SourceControlActionId, T>> | undefined {
if (!isRecord(value)) {
return undefined
}
const normalized: Partial<Record<SourceControlActionId, T>> = {}
for (const actionId of SOURCE_CONTROL_ACTION_IDS) {
if (!Object.prototype.hasOwnProperty.call(value, actionId)) {
continue
}
const normalizedValue = normalizeValue(value[actionId])
if (normalizedValue !== undefined) {
normalized[actionId] = normalizedValue
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined
}
function normalizeRepoInstruction(value: unknown): string | null | undefined {
return typeof value === 'string' || value === null ? value : undefined
}
@@ -218,6 +254,37 @@ export function normalizeRepoSourceControlAiOverrides(
if (instructionsByOperation) {
normalized.instructionsByOperation = instructionsByOperation
}
const actionOverrides = normalizeActionRecord<RepoSourceControlActionOverride>(
value.actionOverrides,
(item) => {
if (!isRecord(item)) {
return undefined
}
const normalized: RepoSourceControlActionOverride = {
...normalizeSourceControlActionRecipe(item)
}
if (item.commandInputTemplate === null) {
normalized.commandInputTemplate = null
}
return Object.keys(normalized).length > 0 ? normalized : undefined
}
)
const migratedActionOverrides = { ...actionOverrides }
for (const operation of SOURCE_CONTROL_TEXT_ACTION_IDS) {
const instruction = instructionsByOperation?.[operation]
if (
typeof instruction === 'string' &&
migratedActionOverrides[operation]?.commandInputTemplate === undefined
) {
migratedActionOverrides[operation] = {
...migratedActionOverrides[operation],
commandInputTemplate: commandTemplateFromInstruction(instruction)
}
}
}
if (Object.keys(migratedActionOverrides).length > 0) {
normalized.actionOverrides = migratedActionOverrides
}
const prCreationDefaults = normalizeRepoPrCreationDefaults(value.prCreationDefaults)
if (prCreationDefaults) {
normalized.prCreationDefaults = prCreationDefaults
@@ -225,9 +292,114 @@ export function normalizeRepoSourceControlAiOverrides(
return normalized
}
function commandTemplateFromInstruction(instruction: string | null | undefined): string {
const trimmed = instruction?.trim()
if (!trimmed) {
return '{basePrompt}'
}
return ['{basePrompt}', '', trimmed].join('\n')
}
function actionRecipeFromLegacyCommitMessageAi(legacy: CommitMessageAiSettings): {
agentId?: TuiAgent | null
commandInputTemplate: string
} {
return {
...(legacy.agentId === null
? { agentId: null }
: legacy.agentId && !isCustomAgentId(legacy.agentId)
? { agentId: legacy.agentId }
: {}),
commandInputTemplate: commandTemplateFromInstruction(legacy.customPrompt)
}
}
function legacyPromptFromCommandTemplate(
template: string | undefined,
fallback: string | undefined
): string {
const trimmed = template?.trim()
if (!trimmed || trimmed === '{basePrompt}') {
return fallback ?? ''
}
if (trimmed.startsWith('{basePrompt}')) {
return trimmed.slice('{basePrompt}'.length).trim()
}
return trimmed
}
function hasActionAgentRecipe(recipe: {
agentId?: TuiAgent | null
}): recipe is { agentId: TuiAgent | null } {
return Object.prototype.hasOwnProperty.call(recipe, 'agentId')
}
function legacyCommitMessageCoreChanges(
legacy: CommitMessageAiSettings,
projected: CommitMessageAiSettings
): Record<'enabled' | 'agentId' | 'customPrompt' | 'customAgentCommand', boolean> {
return {
enabled: legacy.enabled !== projected.enabled,
agentId: legacy.agentId !== projected.agentId,
customPrompt: legacy.customPrompt !== projected.customPrompt,
customAgentCommand: legacy.customAgentCommand !== projected.customAgentCommand
}
}
function hasLegacyCommitMessageCoreChanges(
changes: Record<'enabled' | 'agentId' | 'customPrompt' | 'customAgentCommand', boolean>
): boolean {
return Object.values(changes).some(Boolean)
}
function applyLegacyAgentToActionRecipe(
recipe: SourceControlActionRecipe | undefined,
agentId: CommitMessageAiSettings['agentId']
): SourceControlActionRecipe {
const next = { ...recipe }
if (agentId === null) {
next.agentId = null
} else if (agentId && !isCustomAgentId(agentId)) {
next.agentId = agentId
} else {
delete next.agentId
}
return next
}
function shouldImportLegacyBranchPrompt(
base: SourceControlAiSettings,
projectedLegacy: CommitMessageAiSettings
): boolean {
const branchRecipe = readSourceControlActionDefault(base.actions, 'branchName')
const projectedTemplate = commandTemplateFromInstruction(projectedLegacy.customPrompt)
return (
branchRecipe.commandInputTemplate === undefined ||
branchRecipe.commandInputTemplate ===
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES.branchName ||
// Why: stale legacy branch instructions can remain after a user customizes
// the new branch action recipe; only recipe state can prove it is still coupled.
branchRecipe.commandInputTemplate === projectedTemplate
)
}
function shouldImportLegacyBranchAgent(
base: SourceControlAiSettings,
projectedLegacy: CommitMessageAiSettings
): boolean {
const branchRecipe = readSourceControlActionDefault(base.actions, 'branchName')
return !hasActionAgentRecipe(branchRecipe) || branchRecipe.agentId === projectedLegacy.agentId
}
export function getDefaultSourceControlAiSettings(): SourceControlAiSettings {
return {
enabled: true,
actions: Object.fromEntries(
SOURCE_CONTROL_ACTION_IDS.map((actionId) => [
actionId,
{ commandInputTemplate: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId] }
])
) as SourceControlAiSettings['actions'],
agentId: null,
selectedModelByAgent: {},
selectedModelByAgentByHost: {},
@@ -240,7 +412,8 @@ export function getDefaultSourceControlAiSettings(): SourceControlAiSettings {
pullRequest: '',
branchName: ''
},
prCreationDefaults: { ...DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS }
prCreationDefaults: { ...DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS },
launchActionDefaults: {}
}
}
@@ -251,6 +424,7 @@ export function sourceControlAiSettingsFromLegacy(
if (!legacy) {
return defaults
}
const legacyActionRecipe = actionRecipeFromLegacyCommitMessageAi(legacy)
return {
...defaults,
enabled: legacy.enabled,
@@ -267,6 +441,11 @@ export function sourceControlAiSettingsFromLegacy(
// the first split must preserve that guidance for both released paths.
pullRequest: '',
branchName: legacy.customPrompt ?? ''
},
actions: {
...defaults.actions,
commitMessage: legacyActionRecipe,
branchName: legacyActionRecipe
}
}
}
@@ -389,25 +568,59 @@ export function mergeLegacyCommitMessageAiIntoSourceControlAi(
// Why: rollback builds write commitMessageAi, while new builds project
// commit-message overrides there. Keep those model choices scoped to
// commit-message generation so PR defaults cannot drift on reload.
const legacyActionRecipe = actionRecipeFromLegacyCommitMessageAi(legacy)
const legacyChanges = legacyCommitMessageCoreChanges(legacy, projectedLegacy)
const shouldMergeLegacyCore = hasLegacyCommitMessageCoreChanges(legacyChanges)
const shouldMergeBranchPrompt =
legacyChanges.customPrompt && shouldImportLegacyBranchPrompt(base, projectedLegacy)
const shouldMergeBranchAgent =
legacyChanges.agentId && shouldImportLegacyBranchAgent(base, projectedLegacy)
return normalizeSourceControlAiSettings(
{
...base,
enabled: legacy.enabled,
agentId: legacy.agentId,
discoveredModelsByAgent: copyRecord(legacy.discoveredModelsByAgent) ?? {},
discoveredModelsByAgentByHost: copyRecord(legacy.discoveredModelsByAgentByHost) ?? {},
customAgentCommand: legacy.customAgentCommand,
instructionsByOperation: {
...base.instructionsByOperation,
commitMessage: legacy.customPrompt ?? '',
branchName: legacy.customPrompt ?? '',
...(options.pullRequestInstructionsFromLegacy
? { pullRequest: legacy.customPrompt ?? '' }
: {})
},
...(shouldMergeLegacyCore
? {
// Why: legacy commitMessageAi is also our rollback projection.
// Only import fields that diverged so independent action recipes survive.
...(legacyChanges.enabled ? { enabled: legacy.enabled } : {}),
...(legacyChanges.agentId ? { agentId: legacy.agentId } : {}),
...(legacyChanges.customAgentCommand
? { customAgentCommand: legacy.customAgentCommand }
: {}),
instructionsByOperation: {
...base.instructionsByOperation,
...(legacyChanges.customPrompt ? { commitMessage: legacy.customPrompt ?? '' } : {}),
...(shouldMergeBranchPrompt ? { branchName: legacy.customPrompt ?? '' } : {}),
...(legacyChanges.customPrompt && options.pullRequestInstructionsFromLegacy
? { pullRequest: legacy.customPrompt ?? '' }
: {})
},
actions: {
...base.actions,
commitMessage: {
...(legacyChanges.agentId
? applyLegacyAgentToActionRecipe(base.actions?.commitMessage, legacy.agentId)
: base.actions?.commitMessage),
...(legacyChanges.customPrompt
? { commandInputTemplate: legacyActionRecipe.commandInputTemplate }
: {})
},
branchName: {
...(shouldMergeBranchAgent
? applyLegacyAgentToActionRecipe(base.actions?.branchName, legacy.agentId)
: base.actions?.branchName),
...(shouldMergeBranchPrompt
? { commandInputTemplate: legacyActionRecipe.commandInputTemplate }
: {})
}
}
}
: {}),
modelOverridesByOperation: nextModelOverridesByOperation
},
legacy
shouldMergeLegacyCore ? legacy : undefined
)
}
return normalizeSourceControlAiSettings(
@@ -440,6 +653,40 @@ export function normalizeSourceControlAiSettings(
): SourceControlAiSettings {
const base = value ?? sourceControlAiSettingsFromLegacy(legacy)
const defaults = getDefaultSourceControlAiSettings()
const normalizedActions = {
...normalizeSourceControlAiActionDefaults(base.launchActionDefaults),
...normalizeSourceControlAiActionDefaults(base.actions)
}
const migratedTextActions = Object.fromEntries(
SOURCE_CONTROL_TEXT_ACTION_IDS.map((actionId) => {
const existing = readSourceControlActionDefault(normalizedActions, actionId)
const instruction = base.instructionsByOperation?.[actionId]
const legacyInstruction = actionId === 'commitMessage' ? legacy?.customPrompt : undefined
const instructionTemplate =
instruction || legacyInstruction
? commandTemplateFromInstruction(instruction ?? legacyInstruction)
: undefined
const shouldApplyInstructionTemplate =
instructionTemplate !== undefined &&
(existing.commandInputTemplate === undefined ||
existing.commandInputTemplate ===
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId])
return [
actionId,
{
...defaults.actions?.[actionId],
...(base.agentId && !isCustomAgentId(base.agentId) ? { agentId: base.agentId } : {}),
...existing,
...(shouldApplyInstructionTemplate ? { commandInputTemplate: instructionTemplate } : {})
}
]
})
) as SourceControlAiSettings['actions']
const actions: SourceControlAiSettings['actions'] = {
...defaults.actions,
...normalizedActions,
...migratedTextActions
}
return {
...defaults,
...base,
@@ -462,7 +709,11 @@ export function normalizeSourceControlAiSettings(
prCreationDefaults: {
...defaults.prCreationDefaults,
...base.prCreationDefaults
}
},
actions,
launchActionDefaults:
normalizeSourceControlAiActionDefaults(base.launchActionDefaults) ??
defaults.launchActionDefaults
}
}
@@ -550,9 +801,10 @@ export function projectSourceControlAiToLegacyCommitMessageAi(
previousLegacy?: CommitMessageAiSettings | null
): CommitMessageAiSettings {
const commitMessageChoice = sourceControlAi.modelOverridesByOperation?.commitMessage
const commitRecipe = readSourceControlActionDefault(sourceControlAi.actions, 'commitMessage')
return {
enabled: sourceControlAi.enabled,
agentId: sourceControlAi.agentId,
agentId: hasActionAgentRecipe(commitRecipe) ? commitRecipe.agentId : sourceControlAi.agentId,
selectedModelByAgent: {
...sourceControlAi.selectedModelByAgent,
...commitMessageChoice?.selectedModelByAgent
@@ -567,8 +819,10 @@ export function projectSourceControlAiToLegacyCommitMessageAi(
...sourceControlAi.selectedThinkingByModel,
...commitMessageChoice?.selectedThinkingByModel
},
customPrompt:
sourceControlAi.instructionsByOperation.commitMessage ?? previousLegacy?.customPrompt ?? '',
customPrompt: legacyPromptFromCommandTemplate(
commitRecipe.commandInputTemplate,
sourceControlAi.instructionsByOperation.commitMessage ?? previousLegacy?.customPrompt
),
customAgentCommand: sourceControlAi.customAgentCommand
}
}
@@ -738,6 +992,42 @@ function resolvePrCreationDefaults(
}
}
function resolveActionRecipeForTextOperation(
source: SourceControlAiSettings,
repoOverrides: RepoSourceControlAiOverrides | null | undefined,
operation: SourceControlAiOperation
): { agentId?: TuiAgent | null; commandInputTemplate: string } {
const globalRecipe = readSourceControlActionDefault(source.actions, operation)
const repoRecipe = repoOverrides?.actionOverrides?.[operation]
const repoInstruction = readRepoInstructionOverride(
repoOverrides?.instructionsByOperation,
operation
)
const fallbackTemplate =
repoInstruction !== undefined
? commandTemplateFromInstruction(repoInstruction)
: resolveSourceControlActionCommandTemplate(source.actions, operation)
const repoTemplate =
typeof repoRecipe?.commandInputTemplate === 'string'
? repoRecipe.commandInputTemplate.trim()
: repoRecipe?.commandInputTemplate === null
? ''
: undefined
return {
...(repoRecipe?.agentId !== undefined
? { agentId: repoRecipe.agentId }
: globalRecipe.agentId !== undefined
? { agentId: globalRecipe.agentId }
: {}),
commandInputTemplate:
repoTemplate !== undefined
? repoTemplate
: globalRecipe.commandInputTemplate !== undefined
? globalRecipe.commandInputTemplate
: fallbackTemplate
}
}
export function resolveSourceControlAiPrCreationDefaults(
input: ResolveSourceControlAiPrCreationDefaultsInput
): Required<SourceControlAiPrCreationDefaults> {
@@ -752,6 +1042,32 @@ export function resolveSourceControlAiPrCreationDefaults(
)
}
export function resolveSourceControlActionRecipe(input: {
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo?: Pick<Repo, 'sourceControlAi'> | null
actionId: SourceControlActionId
}): SourceControlActionRecipe {
const source = normalizeSourceControlAiSettings(
input.settings?.sourceControlAi,
input.settings?.commitMessageAi
)
const globalRecipe = readSourceControlActionDefault(source.actions, input.actionId)
const repoRecipe = normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)
?.actionOverrides?.[input.actionId]
if (!repoRecipe) {
return globalRecipe
}
return {
...globalRecipe,
...(repoRecipe.agentId !== undefined ? { agentId: repoRecipe.agentId } : {}),
...(typeof repoRecipe.commandInputTemplate === 'string'
? { commandInputTemplate: repoRecipe.commandInputTemplate.trim() }
: repoRecipe.commandInputTemplate === null
? { commandInputTemplate: '' }
: {})
}
}
export function resolveSourceControlAiForOperation(
input: ResolveSourceControlAiInput
): ResolveSourceControlAiResult {
@@ -760,23 +1076,7 @@ export function resolveSourceControlAiForOperation(
if (!source.enabled) {
return {
ok: false,
error: 'Enable Git AI Author in Settings -> Git.'
}
}
// Why: a normalized null means "use the current default agent"; stale legacy
// commitMessageAi should not make that choice sticky again.
const agentChoice = resolveCommitMessageAgentChoice(
source.agentId,
input.settings.defaultTuiAgent,
input.settings.disabledTuiAgents
)
if (!agentChoice) {
return {
ok: false,
error:
`Default agent "${input.settings.defaultTuiAgent}" does not support Git AI Author. ` +
'Choose Claude, Codex, or Custom in Settings -> Git -> Git AI Author.'
error: 'Enable Source Control AI in Settings -> Git.'
}
}
@@ -786,13 +1086,35 @@ export function resolveSourceControlAiForOperation(
repoOverrides,
input.prCreationProductDefaults
)
const actionRecipe = resolveActionRecipeForTextOperation(source, repoOverrides, input.operation)
if (!actionRecipe.commandInputTemplate.trim()) {
return {
ok: false,
error: `Command template is empty for ${OPERATION_LABEL[input.operation]}.`
}
}
// Why: action recipes own the new customization model. The legacy global
// agent remains a fallback so existing users migrate without losing intent.
const preferredAgent = hasActionAgentRecipe(actionRecipe) ? actionRecipe.agentId : source.agentId
const agentChoice = resolveCommitMessageAgentChoice(
preferredAgent,
input.settings.defaultTuiAgent,
input.settings.disabledTuiAgents
)
if (!agentChoice) {
return {
ok: false,
error:
'Choose a supported Source Control AI agent for this action in Settings -> Git -> Source Control AI.'
}
}
if (isCustomAgentId(agentChoice)) {
const customAgentCommand = source.customAgentCommand.trim()
if (!customAgentCommand) {
return {
ok: false,
error: 'Custom command is empty. Add one in Settings -> Git -> Git AI Author.'
error: 'Custom command is empty. Add one in Settings -> Git -> Source Control AI.'
}
}
return {
@@ -803,6 +1125,7 @@ export function resolveSourceControlAiForOperation(
agentId: CUSTOM_AGENT_ID,
model: '',
customPrompt: resolveSourceControlAiInstructions(input),
commandInputTemplate: actionRecipe.commandInputTemplate,
customAgentCommand
},
prCreationDefaults
@@ -811,11 +1134,26 @@ export function resolveSourceControlAiForOperation(
}
const agentId = agentChoice
const spec = getCommitMessageAgentSpec(agentId)
const actionAgentId = actionRecipe.agentId ?? agentId
const resolvedActionAgentId =
actionAgentId === agentId
? agentId
: resolveCommitMessageAgentChoice(
actionAgentId,
input.settings.defaultTuiAgent,
input.settings.disabledTuiAgents
)
if (!resolvedActionAgentId || isCustomAgentId(resolvedActionAgentId)) {
return {
ok: false,
error: 'Choose a supported Source Control AI agent for this action.'
}
}
const spec = getCommitMessageAgentSpec(resolvedActionAgentId)
if (!spec) {
return {
ok: false,
error: `Agent "${agentId}" does not support Git AI Author ${OPERATION_LABEL[input.operation]}.`
error: `Agent "${resolvedActionAgentId}" does not support Source Control AI ${OPERATION_LABEL[input.operation]}.`
}
}
@@ -826,14 +1164,14 @@ export function resolveSourceControlAiForOperation(
repoOverrides,
operation: input.operation,
hostKey,
agentId,
agentId: resolvedActionAgentId,
defaultModelId: spec.defaultModelId
})
const discoveredModels = getDiscoveredModels(source, legacy, hostKey, agentId)
const discoveredModels = getDiscoveredModels(source, legacy, hostKey, resolvedActionAgentId)
const model =
spec.models.find((candidate) => candidate.id === persistedModelId) ??
discoveredModels.find((candidate) => candidate.id === persistedModelId) ??
getCommitMessageModel(agentId, spec.defaultModelId)
getCommitMessageModel(resolvedActionAgentId, spec.defaultModelId)
if (!model) {
return { ok: false, error: `No model is available for ${spec.label}.` }
}
@@ -845,16 +1183,17 @@ export function resolveSourceControlAiForOperation(
repoOverrides,
operation: input.operation
})
const agentCommandOverride = input.settings.agentCmdOverrides?.[agentId]?.trim()
const agentCommandOverride = input.settings.agentCmdOverrides?.[resolvedActionAgentId]?.trim()
return {
ok: true,
value: {
enabled: true,
params: {
agentId,
agentId: resolvedActionAgentId,
model: model.id,
thinkingLevel,
customPrompt: resolveSourceControlAiInstructions(input),
commandInputTemplate: actionRecipe.commandInputTemplate,
...(agentCommandOverride ? { agentCommandOverride } : {})
},
prCreationDefaults