diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index ca46c909..3efa566f 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -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 ( +
+ + 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", + )} + /> + +
+ ) +} 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). - setGlobalLimit(e.target.value)} - onBlur={handleSave} - className="w-24 shrink-0" + fallback={20} + onInput={setGlobalLimit} + onCommit={(next) => { + setGlobalLimit(next) + void saveLimits(next, chatLimit) + }} /> @@ -2742,16 +2812,34 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) { model-call limit above. - setChatLimit(e.target.value)} - onBlur={handleSave} - placeholder="Same as above" - className="w-32 shrink-0" - /> +
+ {chatLimit.trim() !== "" && ( + + )} + { + setChatLimit(next) + // An emptied chat field on blur means "use the global + // limit" — persist the override removal. + void saveLimits(globalLimit, next) + }} + /> +
diff --git a/apps/x/packages/core/src/config/turn_limits.test.ts b/apps/x/packages/core/src/config/turn_limits.test.ts index b9748615..bb7c3576 100644 --- a/apps/x/packages/core/src/config/turn_limits.test.ts +++ b/apps/x/packages/core/src/config/turn_limits.test.ts @@ -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(); }); }); diff --git a/apps/x/packages/shared/src/turn-limits.ts b/apps/x/packages/shared/src/turn-limits.ts index 6d390c1e..347a1a76 100644 --- a/apps/x/packages/shared/src/turn-limits.ts +++ b/apps/x/packages/shared/src/turn-limits.ts @@ -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()