Skip to content

Commit 38b506c

Browse files
authored
fix(settings): repair keep-warm minutes input UX (#127)
* fix(settings): repair keep-warm minutes input UX Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(settings): guard inactivity save-driver during resync while focused Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> --------- Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
1 parent d9ef3b8 commit 38b506c

3 files changed

Lines changed: 61 additions & 11 deletions

File tree

src/settings/tabs/ModelTab.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) {
7474
const [inactivityMin, setInactivityMin] = useState(
7575
config.inference.keep_warm_inactivity_minutes,
7676
);
77+
const [rawMin, setRawMin] = useState(
78+
String(config.inference.keep_warm_inactivity_minutes),
79+
);
80+
const minFocusedRef = useRef(false);
7781
const [ejecting, setEjecting] = useState(false);
7882
const [loadedModel, setLoadedModel] = useState<string | null>(null);
7983

@@ -138,8 +142,11 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) {
138142

139143
if (prevTokenRef.current !== resyncToken) {
140144
prevTokenRef.current = resyncToken;
141-
setInactivityMin(config.inference.keep_warm_inactivity_minutes);
142-
resetMin(config.inference.keep_warm_inactivity_minutes);
145+
if (!minFocusedRef.current) {
146+
setInactivityMin(config.inference.keep_warm_inactivity_minutes);
147+
setRawMin(String(config.inference.keep_warm_inactivity_minutes));
148+
resetMin(config.inference.keep_warm_inactivity_minutes);
149+
}
143150
const nextCtx = config.inference.num_ctx;
144151
setNumCtx(nextCtx);
145152
setCtxPos(ctxToPos(nextCtx));
@@ -210,16 +217,28 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) {
210217
<input
211218
type="number"
212219
className={styles.keepWarmNumberInput}
213-
value={inactivityMin}
220+
value={rawMin}
214221
min={-1}
215222
max={1440}
216223
aria-label="Release after N minutes"
224+
onFocus={() => {
225+
minFocusedRef.current = true;
226+
}}
217227
onChange={(e) => {
218228
const n = parseInt(e.target.value, 10);
219-
if (!Number.isNaN(n)) {
220-
// Clamp to BOUNDS_KEEP_WARM_INACTIVITY_MINUTES so the UI
221-
// mirrors the backend cap and never desyncs after a save.
222-
setInactivityMin(Math.max(-1, Math.min(1440, n)));
229+
if (Number.isNaN(n)) {
230+
setRawMin(e.target.value);
231+
} else {
232+
const clamped = Math.max(-1, Math.min(1440, n));
233+
setRawMin(String(clamped));
234+
setInactivityMin(clamped);
235+
}
236+
}}
237+
onBlur={() => {
238+
minFocusedRef.current = false;
239+
if (Number.isNaN(parseInt(rawMin, 10))) {
240+
setRawMin('0');
241+
setInactivityMin(0);
223242
}
224243
}}
225244
/>

src/settings/tabs/tabs.test.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,16 +245,28 @@ describe('ModelTab', () => {
245245
expect((input as HTMLInputElement).value).toBe('60');
246246
});
247247

248-
it('non-numeric inactivity input is ignored', async () => {
248+
it('allows empty inactivity input mid-edit; blur defaults to 0', async () => {
249249
await renderModelTab();
250250
const input = screen.getByRole('spinbutton', {
251251
name: 'Release after N minutes',
252252
});
253253
fireEvent.change(input, { target: { value: '' } });
254+
expect((input as HTMLInputElement).value).toBe('');
255+
fireEvent.blur(input);
254256
expect((input as HTMLInputElement).value).toBe('0');
255257
});
256258

257-
it('clamps below-range inactivity input to -1', async () => {
259+
it('blur with a valid inactivity value does not reset the field', async () => {
260+
await renderModelTab();
261+
const input = screen.getByRole('spinbutton', {
262+
name: 'Release after N minutes',
263+
});
264+
fireEvent.change(input, { target: { value: '60' } });
265+
fireEvent.blur(input);
266+
expect((input as HTMLInputElement).value).toBe('60');
267+
});
268+
269+
it('clamps below-range inactivity input to -1 immediately', async () => {
258270
await renderModelTab();
259271
const input = screen.getByRole('spinbutton', {
260272
name: 'Release after N minutes',
@@ -263,7 +275,7 @@ describe('ModelTab', () => {
263275
expect((input as HTMLInputElement).value).toBe('-1');
264276
});
265277

266-
it('clamps above-range inactivity input to 1440', async () => {
278+
it('clamps above-range inactivity input to 1440 immediately', async () => {
267279
await renderModelTab();
268280
const input = screen.getByRole('spinbutton', {
269281
name: 'Release after N minutes',
@@ -371,6 +383,25 @@ describe('ModelTab', () => {
371383
expect((input as HTMLInputElement).value).toBe('60');
372384
});
373385

386+
it('resync does not overwrite rawMin while input is focused', async () => {
387+
const { rerender } = await renderModelTab();
388+
const input = screen.getByRole('spinbutton', {
389+
name: 'Release after N minutes',
390+
});
391+
fireEvent.focus(input);
392+
fireEvent.change(input, { target: { value: '' } });
393+
expect((input as HTMLInputElement).value).toBe('');
394+
395+
const updatedConfig: RawAppConfig = {
396+
...CONFIG,
397+
inference: { ...CONFIG.inference, keep_warm_inactivity_minutes: 60 },
398+
};
399+
rerender(
400+
<ModelTab config={updatedConfig} resyncToken={1} onSaved={() => {}} />,
401+
);
402+
expect((input as HTMLInputElement).value).toBe('');
403+
});
404+
374405
it('renders Context Window section with label, slider, chip, tick marks, and VRAM note', async () => {
375406
await renderModelTab();
376407
expect(screen.getByText('Context Window')).toBeInTheDocument();

src/styles/settings.module.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1166,7 +1166,7 @@
11661166
font-size: 14px;
11671167
font-weight: 600;
11681168
font-variant-numeric: tabular-nums;
1169-
width: 32px;
1169+
width: 48px;
11701170
padding: 1px 4px;
11711171
text-align: center;
11721172
outline: none;

0 commit comments

Comments
 (0)