Hide saved source control launch dialog (#5229)

* Hide saved source control launch dialog

* rm design doc

* rm design doc
This commit is contained in:
Jinjing 2026-06-11 19:03:37 -07:00 committed by GitHub
parent f796121bf9
commit 74b61881e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 955 additions and 164 deletions

View File

@ -0,0 +1,293 @@
// @vitest-environment happy-dom
import path from 'node:path'
import React, { type ReactNode, useState } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
const mocks = vi.hoisted(() => ({
ensureDetectedAgents: vi.fn(),
ensureRemoteDetectedAgents: vi.fn(),
onOpenChange: vi.fn(),
onSaveAgentDefault: vi.fn(),
onLaunched: vi.fn(),
onStart: vi.fn(),
planSourceControlAgentActionLaunch: vi.fn(),
toastError: vi.fn()
}))
vi.mock('@/components/agent/AgentCombobox', () => ({
default: ({ value }: { value: string | null }) =>
React.createElement('div', { 'data-agent-value': value ?? '' })
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) =>
open ? React.createElement('div', { 'data-dialog-open': 'true' }, children) : null,
DialogContent: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
DialogDescription: ({ children }: { children?: ReactNode }) =>
React.createElement('p', null, children),
DialogFooter: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
DialogHeader: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
DialogTitle: ({ children }: { children?: ReactNode }) => React.createElement('h2', null, children)
}))
vi.mock('@/components/ui/select', () => ({
Select: ({ children }: { children?: ReactNode }) => React.createElement('div', null, children),
SelectContent: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
SelectItem: ({ children, value }: { children?: ReactNode; value: string }) =>
React.createElement('div', { 'data-select-item': value }, children),
SelectTrigger: ({ children }: { children?: ReactNode }) =>
React.createElement('button', null, children),
SelectValue: () => React.createElement('span')
}))
vi.mock('../source-control/SourceControlActionVariableChips', () => ({
SourceControlActionVariableChips: () => React.createElement('div')
}))
vi.mock('@/lib/source-control-agent-action-plan', () => ({
planSourceControlAgentActionLaunch: mocks.planSourceControlAgentActionLaunch
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError }
}))
import { useAppStore, type AppState } from '@/store'
import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog'
let container: HTMLDivElement
let root: Root
let initialState: AppState
function settingsWithGlobalRecipe(
recipe: SourceControlActionRecipe | null = {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
},
disabledTuiAgents: GlobalSettings['disabledTuiAgents'] = []
): GlobalSettings {
const base = getDefaultSettings(path.resolve('tmp'))
return {
...base,
defaultTuiAgent: 'codex',
disabledTuiAgents,
sourceControlAi: {
...base.sourceControlAi!,
enabled: true,
agentId: 'codex',
customAgentCommand: '',
actions: recipe ? { resolveConflicts: recipe } : {}
}
}
}
function repoWithSavedRecipe(): Repo {
return {
id: 'repo-1',
sourceControlAi: {
enabled: true,
actionOverrides: {
resolveConflicts: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
}
}
}
} as Repo
}
function resetStore(settings: GlobalSettings, repos: Repo[] = []): void {
useAppStore.setState(
{
...initialState,
settings,
repos,
ensureDetectedAgents: mocks.ensureDetectedAgents,
ensureRemoteDetectedAgents: mocks.ensureRemoteDetectedAgents
},
true
)
}
function renderControlledDialog(
overrides: Partial<React.ComponentProps<typeof SourceControlAgentActionDialog>> = {},
options: { strictMode?: boolean } = {}
): void {
function Harness(): React.JSX.Element {
const [open, setOpen] = useState(true)
return (
<SourceControlAgentActionDialog
open={open}
onOpenChange={(nextOpen) => {
mocks.onOpenChange(nextOpen)
setOpen(nextOpen)
}}
actionId="resolveConflicts"
title="Launch agent"
description="Review the launch recipe before starting."
baseCommandInput="Resolve conflicts."
savedCommandInputTemplate="{basePrompt}"
savedAgentArgs=""
launchSource="source_control_recovery"
savedAgentId="codex"
onSaveAgentDefault={mocks.onSaveAgentDefault}
onLaunched={mocks.onLaunched}
onStart={mocks.onStart}
{...overrides}
/>
)
}
act(() => {
root.render(
options.strictMode ? (
<React.StrictMode>
<Harness />
</React.StrictMode>
) : (
<Harness />
)
)
})
}
async function flushEffects(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
describe('SourceControlAgentActionDialog', () => {
beforeEach(() => {
;(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true
initialState = useAppStore.getState()
vi.clearAllMocks()
mocks.ensureDetectedAgents.mockResolvedValue(['codex'])
mocks.ensureRemoteDetectedAgents.mockResolvedValue(['codex'])
mocks.onStart.mockResolvedValue(true)
mocks.planSourceControlAgentActionLaunch.mockReturnValue({
ok: true,
summary: 'Ready to launch.',
commandLabel: 'codex',
caveat: 'The prompt will be submitted after the agent is ready.'
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
resetStore(settingsWithGlobalRecipe())
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
useAppStore.setState(initialState, true)
})
it('hides the dialog and auto-starts once when the saved global launch recipe matches', async () => {
renderControlledDialog()
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(mocks.onOpenChange).toHaveBeenCalledWith(false))
expect(mocks.ensureDetectedAgents).toHaveBeenCalledTimes(1)
expect(mocks.onStart).toHaveBeenCalledWith({
agent: 'codex',
commandInput: 'Resolve conflicts.',
agentArgs: ''
})
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
expect(container.textContent).not.toContain('Launch agent')
})
it('hides the dialog and auto-starts once when the saved repo launch recipe matches', async () => {
resetStore(
settingsWithGlobalRecipe({ agentId: 'claude', commandInputTemplate: '{basePrompt}' }),
[repoWithSavedRecipe()]
)
renderControlledDialog({ repoId: 'repo-1' })
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(mocks.onOpenChange).toHaveBeenCalledWith(false))
expect(mocks.ensureDetectedAgents).toHaveBeenCalledTimes(1)
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
expect(container.textContent).not.toContain('Launch agent')
})
it('renders the form and does not auto-start when the saved launch recipe mismatches', async () => {
resetStore(
settingsWithGlobalRecipe({ agentId: 'claude', commandInputTemplate: '{basePrompt}' })
)
renderControlledDialog()
await vi.waitFor(() => expect(mocks.ensureDetectedAgents).toHaveBeenCalledTimes(1))
await flushEffects()
expect(mocks.onStart).not.toHaveBeenCalled()
expect(container.textContent).toContain('Launch agent')
expect(container.textContent).toContain('Save & start agent')
})
it('reveals the form with status copy when the saved agent is unavailable', async () => {
mocks.ensureDetectedAgents.mockResolvedValue([])
resetStore(settingsWithGlobalRecipe())
renderControlledDialog()
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() =>
expect(container.textContent?.toLowerCase()).toContain('not enabled or was not detected')
)
expect(mocks.onStart).not.toHaveBeenCalled()
expect(container.textContent).toContain('Launch agent')
})
it('reveals the dialog and remains open when auto-start fails', async () => {
mocks.onStart.mockResolvedValue(false)
renderControlledDialog()
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(container.textContent).toContain('Launch agent'))
expect(mocks.onLaunched).not.toHaveBeenCalled()
expect(mocks.onOpenChange).not.toHaveBeenCalledWith(false)
expect(mocks.toastError).toHaveBeenCalledTimes(1)
})
it('does not auto-start when a saved receipt appears after the dialog is already open', async () => {
let setSavedAgentId: (agent: TuiAgent | null) => void = () => {}
function Harness(): React.JSX.Element {
const [savedAgentId, setNextSavedAgentId] = useState<TuiAgent | null>(null)
setSavedAgentId = setNextSavedAgentId
return (
<SourceControlAgentActionDialog
open
onOpenChange={mocks.onOpenChange}
actionId="resolveConflicts"
title="Launch agent"
description="Review the launch recipe before starting."
baseCommandInput="Resolve conflicts."
savedCommandInputTemplate="{basePrompt}"
savedAgentArgs=""
launchSource="source_control_recovery"
savedAgentId={savedAgentId}
onSaveAgentDefault={mocks.onSaveAgentDefault}
onLaunched={mocks.onLaunched}
onStart={mocks.onStart}
/>
)
}
act(() => {
root.render(<Harness />)
})
await vi.waitFor(() => expect(container.textContent).toContain('Launch agent'))
act(() => {
setSavedAgentId('codex')
})
await flushEffects()
expect(mocks.onStart).not.toHaveBeenCalled()
expect(container.textContent).toContain('Launch agent')
})
it('does not double-start during StrictMode effect replay', async () => {
renderControlledDialog({}, { strictMode: true })
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await flushEffects()
expect(mocks.onStart).toHaveBeenCalledTimes(1)
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
})
})

