mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
WIP: Changes before auto-review fixes
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+16
-2
@@ -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>
|
||||
)
|
||||
}
|
||||
+83
@@ -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.'
|
||||
})
|
||||
})
|
||||
})
|
||||
+354
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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}')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user