mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 00:03:09 +00:00
Hide saved source control launch dialog
This commit is contained in:
@@ -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
|
||||
|
||||
+285
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user