diff --git a/src/webui/features/settings/components/boolean-settings-row.tsx b/src/webui/features/settings/components/boolean-settings-row.tsx
new file mode 100644
index 000000000..e0a755efd
--- /dev/null
+++ b/src/webui/features/settings/components/boolean-settings-row.tsx
@@ -0,0 +1,58 @@
+import {Switch} from '@campfirein/byterover-packages/components/switch'
+import {useId} from 'react'
+import {toast} from 'sonner'
+
+import type {SettingsRow as SettingsRowData} from '../../../../shared/types/settings-row'
+
+import {formatError} from '../../../lib/error-messages'
+import {noop} from '../../../lib/noop'
+import {useSetSetting} from '../api/set-setting'
+import {labelFor} from '../lib/labels'
+import {useRestartBannerStore} from '../stores/restart-banner-store'
+
+type Props = {
+ row: SettingsRowData
+}
+
+export function BooleanSettingsRow({row}: Props) {
+ const setMutation = useSetSetting()
+ const markDirty = useRestartBannerStore((s) => s.markDirty)
+ const descriptionId = useId()
+
+ const label = labelFor(row.key)
+ const checked = typeof row.current === 'boolean' ? row.current : false
+
+ const toggle = async (next: boolean) => {
+ try {
+ const response = await setMutation.mutateAsync({key: row.key, value: next})
+ if (response.ok) {
+ markDirty(row.key, row.restartRequired)
+ toast.success(`${label} ${next ? 'enabled' : 'disabled'}`)
+ return
+ }
+
+ toast.error(response.error.message)
+ } catch (error) {
+ toast.error(formatError(error, `Failed to update ${label}`))
+ }
+ }
+
+ return (
+
+
+ {label}
+
+ {row.description}
+
+
+
{
+ toggle(next).catch(noop)
+ }}
+ />
+
+ )
+}
diff --git a/src/webui/features/settings/components/settings-row.tsx b/src/webui/features/settings/components/settings-row.tsx
index cc81b124c..09278996a 100644
--- a/src/webui/features/settings/components/settings-row.tsx
+++ b/src/webui/features/settings/components/settings-row.tsx
@@ -14,12 +14,19 @@ import {useResetSetting} from '../api/reset-setting'
import {useSetSetting} from '../api/set-setting'
import {labelFor} from '../lib/labels'
import {useRestartBannerStore} from '../stores/restart-banner-store'
+import {BooleanSettingsRow} from './boolean-settings-row'
type Props = {
row: SettingsRowData
}
export function SettingsRow({row}: Props) {
+ if (row.type === 'boolean') return
+
+ return
+}
+
+function IntegerSettingsRow({row}: Props) {
const setMutation = useSetSetting()
const resetMutation = useResetSetting()
const markDirty = useRestartBannerStore((s) => s.markDirty)
@@ -57,7 +64,7 @@ export function SettingsRow({row}: Props) {
setError(undefined)
const response = await setMutation.mutateAsync({key: row.key, value: parsed.value})
if (response.ok) {
- markDirty(row.key)
+ markDirty(row.key, row.restartRequired)
isUserEditingRef.current = false
toast.success(`${label} set to ${toastValue(parsed.value)}`)
return
@@ -70,7 +77,7 @@ export function SettingsRow({row}: Props) {
setError(undefined)
const response = await resetMutation.mutateAsync({key: row.key})
if (response.ok) {
- markDirty(row.key)
+ markDirty(row.key, row.restartRequired)
isUserEditingRef.current = false
toast.success(`${label} reset to default`)
return
diff --git a/src/webui/features/settings/components/updates-panel.tsx b/src/webui/features/settings/components/updates-panel.tsx
new file mode 100644
index 000000000..8b09d2960
--- /dev/null
+++ b/src/webui/features/settings/components/updates-panel.tsx
@@ -0,0 +1,42 @@
+import {LoaderCircle} from 'lucide-react'
+import {Fragment, useMemo} from 'react'
+
+import {buildSettingsRows} from '../../../../shared/utils/format-settings'
+import {noop} from '../../../lib/noop'
+import {SettingsSection} from '../../vc/components/settings-section'
+import {useGetSettings} from '../api/list-settings'
+import {SettingsRow} from './settings-row'
+import {SettingsSkeleton} from './settings-skeleton'
+
+export function UpdatesPanel() {
+ const {data, error, isError, isLoading, refetch} = useGetSettings()
+
+ const rows = useMemo(() => {
+ if (!data) return []
+ return buildSettingsRows(data.items).filter((row) => row.category === 'updates')
+ }, [data?.items])
+
+ return (
+ : undefined}
+ description="Update checks performed when brv starts."
+ error={isError ? error : undefined}
+ errorFallback="Failed to load updates settings"
+ onRetry={() => refetch().catch(noop)}
+ title="Updates"
+ >
+ {data ? (
+
+ {rows.map((row, index) => (
+
+
+ {index < rows.length - 1 && }
+
+ ))}
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/webui/features/settings/lib/labels.ts b/src/webui/features/settings/lib/labels.ts
index 83e9112e2..1c5368197 100644
--- a/src/webui/features/settings/lib/labels.ts
+++ b/src/webui/features/settings/lib/labels.ts
@@ -4,6 +4,7 @@ const LABELS: Record = {
'llm.iterationBudgetMs': 'Agentic loop budget',
'llm.requestTimeoutMs': 'LLM request timeout',
'taskHistory.maxEntries': 'Task history size',
+ 'update.checkForUpdates': 'Check for updates at startup',
}
export function labelFor(key: string): string {
diff --git a/src/webui/features/settings/stores/restart-banner-store.ts b/src/webui/features/settings/stores/restart-banner-store.ts
index 27b4ae802..653e3879c 100644
--- a/src/webui/features/settings/stores/restart-banner-store.ts
+++ b/src/webui/features/settings/stores/restart-banner-store.ts
@@ -3,14 +3,15 @@ import {create} from 'zustand'
interface RestartBannerState {
clear: () => void
dirtyKeys: ReadonlySet
- markDirty: (key: string) => void
+ markDirty: (key: string, restartRequired: boolean) => void
}
export const useRestartBannerStore = create((set) => ({
clear: () => set({dirtyKeys: new Set()}),
dirtyKeys: new Set(),
- markDirty: (key) =>
+ markDirty: (key, restartRequired) =>
set((state) => {
+ if (!restartRequired) return state
if (state.dirtyKeys.has(key)) return state
const next = new Set(state.dirtyKeys)
next.add(key)
diff --git a/src/webui/pages/configuration/general.tsx b/src/webui/pages/configuration/general.tsx
index 5b4f85d56..a5e5c1237 100644
--- a/src/webui/pages/configuration/general.tsx
+++ b/src/webui/pages/configuration/general.tsx
@@ -1,6 +1,7 @@
import {ConcurrencyPanel} from '../../features/settings/components/concurrency-panel'
import {LlmPanel} from '../../features/settings/components/llm-panel'
import {TaskHistoryPanel} from '../../features/settings/components/task-history-panel'
+import {UpdatesPanel} from '../../features/settings/components/updates-panel'
export function GeneralSection() {
return (
@@ -8,6 +9,7 @@ export function GeneralSection() {
+
>
)
}
diff --git a/test/unit/webui/features/settings/stores/restart-banner-store.test.ts b/test/unit/webui/features/settings/stores/restart-banner-store.test.ts
index eb30c1e00..ac4b55b9d 100644
--- a/test/unit/webui/features/settings/stores/restart-banner-store.test.ts
+++ b/test/unit/webui/features/settings/stores/restart-banner-store.test.ts
@@ -11,34 +11,61 @@ describe('useRestartBannerStore', () => {
expect(useRestartBannerStore.getState().dirtyKeys.size).to.equal(0)
})
- it('markDirty adds the key to the dirty set', () => {
- useRestartBannerStore.getState().markDirty('agentPool.maxSize')
+ it('markDirty adds the key to the dirty set when restartRequired is true', () => {
+ useRestartBannerStore.getState().markDirty('agentPool.maxSize', true)
expect(useRestartBannerStore.getState().dirtyKeys.has('agentPool.maxSize')).to.equal(true)
})
it('markDirty is idempotent — same key twice yields size 1', () => {
- useRestartBannerStore.getState().markDirty('agentPool.maxSize')
- useRestartBannerStore.getState().markDirty('agentPool.maxSize')
+ useRestartBannerStore.getState().markDirty('agentPool.maxSize', true)
+ useRestartBannerStore.getState().markDirty('agentPool.maxSize', true)
expect(useRestartBannerStore.getState().dirtyKeys.size).to.equal(1)
})
it('markDirty tracks multiple distinct keys', () => {
- useRestartBannerStore.getState().markDirty('agentPool.maxSize')
- useRestartBannerStore.getState().markDirty('llm.iterationBudgetMs')
+ useRestartBannerStore.getState().markDirty('agentPool.maxSize', true)
+ useRestartBannerStore.getState().markDirty('llm.iterationBudgetMs', true)
expect(useRestartBannerStore.getState().dirtyKeys.size).to.equal(2)
})
it('clear empties the dirty set', () => {
- useRestartBannerStore.getState().markDirty('agentPool.maxSize')
- useRestartBannerStore.getState().markDirty('llm.iterationBudgetMs')
+ useRestartBannerStore.getState().markDirty('agentPool.maxSize', true)
+ useRestartBannerStore.getState().markDirty('llm.iterationBudgetMs', true)
useRestartBannerStore.getState().clear()
expect(useRestartBannerStore.getState().dirtyKeys.size).to.equal(0)
})
- it('produces a new Set instance on each mutation so React selectors detect the change', () => {
+ it('produces a new Set instance on each restart-required mutation so React selectors detect the change', () => {
const before = useRestartBannerStore.getState().dirtyKeys
- useRestartBannerStore.getState().markDirty('agentPool.maxSize')
+ useRestartBannerStore.getState().markDirty('agentPool.maxSize', true)
const after = useRestartBannerStore.getState().dirtyKeys
expect(after).to.not.equal(before)
})
+
+ it('markDirty with restartRequired false does not add the key to the dirty set', () => {
+ useRestartBannerStore.getState().markDirty('update.checkForUpdates', false)
+ expect(useRestartBannerStore.getState().dirtyKeys.size).to.equal(0)
+ })
+
+ it('markDirty with restartRequired false preserves Set identity so React selectors do not re-fire', () => {
+ const before = useRestartBannerStore.getState().dirtyKeys
+ useRestartBannerStore.getState().markDirty('update.checkForUpdates', false)
+ const after = useRestartBannerStore.getState().dirtyKeys
+ expect(after).to.equal(before)
+ })
+
+ it('mixed sequence: only restart-required keys land in the dirty set', () => {
+ useRestartBannerStore.getState().markDirty('agentPool.maxSize', true)
+ useRestartBannerStore.getState().markDirty('update.checkForUpdates', false)
+ const {dirtyKeys} = useRestartBannerStore.getState()
+ expect(dirtyKeys.size).to.equal(1)
+ expect(dirtyKeys.has('agentPool.maxSize')).to.equal(true)
+ expect(dirtyKeys.has('update.checkForUpdates')).to.equal(false)
+ })
+
+ it('a later non-restart change for the same key does not unset its dirty marker', () => {
+ useRestartBannerStore.getState().markDirty('agentPool.maxSize', true)
+ useRestartBannerStore.getState().markDirty('agentPool.maxSize', false)
+ expect(useRestartBannerStore.getState().dirtyKeys.has('agentPool.maxSize')).to.equal(true)
+ })
})