Regroup chat settings sidebar into focused sections (#4551)

* feat(chat): regroup settings sidebar into Model, Sampling, Tools, and Preferences sections

Split the monolithic Settings collapsible into focused sections with
icons. Model section shows context length and KV cache dtype for GGUF
models, trust remote code for non GGUF. Tools section groups auto heal,
max tool calls, and tool call timeout. Preferences section holds auto
title toggle.

* feat(chat): persist collapsible section open/closed state in localStorage

Remember which sections the user expanded or collapsed across sidebar
toggles, mobile sheet reopens, and browser sessions.

* fix(chat): harden collapsible state persistence and restore defaultOpen

- Validate localStorage values are booleans before using them, preventing
  corrupted entries like string "false" from being treated as truthy
- Use Object.hasOwn() instead of `in` operator to avoid prototype chain
  matches on keys like "constructor" or "toString"
- Restore defaultOpen={true} on Model and Preferences sections so they
  are expanded on first visit, matching the old Settings section behavior
- Fix misleading Context Length description to reflect it is read-only
- Downgrade console.error to console.warn for non-critical localStorage
  parse failures

* fix(chat): remove redundant disabled styles on Context Length input

The Input component already applies opacity-50 and cursor-not-allowed
via its disabled: variants. Specifying them unconditionally in the
className is redundant.

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
Wasim Yousef Said 2026-03-25 03:39:27 +01:00 committed by GitHub
commit 085f9529b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -28,6 +28,8 @@ import {
PencilEdit01Icon,
Settings02Icon,
SlidersHorizontalIcon,
UserSettings01Icon,
Wrench01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
@ -164,6 +166,39 @@ function ParamSlider({
);
}
const COLLAPSIBLE_STATE_KEY = "unsloth_chat_collapsible_state";
function loadCollapsibleState(): Record<string, boolean> {
if (!canUseStorage()) return {};
try {
const raw = localStorage.getItem(COLLAPSIBLE_STATE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return {};
}
return Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, boolean] => typeof entry[1] === "boolean",
),
);
} catch (error) {
console.warn("Failed to load collapsible state from localStorage:", error);
return {};
}
}
function saveCollapsibleOpen(label: string, open: boolean) {
if (!canUseStorage()) return;
try {
const state = loadCollapsibleState();
state[label] = open;
localStorage.setItem(COLLAPSIBLE_STATE_KEY, JSON.stringify(state));
} catch {
// ignore
}
}
function CollapsibleSection({
icon,
label,
@ -175,13 +210,20 @@ function CollapsibleSection({
children?: ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const [open, setOpen] = useState(() => {
const saved = loadCollapsibleState();
return Object.hasOwn(saved, label) ? saved[label] : defaultOpen;
});
return (
<div>
<button
type="button"
onClick={() => setOpen(!open)}
onClick={() => {
const next = !open;
setOpen(next);
saveCollapsibleOpen(label, next);
}}
className="flex w-full items-center corner-squircle gap-2.5 rounded-md px-2 py-2 text-sm transition-colors hover:bg-accent"
>
<HugeiconsIcon icon={icon} className="size-4 text-muted-foreground" />
@ -421,6 +463,69 @@ export function ChatSettingsPanel({
/>
</div>
<CollapsibleSection icon={Settings02Icon} label="Model" defaultOpen={true}>
<div className="flex flex-col gap-3 py-1">
{isGguf && (
<>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium">Context Length</div>
<div className="text-[11px] text-muted-foreground">
Reported by the loaded GGUF model.
</div>
</div>
<Input
value={ggufContextLength ?? ""}
placeholder="Loading..."
disabled={true}
className="h-7 w-[90px] text-xs"
/>
</div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium">KV Cache Dtype</div>
<div className="text-[11px] text-muted-foreground">
Quantize KV cache to reduce VRAM. Reload to apply.
</div>
</div>
<Select
value={kvCacheDtype ?? "f16"}
onValueChange={(v) => {
setKvCacheDtype(v === "f16" ? null : v);
onReloadModel?.();
}}
>
<SelectTrigger className="h-7 w-[90px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="f16">f16</SelectItem>
<SelectItem value="bf16">bf16</SelectItem>
<SelectItem value="q8_0">q8_0</SelectItem>
<SelectItem value="q5_1">q5_1</SelectItem>
<SelectItem value="q4_1">q4_1</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
{!isGguf && (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium">Trust remote code</div>
<div className="text-[11px] text-muted-foreground">
Allow models with custom code (e.g. Nemotron). Only enable for repos you trust.
</div>
</div>
<Switch
checked={params.trustRemoteCode ?? false}
onCheckedChange={set("trustRemoteCode")}
/>
</div>
)}
</div>
</CollapsibleSection>
<CollapsibleSection
icon={SlidersHorizontalIcon}
label="Sampling"
@ -505,7 +610,15 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
<CollapsibleSection icon={Settings02Icon} label="Settings" defaultOpen={true}>
<CollapsibleSection icon={Wrench01Icon} label="Tools">
<div className="flex flex-col gap-3 py-1">
<AutoHealToolCallsToggle />
<MaxToolCallsSlider />
<ToolCallTimeoutSlider />
</div>
</CollapsibleSection>
<CollapsibleSection icon={UserSettings01Icon} label="Preferences" defaultOpen={true}>
<div className="flex flex-col gap-3 py-1">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
@ -519,49 +632,6 @@ export function ChatSettingsPanel({
onCheckedChange={onAutoTitleChange}
/>
</div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium">Trust remote code</div>
<div className="text-[11px] text-muted-foreground">
Allow models with custom code (e.g. Nemotron). Only enable for repos you trust.
</div>
</div>
<Switch
checked={params.trustRemoteCode ?? false}
onCheckedChange={set("trustRemoteCode")}
/>
</div>
{isGguf && (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium">KV Cache Dtype</div>
<div className="text-[11px] text-muted-foreground">
Quantize KV cache to reduce VRAM. Reload to apply.
</div>
</div>
<Select
value={kvCacheDtype ?? "f16"}
onValueChange={(v) => {
setKvCacheDtype(v === "f16" ? null : v);
onReloadModel?.();
}}
>
<SelectTrigger className="h-7 w-[90px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="f16">f16</SelectItem>
<SelectItem value="bf16">bf16</SelectItem>
<SelectItem value="q8_0">q8_0</SelectItem>
<SelectItem value="q5_1">q5_1</SelectItem>
<SelectItem value="q4_1">q4_1</SelectItem>
</SelectContent>
</Select>
</div>
)}
<AutoHealToolCallsToggle />
<MaxToolCallsSlider />
<ToolCallTimeoutSlider />
</div>
</CollapsibleSection>