Files
orca/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx
T
Brennan Benson f06f737802 Unify Source Control action permissions and card layout (#30449)
* Source control actions start chats and terminals on their saved model

A "start an agent" action's model lived only in free-text CLI arguments, which a native chat
never reads, so AI Fix opened a chat on the default model. Each action now saves a structured
model and effort that both routes apply: chat create options, or the agent's own flags in a
terminal (winning over the same flag in CLI arguments). Desktop, dialog, fix-checks and mobile
launches carry it; Settings rows get Model/Effort selects and the CLI arguments field says it is
terminal-only.

Tracked in STA-11102.

* Name the model in the repository override note

* Type the dialog's model hand-off; keep Agent in English in Japanese copy

* fix: preserve action chat model choices through launch

* Preserve action selection ownership across chat launches

* Keep terminal host contract fixtures aligned with preflight

* fix: wait for action Settings agent inventory

* fix: consolidate direct agent routing imports

* fix: use standard action option labels

* fix: align action launch contracts with shared types

* fix: sync action option localization metadata

* test: provide action Settings inventory in mounted fixtures

* Keep action options row height stable while loading

* Let each Source Control action choose its chat permission mode

An action's saved chat choice may now carry a permission mode, with or without a model;
"Same as new chats" saves nothing. A model is replaced only when the action names one,
so a permission-only choice keeps the chat's remembered model and options.

* fix(native-chat): leave older hosts their own permission mode and keep action options readable

- An action's saved permission mode is sent only to hosts that advertise
  agentSession.create.options-mode.v1. Older hosts get no mode, in create
  options or a later setOption, so the chat starts on that host's new-chat
  default, as a phone launch to such a host already does. Hosts from
  1.4.225 to 1.4.227 refused a mode their tables lacked (OpenCode, Grok,
  OMP), leaving the action unable to start.
- Settings action rows give Model and Permissions full rows in the options
  column so saved values are readable; the loading placeholder reserves the
  same rows.
- Remove two unused locale keys; fix two stale comments.
- Tests: the older-host create carries no permission mode; a permission
  mode picked alone keeps the remembered model on an acquired create.

* fix(settings): reserve one options row while loading an action with nothing saved

Terminal and permissions-only rows no longer shrink when loading ends; a chat
agent with a model list may grow by its Model row once known.

* fix(native-chat): keep the create options mode a checked literal type

* fix(settings): share verified action option fields

* fix(settings): keep action selectors on standard typography

* Keep the launch files under their length limits after the main merge

The structured route imports its result types where they are defined, and the two older-host
action-launch tests share one setup helper.

* One action settings section for chat and terminal launches

A Source Control action's Model, model options and Permissions now apply whether it opens a
chat or a terminal; terminals receive them as the agent's own flags, and a repository's own
CLI arguments keep the flags it sets. Extra CLI arguments stay terminal-only.

* fix(source-control-ai): one terminal projection for saved action choices

Ask in a terminal now states its permission instead of leaving it to the
user's own config: Claude and Grok pass --permission-mode default, Codex
passes -a on-request -s read-only -c approvals_reviewer="user" (the
interactive CLI has no untrusted policy, so a read-only sandbox is what
asks before edits). Agents whose CLI has no flag for a mode show the
existing "Applies to chats only" hint.

The strict launch-preferences projection now requires the launch agent at
every caller, composes model ids through the resolver's own step, carries
a mode only where the agent's terminal has a flag for it, and the local
string wire keeps booleans as 'true'/'false', so Cursor Thinking/Fast reach
the terminal and an OpenCode permission-only choice is not refused.

The argument parser knows flag arity, so removing a boolean permission
flag keeps other options' values, following positionals and '--'.
Pre-admission and executor share one terminal admission check on the
options the terminal will receive. Customize with Save launches the recipe
as it will resolve once saved; legacy partial repository overrides are
edited as the complete recipe they resolve to. Empty Extra CLI arguments
start collapsed.

* fix(source-control-ai): an edited repository action saves complete

Every Settings edit of a repository action (agent, model, permission,
prompt or arguments) now starts from the complete recipe the action
resolves to, whether its stored override is owned or a legacy partial.
The resolved agent selection is kept (never an agent only detected to run
a default), an explicit null args clear stays null, and empty args stay
empty instead of being dropped. Clearing the last preference therefore
means the action agent's own defaults rather than re-inheriting Global.

The action text draft is lossless: unset or cleared prompt and args stay
so until the user types, and the inputs show them as empty, so saving a
prompt no longer writes empty args that suppress the agent's default
flags. Untouched sibling actions are not rewritten by the edit.

* fix(settings): style the Extra CLI arguments toggle through Button's own variant and size

CI's changed-code quality check refuses restyle classes on Button and
CollapsibleContent. Use the ghost variant at size xs with a margin, and
put the content spacing on a plain inner wrapper.

* fix: keep saved action and restart launch recipes consistent

* fix: describe inherited arguments and chat-only permissions accurately

* test: check repository action settings fixture type

* test: map saved action options into terminal compiler inputs

* fix: retain saved action controls during agent detection

* fix(i18n): retire obsolete Portuguese action labels

* test: align folder launch fallback mock with outcome export

* fix: carry resolved connector launch intent and mode together

* test: pin shared action launch branches to recorded agent output

* test: preserve saved source control choices through detection and migration

* test: align launch fixtures with integrated result contract

* test: retain recipe and restart contracts after main integration

* Unify Source Control action permission settings

* Keep native Ask copy with its owning change

* Preserve action drafts and old-host permission intent

* Consume rejected Source Control action saves

* Follow the moved Settings persistence fixture

* Keep permission integration contracts in the cross-version test project

* Include the action permission inheritance label in English catalogs

* Align launch contracts with shared permission recipes

* Keep Source Control action controls within their Settings columns

* Use primitive variants for compact action settings controls

* Show action arguments only for terminal defaults and widen recipe rows

* Apply terminal-only action arguments visibility to repository overrides

* Align action permission control with model controls

* Use shared settings label for action permissions

* Hide action model controls until argument migration ships
2026-10-26 16:01:56 -07:00

266 lines
11 KiB
TypeScript

import { SourceControlActionArguments } from './SourceControlActionArguments'
import { Terminal } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { CustomAgentId } from '../../../../shared/commit-message-agent-spec'
import { CUSTOM_AGENT_ID, isCustomAgentId } from '../../../../shared/commit-message-agent-spec'
import {
SOURCE_CONTROL_ACTION_LABELS,
type AiActionId,
type SourceControlActionLaunchPreferences
} from '../../../../shared/source-control-ai-actions'
import { AgentIcon } from '@/lib/agent-catalog'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import type { ActionRecipeDraftValue } from './source-control-ai-action-recipe-draft'
import {
getActionDescriptions,
AI_TEXT_ACTION_ID_SET,
getAgentCatalogForAction,
getSourceControlActionAgentSupportText,
getSourceControlActionAgentWarningText,
getSourceControlAgentArgsPlaceholder
} from './source-control-action-recipe-options'
import { translate } from '@/i18n/i18n'
import { SourceControlActionOptionField } from './SourceControlActionOptionField'
import { useSourceControlActionAgent } from './use-source-control-action-agent'
import { shouldShowSourceControlActionArguments } from './source-control-action-arguments-visibility'
import { chatProviderAgents, useHostStructuredAgentsVersion } from '@/lib/chat-provider-agents'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { useAppStore } from '@/store'
const DEFAULT_AGENT_VALUE = '__default_agent__'
type SourceControlActionRecipeRowProps<ActionId extends AiActionId> = {
actionId: ActionId
selectedAgent: TuiAgent | CustomAgentId | null
draftValue: ActionRecipeDraftValue
baseValue: ActionRecipeDraftValue
defaultTuiAgent: GlobalSettings['defaultTuiAgent']
isSavingTemplate: boolean
hasUnsavedChanges?: boolean
repoOverrideNote?: React.ReactNode
onAgentChange: (actionId: ActionId, value: string) => void
/** Launch actions only, for chats and terminals alike; text actions run headless through CLI arguments. */
launchPreferences?: SourceControlActionLaunchPreferences
onLaunchPreferencesChange?: (
actionId: ActionId,
value: SourceControlActionLaunchPreferences | undefined
) => void
onTemplateChange: (actionId: ActionId, value: string) => void
onAgentArgsChange: (actionId: ActionId, value: string, commit?: boolean) => void
onAppendVariable: (actionId: ActionId, variable: string) => void
onDiscard: (actionId: ActionId) => void
onSave: (actionId: ActionId) => void
}
function resolveAgentArgsPlaceholderAgent(
selectedAgent: TuiAgent | CustomAgentId | null | undefined,
defaultTuiAgent: GlobalSettings['defaultTuiAgent']
): TuiAgent | null {
if (selectedAgent && !isCustomAgentId(selectedAgent)) {
return selectedAgent
}
return defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null
}
function getActionLabel(actionId: AiActionId): string {
return actionId === 'conversationName'
? translate('settings.chat.names.recipe', 'Chat name')
: SOURCE_CONTROL_ACTION_LABELS[actionId]
}
export function SourceControlActionRecipeRow<ActionId extends AiActionId>({
actionId,
selectedAgent,
draftValue,
baseValue,
defaultTuiAgent,
isSavingTemplate,
hasUnsavedChanges,
repoOverrideNote,
onAgentChange,
launchPreferences,
onLaunchPreferencesChange,
onTemplateChange,
onAgentArgsChange,
onAppendVariable,
onDiscard,
onSave
}: SourceControlActionRecipeRowProps<ActionId>): React.JSX.Element {
const actual = useSourceControlActionAgent(selectedAgent, defaultTuiAgent)
const settings = useAppStore((state) => state.settings)
useHostStructuredAgentsVersion()
const showArguments = shouldShowSourceControlActionArguments({
agent: actual.status === 'ready' ? actual.agent : null,
settings,
chatAgents: chatProviderAgents(LOCAL_EXECUTION_HOST_ID, undefined)
})
const expectedAgent = resolveAgentArgsPlaceholderAgent(selectedAgent, defaultTuiAgent)
const pickerAgent = selectedAgent || actual.status === 'pending' ? expectedAgent : actual.agent
const isLaunchAction = !AI_TEXT_ACTION_ID_SET.has(actionId)
const templateDirty =
hasUnsavedChanges ?? JSON.stringify(draftValue) !== JSON.stringify(baseValue)
const agentArgsPlaceholder = getSourceControlAgentArgsPlaceholder(
resolveAgentArgsPlaceholderAgent(selectedAgent, defaultTuiAgent)
)
const agentOptions = getAgentCatalogForAction(actionId, selectedAgent)
const agentWarningText = getSourceControlActionAgentWarningText(actionId, selectedAgent)
const agentSupportText = getSourceControlActionAgentSupportText(actionId)
return (
<div className="rounded-md border border-border px-3 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-0.5">
<p className="text-xs font-medium text-foreground">{getActionLabel(actionId)}</p>
<p className="text-[11px] text-muted-foreground">{getActionDescriptions()[actionId]}</p>
</div>
<div className="w-full shrink-0 space-y-1 sm:w-[220px]">
<Select
value={selectedAgent ?? DEFAULT_AGENT_VALUE}
onValueChange={(value) => onAgentChange(actionId, value)}
>
<SelectTrigger size="sm" density="compact" className="h-8 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_AGENT_VALUE}>
<span className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground" />
{translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.ee0e5c2a48',
'Use default agent'
)}
</span>
</SelectItem>
{AI_TEXT_ACTION_ID_SET.has(actionId) ? (
<SelectItem value={CUSTOM_AGENT_ID}>
<span className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground" />
{translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.0740d30915',
'Custom command'
)}
</span>
</SelectItem>
) : null}
{agentOptions.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<span className="flex items-center gap-2">
<AgentIcon agent={agent.id} size={14} />
{agent.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{agentWarningText ? (
<p className="text-[11px] text-destructive">{agentWarningText}</p>
) : agentSupportText ? (
<p className="text-[11px] text-muted-foreground">{agentSupportText}</p>
) : null}
</div>
</div>
<div className="mt-3 grid min-w-0 grid-cols-1 items-start gap-3">
{isLaunchAction ? (
<div className="flex min-w-0 flex-wrap items-start gap-3">
<SourceControlActionArguments
showArguments={showArguments}
agent={pickerAgent}
value={draftValue.agentArgs}
preferences={launchPreferences}
disabled={actual.status === 'pending' || isSavingTemplate}
onArgsChange={(value, commit) => onAgentArgsChange(actionId, value, commit)}
onPreferencesChange={(value) => onLaunchPreferencesChange?.(actionId, value)}
/>
</div>
) : (
<SourceControlActionOptionField
label={translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
>
<Input
value={draftValue.agentArgs}
spellCheck={false}
placeholder={agentArgsPlaceholder}
onChange={(event) => onAgentArgsChange(actionId, event.target.value)}
variant="code"
className="h-8"
/>
</SourceControlActionOptionField>
)}
<div className="min-w-0 space-y-2">
<Label variant="settings">
{translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.fb09da4345',
'Command template'
)}
</Label>
<textarea
value={draftValue.commandInputTemplate}
rows={3}
spellCheck={false}
onChange={(event) => onTemplateChange(actionId, event.target.value)}
className="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"
/>
<SourceControlActionVariableChips
actionId={actionId}
onInsert={(variable) => onAppendVariable(actionId, variable)}
/>
</div>
</div>
<div className="mt-3 space-y-2">
{repoOverrideNote}
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{templateDirty
? translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e',
'Unsaved changes'
)
: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8',
'Saved'
)}
</p>
<div className="flex items-center gap-2">
{templateDirty ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onDiscard(actionId)}
disabled={isSavingTemplate}
>
{translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.b3914ecbbc',
'Discard'
)}
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => onSave(actionId)}
disabled={!templateDirty || isSavingTemplate}
>
{isSavingTemplate
? translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.4f549a5fa8',
'Saving...'
)
: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.d18d665e12',
'Save'
)}
</Button>
</div>
</div>
</div>
</div>
)
}