From b8c29ad144c5a4841e581e22c0aa42f73ff7fcfa Mon Sep 17 00:00:00 2001 From: ANonABento Date: Tue, 9 Jun 2026 00:38:44 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(settings):=20expose=20backend=20AppSet?= =?UTF-8?q?tings=20via=20a=20new=20System=20=E2=86=92=20Runtime=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A settings audit found the backend AppSettings (global defaults, session/GC lifecycle, limits) had NO UI — they were only editable by hand-editing ~/.kaitencode/settings.json, even though the app already has get_app_settings/update_app_settings commands. The "raise mcp_max_recursion_depth" error even told users to change a setting with no control. Adds Settings → System → Runtime exposing every previously-UI-less AppSettings, grouped: - Global defaults: default agent CLI, model, session strategy, advance mode. - Sessions & cleanup: max agent sessions, GC interval, idle sleep/kill, archive purge. - Limits: MCP recursion depth, claude/codex min versions. Text/number fields commit on blur (no settings.json write per keystroke); selects commit on change. Extends the AppSettings IPC type with claude_min_version / codex_min_version / mcp_max_recursion_depth (all already handled by the backend merge_update). Verified merge_update covers every exposed key. Audit corrections: the Voice tab already exposes language/hotkey/push-to-talk/ sensitivity (audit was wrong), and advanced.notesDebounceMs/outputBufferIntervalMs are dead settings (unused anywhere) so deliberately not surfaced. tsc · lint · settings tests green. --- src/components/settings/settings-panel.tsx | 5 + src/components/settings/tabs/system-tab.tsx | 183 ++++++++++++++++++++ src/lib/ipc/settings.ts | 5 + 3 files changed, 193 insertions(+) create mode 100644 src/components/settings/tabs/system-tab.tsx diff --git a/src/components/settings/settings-panel.tsx b/src/components/settings/settings-panel.tsx index 4782b5cb..8def206f 100644 --- a/src/components/settings/settings-panel.tsx +++ b/src/components/settings/settings-panel.tsx @@ -8,6 +8,7 @@ import { McpTab } from './tabs/mcp-tab' import { BoardTab } from './tabs/board-tab' import { VoiceTab } from './tabs/voice-tab' import { AdvancedTab } from './tabs/advanced-tab' +import { SystemTab } from './tabs/system-tab' import { GitTab } from './tabs/git-tab' import { GithubTab } from './tabs/github-tab' import { DiscordTab } from './tabs/discord-tab' @@ -81,6 +82,7 @@ type TabId = | 'github' | 'discord' | 'batches' + | 'runtime' | 'updates' | 'advanced' @@ -128,6 +130,7 @@ const TAB_GROUPS: TabGroup[] = [ id: 'system', label: 'System', tabs: [ + { id: 'runtime', label: 'Runtime', hint: 'Global defaults, sessions, GC, limits' }, { id: 'advanced', label: 'Advanced', hint: 'Terminal, panels, performance, git, shortcuts' }, { id: 'updates', label: 'Updates', hint: 'Check for app updates' }, ], @@ -208,6 +211,8 @@ export function SettingsPanel() { return case 'batches': return + case 'runtime': + return case 'updates': return case 'advanced': diff --git a/src/components/settings/tabs/system-tab.tsx b/src/components/settings/tabs/system-tab.tsx new file mode 100644 index 00000000..1a313541 --- /dev/null +++ b/src/components/settings/tabs/system-tab.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState } from 'react' +import { SettingSection, SettingRow } from '@/components/shared/setting-components' +import { getAppSettings, updateAppSettings, type AppSettings } from '@/lib/ipc/settings' + +/** + * Global backend settings (`~/.kaitencode/settings.json` via get/update_app_settings). + * These were previously only editable by hand-editing the JSON. See the settings + * audit — this tab closes the "no UI" gap for AppSettings. + */ +export function SystemTab() { + const [s, setS] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + getAppSettings() + .then(setS) + .catch((e: unknown) => { setError(e instanceof Error ? e.message : String(e)) }) + }, []) + + const commit = (patch: Partial) => { + setS((prev) => (prev ? { ...prev, ...patch } : prev)) + void updateAppSettings(patch).catch((e: unknown) => { + setError(e instanceof Error ? e.message : String(e)) + }) + } + + if (!s) { + return
{error ?? 'Loading…'}
+ } + + return ( +
+ {error &&
{error}
} + + + + { commit({ default_session_strategy: v }) }} + options={[{ value: 'fresh', label: 'Fresh' }, { value: 'reuse', label: 'Reuse' }]} + /> + + + { onChange(e.target.value) }} + className="rounded-md border border-border-default bg-bg px-2.5 py-1.5 text-sm text-text-primary focus:border-accent focus:outline-none" + > + {options.map((o) => ( + + ))} + + ) +} + +/** Commits on blur / Enter so we don't write settings.json on every keystroke. */ +function Text({ + value, + placeholder, + onCommit, + mono = false, +}: { + value: string + placeholder?: string + onCommit: (v: string) => void + mono?: boolean +}) { + const [local, setLocal] = useState(value) + useEffect(() => { setLocal(value) }, [value]) + return ( + { setLocal(e.target.value) }} + onBlur={() => { if (local !== value) onCommit(local.trim()) }} + onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} + className={`w-40 rounded-md border border-border-default bg-bg px-2.5 py-1.5 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none ${mono ? 'font-mono' : ''}`} + /> + ) +} + +function Num({ + value, + min, + onCommit, +}: { + value: number + min: number + onCommit: (v: number) => void +}) { + const [local, setLocal] = useState(String(value)) + useEffect(() => { setLocal(String(value)) }, [value]) + return ( + { setLocal(e.target.value) }} + onBlur={() => { + const n = Number.parseInt(local, 10) + if (Number.isFinite(n) && n >= min && n !== value) onCommit(n) + else setLocal(String(value)) + }} + onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} + className="w-28 rounded-md border border-border-default bg-bg px-2.5 py-1.5 text-sm text-text-primary focus:border-accent focus:outline-none" + /> + ) +} diff --git a/src/lib/ipc/settings.ts b/src/lib/ipc/settings.ts index 47b0964f..6124dd6e 100644 --- a/src/lib/ipc/settings.ts +++ b/src/lib/ipc/settings.ts @@ -19,6 +19,11 @@ export interface AppSettings { default_runtime_mode: string /** Opt-in gate for interactive runtime mode (Phase 3). */ interactive_mode_enabled: boolean + /** Startup CLI-version warning floors. null/"" = accept any version. */ + claude_min_version: string | null + codex_min_version: string | null + /** Max depth of an agent-spawned task chain (MCP recursion guard). */ + mcp_max_recursion_depth: number } export async function getAppSettings(): Promise { From d232cfb5f3c5b97314aa43fe0b0172692b10d702 Mon Sep 17 00:00:00 2001 From: ANonABento Date: Tue, 9 Jun 2026 00:42:35 -0400 Subject: [PATCH 2/2] fix(settings): stop workspace-config writers from clobbering each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace `config` JSON has two writers and they overwrote each other's keys (last-write-wins on the whole blob — silent settings loss): - The Workspace tab spreads the full current config (`{...config, ...patch}`), so it preserved everything. - `useSettingsSync` serialized ONLY its nested `{agent,git,voice,model}` slices and wrote that as the entire config — wiping the flat keys the Workspace tab sets (defaultModel, maxConcurrentAgents, defaultBaseBranch, autoAdvance, persistentAgentLifecycle, autoArchive…). Fix: `useSettingsSync` now re-reads the current config and MERGES its slices in (symmetric with the Workspace tab) instead of replacing the blob. The backend `effective_pipeline_settings` already reads both the flat and nested shapes, so they coexist safely. Also reconciles the branchPrefix split-brain: DEFAULT_SETTINGS.git.branchPrefix was 'feat/' while the backend DEFAULT_BRANCH_PREFIX and workspaceDefaults both use 'kaitencode/' — three defaults for one concept. Aligned to 'kaitencode/'. tsc · lint · vitest (419) green. --- src/hooks/use-settings-sync.ts | 27 ++++++++++++++++++--------- src/types/settings.ts | 4 +++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/hooks/use-settings-sync.ts b/src/hooks/use-settings-sync.ts index 5db5b572..e0b4d528 100644 --- a/src/hooks/use-settings-sync.ts +++ b/src/hooks/use-settings-sync.ts @@ -54,25 +54,34 @@ export function useSettingsSync() { void loadSettings() }, [activeWorkspaceId, loadWorkspaceSettings]) - // Save workspace settings to backend (debounced) + // Save workspace settings to backend (debounced). const saveSettings = useCallback(async (workspaceId: string, settings: WorkspaceSettings) => { - // Filter to only workspace-specific settings + // Only the nested {agent,git,voice,model} slices are workspace-synced. const workspaceSpecificSettings: WorkspaceSettings = {} - if (settings.agent) workspaceSpecificSettings.agent = { ...settings.agent, envVars: {} } if (settings.git) workspaceSpecificSettings.git = settings.git if (settings.voice) workspaceSpecificSettings.voice = settings.voice if (settings.model) workspaceSpecificSettings.model = settings.model - // Skip if nothing to save if (Object.keys(workspaceSpecificSettings).length === 0) return - const configJson = JSON.stringify(workspaceSpecificSettings) - - // Skip if unchanged - if (configJson === lastSavedRef.current) return - try { + // MERGE into the current config rather than replacing it. The Workspace + // tab writes flat keys (defaultModel, maxConcurrentAgents, autoAdvance, …) + // to the SAME config JSON; serializing only our nested slices used to + // overwrite the whole blob and wipe those flat keys (and vice-versa). + // Re-read right before write to minimise the clobber window. + const current = await getWorkspace(workspaceId) + const existing: Record = + current.config && current.config !== '{}' + ? (JSON.parse(current.config) as Record) + : {} + const merged = { ...existing, ...workspaceSpecificSettings } + const configJson = JSON.stringify(merged) + + // Skip if our merge wouldn't change anything. + if (configJson === lastSavedRef.current) return + await updateWorkspaceConfig(workspaceId, configJson) lastSavedRef.current = configJson } catch (error) { diff --git a/src/types/settings.ts b/src/types/settings.ts index 7d607252..8dbaf8b7 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -211,7 +211,9 @@ export const DEFAULT_SETTINGS: Settings = { pushToTalk: true, }, git: { - branchPrefix: 'feat/', + // Match the backend DEFAULT_BRANCH_PREFIX ('kaitencode/') and + // workspaceDefaults.branchPrefix — these were three different defaults. + branchPrefix: 'kaitencode/', autoPr: false, prTemplate: '', mergeStrategy: 'squash',