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
33 changes: 26 additions & 7 deletions src/settings/tabs/ModelTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -210,16 +217,28 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) {
<input
type="number"
className={styles.keepWarmNumberInput}
value={inactivityMin}
value={rawMin}
min={-1}
max={1440}
aria-label="Release after N minutes"
onFocus={() => {
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);
}
}}
/>
Expand Down
37 changes: 34 additions & 3 deletions src/settings/tabs/tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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(
<ModelTab config={updatedConfig} resyncToken={1} onSaved={() => {}} />,
);
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();
Expand Down
2 changes: 1 addition & 1 deletion src/styles/settings.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down