Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/webui/features/settings/components/boolean-settings-row.tsx
Original file line number Diff line number Diff line change
@@ -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
Comment thread
ncnthien marked this conversation as resolved.

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 (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground text-sm font-medium">{label}</span>
<span className="text-muted-foreground text-xs leading-snug" id={descriptionId}>
{row.description}
</span>
</div>
<Switch
aria-describedby={descriptionId}
checked={checked}
disabled={setMutation.isPending}
onCheckedChange={(next) => {
toggle(next).catch(noop)
}}
/>
Comment thread
ncnthien marked this conversation as resolved.
</div>
)
}
11 changes: 9 additions & 2 deletions src/webui/features/settings/components/settings-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <BooleanSettingsRow row={row} />

return <IntegerSettingsRow row={row} />
}
Comment thread
ncnthien marked this conversation as resolved.

function IntegerSettingsRow({row}: Props) {
const setMutation = useSetSetting()
const resetMutation = useResetSetting()
const markDirty = useRestartBannerStore((s) => s.markDirty)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions src/webui/features/settings/components/updates-panel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SettingsSection
action={isLoading ? <LoaderCircle className="text-muted-foreground mt-1 size-4 animate-spin" /> : 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 ? (
<div className="flex flex-col gap-5">
{rows.map((row, index) => (
<Fragment key={row.key}>
<SettingsRow row={row} />
{index < rows.length - 1 && <div className="border-b" />}
</Fragment>
))}
</div>
) : (
<SettingsSkeleton />
)}
</SettingsSection>
)
}
1 change: 1 addition & 0 deletions src/webui/features/settings/lib/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const LABELS: Record<string, string> = {
'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 {
Expand Down
5 changes: 3 additions & 2 deletions src/webui/features/settings/stores/restart-banner-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import {create} from 'zustand'
interface RestartBannerState {
clear: () => void
dirtyKeys: ReadonlySet<string>
markDirty: (key: string) => void
markDirty: (key: string, restartRequired: boolean) => void
}

export const useRestartBannerStore = create<RestartBannerState>((set) => ({
clear: () => set({dirtyKeys: new Set<string>()}),
dirtyKeys: new Set<string>(),
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)
Expand Down
2 changes: 2 additions & 0 deletions src/webui/pages/configuration/general.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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 (
<>
<ConcurrencyPanel />
<LlmPanel />
<TaskHistoryPanel />
<UpdatesPanel />
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Comment thread
ncnthien marked this conversation as resolved.
})
Loading