diff --git a/src/settings/tabs/ModelTab.tsx b/src/settings/tabs/ModelTab.tsx index a4de008..d037325 100644 --- a/src/settings/tabs/ModelTab.tsx +++ b/src/settings/tabs/ModelTab.tsx @@ -74,6 +74,10 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { const [inactivityMin, setInactivityMin] = useState( config.inference.keep_warm_inactivity_minutes, ); + const [rawMin, setRawMin] = useState( + String(config.inference.keep_warm_inactivity_minutes), + ); + const minFocusedRef = useRef(false); const [ejecting, setEjecting] = useState(false); const [loadedModel, setLoadedModel] = useState(null); @@ -138,8 +142,11 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { if (prevTokenRef.current !== resyncToken) { prevTokenRef.current = resyncToken; - setInactivityMin(config.inference.keep_warm_inactivity_minutes); - resetMin(config.inference.keep_warm_inactivity_minutes); + if (!minFocusedRef.current) { + setInactivityMin(config.inference.keep_warm_inactivity_minutes); + setRawMin(String(config.inference.keep_warm_inactivity_minutes)); + resetMin(config.inference.keep_warm_inactivity_minutes); + } const nextCtx = config.inference.num_ctx; setNumCtx(nextCtx); setCtxPos(ctxToPos(nextCtx)); @@ -210,16 +217,28 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { { + minFocusedRef.current = true; + }} onChange={(e) => { const n = parseInt(e.target.value, 10); - if (!Number.isNaN(n)) { - // Clamp to BOUNDS_KEEP_WARM_INACTIVITY_MINUTES so the UI - // mirrors the backend cap and never desyncs after a save. - setInactivityMin(Math.max(-1, Math.min(1440, n))); + if (Number.isNaN(n)) { + setRawMin(e.target.value); + } else { + const clamped = Math.max(-1, Math.min(1440, n)); + setRawMin(String(clamped)); + setInactivityMin(clamped); + } + }} + onBlur={() => { + minFocusedRef.current = false; + if (Number.isNaN(parseInt(rawMin, 10))) { + setRawMin('0'); + setInactivityMin(0); } }} /> diff --git a/src/settings/tabs/tabs.test.tsx b/src/settings/tabs/tabs.test.tsx index ab69272..0f53fd0 100644 --- a/src/settings/tabs/tabs.test.tsx +++ b/src/settings/tabs/tabs.test.tsx @@ -245,16 +245,28 @@ describe('ModelTab', () => { expect((input as HTMLInputElement).value).toBe('60'); }); - it('non-numeric inactivity input is ignored', async () => { + it('allows empty inactivity input mid-edit; blur defaults to 0', async () => { await renderModelTab(); const input = screen.getByRole('spinbutton', { name: 'Release after N minutes', }); fireEvent.change(input, { target: { value: '' } }); + expect((input as HTMLInputElement).value).toBe(''); + fireEvent.blur(input); expect((input as HTMLInputElement).value).toBe('0'); }); - it('clamps below-range inactivity input to -1', async () => { + it('blur with a valid inactivity value does not reset the field', async () => { + await renderModelTab(); + const input = screen.getByRole('spinbutton', { + name: 'Release after N minutes', + }); + fireEvent.change(input, { target: { value: '60' } }); + fireEvent.blur(input); + expect((input as HTMLInputElement).value).toBe('60'); + }); + + it('clamps below-range inactivity input to -1 immediately', async () => { await renderModelTab(); const input = screen.getByRole('spinbutton', { name: 'Release after N minutes', @@ -263,7 +275,7 @@ describe('ModelTab', () => { expect((input as HTMLInputElement).value).toBe('-1'); }); - it('clamps above-range inactivity input to 1440', async () => { + it('clamps above-range inactivity input to 1440 immediately', async () => { await renderModelTab(); const input = screen.getByRole('spinbutton', { name: 'Release after N minutes', @@ -371,6 +383,25 @@ describe('ModelTab', () => { expect((input as HTMLInputElement).value).toBe('60'); }); + it('resync does not overwrite rawMin while input is focused', async () => { + const { rerender } = await renderModelTab(); + const input = screen.getByRole('spinbutton', { + name: 'Release after N minutes', + }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '' } }); + expect((input as HTMLInputElement).value).toBe(''); + + const updatedConfig: RawAppConfig = { + ...CONFIG, + inference: { ...CONFIG.inference, keep_warm_inactivity_minutes: 60 }, + }; + rerender( + {}} />, + ); + expect((input as HTMLInputElement).value).toBe(''); + }); + it('renders Context Window section with label, slider, chip, tick marks, and VRAM note', async () => { await renderModelTab(); expect(screen.getByText('Context Window')).toBeInTheDocument(); diff --git a/src/styles/settings.module.css b/src/styles/settings.module.css index a48d3e6..8e541f3 100644 --- a/src/styles/settings.module.css +++ b/src/styles/settings.module.css @@ -1166,7 +1166,7 @@ font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; - width: 32px; + width: 48px; padding: 1px 4px; text-align: center; outline: none;