mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
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:
parent
eece1af90e
commit
dd04738015
3 changed files with 123 additions and 35 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue