mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Keep the permission pill inside Arguments and share the Permissions menu (#32971)
* Keep the permission pill inside the Arguments box * Share the Settings permission menu and commit Arguments on field exit * Honor changed permission choices and acknowledged host picks in reset notices * Show acknowledged permission resets with scoped Undo * Consume removed permission recipes in the Arguments draft * Keep reset notices separate from the inline permission field * Use Textarea variant for composite arguments field * Fix permission test query typing and sync English runtime catalog * Flow arguments beneath the permission chip * Keep composite argument typography in Textarea variant * Share native scrolling between permission chip and arguments * Place all-agents permission menu beside its label * Preserve permission menu width while settings load
This commit is contained in:
@@ -26,6 +26,7 @@ vi.mock('@/components/ui/tooltip', () => ({
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', async () => {
|
||||
const React = await import('react')
|
||||
const Selection = React.createContext<string | undefined>(undefined)
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({
|
||||
@@ -45,48 +46,38 @@ vi.mock('@/components/ui/dropdown-menu', async () => {
|
||||
DropdownMenuRadioGroup: ({
|
||||
children,
|
||||
value,
|
||||
onValueChange,
|
||||
'aria-label': ariaLabel
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
'aria-label'?: string
|
||||
}) => (
|
||||
<div role="radiogroup" aria-label={ariaLabel}>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (
|
||||
!React.isValidElement<{
|
||||
value?: string
|
||||
disabled?: boolean
|
||||
children?: React.ReactNode
|
||||
onSelect?: () => void
|
||||
}>(child)
|
||||
) {
|
||||
return child
|
||||
}
|
||||
const props = child.props
|
||||
return (
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={props.value === value}
|
||||
disabled={props.disabled}
|
||||
data-value={props.value}
|
||||
onClick={() => {
|
||||
props.onSelect?.()
|
||||
if (props.value !== undefined) {
|
||||
onValueChange?.(props.value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Selection.Provider value={value}>
|
||||
<div role="radiogroup" aria-label={ariaLabel}>
|
||||
{children}
|
||||
</div>
|
||||
</Selection.Provider>
|
||||
),
|
||||
DropdownMenuRadioItem: ({ children }: { children: React.ReactNode; value: string }) => (
|
||||
<span>{children}</span>
|
||||
DropdownMenuRadioItem: ({
|
||||
children,
|
||||
value,
|
||||
disabled,
|
||||
onSelect
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
value: string
|
||||
disabled?: boolean
|
||||
onSelect?: () => void
|
||||
}) => (
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={React.useContext(Selection) === value}
|
||||
disabled={disabled}
|
||||
data-value={value}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
} from './native-chat-permission-mode-labels'
|
||||
import { runSessionOptionSurfaceCall } from './native-chat-session-option-surface-call'
|
||||
|
||||
function ModeName(props: { mode: AgentChatPermissionMode; truncate?: boolean }): React.JSX.Element {
|
||||
export function NativeChatPermissionModeName(props: {
|
||||
mode: AgentChatPermissionMode
|
||||
truncate?: boolean
|
||||
}): React.JSX.Element {
|
||||
const Icon = NATIVE_CHAT_PERMISSION_MODE_ICONS[props.mode]
|
||||
const label = nativeChatPermissionModeLabel(props.mode)
|
||||
const body = (
|
||||
@@ -43,6 +46,36 @@ function ModeName(props: { mode: AgentChatPermissionMode; truncate?: boolean }):
|
||||
)
|
||||
}
|
||||
|
||||
export function NativeChatPermissionModeItems({
|
||||
modes,
|
||||
agent,
|
||||
disabled,
|
||||
onSelect
|
||||
}: Pick<
|
||||
NativeChatPermissionModePickerProps,
|
||||
'modes' | 'agent' | 'disabled' | 'onSelect'
|
||||
>): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{modes.map((mode) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={mode}
|
||||
value={mode}
|
||||
disabled={disabled}
|
||||
onSelect={() => onSelect(mode)}
|
||||
>
|
||||
<div className="min-w-0 py-0.5">
|
||||
<NativeChatPermissionModeName mode={mode} />
|
||||
<div className="text-xs font-normal text-muted-foreground">
|
||||
{nativeChatPermissionModeDescription(mode, agent)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export type NativeChatPermissionModePickerProps = {
|
||||
current: AgentChatPermissionMode
|
||||
modes: readonly AgentChatPermissionMode[]
|
||||
@@ -77,7 +110,7 @@ function NativeChatPermissionModePickerInner({
|
||||
)}
|
||||
className="max-w-40 min-w-0 shrink"
|
||||
>
|
||||
<ModeName mode={current} truncate />
|
||||
<NativeChatPermissionModeName mode={current} truncate />
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -88,21 +121,12 @@ function NativeChatPermissionModePickerInner({
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" side="top" collisionPadding={8} className="w-72">
|
||||
<DropdownMenuRadioGroup aria-label={title} value={current}>
|
||||
{modes.map((mode) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={mode}
|
||||
value={mode}
|
||||
disabled={disabled}
|
||||
onSelect={() => onSelect(mode)}
|
||||
>
|
||||
<div className="min-w-0 py-0.5">
|
||||
<ModeName mode={mode} />
|
||||
<div className="text-xs font-normal text-muted-foreground">
|
||||
{nativeChatPermissionModeDescription(mode, agent)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
<NativeChatPermissionModeItems
|
||||
modes={modes}
|
||||
agent={agent}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { AgentSessionSourceHomeControl } from './codex-session-source-home-
|
||||
import { AgentSessionSourceHomeInput } from './codex-session-source-home-control'
|
||||
import { resolveTuiAgentPermissionMode } from '../../../../shared/tui-agent-permissions'
|
||||
import { stringifyAgentDefaultEnvDraft } from './agent-default-env-draft'
|
||||
import { liftAgentPermissionArgs } from '../../../../shared/tui-agent-permission-lift'
|
||||
import {
|
||||
AgentPermissionExceptionBadge,
|
||||
AgentRowPermissionControl,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
AgentDefaultEnvInput
|
||||
} from './AgentLaunchDefaultsEditor'
|
||||
import {
|
||||
AgentPermissionArgsChip,
|
||||
agentPermissionChoiceLabel,
|
||||
AgentPermissionMenu,
|
||||
type AgentPermissionChoiceRow
|
||||
@@ -299,13 +299,17 @@ export function AgentCatalogRow({
|
||||
argsOverride={argsOverride}
|
||||
onSaveArgs={onSaveArgs}
|
||||
permissionChip={
|
||||
permissionChoice?.flag && !permissionChoice.argsSetPermission ? (
|
||||
<AgentPermissionArgsChip
|
||||
label={label}
|
||||
flag={permissionChoice.flag}
|
||||
onRemove={() => permissionChoice.onSelect('own')}
|
||||
/>
|
||||
) : undefined
|
||||
permissionChoice?.flag && !permissionChoice.argsSetPermission
|
||||
? {
|
||||
label,
|
||||
flag: permissionChoice.flag,
|
||||
removeRecipe: (text) => {
|
||||
const lifted = liftAgentPermissionArgs(agentId, text)
|
||||
return lifted.choice !== null ? lifted.args : null
|
||||
},
|
||||
onRemove: () => permissionChoice.onSelect('own')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
note={
|
||||
permissionChoice?.argsSetPermission
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Textarea } from '../ui/textarea'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
parseAgentDefaultEnvDraft,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
type AgentDefaultEnvDraftInvalidReason
|
||||
} from './agent-default-env-draft'
|
||||
import { useAgentLaunchFieldWrite } from './use-agent-launch-field-write'
|
||||
import { AgentPermissionArgsChip } from './AgentPermissionChoiceControls'
|
||||
|
||||
export function AgentCommandOverrideInput({
|
||||
defaultCmd,
|
||||
@@ -91,13 +93,38 @@ export function AgentDefaultArgsInput({
|
||||
defaultArgs: string
|
||||
argsOverride: string
|
||||
onSaveArgs: (value: string) => void | Promise<void>
|
||||
/** The permission flag terminals add ahead of the text, drawn just before the box. */
|
||||
permissionChip?: React.ReactNode
|
||||
permissionChip?: {
|
||||
label: string
|
||||
flag: string
|
||||
removeRecipe: (text: string) => string | null
|
||||
onRemove: () => void
|
||||
}
|
||||
note?: string
|
||||
}): React.JSX.Element {
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const chipRef = useRef<HTMLSpanElement>(null)
|
||||
const chipButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const argsDraft = draft ?? argsOverride
|
||||
const { pending, write } = useAgentLaunchFieldWrite()
|
||||
useLayoutEffect(() => {
|
||||
const input = inputRef.current
|
||||
const chip = chipRef.current
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
input.style.textIndent = ''
|
||||
if (!chip) {
|
||||
return
|
||||
}
|
||||
const measure = (): void => {
|
||||
input.style.textIndent = `calc(${chip.getBoundingClientRect().width}px + var(--spacing) * 2)`
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(chip)
|
||||
return () => observer.disconnect()
|
||||
}, [permissionChip?.flag])
|
||||
const commitArgs = (): void => {
|
||||
if (pending || draft === null) {
|
||||
return
|
||||
@@ -120,28 +147,72 @@ export function AgentDefaultArgsInput({
|
||||
{translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissionChip}
|
||||
<Input
|
||||
value={argsDraft}
|
||||
readOnly={pending}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={commitArgs}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
<div
|
||||
className="scrollbar-sleek relative max-h-[calc(var(--spacing)*41+2px)] min-h-7 min-w-0 flex-1 overflow-x-hidden overflow-y-auto rounded-md border border-input px-3 py-0.5 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
commitArgs()
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setDraft(null)
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
defaultArgs ||
|
||||
translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments')
|
||||
}
|
||||
spellCheck={false}
|
||||
className="h-7 flex-1 font-mono text-xs"
|
||||
/>
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{permissionChip && (
|
||||
<AgentPermissionArgsChip
|
||||
{...permissionChip}
|
||||
chipRef={chipRef}
|
||||
buttonRef={chipButtonRef}
|
||||
disabled={pending}
|
||||
onRemove={() => {
|
||||
if (draft !== null) {
|
||||
setDraft(permissionChip.removeRecipe(draft) ?? draft)
|
||||
}
|
||||
inputRef.current?.focus()
|
||||
permissionChip.onRemove()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
variant="composite"
|
||||
ref={inputRef}
|
||||
rows={1}
|
||||
value={argsDraft}
|
||||
readOnly={pending}
|
||||
onChange={(event) => setDraft(event.target.value.replace(/[\r\n]/g, ''))}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
permissionChip &&
|
||||
event.key === 'Backspace' &&
|
||||
!event.repeat &&
|
||||
!event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
event.currentTarget.selectionStart === 0 &&
|
||||
event.currentTarget.selectionEnd === 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
chipButtonRef.current?.focus()
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commitArgs()
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setDraft(null)
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
defaultArgs ||
|
||||
translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments')
|
||||
}
|
||||
spellCheck={false}
|
||||
className="block min-h-5 [field-sizing:content]"
|
||||
/>
|
||||
</div>
|
||||
{(argsOverride !== defaultArgs || argsDraft !== defaultArgs) && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -263,7 +263,6 @@ export function AgentLaunchSettingsCatalog({
|
||||
(agent) => !agentPermissionChoices(agent).includes('auto')
|
||||
).map((agent) => resolveAgentPermission(agent, permissionSettings).choice)
|
||||
)}
|
||||
ownChoiceCount={Object.keys(permissionSettings.agentPermissionOverrides ?? {}).length}
|
||||
onChange={(choice) => save({ type: 'permission', choice })}
|
||||
onChangeWithoutAuto={(choice) => save({ type: 'permission', choice, withoutAuto: true })}
|
||||
/>
|
||||
@@ -304,3 +303,27 @@ export function AgentLaunchSettingsCatalog({
|
||||
function sharedChoice(choices: readonly AgentPermissionChoice[]): AgentPermissionChoice | null {
|
||||
return choices.length > 0 && choices.every((choice) => choice === choices[0]) ? choices[0] : null
|
||||
}
|
||||
|
||||
export function AgentLaunchSettingsLoading(): React.JSX.Element {
|
||||
return (
|
||||
<fieldset disabled className="min-w-0 space-y-8">
|
||||
<AgentDefaultSetting
|
||||
defaultAgent="blank"
|
||||
detectedIds={null}
|
||||
enabledDetectedAgents={[]}
|
||||
catalog={[]}
|
||||
description={translate(
|
||||
'settings.agents.hostDefaults',
|
||||
'Defaults for new agents on the selected host.'
|
||||
)}
|
||||
onSetDefault={() => {}}
|
||||
/>
|
||||
<AllAgentsPermissionSetting
|
||||
choice={null}
|
||||
withoutAutoChoice={null}
|
||||
onChange={() => {}}
|
||||
onChangeWithoutAuto={() => {}}
|
||||
/>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock('./HostAgentLaunchSettings', async () => {
|
||||
}
|
||||
})
|
||||
vi.mock('./AgentLaunchSettingsCatalog', () => ({
|
||||
AgentLaunchSettingsLoading: () => <button disabled>Permissions</button>,
|
||||
AgentLaunchSettingsCatalog: (props: unknown) => {
|
||||
state.local(props)
|
||||
return <p>Local launch settings</p>
|
||||
@@ -93,7 +94,7 @@ describe('launch settings host', () => {
|
||||
it('waits for the server list instead of displaying desktop defaults', () => {
|
||||
state.runtimeEnvironments = []
|
||||
render(section(SSH_HOST))
|
||||
expect(screen.getByText('Loading agent settings…')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Permissions' })).toHaveProperty('disabled', true)
|
||||
expect(state.local).not.toHaveBeenCalled()
|
||||
expect(state.remote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import {
|
||||
AgentLaunchSettingsCatalog,
|
||||
AgentLaunchSettingsLoading,
|
||||
type AgentPermissionsRenderer
|
||||
} from './AgentLaunchSettingsCatalog'
|
||||
import { HostAgentLaunchSettings } from './HostAgentLaunchSettings'
|
||||
@@ -55,9 +56,7 @@ export function AgentLaunchSettingsSection({
|
||||
) : !hostScope.available ? (
|
||||
<SettingsHostScopeUnavailableNotice />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{translate('settings.agents.loadingHost', 'Loading agent settings…')}
|
||||
</p>
|
||||
<AgentLaunchSettingsLoading />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronDown, X } from 'lucide-react'
|
||||
import { ChevronDown, Terminal, X } from 'lucide-react'
|
||||
import type { Ref } from 'react'
|
||||
import type { AgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode'
|
||||
import type { AgentPermissionChoice } from '../../../../shared/tui-agent-permission-choice'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -17,6 +18,10 @@ import {
|
||||
nativeChatPermissionModeDescription,
|
||||
nativeChatPermissionModeLabel
|
||||
} from '../native-chat/native-chat-permission-mode-labels'
|
||||
import {
|
||||
NativeChatPermissionModeItems,
|
||||
NativeChatPermissionModeName
|
||||
} from '../native-chat/NativeChatPermissionModePicker'
|
||||
import { SettingsSegmentedControl } from './SettingsFormControls'
|
||||
|
||||
/** Use chat-mode wording; own names the agent when available. */
|
||||
@@ -45,57 +50,81 @@ function ownDescription(): string {
|
||||
export function AllAgentsPermissionSetting({
|
||||
choice,
|
||||
withoutAutoChoice,
|
||||
ownChoiceCount,
|
||||
onChange,
|
||||
onChangeWithoutAuto
|
||||
}: {
|
||||
choice: AgentPermissionChoice
|
||||
choice: AgentPermissionChoice | null
|
||||
/** What the agents without Approve for me share; null when they differ. */
|
||||
withoutAutoChoice: AgentPermissionChoice | null
|
||||
/** Agents set separately, which choosing a mode here resets. */
|
||||
ownChoiceCount: number
|
||||
onChange: (choice: AgentPermissionChoice) => void
|
||||
onChangeWithoutAuto: (choice: AgentPermissionChoice) => void
|
||||
}): React.JSX.Element {
|
||||
const title = translate(
|
||||
'auto.components.settings.AgentsPane.agentPermissions',
|
||||
'Agent Permissions'
|
||||
)
|
||||
const title = translate('settings.agents.permissionsTitle', 'Permissions')
|
||||
const ownLabel = translate('settings.agents.permissionEachOwn', "Each agent's own settings")
|
||||
return (
|
||||
<section className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<section className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{choice === 'own'
|
||||
? ownDescription()
|
||||
: translate(
|
||||
'settings.agents.permissionChoiceDescription',
|
||||
'Applies where each agent supports this mode. Change one agent below.'
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={choice === null}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
aria-label={title}
|
||||
className="w-48 max-w-full justify-between"
|
||||
>
|
||||
{choice === null ? (
|
||||
<span className="h-4" />
|
||||
) : choice === 'own' ? (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<Terminal className="size-3.5 shrink-0" />
|
||||
{ownLabel}
|
||||
</span>
|
||||
) : (
|
||||
<NativeChatPermissionModeName mode={choice} truncate />
|
||||
)}
|
||||
</p>
|
||||
{ownChoiceCount > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'settings.agents.permissionResetsOwnChoices',
|
||||
'Choosing a mode here resets the agents marked as set separately.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<ChevronDown className="size-3 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" collisionPadding={8} className="w-72">
|
||||
<DropdownMenuRadioGroup aria-label={title} value={choice ?? ''}>
|
||||
<NativeChatPermissionModeItems
|
||||
modes={['ask', 'auto', 'full-access']}
|
||||
onSelect={onChange}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioItem value="own" onSelect={() => onChange('own')}>
|
||||
<div className="min-w-0 py-0.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Terminal className="size-3.5 shrink-0" />
|
||||
{ownLabel}
|
||||
</span>
|
||||
<div className="text-xs font-normal text-muted-foreground">
|
||||
{translate(
|
||||
'settings.agents.permissionEachOwnDescription',
|
||||
'Orca adds nothing; each CLI uses its own config'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<SettingsSegmentedControl<AgentPermissionChoice>
|
||||
value={choice}
|
||||
onChange={onChange}
|
||||
ariaLabel={title}
|
||||
size="sm"
|
||||
options={(['own', 'ask', 'auto', 'full-access'] as const).map((value) => ({
|
||||
value,
|
||||
label:
|
||||
value === 'own'
|
||||
? translate('settings.agents.permissionOwnShort', 'Own settings')
|
||||
: nativeChatPermissionModeLabel(value)
|
||||
}))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'settings.agents.permissionsChoiceExplanation',
|
||||
'What every agent may do without asking. You can set one agent differently below.'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'settings.agents.permissionsResetExplanation',
|
||||
'Picking a mode here resets agents you set separately.'
|
||||
)}
|
||||
</p>
|
||||
<div className="min-h-6">
|
||||
{choice === 'auto' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -223,26 +252,45 @@ export function AgentPermissionMenu({
|
||||
export function AgentPermissionArgsChip({
|
||||
label,
|
||||
flag,
|
||||
onRemove
|
||||
onRemove,
|
||||
disabled,
|
||||
chipRef,
|
||||
buttonRef
|
||||
}: {
|
||||
label: string
|
||||
flag: string
|
||||
onRemove: () => void
|
||||
disabled?: boolean
|
||||
chipRef?: Ref<HTMLSpanElement>
|
||||
buttonRef?: Ref<HTMLButtonElement>
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-5 max-w-[60%] shrink-0 items-center gap-1 rounded bg-muted px-1.5 font-mono text-[11px] text-foreground/80">
|
||||
<span
|
||||
ref={chipRef}
|
||||
className="relative z-10 -mb-5 flex h-5 w-fit max-w-[60%] items-center gap-1 rounded bg-muted px-1.5 font-mono text-xs leading-5 text-foreground/80 focus-within:ring-2 focus-within:ring-ring"
|
||||
>
|
||||
<span className="truncate">{flag}</span>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
if (!event.repeat && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
onRemove()
|
||||
}
|
||||
}
|
||||
}}
|
||||
aria-label={translate(
|
||||
'settings.agents.permissionChipRemove',
|
||||
"Remove {{flag}} and use {{agent}}'s own settings",
|
||||
{ flag, agent: label }
|
||||
)}
|
||||
className="rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
className="rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
|
||||
@@ -209,8 +209,9 @@ describe('AgentsPane', () => {
|
||||
})
|
||||
|
||||
expect(detectedAgentsMock.lastTarget).toBeUndefined()
|
||||
expect(markup).toContain('Loading agent settings…')
|
||||
expect(markup).not.toContain('Default Agent')
|
||||
expect(markup).toContain('<fieldset disabled=""')
|
||||
expect(markup).toContain('aria-label="Permissions"')
|
||||
expect(markup).not.toContain('Installed')
|
||||
} finally {
|
||||
initialState.runtimeEnvironments = priorRuntimeEnvironments
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
AgentLaunchSettingsMutation
|
||||
} from '../../../../shared/agent-launch-settings'
|
||||
import type { AgentPermissionsRenderer } from './AgentLaunchSettingsCatalog'
|
||||
import { AgentLaunchSettingsCatalog } from './AgentLaunchSettingsCatalog'
|
||||
import {
|
||||
AgentLaunchSettingsCatalog,
|
||||
AgentLaunchSettingsLoading
|
||||
} from './AgentLaunchSettingsCatalog'
|
||||
import {
|
||||
mutateHostAgentLaunchSettings,
|
||||
readHostAgentLaunchSettings,
|
||||
@@ -105,21 +108,22 @@ export function HostAgentLaunchSettings({
|
||||
mutationTail.current = write
|
||||
return write
|
||||
}
|
||||
if (read.kind === 'loading') {
|
||||
return <AgentLaunchSettingsLoading />
|
||||
}
|
||||
if (read.kind !== 'ready') {
|
||||
return (
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
{read.kind === 'loading'
|
||||
? translate('settings.agents.loadingHost', 'Loading agent settings…')
|
||||
: read.kind === 'unsupported'
|
||||
? translate(
|
||||
'settings.agents.updateHost',
|
||||
'Update this server to edit its agent launch settings.'
|
||||
)
|
||||
: translate(
|
||||
'settings.agents.readFailed',
|
||||
'Couldn’t read agent settings. Check the host connection and try again.'
|
||||
)}
|
||||
{read.kind === 'unsupported'
|
||||
? translate(
|
||||
'settings.agents.updateHost',
|
||||
'Update this server to edit its agent launch settings.'
|
||||
)
|
||||
: translate(
|
||||
'settings.agents.readFailed',
|
||||
'Couldn’t read agent settings. Check the host connection and try again.'
|
||||
)}
|
||||
</p>
|
||||
{read.kind === 'failed' ? (
|
||||
<Button type="button" variant="ghost" size="xs" onClick={() => void refresh()}>
|
||||
|
||||
+85
-3
@@ -94,7 +94,7 @@ function AgentsHarness() {
|
||||
)
|
||||
}
|
||||
|
||||
function argumentsInput(label: string): HTMLInputElement {
|
||||
function argumentsInput(label: string): HTMLTextAreaElement {
|
||||
const row = screen.getByText(label, { selector: 'span' }).closest('div.py-3')
|
||||
if (!(row instanceof HTMLElement)) {
|
||||
throw new Error('Missing agent row')
|
||||
@@ -104,7 +104,7 @@ function argumentsInput(label: string): HTMLInputElement {
|
||||
fireEvent.click(expand)
|
||||
}
|
||||
const input = within(row).getByPlaceholderText('No default arguments')
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
if (!(input instanceof HTMLTextAreaElement)) {
|
||||
throw new Error('Missing Arguments input')
|
||||
}
|
||||
return input
|
||||
@@ -169,6 +169,85 @@ it.each(
|
||||
expect(persist).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each(['none', '×', 'Backspace'] as const)(
|
||||
'keeps dirty Arguments and explicit %s permission removal in order',
|
||||
async (removal) => {
|
||||
savedArguments('--model sonnet', 'full-access')
|
||||
const own = removal === 'Backspace' ? deferredSave() : null
|
||||
render(<AgentsHarness />)
|
||||
const user = userEvent.setup()
|
||||
const input = argumentsInput('Claude')
|
||||
const chip = screen.getByRole('button', { name: /Remove .* and use Claude's own settings/ })
|
||||
await user.click(input)
|
||||
await user.keyboard('{Home}{Shift>}{ArrowRight}{/Shift}{Backspace}')
|
||||
expect(document.activeElement).toBe(input)
|
||||
fireEvent.change(input, { target: { value: '--permission-mode auto --model opus' } })
|
||||
input.setSelectionRange(0, 0)
|
||||
for (const modifiers of [
|
||||
{ ctrlKey: true },
|
||||
{ altKey: true },
|
||||
{ metaKey: true },
|
||||
{ repeat: true }
|
||||
]) {
|
||||
fireEvent.keyDown(input, { key: 'Backspace', ...modifiers })
|
||||
expect(document.activeElement).toBe(input)
|
||||
}
|
||||
await user.keyboard('{Home}{Backspace}')
|
||||
expect(document.activeElement).toBe(chip)
|
||||
fireEvent.keyDown(chip, { key: 'Backspace', repeat: true })
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().settings?.agentPermissionDefault).toBe('full-access')
|
||||
await user.tab()
|
||||
expect(document.activeElement).toBe(input)
|
||||
await user.tab({ shift: true })
|
||||
expect(document.activeElement).toBe(chip)
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
if (removal === 'none') {
|
||||
await user.tab({ shift: true })
|
||||
await waitFor(() => expect(input.value).toBe('--model opus'))
|
||||
expect(useAppStore.getState().settings?.agentPermissionOverrides?.claude).toBe('auto')
|
||||
expect(toast.success).toHaveBeenCalledTimes(1)
|
||||
return
|
||||
}
|
||||
await (removal === '×' ? user.click(chip) : user.keyboard('{Backspace}'))
|
||||
expect(document.activeElement).toBe(input)
|
||||
expect(input.value).toBe('--model opus')
|
||||
await waitFor(() => expect(persist).toHaveBeenCalledTimes(1))
|
||||
const raw = own ? deferredSave() : null
|
||||
const reset = own ? deferredSave() : null
|
||||
await user.tab()
|
||||
if (own && raw && reset) {
|
||||
expect(input.readOnly).toBe(true)
|
||||
expect(chip).toHaveProperty('disabled', true)
|
||||
const row = input.closest('div.py-3')
|
||||
if (!(row instanceof HTMLElement)) {
|
||||
throw new Error('Missing row')
|
||||
}
|
||||
await user.click(within(row).getByRole('button', { name: 'Reset' }))
|
||||
await act(async () => own.resolve())
|
||||
await waitFor(() => expect(persist).toHaveBeenCalledTimes(2))
|
||||
expect(input.value).toBe('--model opus')
|
||||
await act(async () => raw.resolve())
|
||||
await waitFor(() => expect(persist).toHaveBeenCalledTimes(3))
|
||||
await act(async () => reset.reject(new Error('unknown acknowledgement')))
|
||||
await waitFor(() => expect(input.readOnly).toBe(false))
|
||||
expect(input.value).toBe('--model opus')
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
expect(toast.error).toHaveBeenCalledTimes(1)
|
||||
await user.click(within(row).getByRole('button', { name: 'Reset' }))
|
||||
await waitFor(() => expect(input.value).toBe(''))
|
||||
} else {
|
||||
await waitFor(() => expect(persist).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => expect(input.readOnly).toBe(false))
|
||||
expect(useAppStore.getState().settings?.agentDefaultArgs?.claude).toBe('--model opus')
|
||||
}
|
||||
const settings = useAppStore.getState().settings
|
||||
expect(settings?.agentPermissionOverrides?.claude).toBe('own')
|
||||
expect(resolveTuiAgentLaunchArgs('claude', settings ?? {})).toBe(own ? '' : '--model opus')
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('retains failed conversion through the ordinary callback and announces only the saved retry', async () => {
|
||||
savedArguments('--model opus')
|
||||
const save = deferredSave()
|
||||
@@ -459,13 +538,16 @@ it.each([20, 200])('keeps Goose Environment editable after Reset/reopen (%i)', a
|
||||
it('reports one direct local permission action failure without changing saved intent', async () => {
|
||||
persist.mockRejectedValueOnce(new Error('private error'))
|
||||
render(<AgentsHarness />)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full access' }))
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'Permissions' }))
|
||||
await user.click(await screen.findByRole('menuitemradio', { name: /Full access/ }))
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledExactlyOnceWith(
|
||||
'Agent settings could not be saved. Try again.'
|
||||
)
|
||||
)
|
||||
expect(useAppStore.getState().settings?.agentPermissionDefault).toBe('ask')
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const uiWork = { entries: 0, bytes: 0 }
|
||||
|
||||
@@ -9,7 +9,9 @@ const textareaVariants = cva('', {
|
||||
variant: {
|
||||
default: '',
|
||||
code: 'font-mono',
|
||||
cell: 'h-full min-h-0 resize-none rounded-none border-0 px-2 py-0 font-mono text-xs shadow-none md:text-xs focus-visible:ring-1 focus-visible:ring-inset'
|
||||
cell: 'h-full min-h-0 resize-none rounded-none border-0 px-2 py-0 font-mono text-xs shadow-none md:text-xs focus-visible:ring-1 focus-visible:ring-inset',
|
||||
composite:
|
||||
'resize-none rounded-none border-0 p-0 font-mono text-xs leading-5 shadow-none md:text-xs focus-visible:ring-0 dark:bg-transparent'
|
||||
}
|
||||
},
|
||||
defaultVariants: { variant: 'default' }
|
||||
|
||||
+5
@@ -3458,6 +3458,11 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"agents": {
|
||||
"loadingHost": "Loading agent settings…",
|
||||
"permissionChoiceDescription": "Applies where each agent supports this mode. Change one agent below.",
|
||||
"permissionResetsOwnChoices": "Choosing a mode here resets the agents marked as set separately."
|
||||
},
|
||||
"browser": {
|
||||
"clientHostedRemote": {
|
||||
"description": "Render remote workspace pages on this desktop; network traffic still goes through the remote host. Applies to new pages only.",
|
||||
|
||||
@@ -461,6 +461,11 @@
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"permissionsTitle": "Permissions",
|
||||
"permissionsResetExplanation": "Picking a mode here resets agents you set separately.",
|
||||
"permissionsChoiceExplanation": "What every agent may do without asking. You can set one agent differently below.",
|
||||
"permissionEachOwn": "Each agent's own settings",
|
||||
"permissionEachOwnDescription": "Orca adds nothing; each CLI uses its own config",
|
||||
"codexTerminalServerIsolation": {
|
||||
"title": "Run each Codex terminal on its own server",
|
||||
"description": "Keeps Orca's status and closing tabs working correctly. Turn off to use Codex's shared server and its agents overview. Applies to new terminals."
|
||||
|
||||
Reference in New Issue
Block a user