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) + }) })