View File

@ -64,6 +64,7 @@ export function SourceControlAgentActionDialog(
} = props
const {
handleOpenChange,
shouldRenderDialog,
agentOptions,
selectedAgent,
hasEnabledAgents,
@ -89,42 +90,46 @@ export function SourceControlAgentActionDialog(
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[min(82vh,42rem)] min-w-0 flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">{description}</DialogDescription>
</DialogHeader>
<SourceControlAgentActionDialogForm
actionId={actionId}
baseCommandInput={baseCommandInput}
agentOptions={agentOptions}
selectedAgent={selectedAgent}
hasEnabledAgents={hasEnabledAgents}
detecting={detecting}
statusCopy={statusCopy}
agentArgs={agentArgs}
commandTemplate={commandTemplate}
savedCommandInputTemplate={savedCommandInputTemplate}
saveLaunchRecipe={saveLaunchRecipe}
saveTargetValue={saveTargetValue}
saveTargets={saveTargets}
settings={settings}
repo={repo}
canSaveAgentDefault={Boolean(onSaveAgentDefault)}
deliveryPlan={deliveryPlan}
canStart={canStart}
isStarting={isStarting}
startLabel={startLabel}
onSelectedAgentChange={onSelectedAgentChange}
onAgentArgsChange={onAgentArgsChange}
onCommandTemplateChange={onCommandTemplateChange}
onSaveLaunchRecipeChange={onSaveLaunchRecipeChange}
onSaveAgentDefaultChange={onSaveAgentDefaultChange}
onOpenSettings={onOpenSettings}
onCancel={() => handleOpenChange(false)}
onStart={() => void handleStart()}
/>
</DialogContent>
{/* Why: saved receipts auto-start in the background, so the fallback content
stays unmounted to avoid flashing a dialog the user already skipped. */}
{shouldRenderDialog ? (
<DialogContent className="flex max-h-[min(82vh,42rem)] min-w-0 flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">{description}</DialogDescription>
</DialogHeader>
<SourceControlAgentActionDialogForm
actionId={actionId}
baseCommandInput={baseCommandInput}
agentOptions={agentOptions}
selectedAgent={selectedAgent}
hasEnabledAgents={hasEnabledAgents}
detecting={detecting}
statusCopy={statusCopy}
agentArgs={agentArgs}
commandTemplate={commandTemplate}
savedCommandInputTemplate={savedCommandInputTemplate}
saveLaunchRecipe={saveLaunchRecipe}
saveTargetValue={saveTargetValue}
saveTargets={saveTargets}
settings={settings}
repo={repo}
canSaveAgentDefault={Boolean(onSaveAgentDefault)}
deliveryPlan={deliveryPlan}
canStart={canStart}
isStarting={isStarting}
startLabel={startLabel}
onSelectedAgentChange={onSelectedAgentChange}
onAgentArgsChange={onAgentArgsChange}
onCommandTemplateChange={onCommandTemplateChange}
onSaveLaunchRecipeChange={onSaveLaunchRecipeChange}
onSaveAgentDefaultChange={onSaveAgentDefaultChange}
onOpenSettings={onOpenSettings}
onCancel={() => handleOpenChange(false)}
onStart={() => void handleStart()}
/>
</DialogContent>
) : null}
</Dialog>
)
}

