Hide saved source control launch dialog

This commit is contained in:
Jinjing
2026-06-11 17:55:51 -07:00
parent d6c9d72f01
commit 9fcaccae37
9 changed files with 1080 additions and 164 deletions
+130
View File
@@ -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.
@@ -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<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,44 @@ 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>
{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,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<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 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 || 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 }
}