Improve source control action dialog layout and recipe saving UX (#5153)

* Improve source control agent action dialog layout and recipe UX

- Constrain dialog and scroll area heights to prevent viewport overflow.
- Add variable chips to easily insert the base prompt with tooltip previews.
- Keep the recipe save controls visible when a recipe is already saved, showing informational status text instead of hiding them.
- Update localized copy across multiple languages and reduce textarea rows.
- Add unit tests for the variable chip preview and save target visibility.

* Fix recipe-saved check in source control action dialog

* Evaluate only the selected save target instead of checking all available targets, as the action only writes to the selected target.
* Update daemon PTY adapter test fake PID to prevent collision with real host OS processes during runtime directory lookups.
This commit is contained in:
Jinjing
2026-06-11 12:54:07 -07:00
committed by Jinwoo-H
parent e90b25ac2c
commit da8a036029
9 changed files with 223 additions and 43 deletions
+3 -1
View File
@@ -33,7 +33,9 @@ function createMockSubprocess(): SubprocessHandle & {
let onDataCb: ((data: string) => void) | null = null
let onExitCb: ((code: number) => void) | null = null
return {
pid: 66666,
// Why: getCwd falls back to OS pid lookup; a plausible fake pid can
// collide with an unrelated local process and leak its cwd into tests.
pid: 999_999_999,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
@@ -56,6 +56,7 @@ export function SourceControlAgentActionDialog(
actionId,
title,
description,
baseCommandInput,
savedCommandInputTemplate,
onOpenSettings,
startLabel = 'Start agent',
@@ -88,13 +89,14 @@ export function SourceControlAgentActionDialog(
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex h-[70vh] min-w-0 flex-col overflow-hidden sm:max-w-2xl">
<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}
@@ -0,0 +1,146 @@
import React, { type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { SourceControlAgentActionDialogForm } from './SourceControlAgentActionDialogForm'
import type { GlobalSettings, Repo } from '../../../../shared/types'
vi.mock('@/components/agent/AgentCombobox', () => ({
default: ({ value }: { value: string | null }) =>
React.createElement('div', { 'data-agent-value': value ?? '' })
}))
vi.mock('@/components/ui/dialog', () => ({
DialogFooter: ({ children }: { children?: ReactNode }) =>
React.createElement('div', 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: ({
variablePreviews
}: {
variablePreviews?: Partial<Record<string, string>>
}) =>
React.createElement('div', {
'data-variable-previews': JSON.stringify(variablePreviews ?? {})
})
}))
function renderForm(
overrides: Partial<React.ComponentProps<typeof SourceControlAgentActionDialogForm>> = {}
): string {
return renderToStaticMarkup(
React.createElement(SourceControlAgentActionDialogForm, {
actionId: 'resolveConflicts',
baseCommandInput: 'Resolve the merge conflicts reported for this pull request.',
agentOptions: [],
selectedAgent: 'codex',
hasEnabledAgents: true,
detecting: false,
statusCopy: null,
agentArgs: '',
commandTemplate: '{basePrompt}',
savedCommandInputTemplate: '{basePrompt}',
saveLaunchRecipe: true,
saveTargetValue: 'global',
saveTargets: [
{ value: 'none', label: "Don't save" },
{ value: 'global', label: 'All repositories' }
],
settings: null,
repo: null,
canSaveAgentDefault: true,
deliveryPlan: { status: 'idle' },
canStart: true,
isStarting: false,
startLabel: 'Start agent',
onSelectedAgentChange: () => {},
onAgentArgsChange: () => {},
onCommandTemplateChange: () => {},
onSaveLaunchRecipeChange: () => {},
onSaveAgentDefaultChange: () => {},
onCancel: () => {},
onStart: () => {},
...overrides
})
)
}
function settingsWithSavedGlobalRecipe(): GlobalSettings {
return {
sourceControlAi: {
enabled: true,
agentId: 'codex',
selectedModelByAgent: {},
selectedThinkingByModel: {},
customAgentCommand: '',
instructionsByOperation: {},
actions: {
resolveConflicts: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}'
}
}
}
} as unknown as GlobalSettings
}
const repoWithoutSavedRecipe = {
id: 'repo-1',
sourceControlAi: { enabled: true }
} satisfies Pick<Repo, 'id' | 'sourceControlAi'>
describe('SourceControlAgentActionDialogForm', () => {
it('passes the base prompt preview to the variable chip hover content', () => {
const markup = renderForm()
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 = [
{ value: 'none', label: "Don't save" },
{ value: 'repo', label: 'This repository' },
{ value: 'global', label: 'All repositories' }
]
const globalMarkup = renderForm({
settings,
repo: repoWithoutSavedRecipe,
saveTargets,
saveTargetValue: 'global'
})
const repoMarkup = renderForm({
settings,
repo: repoWithoutSavedRecipe,
saveTargets,
saveTargetValue: 'repo'
})
expect(globalMarkup).toContain('Launch recipe already saved')
expect(globalMarkup).not.toContain('Save &amp; start agent')
expect(repoMarkup).not.toContain('Launch recipe already saved')
expect(repoMarkup).toContain('Save &amp; start agent')
})
})
@@ -17,6 +17,7 @@ import { cn } from '@/lib/utils'
import type { 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 { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
import { translate } from '@/i18n/i18n'
@@ -27,6 +28,7 @@ export type SourceControlAgentActionDeliveryPlanState =
type SourceControlAgentActionDialogFormProps = {
actionId: SourceControlLaunchActionId
baseCommandInput: string
agentOptions: AgentCatalogEntry[]
selectedAgent: TuiAgent | null
hasEnabledAgents: boolean
@@ -70,6 +72,7 @@ function sourceControlLaunchSaveTargetFromValue(
export function SourceControlAgentActionDialogForm({
actionId,
baseCommandInput,
agentOptions,
selectedAgent,
hasEnabledAgents,
@@ -106,26 +109,24 @@ export function SourceControlAgentActionDialogForm({
agentArgs
}
: null
const savableTargets = saveTargets
.map((target) => sourceControlLaunchSaveTargetFromValue(target.value, repo))
.filter((target): target is SourceControlAiWriteTarget => target !== null)
const allLaunchRecipesAlreadySaved = Boolean(
const selectedSaveTarget = sourceControlLaunchSaveTargetFromValue(saveTargetValue, repo)
// Why: start/save only writes the selected target, so the dialog copy must not
// depend on whether other available targets also match.
const selectedLaunchRecipeAlreadySaved = Boolean(
selectedRecipe &&
savableTargets.length > 0 &&
savableTargets.every((target) =>
sourceControlActionRecipeMatchesTarget({
actionId,
target,
recipe: selectedRecipe,
settings,
repo
})
)
selectedSaveTarget &&
sourceControlActionRecipeMatchesTarget({
actionId,
target: selectedSaveTarget,
recipe: selectedRecipe,
settings,
repo
})
)
const showSaveLaunchRecipe = canSaveAgentDefault && selectedAgent && !allLaunchRecipesAlreadySaved
const showSaveLaunchRecipe = Boolean(canSaveAgentDefault && selectedAgent)
const saveScopeTargets = saveTargets.filter((target) => target.value !== 'none')
const effectiveStartLabel =
showSaveLaunchRecipe && saveLaunchRecipe
showSaveLaunchRecipe && saveLaunchRecipe && !selectedLaunchRecipeAlreadySaved
? translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.5421a96acb',
'Save & start agent'
@@ -133,8 +134,8 @@ export function SourceControlAgentActionDialogForm({
: startLabel
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="min-h-0 min-w-0 flex-1 space-y-4 overflow-y-auto pr-1 scrollbar-sleek">
<div className="flex min-h-0 flex-col gap-4">
<div className="min-h-0 min-w-0 max-h-[min(60vh,31rem)] space-y-4 overflow-y-auto pr-1 scrollbar-sleek">
<div className="space-y-2">
<Label className="text-xs">
{translate(
@@ -213,8 +214,8 @@ export function SourceControlAgentActionDialogForm({
</Label>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bb611240f',
"Use {basePrompt} for Orca's default prompt."
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.5c75b24735',
'Customize what the agent receives before Orca starts it.'
)}
</p>
</div>
@@ -234,17 +235,21 @@ export function SourceControlAgentActionDialogForm({
</div>
<textarea
id="source-control-agent-command-input"
rows={10}
rows={7}
value={commandTemplate}
onChange={(event) => onCommandTemplateChange(event.target.value)}
className="box-border min-h-[7.75rem] min-w-0 w-full max-w-full resize-y rounded-md border border-border bg-background px-2.5 py-2 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
className="box-border min-h-[6.5rem] min-w-0 w-full max-w-full resize-y rounded-md border border-border bg-background px-2.5 py-2 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
spellCheck={false}
/>
<SourceControlActionVariableChips
actionId={actionId}
variablePreviews={{ basePrompt: baseCommandInput }}
onInsert={(variable) => {
const separator =
commandTemplate.endsWith('\n') || commandTemplate.length === 0 ? '' : ' '
onCommandTemplateChange(`${commandTemplate}${separator}{${variable}}`)
}}
/>
<p className="text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.d8f40128ee',
"{basePrompt} is Orca's default prompt."
)}
</p>
{!commandTemplateIncludesBasePrompt ? (
<p className="flex items-start gap-1.5 rounded-md border border-destructive/30 bg-destructive/5 px-2.5 py-2 text-[11px] leading-4 text-destructive">
<TriangleAlert className="mt-px size-3 shrink-0" />
@@ -274,16 +279,26 @@ export function SourceControlAgentActionDialogForm({
/>
<span>
<span className="block text-xs font-semibold">
{translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.c29f9cf266',
"Save this prompt and don't show this review next time"
)}
{selectedLaunchRecipeAlreadySaved
? translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.b0da3a4d3e',
'Launch recipe already saved'
)
: translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.c29f9cf266',
"Save this prompt and don't show this review next time"
)}
</span>
<span className="mt-0.5 block text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.6cefcdfba1',
'You can change it later in Source Control AI settings.'
)}
{selectedLaunchRecipeAlreadySaved
? translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.bff4795a6d',
'Change the agent, arguments, or prompt template to update the saved recipe.'
)
: translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.6cefcdfba1',
'You can change it later in Source Control AI settings.'
)}
</span>
</span>
</label>
+4 -1
View File
@@ -7980,7 +7980,10 @@
"3e8f21954f": "error",
"74168d7ada": "idle",
"1d47db9bf0": "No enabled agents",
"c7ff8cef11": "Detecting agents..."
"c7ff8cef11": "Detecting agents...",
"b0da3a4d3e": "Launch recipe already saved",
"bff4795a6d": "Change the agent, arguments, or prompt template to update the saved recipe.",
"5c75b24735": "Customize what the agent receives before Orca starts it."
},
"SourceControlTextGenerationDialog": {
"c5b7fa7cb6": "Save as global default",
+4 -1
View File
@@ -7853,7 +7853,10 @@
"3e8f21954f": "error",
"74168d7ada": "idle",
"1d47db9bf0": "No hay agents habilitados",
"c7ff8cef11": "Detectando agents..."
"c7ff8cef11": "Detectando agents...",
"b0da3a4d3e": "La receta de lanzamiento ya está guardada",
"bff4795a6d": "Cambia el agente, los argumentos o la plantilla del prompt para actualizar la receta guardada.",
"5c75b24735": "Personaliza lo que recibe el agente antes de que Orca lo inicie."
},
"SourceControlTextGenerationDialog": {
"c5b7fa7cb6": "Guardar como valor predeterminado global",
+4 -1
View File
@@ -7961,7 +7961,10 @@
"6cefcdfba1": "You can change it later in Source Control AI settings.",
"c29f9cf266": "Save this prompt and don't show this review next time",
"d8f40128ee": "{basePrompt} is Orca's default prompt.",
"ea4788705e": "Cancel"
"ea4788705e": "Cancel",
"b0da3a4d3e": "起動レシピはすでに保存されています",
"bff4795a6d": "保存済みのレシピを更新するには、agent、引数、またはプロンプトテンプレートを変更してください。",
"5c75b24735": "Orca が起動する前に agent が受け取る内容をカスタマイズします。"
},
"SourceControlTextGenerationDialog": {
"c5b7fa7cb6": "グローバルデフォルトとして保存",
+4 -1
View File
@@ -7961,7 +7961,10 @@
"6cefcdfba1": "You can change it later in Source Control AI settings.",
"c29f9cf266": "Save this prompt and don't show this review next time",
"d8f40128ee": "{basePrompt} is Orca's default prompt.",
"ea4788705e": "Cancel"
"ea4788705e": "Cancel",
"b0da3a4d3e": "실행 레시피가 이미 저장되었습니다",
"bff4795a6d": "저장된 레시피를 업데이트하려면 agent, 인수 또는 프롬프트 템플릿을 변경하세요.",
"5c75b24735": "Orca가 시작하기 전에 agent가 받을 내용을 사용자 지정합니다."
},
"SourceControlTextGenerationDialog": {
"c5b7fa7cb6": "전역 기본값으로 저장",
+4 -1
View File
@@ -7961,7 +7961,10 @@
"6cefcdfba1": "You can change it later in Source Control AI settings.",
"c29f9cf266": "Save this prompt and don't show this review next time",
"d8f40128ee": "{basePrompt} is Orca's default prompt.",
"ea4788705e": "Cancel"
"ea4788705e": "Cancel",
"b0da3a4d3e": "启动配方已保存",
"bff4795a6d": "更改 Agent、参数或提示词模板以更新已保存的配方。",
"5c75b24735": "自定义 Orca 启动 Agent 前发送给它的内容。"
},
"SourceControlTextGenerationDialog": {
"c5b7fa7cb6": "保存为全局默认值",