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_model: v }) }} mono />
+
+
+
+
+
+
+
+
+
+ { commit({ max_agent_sessions: v }) }} />
+
+
+ { commit({ gc_interval_minutes: v }) }} />
+
+
+ { commit({ idle_sleep_minutes: v }) }} />
+
+
+ { commit({ idle_kill_hours: v }) }} />
+
+
+ { commit({ archive_purge_days: v }) }} />
+
+
+
+
+
+ { commit({ mcp_max_recursion_depth: v }) }} />
+
+
+ { commit({ claude_min_version: v }) }} mono />
+
+
+ { commit({ codex_min_version: v }) }} mono />
+
+
+
+ )
+}
+
+// ─── Local field controls ─────────────────────────────────────────────────
+
+function Select({
+ value,
+ onChange,
+ options,
+}: {
+ value: string
+ onChange: (v: string) => void
+ options: { value: string; label: string }[]
+}) {
+ return (
+
+ )
+}
+
+/** 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/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/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 {
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',