Files
orca/src/main/runtime/runtime-client-settings.ts
T
Neil eb7f427426 Clear agent selections in workspace creation and settings (#28543)
* Replace workspace None agent option with a clear action

* Limit agent clearing to new workspace creation

* Let selected default agents toggle off in settings

* Keep default tab templates out of setup-only creation

* Remove duplicate clock property blocking CI
2026-10-13 22:57:44 -07:00

271 lines
11 KiB
TypeScript

import { getAppEnvironment } from '../../shared/app-environment'
import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate'
import { isAgentSkillSharingEnabled } from '../../shared/agent-skill-sharing-gate'
import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
import { TASK_PROVIDERS } from '../../shared/task-providers'
import {
applyTerminalQuickCommandMutation,
MAX_QUICK_COMMANDS,
type TerminalQuickCommandMutation
} from '../../shared/terminal-quick-commands'
import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection'
import { normalizeSourceControlAiSettings } from '../../shared/source-control-ai'
import { SOURCE_CONTROL_LAUNCH_ACTION_IDS } from '../../shared/source-control-ai-actions'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { applyNativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-option-defaults'
import type { NativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-options'
import { getHostDisplayLabelOverrides } from '../../shared/host-setting-overrides'
import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types'
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting'
import type { RuntimeStore } from './runtime-store-contract'
import {
agentLaunchSettingsMutationUpdates,
projectAgentLaunchSettings,
type AgentLaunchSettings,
type AgentLaunchSettingsMutation
} from '../../shared/agent-launch-settings'
import { agentLaunchSettingsRollbackUpdates } from './agent-launch-settings-rollback'
import type {
RuntimeClientSettings,
RuntimeClientSettingsUpdate,
RuntimeClientSourceControlAi,
RuntimeHostDisplayLabelOverrides
} from './runtime-client-settings-shape'
export class RuntimeClientSettingsController {
private reconciliationGeneration = 0
private reconciliationTail: Promise<void> = Promise.resolve()
constructor(
private readonly store: Pick<
RuntimeStore,
'getSettings' | 'updateSettings' | 'runDurableMutation'
> | null,
private readonly notifyReposChanged: (() => void) | undefined = undefined
) {}
get(): RuntimeClientSettings {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
}
const settings = this.store.getSettings()
return {
defaultTuiAgent: settings.defaultTuiAgent ?? null,
newWorkspaceSetupOnly: false,
disabledTuiAgents: settings.disabledTuiAgents ?? [],
agentCmdOverrides: settings.agentCmdOverrides ?? {},
agentDefaultArgs: settings.agentDefaultArgs ?? {},
agentDefaultEnv: settings.agentDefaultEnv ?? {},
agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false,
// Why projected: mobile's terminal Copy honours this, and a host predating
// the setting sends no key, which the client reads as on (#19770).
terminalCopyTrimsGutter: settings.terminalCopyTrimsGutter !== false,
defaultTaskSource: settings.defaultTaskSource ?? 'github',
defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues',
visibleTaskProviders: settings.visibleTaskProviders ?? [...TASK_PROVIDERS],
defaultRepoSelection: settings.defaultRepoSelection ?? null,
// Persisted settings can violate the paired client's string-array contract.
defaultLinearTeamSelection: Array.isArray(settings.defaultLinearTeamSelection)
? settings.defaultLinearTeamSelection.filter((id): id is string => typeof id === 'string')
: null,
githubProjects: settings.githubProjects,
experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true,
experimentalNativeChat: settings.experimentalNativeChat === true,
// Older clients use this key to fall back to terminal-backed chat. Keep their default terminal.
openAgentTabsInChatByDefault: false,
experimentalStructuredNativeChat: settings.experimentalNativeChat === true,
compactWorktreeCards: settings.compactWorktreeCards === true,
minimaxGroupId: settings.minimaxGroupId ?? '',
minimaxUsageModels: settings.minimaxUsageModels ?? 'general',
minimaxEndpoint: settings.minimaxEndpoint ?? 'overseas',
zcodePlanSite: settings.zcodePlanSite ?? 'zai',
prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [],
artifactSharingEnabled: isArtifactSharingEnabled(settings),
worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' },
agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings),
machineName: settings.machineName ?? '',
// Why projected: a web client's Settings → Chat edits this host's new-chat default.
...(settings.nativeChatPermissionMode
? { nativeChatPermissionMode: settings.nativeChatPermissionMode }
: {}),
// Why projected: a paired client's AI buttons start these actions' agents, and must honour
// the agent saved for each one as the desktop does. Absent on older hosts.
sourceControlAi: projectSourceControlLaunchRecipes(settings),
hostSettingOverrides: Object.fromEntries(
[
...getHostDisplayLabelOverrides({ hostSettingOverrides: settings.hostSettingOverrides })
].map(([hostId, displayLabel]) => [hostId, { displayLabel }])
) as RuntimeHostDisplayLabelOverrides
}
}
async update(updates: RuntimeClientSettingsUpdate): Promise<RuntimeClientSettings> {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const beforeSettings = this.store.getSettings()
const before = beforeSettings.agentStatusHooksEnabled !== false
// Legacy clients can select an agent or a shell, but cannot select None.
this.store.updateSettings(
{
...updates,
...(updates.defaultTuiAgent !== undefined ? { newWorkspaceSetupOnly: false } : {})
},
{ notifyListeners: true }
)
const settings = this.store.getSettings()
if (updates.worktreeVisibilityDefaults !== undefined) {
this.notifyReposChanged?.()
}
if (
(typeof updates.agentStatusHooksEnabled === 'boolean' &&
before !== updates.agentStatusHooksEnabled) ||
(updates.disabledTuiAgents !== undefined &&
!haveSameDisabledTuiAgents(beforeSettings.disabledTuiAgents, settings.disabledTuiAgents))
) {
await this.reconcileManagedAgentHooks()
}
return this.get()
}
getTerminalQuickCommands(): TerminalQuickCommand[] {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
}
return this.store.getSettings().terminalQuickCommands ?? []
}
getAgentLaunch(): AgentLaunchSettings {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
}
return projectAgentLaunchSettings(this.store.getSettings())
}
async mutateAgentLaunch(mutation: AgentLaunchSettingsMutation): Promise<AgentLaunchSettings> {
const store = this.store
if (!store?.getSettings || !store.updateSettings || !store.runDurableMutation) {
throw new Error('runtime_unavailable')
}
const applySettings = store.updateSettings.bind(store)
const committed = await store.runDurableMutation(() => {
const before = store.getSettings()
const updates = agentLaunchSettingsMutationUpdates(before, mutation, process.platform)
applySettings(updates, { notifyListeners: true })
const applied = store.getSettings()
return {
value: {
settings: projectAgentLaunchSettings(applied),
reconcileHooks:
updates.disabledTuiAgents !== undefined &&
!haveSameDisabledTuiAgents(before.disabledTuiAgents, applied.disabledTuiAgents)
},
rollback: () => {
const rollback = agentLaunchSettingsRollbackUpdates(
before,
applied,
store.getSettings(),
updates
)
if (Object.keys(rollback).length > 0) {
applySettings(rollback, { notifyListeners: true })
}
}
}
})
if (committed.reconcileHooks) {
void this.reconcileManagedAgentHooks().catch((error) => {
console.error(
'[agent-hooks] Failed to reconcile managed hooks after saving launch settings:',
error
)
})
}
return committed.settings
}
updateTerminalQuickCommands(mutation: TerminalQuickCommandMutation): TerminalQuickCommand[] {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const current = this.getTerminalQuickCommands()
if (
mutation.type === 'upsert' &&
!current.some((command) => command.id === mutation.command.id) &&
current.length >= MAX_QUICK_COMMANDS
) {
throw new Error('Quick command limit reached')
}
const next = applyTerminalQuickCommandMutation(current, mutation)
this.store.updateSettings({ terminalQuickCommands: next }, { notifyListeners: true })
return this.getTerminalQuickCommands()
}
updatePRBotAuthorOverride(args: { author: string; isBot: boolean }): RuntimeClientSettings {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const current = this.store.getSettings().prBotAuthorOverrides
this.store.updateSettings(
{ prBotAuthorOverrides: applyPRBotAuthorOverride(current, args.author, args.isBot) },
{ notifyListeners: true }
)
return this.get()
}
updateNativeChatSessionOptions(mutation: NativeChatSessionOptionSettingsMutation): void {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const next = applyNativeChatSessionOptionSettingsMutation(
this.store.getSettings().nativeChatSessionOptions,
mutation
)
if (next) {
this.store.updateSettings({ nativeChatSessionOptions: next }, { notifyListeners: true })
}
}
private reconcileManagedAgentHooks(): Promise<void> {
const generation = ++this.reconciliationGeneration
const reconciliation = this.reconciliationTail.then(async () => {
if (generation !== this.reconciliationGeneration) {
return
}
const settings = this.store?.getSettings()
if (!settings) {
return
}
await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, {
shouldHydrateShellPath: getAppEnvironment().isPackaged(),
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const current = this.store?.getSettings()
return current !== undefined && isAgentStatusHooksEnabledForAgent(current, agent)
}
})
})
this.reconciliationTail = reconciliation.catch(() => {})
return reconciliation
}
}
function projectSourceControlLaunchRecipes(
settings: Partial<Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'>>
): RuntimeClientSourceControlAi {
const { actions } = normalizeSourceControlAiSettings(
settings.sourceControlAi,
settings.commitMessageAi
)
return {
actions: Object.fromEntries(
SOURCE_CONTROL_LAUNCH_ACTION_IDS.flatMap((actionId) => {
const recipe = actions?.[actionId]
return recipe ? [[actionId, recipe]] : []
})
)
}
}