feat(x): raise model-call limit cap to 500, polish limit stepper UI

Validation range is now 1-500. The Advanced tab's native number inputs
are replaced with a segmented minus/value/plus stepper (custom buttons,
digits-only typing, range clamping, immediate save on step). The chat
override gets a smaller placeholder, steps from the effective global
limit when empty, and has a clear button to fall back to the global
limit.
This commit is contained in:
Gagan 2026-07-21 15:29:11 +05:30
parent eece1af90e
commit dd04738015
3 changed files with 123 additions and 35 deletions

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, Minus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
import {
Dialog,
@ -2641,11 +2641,86 @@ function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) {
// --- Advanced (runtime/cost controls) tab ---
const MODEL_CALL_LIMIT_MIN = 1
const MODEL_CALL_LIMIT_MAX = 100
const MODEL_CALL_LIMIT_MAX = 500
function parseLimit(value: string): number | null {
const n = Number(value.trim())
if (!Number.isInteger(n) || n < MODEL_CALL_LIMIT_MIN || n > MODEL_CALL_LIMIT_MAX) return null
return n
}
/**
* Compact segmented / value / + stepper. The native number-input spinners
* are replaced entirely: typing is free-form digits, stepping clamps to the
* range and commits immediately. An empty value steps from `fallback` (the
* chat field starts from the global limit).
*/
function LimitStepper({
value,
fallback,
placeholder,
onInput,
onCommit,
}: {
value: string
fallback: number
placeholder?: string
/** Every keystroke (no save). */
onInput: (next: string) => void
/** A settled change: step click or blur. */
onCommit: (next: string) => void
}) {
const current = parseLimit(value)
const step = (delta: number) => {
// From an empty/invalid field, the first step lands on the fallback so
// the override starts where the effective value already is.
const next = current === null
? Math.min(MODEL_CALL_LIMIT_MAX, Math.max(MODEL_CALL_LIMIT_MIN, fallback))
: Math.min(MODEL_CALL_LIMIT_MAX, Math.max(MODEL_CALL_LIMIT_MIN, current + delta))
onCommit(String(next))
}
return (
<div className="flex h-8 items-center overflow-hidden rounded-md border border-input bg-background shadow-xs shrink-0">
<button
type="button"
aria-label="Decrease limit"
onClick={() => step(-1)}
disabled={current !== null && current <= MODEL_CALL_LIMIT_MIN}
className="flex h-full w-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
>
<Minus className="size-3" />
</button>
<input
type="text"
inputMode="numeric"
value={value}
placeholder={placeholder}
onChange={(e) => onInput(e.target.value.replace(/[^0-9]/g, ""))}
onBlur={() => onCommit(value)}
className={cn(
"h-full border-x border-input bg-transparent text-center text-sm tabular-nums outline-none",
"placeholder:text-[11px] placeholder:text-muted-foreground/70",
placeholder ? "w-24" : "w-16",
)}
/>
<button
type="button"
aria-label="Increase limit"
onClick={() => step(1)}
disabled={current !== null && current >= MODEL_CALL_LIMIT_MAX}
className="flex h-full w-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
>
<Plus className="size-3" />
</button>
</div>
)
}
function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) {
// Inputs are kept as strings so the user can clear a field while typing;
// validation happens on save (blur).
// validation happens on commit (step click or blur).
const [globalLimit, setGlobalLimit] = useState("")
const [chatLimit, setChatLimit] = useState("")
const [loaded, setLoaded] = useState(false)
@ -2666,21 +2741,17 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) {
return () => { cancelled = true }
}, [dialogOpen])
const parseLimit = (value: string): number | null => {
const n = Number(value.trim())
if (!Number.isInteger(n) || n < MODEL_CALL_LIMIT_MIN || n > MODEL_CALL_LIMIT_MAX) return null
return n
}
const handleSave = useCallback(async () => {
const global = parseLimit(globalLimit)
// Saves silently on success (a toast per stepper click would be noisy,
// matching the notification toggles); errors still surface.
const saveLimits = useCallback(async (globalStr: string, chatStr: string) => {
const global = parseLimit(globalStr)
if (global === null) {
toast.error(`Model-call limit must be a whole number between ${MODEL_CALL_LIMIT_MIN} and ${MODEL_CALL_LIMIT_MAX}`)
return
}
let chat: number | undefined
if (chatLimit.trim() !== "") {
const parsed = parseLimit(chatLimit)
if (chatStr.trim() !== "") {
const parsed = parseLimit(chatStr)
if (parsed === null) {
toast.error(`Chat limit must be empty or a whole number between ${MODEL_CALL_LIMIT_MIN} and ${MODEL_CALL_LIMIT_MAX}`)
return
@ -2692,11 +2763,10 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) {
maxModelCalls: global,
...(chat === undefined ? {} : { chatMaxModelCalls: chat }),
})
toast.success("Model-call limits saved. Applies to new turns.")
} catch {
toast.error("Failed to save model-call limits")
}
}, [globalLimit, chatLimit])
}, [])
if (!loaded) {
return (
@ -2723,14 +2793,14 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) {
knowledge work, scheduled agents, and sub-agents (it also caps sub-agent budgets).
</div>
</div>
<Input
type="number"
min={MODEL_CALL_LIMIT_MIN}
max={MODEL_CALL_LIMIT_MAX}
<LimitStepper
value={globalLimit}
onChange={(e) => setGlobalLimit(e.target.value)}
onBlur={handleSave}
className="w-24 shrink-0"
fallback={20}
onInput={setGlobalLimit}
onCommit={(next) => {
setGlobalLimit(next)
void saveLimits(next, chatLimit)
}}
/>
</div>
@ -2742,16 +2812,34 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) {
model-call limit above.
</div>
</div>
<Input
type="number"
min={MODEL_CALL_LIMIT_MIN}
max={MODEL_CALL_LIMIT_MAX}
value={chatLimit}
onChange={(e) => setChatLimit(e.target.value)}
onBlur={handleSave}
placeholder="Same as above"
className="w-32 shrink-0"
/>
<div className="flex items-center gap-1.5 shrink-0">
{chatLimit.trim() !== "" && (
<button
type="button"
aria-label="Use the global limit"
title="Use the global limit"
onClick={() => {
setChatLimit("")
void saveLimits(globalLimit, "")
}}
className="flex size-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
<LimitStepper
value={chatLimit}
fallback={parseLimit(globalLimit) ?? 20}
placeholder="Same as above"
onInput={setChatLimit}
onCommit={(next) => {
setChatLimit(next)
// An emptied chat field on blur means "use the global
// limit" — persist the override removal.
void saveLimits(globalLimit, next)
}}
/>
</div>
</div>
</div>
</div>

View file

@ -92,9 +92,9 @@ describe("saveTurnLimitsSettings", () => {
it("rejects out-of-range values", async () => {
const { saveTurnLimitsSettings } = await loadTurnLimits();
expect(() => saveTurnLimitsSettings({ maxModelCalls: 0 })).toThrow();
expect(() => saveTurnLimitsSettings({ maxModelCalls: 101 })).toThrow();
expect(() => saveTurnLimitsSettings({ maxModelCalls: 501 })).toThrow();
expect(() =>
saveTurnLimitsSettings({ maxModelCalls: 20, chatMaxModelCalls: 500 }),
saveTurnLimitsSettings({ maxModelCalls: 20, chatMaxModelCalls: 501 }),
).toThrow();
});
});

View file

@ -14,7 +14,7 @@ import { DEFAULT_MAX_MODEL_CALLS } from './turns.js';
* resolved limit in turn_created.config.maxModelCalls.
*/
export const MIN_MODEL_CALL_LIMIT = 1;
export const MAX_MODEL_CALL_LIMIT = 100;
export const MAX_MODEL_CALL_LIMIT = 500;
const limit = z
.number()