Hide saved source control launch dialog (#5229)
* Hide saved source control launch dialog * rm design doc * rm design doc
This commit is contained in:
parent
f796121bf9
commit
74b61881e4
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
Loading…
Reference in New Issue