From 74b61881e4dd67d642a11b5237f2824cc48f466e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:03:37 -0700 Subject: [PATCH] Hide saved source control launch dialog (#5229) * Hide saved source control launch dialog * rm design doc * rm design doc --- .../SourceControlAgentActionDialog.test.tsx | 293 ++++++++++++++++++ .../SourceControlAgentActionDialog.tsx | 77 ++--- ...ourceControlAgentActionDialogForm.test.tsx | 10 - ...source-control-action-recipe-match.test.ts | 27 ++ ...urce-control-agent-action-dialog-result.ts | 1 + ...eSavedSourceControlAgentActionAutoStart.ts | 285 +++++++++++++++++ .../useSourceControlAgentActionDialog.ts | 223 +++++++------ .../useSourceControlAgentActionStart.ts | 203 ++++++++++++ 8 files changed, 955 insertions(+), 164 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts create mode 100644 src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx new file mode 100644 index 000000000..63c2987b4 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx @@ -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> = {}, + options: { strictMode?: boolean } = {} +): void { + function Harness(): React.JSX.Element { + const [open, setOpen] = useState(true) + return ( + { + 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 ? ( + + + + ) : ( + + ) + ) + }) +} + +async function flushEffects(): Promise { + 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(null) + setSavedAgentId = setNextSavedAgentId + return ( + + ) + } + act(() => { + root.render() + }) + 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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx index eb3429bb1..49d01dd34 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx @@ -64,6 +64,7 @@ export function SourceControlAgentActionDialog( } = props const { handleOpenChange, + shouldRenderDialog, agentOptions, selectedAgent, hasEnabledAgents, @@ -89,42 +90,46 @@ export function SourceControlAgentActionDialog( return ( - - - {title} - {description} - - handleOpenChange(false)} - onStart={() => void handleStart()} - /> - + {/* Why: saved receipts auto-start in the background, so the fallback content + stays unmounted to avoid flashing a dialog the user already skipped. */} + {shouldRenderDialog ? ( + + + {title} + {description} + + handleOpenChange(false)} + onStart={() => void handleStart()} + /> + + ) : null} ) } diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx index d1bc4dfea..cde7caca5 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx @@ -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 = [ diff --git a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts index ac243e0fd..3c806438c 100644 --- a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts @@ -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 + }) + ).toBe(true) + }) + it('returns true when a repo recipe inherits the global command template', () => { const currentSettings = settings() currentSettings.sourceControlAi = { diff --git a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts index c061873de..36de9c826 100644 --- a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts +++ b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts @@ -6,6 +6,7 @@ import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlA export type UseSourceControlAgentActionDialogResult = { handleOpenChange: (nextOpen: boolean) => void + shouldRenderDialog: boolean agentOptions: ReturnType selectedAgent: TuiAgent | null hasEnabledAgents: boolean diff --git a/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts b/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts new file mode 100644 index 000000000..c3dd7a10a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts @@ -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 | null | undefined + repo: Pick | 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 +} + +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 | null | undefined + repo: Pick | 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(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 } +} diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts index ad62bfae9..c3162c726 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts @@ -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(savedAgentId ?? null) const [detectedAgents, setDetectedAgents] = useState([]) const [detecting, setDetecting] = useState(false) - const [deliveryPlan, setDeliveryPlan] = useState({ - status: 'idle' - }) - const [isStarting, setIsStarting] = useState(false) + const openCycleRef = useRef(0) + const wasOpenRef = useRef(false) + const [openCycle, setOpenCycle] = useState(0) + const [detectedOpenCycle, setDetectedOpenCycle] = useState(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 => { - 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, diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts new file mode 100644 index 000000000..a8fde187e --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -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 | null + worktreeId?: string | null + groupId?: string | null + promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' + launchPlatform?: NodeJS.Platform + launchSource: LaunchSource + connectionUnavailable: boolean + refreshDetectedAgents: () => Promise + onStart?: (args: { + agent: TuiAgent + commandInput: string + agentArgs: string + }) => boolean | Promise + onSaveAgentDefault?: ( + target: SourceControlAiWriteTarget, + actionId: SourceControlLaunchActionId, + recipe: SourceControlActionRecipe + ) => void | Promise + onLaunched?: () => void + onClose: () => void +} + +type SourceControlAgentActionStartWithDetectedAgentsArgs = { + detectedAgents: TuiAgent[] + saveTargetValueOverride?: string +} + +type UseSourceControlAgentActionStartResult = { + deliveryPlan: SourceControlAgentActionDeliveryPlanState + resetDeliveryPlan: () => void + isStarting: boolean + handleStart: () => Promise + startWithDetectedAgents: ( + args: SourceControlAgentActionStartWithDetectedAgentsArgs + ) => Promise +} + +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({ + status: 'idle' + }) + const [isStarting, setIsStarting] = useState(false) + const isStartingRef = useRef(false) + const resetDeliveryPlan = useCallback(() => setDeliveryPlan({ status: 'idle' }), []) + + const buildPlan = useCallback( + async (agentsOverride?: TuiAgent[]): Promise => { + 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 => { + 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 } +}