View File

@ -107,16 +107,6 @@ describe('SourceControlAgentActionDialogForm', () => {
expect(markup).toContain('Resolve the merge conflicts reported for this pull request.')
})
it('keeps save target controls visible when the current launch recipe is already saved', () => {
const settings = settingsWithSavedGlobalRecipe()
const markup = renderForm({ settings })
expect(markup).toContain('Launch recipe already saved')
expect(markup).toContain('Save for')
expect(markup).toContain('All repositories')
})
it('checks already-saved copy against the selected save target', () => {
const settings = settingsWithSavedGlobalRecipe()
const saveTargets = [

View File

@ -80,6 +80,33 @@ describe('sourceControlActionRecipeMatchesTarget', () => {
).toBe(true)
})
it('returns true when the resolve conflicts recipe matches the repo saved recipe', () => {
expect(
sourceControlActionRecipeMatchesTarget({
actionId: 'resolveConflicts',
target: { type: 'repo', repoId: 'repo-1' },
recipe: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
},
settings: settings(),
repo: {
sourceControlAi: {
enabled: true,
actionOverrides: {
resolveConflicts: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
}
}
}
} satisfies Pick<Repo, 'sourceControlAi'>
})
).toBe(true)
})
it('returns true when a repo recipe inherits the global command template', () => {
const currentSettings = settings()
currentSettings.sourceControlAi = {

View File

@ -6,6 +6,7 @@ import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlA
export type UseSourceControlAgentActionDialogResult = {
handleOpenChange: (nextOpen: boolean) => void
shouldRenderDialog: boolean
agentOptions: ReturnType<typeof getAgentCatalog>
selectedAgent: TuiAgent | null
hasEnabledAgents: boolean

View File

@ -0,0 +1,285 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
import { isSourceControlAgentDetectedAndEnabled } from './source-control-agent-action-dialog-support'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
type SavedSourceControlAgentActionTargetValue = 'repo' | 'global'
const NO_SAVED_RECEIPT_KEY = '__no_saved_receipt__'
type UseSavedSourceControlAgentActionAutoStartArgs = {
open: boolean
openCycle: number
detectionReady: boolean
actionId: SourceControlLaunchActionId
baseCommandInput: string
savedAgentId?: TuiAgent | null
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo: Pick<Repo, 'sourceControlAi'> | null
repoId?: string | null
worktreeId?: string | null
connectionId?: string | null
selectedAgent: TuiAgent | null
trimmedCommandInput: string
connectionUnavailable: boolean
detecting: boolean
isStarting: boolean
detectedAgents: TuiAgent[]
disabledAgents: TuiAgent[] | undefined
onAutoStart: (args: {
detectedAgents: TuiAgent[]
saveTargetValue: SavedSourceControlAgentActionTargetValue
}) => Promise<boolean>
}
type SavedSourceControlAgentActionAutoStartResult = {
autoLaunchPending: boolean
matchedSavedReceiptTargetValue: SavedSourceControlAgentActionTargetValue | null
}
type AutoLaunchReceiptState = {
openCycle: number
receiptKey: string
revealed: boolean
}
function buildSavedLaunchRecipe(input: {
savedAgentId?: TuiAgent | null
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
}): SourceControlActionRecipe | null {
if (!input.savedAgentId) {
return null
}
return {
agentId: input.savedAgentId,
commandInputTemplate: input.savedCommandInputTemplate ?? '{basePrompt}',
agentArgs: input.savedAgentArgs ?? ''
}
}
function getMatchedSavedReceiptTargetValue(input: {
actionId: SourceControlLaunchActionId
recipe: SourceControlActionRecipe | null
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo: Pick<Repo, 'sourceControlAi'> | null
repoId?: string | null
}): SavedSourceControlAgentActionTargetValue | null {
if (!input.recipe) {
return null
}
if (
input.repoId &&
input.repo &&
sourceControlActionRecipeMatchesTarget({
actionId: input.actionId,
target: { type: 'repo', repoId: input.repoId },
recipe: input.recipe,
settings: input.settings,
repo: input.repo
})
) {
return 'repo'
}
if (
sourceControlActionRecipeMatchesTarget({
actionId: input.actionId,
target: { type: 'global' },
recipe: input.recipe,
settings: input.settings,
repo: input.repo
})
) {
return 'global'
}
return null
}
function buildReceiptKey(input: {
actionId: SourceControlLaunchActionId
targetValue: SavedSourceControlAgentActionTargetValue
savedAgentId: TuiAgent
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
repoId?: string | null
connectionId?: string | null
worktreeId?: string | null
baseCommandInput: string
}): string {
return JSON.stringify([
input.actionId,
input.targetValue,
input.savedAgentId,
input.savedCommandInputTemplate ?? '{basePrompt}',
input.savedAgentArgs ?? '',
input.repoId ?? null,
input.connectionId ?? null,
input.worktreeId ?? null,
input.baseCommandInput
])
}
export function useSavedSourceControlAgentActionAutoStart({
open,
openCycle,
detectionReady,
actionId,
baseCommandInput,
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
settings,
repo,
repoId,
worktreeId,
connectionId,
selectedAgent,
trimmedCommandInput,
connectionUnavailable,
detecting,
isStarting,
detectedAgents,
disabledAgents,
onAutoStart
}: UseSavedSourceControlAgentActionAutoStartArgs): SavedSourceControlAgentActionAutoStartResult {
const autoStartedOpenCycleRef = useRef(0)
const [receiptState, setReceiptState] = useState<AutoLaunchReceiptState | null>(null)
const savedLaunchRecipe = useMemo(
() =>
buildSavedLaunchRecipe({
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs
}),
[savedAgentArgs, savedAgentId, savedCommandInputTemplate]
)
const matchedSavedReceiptTargetValue = useMemo(
() =>
getMatchedSavedReceiptTargetValue({
actionId,
recipe: savedLaunchRecipe,
settings,
repo,
repoId
}),
[actionId, repo, repoId, savedLaunchRecipe, settings]
)
const receiptKey = useMemo(() => {
if (!savedAgentId || !matchedSavedReceiptTargetValue) {
return null
}
return buildReceiptKey({
actionId,
targetValue: matchedSavedReceiptTargetValue,
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
repoId,
connectionId,
worktreeId,
baseCommandInput
})
}, [
actionId,
baseCommandInput,
connectionId,
matchedSavedReceiptTargetValue,
repoId,
savedAgentArgs,
savedAgentId,
savedCommandInputTemplate,
worktreeId
])
const currentReceiptState = receiptState?.openCycle === openCycle ? receiptState : null
const consideredDifferentReceipt = Boolean(
currentReceiptState && receiptKey && currentReceiptState.receiptKey !== receiptKey
)
const autoLaunchPending = Boolean(
open &&
matchedSavedReceiptTargetValue &&
receiptKey &&
!consideredDifferentReceipt &&
!currentReceiptState?.revealed
)
useEffect(() => {
if (!open) {
autoStartedOpenCycleRef.current = 0
setReceiptState(null)
return
}
if (receiptState?.openCycle !== openCycle) {
setReceiptState({
openCycle,
receiptKey: receiptKey ?? NO_SAVED_RECEIPT_KEY,
revealed: !receiptKey
})
}
if (!matchedSavedReceiptTargetValue || !receiptKey || !savedAgentId) {
return
}
if (receiptState?.openCycle === openCycle && receiptState.receiptKey !== receiptKey) {
return
}
if (receiptState?.openCycle === openCycle && receiptState.revealed) {
return
}
const revealDialog = (): void => {
setReceiptState({ openCycle, receiptKey, revealed: true })
}
if (!detectionReady || detecting || isStarting) {
return
}
if (
selectedAgent !== savedAgentId ||
!trimmedCommandInput ||
connectionUnavailable ||
!isSourceControlAgentDetectedAndEnabled(savedAgentId, detectedAgents, disabledAgents)
) {
revealDialog()
return
}
if (autoStartedOpenCycleRef.current === openCycle) {
return
}
autoStartedOpenCycleRef.current = openCycle
void onAutoStart({
detectedAgents,
saveTargetValue: matchedSavedReceiptTargetValue
})
.then((launched) => {
if (!launched) {
revealDialog()
}
})
.catch(() => {
revealDialog()
})
}, [
connectionUnavailable,
detectedAgents,
detectionReady,
detecting,
disabledAgents,
isStarting,
matchedSavedReceiptTargetValue,
onAutoStart,
open,
openCycle,
receiptKey,
receiptState,
savedAgentId,
selectedAgent,
trimmedCommandInput
])
return { autoLaunchPending, matchedSavedReceiptTargetValue }
}

View File

@ -1,22 +1,20 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { pickSourceControlLaunchAgent } from '@/lib/source-control-launch-agent-selection'
import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan'
import { useAppStore } from '@/store'
import { useRepoById } from '@/store/selectors'
import { renderSourceControlActionCommandTemplate } from '../../../../shared/source-control-ai-actions'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import type { TuiAgent } from '../../../../shared/types'
import { type SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
import type { SourceControlAgentActionDialogProps } from './SourceControlAgentActionDialog'
import type { UseSourceControlAgentActionDialogResult } from './source-control-agent-action-dialog-result'
import { useSavedSourceControlAgentActionAutoStart } from './useSavedSourceControlAgentActionAutoStart'
import {
buildSourceControlAgentConnectionErrorPlan,
buildSourceControlAgentSaveTargets,
buildSourceControlAgentStatusCopy,
isSourceControlAgentDetectedAndEnabled
} from './source-control-agent-action-dialog-support'
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
import { useSourceControlAgentActionStart } from './useSourceControlAgentActionStart'
const DEFAULT_SAVE_TARGET_VALUE = 'global'
@ -50,10 +48,10 @@ export function useSourceControlAgentActionDialog({
const [selectedAgent, setSelectedAgent] = useState<TuiAgent | null>(savedAgentId ?? null)
const [detectedAgents, setDetectedAgents] = useState<TuiAgent[]>([])
const [detecting, setDetecting] = useState(false)
const [deliveryPlan, setDeliveryPlan] = useState<SourceControlAgentActionDeliveryPlanState>({
status: 'idle'
})
const [isStarting, setIsStarting] = useState(false)
const openCycleRef = useRef(0)
const wasOpenRef = useRef(false)
const [openCycle, setOpenCycle] = useState(0)
const [detectedOpenCycle, setDetectedOpenCycle] = useState<number | null>(null)
const saveTargets = useMemo(() => buildSourceControlAgentSaveTargets(repoId), [repoId])
const [saveLaunchRecipe, setSaveLaunchRecipe] = useState(true)
const [saveTargetValue, setSaveTargetValue] = useState(DEFAULT_SAVE_TARGET_VALUE)
@ -82,8 +80,16 @@ export function useSourceControlAgentActionDialog({
useEffect(() => {
if (!open) {
wasOpenRef.current = false
return
}
const cycle = wasOpenRef.current ? openCycleRef.current : openCycleRef.current + 1
if (!wasOpenRef.current) {
openCycleRef.current = cycle
setOpenCycle(cycle)
}
wasOpenRef.current = true
setDetectedOpenCycle(null)
setCommandTemplate(savedCommandInputTemplate ?? '{basePrompt}')
setAgentArgs(savedAgentArgs ?? '')
setSelectedAgent(savedAgentId ?? null)
@ -91,7 +97,7 @@ export function useSourceControlAgentActionDialog({
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
let stale = false
void refreshDetectedAgents().then((nextAgents) => {
if (stale) {
if (stale || openCycleRef.current !== cycle) {
return
}
setSelectedAgent(
@ -104,6 +110,7 @@ export function useSourceControlAgentActionDialog({
disabledAgents
})
)
setDetectedOpenCycle(cycle)
})
return () => {
stale = true
@ -119,17 +126,7 @@ export function useSourceControlAgentActionDialog({
settings?.defaultTuiAgent
])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
setDeliveryPlan({ status: 'idle' })
setSaveLaunchRecipe(true)
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
}
onOpenChange(nextOpen)
},
[onOpenChange]
)
const closeDialog = useCallback(() => onOpenChange(false), [onOpenChange])
const enabledDetectedAgents = useMemo(
() => detectedAgents.filter((agent) => isTuiAgentEnabled(agent, disabledAgents)),
@ -151,6 +148,33 @@ export function useSourceControlAgentActionDialog({
basePrompt: baseCommandInput
})
const trimmedCommandInput = commandInput.trim()
const { deliveryPlan, resetDeliveryPlan, isStarting, handleStart, startWithDetectedAgents } =
useSourceControlAgentActionStart({
selectedAgent,
commandInput,
trimmedCommandInput,
agentArgs,
commandTemplate,
saveLaunchRecipe,
saveTargetValue,
actionId,
repoId,
settings,
repo,
worktreeId,
groupId,
promptDelivery,
launchPlatform,
launchSource,
connectionUnavailable,
refreshDetectedAgents,
onStart,
onSaveAgentDefault,
onLaunched,
onClose: closeDialog
})
const canStart =
Boolean(trimmedCommandInput) &&
Boolean(selectedAgent) &&
@ -159,95 +183,45 @@ export function useSourceControlAgentActionDialog({
!detecting &&
!isStarting
const buildPlan = useCallback(
async (agentsOverride?: TuiAgent[]): Promise<SourceControlAgentActionDeliveryPlanState> => {
const currentDetectedAgents = agentsOverride ?? (await refreshDetectedAgents())
return buildSourceControlAgentDeliveryPlan({
selectedAgent,
commandInput,
agentArgs,
promptDelivery,
detectedAgents: currentDetectedAgents,
connectionUnavailable,
launchPlatform
})
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
resetDeliveryPlan()
setSaveLaunchRecipe(true)
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
}
onOpenChange(nextOpen)
},
[
agentArgs,
commandInput,
connectionUnavailable,
promptDelivery,
refreshDetectedAgents,
selectedAgent,
launchPlatform
]
[onOpenChange, resetDeliveryPlan]
)
const handleStart = useCallback(async () => {
if (!selectedAgent || isStarting) {
return
}
if (connectionUnavailable) {
setDeliveryPlan(buildSourceControlAgentConnectionErrorPlan())
return
}
setIsStarting(true)
try {
const nextAgents = await refreshDetectedAgents()
const nextPlan = await buildPlan(nextAgents)
if (nextPlan.status === 'error') {
setDeliveryPlan(nextPlan)
return
}
setDeliveryPlan(nextPlan)
await runSourceControlAgentActionStart({
selectedAgent,
trimmedCommandInput,
agentArgs,
commandTemplate,
saveTargetValue: saveLaunchRecipe ? saveTargetValue : 'none',
actionId,
repoId,
settings,
repo,
worktreeId,
groupId,
promptDelivery,
launchPlatform,
launchSource,
onStart,
onSaveAgentDefault,
onLaunched,
onClose: () => handleOpenChange(false)
})
} finally {
setIsStarting(false)
}
}, [
const { autoLaunchPending } = useSavedSourceControlAgentActionAutoStart({
open,
openCycle,
detectionReady: detectedOpenCycle === openCycle,
actionId,
agentArgs,
buildPlan,
commandTemplate,
connectionUnavailable,
groupId,
isStarting,
launchSource,
launchPlatform,
handleOpenChange,
onLaunched,
onSaveAgentDefault,
onStart,
promptDelivery,
refreshDetectedAgents,
baseCommandInput,
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
settings,
repo,
repoId,
saveLaunchRecipe,
saveTargetValue,
settings,
worktreeId,
connectionId,
selectedAgent,
trimmedCommandInput,
worktreeId
])
connectionUnavailable,
detecting,
isStarting,
detectedAgents,
disabledAgents,
onAutoStart: ({ detectedAgents: agentsForLaunch, saveTargetValue: matchedTargetValue }) =>
startWithDetectedAgents({
detectedAgents: agentsForLaunch,
saveTargetValueOverride: matchedTargetValue
})
})
const statusCopy = buildSourceControlAgentStatusCopy({
selectedAgent,
@ -257,25 +231,38 @@ export function useSourceControlAgentActionDialog({
detecting
})
const onSelectedAgentChange = useCallback((agent: TuiAgent | null) => {
setSelectedAgent(agent)
setDeliveryPlan({ status: 'idle' })
}, [])
const onAgentArgsChange = useCallback((value: string) => {
setAgentArgs(value)
setDeliveryPlan({ status: 'idle' })
}, [])
const onCommandTemplateChange = useCallback((value: string) => {
setCommandTemplate(value)
setDeliveryPlan({ status: 'idle' })
}, [])
const onSaveLaunchRecipeChange = useCallback((value: boolean) => {
setSaveLaunchRecipe(value)
setDeliveryPlan({ status: 'idle' })
}, [])
const onSelectedAgentChange = useCallback(
(agent: TuiAgent | null) => {
setSelectedAgent(agent)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
const onAgentArgsChange = useCallback(
(value: string) => {
setAgentArgs(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
const onCommandTemplateChange = useCallback(
(value: string) => {
setCommandTemplate(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
const onSaveLaunchRecipeChange = useCallback(
(value: boolean) => {
setSaveLaunchRecipe(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
return {
handleOpenChange,
shouldRenderDialog: !autoLaunchPending,
agentOptions,
selectedAgent,
hasEnabledAgents,

View File

@ -0,0 +1,203 @@
import { useCallback, useRef, useState } from 'react'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan'
import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support'
type UseSourceControlAgentActionStartArgs = {
selectedAgent: TuiAgent | null
commandInput: string
trimmedCommandInput: string
agentArgs: string
commandTemplate: string
saveLaunchRecipe: boolean
saveTargetValue: string
actionId: SourceControlLaunchActionId
repoId?: string | null
settings: GlobalSettings | null
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
worktreeId?: string | null
groupId?: string | null
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
launchPlatform?: NodeJS.Platform
launchSource: LaunchSource
connectionUnavailable: boolean
refreshDetectedAgents: () => Promise<TuiAgent[]>
onStart?: (args: {
agent: TuiAgent
commandInput: string
agentArgs: string
}) => boolean | Promise<boolean>
onSaveAgentDefault?: (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => void | Promise<void>
onLaunched?: () => void
onClose: () => void
}
type SourceControlAgentActionStartWithDetectedAgentsArgs = {
detectedAgents: TuiAgent[]
saveTargetValueOverride?: string
}
type UseSourceControlAgentActionStartResult = {
deliveryPlan: SourceControlAgentActionDeliveryPlanState
resetDeliveryPlan: () => void
isStarting: boolean
handleStart: () => Promise<void>
startWithDetectedAgents: (
args: SourceControlAgentActionStartWithDetectedAgentsArgs
) => Promise<boolean>
}
export function useSourceControlAgentActionStart({
selectedAgent,
commandInput,
trimmedCommandInput,
agentArgs,
commandTemplate,
saveLaunchRecipe,
saveTargetValue,
actionId,
repoId,
settings,
repo,
worktreeId,
groupId,
promptDelivery,
launchPlatform,
launchSource,
connectionUnavailable,
refreshDetectedAgents,
onStart,
onSaveAgentDefault,
onLaunched,
onClose
}: UseSourceControlAgentActionStartArgs): UseSourceControlAgentActionStartResult {
const [deliveryPlan, setDeliveryPlan] = useState<SourceControlAgentActionDeliveryPlanState>({
status: 'idle'
})
const [isStarting, setIsStarting] = useState(false)
const isStartingRef = useRef(false)
const resetDeliveryPlan = useCallback(() => setDeliveryPlan({ status: 'idle' }), [])
const buildPlan = useCallback(
async (agentsOverride?: TuiAgent[]): Promise<SourceControlAgentActionDeliveryPlanState> => {
const currentDetectedAgents = agentsOverride ?? (await refreshDetectedAgents())
return buildSourceControlAgentDeliveryPlan({
selectedAgent,
commandInput,
agentArgs,
promptDelivery,
detectedAgents: currentDetectedAgents,
connectionUnavailable,
launchPlatform
})
},
[
agentArgs,
commandInput,
connectionUnavailable,
promptDelivery,
refreshDetectedAgents,
selectedAgent,
launchPlatform
]
)
const startWithDetectedAgents = useCallback(
async ({
detectedAgents: nextAgents,
saveTargetValueOverride
}: SourceControlAgentActionStartWithDetectedAgentsArgs): Promise<boolean> => {
if (!selectedAgent || isStartingRef.current) {
return false
}
if (connectionUnavailable) {
setDeliveryPlan(buildSourceControlAgentConnectionErrorPlan())
return false
}
isStartingRef.current = true
setIsStarting(true)
try {
const nextPlan = await buildPlan(nextAgents)
if (nextPlan.status === 'error') {
setDeliveryPlan(nextPlan)
return false
}
setDeliveryPlan(nextPlan)
return await runSourceControlAgentActionStart({
selectedAgent,
trimmedCommandInput,
agentArgs,
commandTemplate,
saveTargetValue: saveLaunchRecipe ? (saveTargetValueOverride ?? saveTargetValue) : 'none',
actionId,
repoId,
settings,
repo,
worktreeId,
groupId,
promptDelivery,
launchPlatform,
launchSource,
onStart,
onSaveAgentDefault,
onLaunched,
onClose: () => {
resetDeliveryPlan()
onClose()
}
})
} finally {
isStartingRef.current = false
setIsStarting(false)
}
},
[
actionId,
agentArgs,
buildPlan,
commandTemplate,
connectionUnavailable,
groupId,
launchSource,
launchPlatform,
onClose,
onLaunched,
onSaveAgentDefault,
onStart,
promptDelivery,
resetDeliveryPlan,
repo,
repoId,
saveLaunchRecipe,
saveTargetValue,
settings,
selectedAgent,
trimmedCommandInput,
worktreeId
]
)
const handleStart = useCallback(async () => {
if (!selectedAgent || isStartingRef.current) {
return
}
// Why: manual starts intentionally re-check the current host, while the
// saved-receipt bypass reuses the detection result that unlocked it.
const nextAgents = await refreshDetectedAgents()
await startWithDetectedAgents({ detectedAgents: nextAgents })
}, [refreshDetectedAgents, selectedAgent, startWithDetectedAgents])
return { deliveryPlan, resetDeliveryPlan, isStarting, handleStart, startWithDetectedAgents }
}