diff --git a/docs/no-dialog-if-launch-receipt-saved.md b/docs/no-dialog-if-launch-receipt-saved.md new file mode 100644 index 00000000000..de0238270f6 --- /dev/null +++ b/docs/no-dialog-if-launch-receipt-saved.md @@ -0,0 +1,130 @@ +# Hide Saved Launch Receipt Dialog + +## Problem + +When a Source Control AI launch action already has a saved launch receipt, some entry points still open `SourceControlAgentActionDialog`. The form then renders the "Launch recipe already saved" state and save-scope controls even though the user has already opted into a receipt-backed launch. + +This is visible today because: +- `SourceControlAgentActionDialogForm` computes `selectedLaunchRecipeAlreadySaved` only after the dialog is rendered. +- `useSourceControlAgentActionDialog` resets form state and detects agents on every open, but has no saved-receipt bypass. +- `runSourceControlAgentActionStart` already skips rewriting a matching saved recipe, but it is only reached after the user presses Start. +- The form test currently locks in the obsolete behavior where an already-saved recipe still shows visible dialog chrome. + +## Non-goals + +- Do not change how source-control recipes are saved, normalized, inherited, or migrated. +- Do not change source-control text generation dialogs. +- Do not add persisted state or telemetry. +- Do not add custom-agent launch support here. `SourceControlAgentActionDialog` starts `TuiAgent` values; saved custom-agent receipts currently arrive as `savedAgentId: null` because `readSourceControlLaunchRecipeAgentId` filters custom IDs. + +## Design + +1. Move saved-launch bypass detection into the dialog hook. + + Build the candidate launch recipe from the dialog's initial saved props: + + ```ts + { + agentId: savedAgentId, + commandInputTemplate: savedCommandInputTemplate ?? '{basePrompt}', + agentArgs: savedAgentArgs ?? '' + } + ``` + + Only consider the candidate when `savedAgentId` is non-null. A default action template without a saved agent is not a launch receipt and must keep showing the dialog. + +2. Match the actual saved target, not only the default UI target. + + Use `sourceControlActionRecipeMatchesTarget` against the available persisted targets and select the target that proves the receipt exists: + - If `repoId` and `repo` are present, check `{ type: 'repo', repoId }` first. + - Then check `{ type: 'global' }`. + - If neither target matches, render the dialog normally. + + This fixes the important repo-override case: callers pass the resolved recipe, so a repository-specific receipt can be the active saved receipt even though the form's default save target is currently global. + +3. Suppress the modal while an eligible saved receipt is being verified/launched. + + A hook-only auto-start effect is not enough: React would still render the open dialog while agent detection is pending, causing the exact modal flash this feature is meant to remove. Return a render gate such as `shouldRenderDialog` or `autoLaunchPending` from `useSourceControlAgentActionDialog`, and have `SourceControlAgentActionDialog` omit `DialogContent` while a matching saved receipt is pending detection/start. + + If the bypass later proves invalid or launch fails, clear the pending state and show the normal dialog with the existing status copy, delivery-plan panel, and toast behavior. + +4. Reuse one start implementation, but avoid a second detection call. + + Today `handleStart` always calls `refreshDetectedAgents()` before building the delivery plan. For the bypass, detection has already completed, so extract an internal start helper that accepts the detected-agent list: + + - manual Start: refresh agents, then call the helper + - saved-receipt bypass: use the just-detected agents, then call the helper + + Pass the matched save target value (`'repo'` or `'global'`) into `runSourceControlAgentActionStart`. It will still skip `onSaveAgentDefault` when the same target already matches. + +5. Guard launches by open cycle and receipt key. + + The auto-launch effect must launch at most once for a single dialog-open cycle, including React StrictMode replays and stale detection promises. Key the guard by the open cycle plus the matched receipt identity: action id, target, agent id, command template, args, repo id, connection id, worktree id, and base prompt. + + Reset the guard when the dialog closes. If props change while open, do not auto-launch a second receipt in the same open cycle; show the dialog instead. + +## Data flow + +- Caller opens `SourceControlAgentActionDialog`. +- The hook resets form state from the saved recipe props. +- The hook computes `matchedSavedReceiptTargetValue` from current `settings`/`repo`. +- If there is no concrete matched receipt, the dialog renders as today. +- If there is a match, the component hides dialog content while the hook detects local or remote agents. +- If the saved agent is detected and enabled on the current host: + - render the command template with `basePrompt` + - reject empty rendered prompts + - build the delivery plan using the already-detected agents + - call `runSourceControlAgentActionStart` with the matched save target + - on success, call `onLaunched` and close +- If any prerequisite fails, the hook reveals the normal dialog instead of silently swallowing the action. + +## Edge cases + +- No saved agent id: no bypass. This includes default templates and custom-agent receipts. +- Saved agent missing, disabled, or not detected: show the existing unavailable-agent copy. +- Remote/SSH workspace with `worktreeId` and unresolved `connectionId === undefined`: do not auto-launch; show the connection error path. +- Hosted/PR-page checks path with `onStart` and no `worktreeId`: still eligible; `onStart` is the launch implementation for that caller. +- Empty rendered command input: do not auto-launch; show the dialog disabled as today. +- Repo receipt exists and matches: bypass using the repo target, even if the form would have defaulted the save selector to global. +- Global receipt exists and matches, but repo override differs: do not treat the global receipt as matching the active resolved recipe. +- Launch returns false or throws: leave the dialog open. Existing behavior is a toast plus whatever delivery-plan state was computed; do not close silently. +- Dialog closes before detection resolves: ignore the stale result. +- Settings/repo mutate in another window while open: derive the match from subscribed `settings`/`repo` snapshots, and key the auto-launch guard so a late prop/state change cannot double-launch. +- Agent detection results change between detection and launch: the bypass uses the detected-agent list it just verified; manual starts can continue to refresh. + +## Test plan + +- Add `SourceControlAgentActionDialog.test.tsx` coverage: + - matching saved global receipt hides the dialog, calls `onStart`/launch once, calls `onLaunched`, and closes + - matching saved repo receipt bypasses using the repo target + - mismatch renders the form and does not auto-start + - saved agent unavailable renders the form with status copy and does not auto-start + - `onStart` returns `false`: dialog becomes visible/remains open and `onLaunched` is not called + - StrictMode/effect replay does not double-start +- Update `SourceControlAgentActionDialogForm.test.tsx`: + - remove the test that treats visible "Launch recipe already saved" chrome as the desired saved-receipt path + - keep selected-target copy coverage for manual customization/mismatch states +- Keep `source-control-action-recipe-match.test.ts` as the unit boundary for global/repo matching; add a repo-target regression if one is missing for the exact action used in the dialog tests. +- Run targeted Vitest for the right-sidebar dialog/matcher tests, then `pnpm typecheck` and `pnpm lint`. + +## UI quality bar + +The saved-receipt path should be visually absent: no modal flash, no "Launch recipe already saved" card, and no layout shift. The manual, mismatch, unavailable-agent, and failed-launch paths should remain visually unchanged and keep using existing shadcn primitives and tokens from `docs/STYLEGUIDE.md`. + +## Review screenshots + +Required before merge: +1. Saved global or repo receipt path: action starts with no customization dialog visible. +2. Mismatch/customize path: dialog still appears with prompt, agent, and save controls. +3. Unavailable-agent path: dialog appears with the existing status copy instead of auto-launching. + +If a true saved-receipt Electron setup would mutate local user settings, capture the nearest safe state and document the gap in the PR notes rather than committing evidence images. + +## Lightweight Eng Review + +- Scope: One dialog component, its hook, and focused tests. No persistence, recipe resolver, telemetry, or layout-system changes. +- Architecture/data flow: Correct place is the hook plus a small render gate in the component. The form is too late because rendering it already violates the UX requirement. +- Failure modes: Must cover stale detection, StrictMode replay, remote connection unresolved, unavailable saved agent, empty prompt, repo/global target mismatch, custom-agent receipt exclusion, and failed launch. +- Performance/blast radius: Low, but not free if implemented naively. Avoid redetecting agents in the bypass by sharing a start helper that accepts already-detected agents. +- UI quality: The success path has no UI; all fallback paths preserve existing visuals. +- Residual risk: Multi-window settings changes can race with an already-open dialog. Using subscribed settings/repo snapshots plus an open-cycle guard keeps the behavior consistent and prevents duplicate launches, but it cannot make a launch atomic across windows. 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 00000000000..b2e503d7ea0 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx @@ -0,0 +1,292 @@ +// @vitest-environment happy-dom + +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('/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 eb3429bb187..ab4bda179db 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,44 @@ export function SourceControlAgentActionDialog( return ( - - - {title} - {description} - - handleOpenChange(false)} - onStart={() => void handleStart()} - /> - + {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 d1bc4dfeaf4..cde7caca521 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 ac243e0fdb9..3c806438ceb 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 c061873debf..36de9c82648 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 00000000000..c3dd7a10a9c --- /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 ad62bfae98f..c3162c726b2 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 00000000000..640fbba295b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -0,0 +1,201 @@ +import { useCallback, 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 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 || isStarting) { + return false + } + if (connectionUnavailable) { + setDeliveryPlan(buildSourceControlAgentConnectionErrorPlan()) + return false + } + 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 { + setIsStarting(false) + } + }, + [ + actionId, + agentArgs, + buildPlan, + commandTemplate, + connectionUnavailable, + groupId, + isStarting, + launchSource, + launchPlatform, + onClose, + onLaunched, + onSaveAgentDefault, + onStart, + promptDelivery, + resetDeliveryPlan, + repo, + repoId, + saveLaunchRecipe, + saveTargetValue, + settings, + selectedAgent, + trimmedCommandInput, + worktreeId + ] + ) + + const handleStart = useCallback(async () => { + if (!selectedAgent || isStarting) { + 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 }) + }, [isStarting, refreshDetectedAgents, selectedAgent, startWithDetectedAgents]) + + return { deliveryPlan, resetDeliveryPlan, isStarting, handleStart, startWithDetectedAgents } +}