Studio: make the run settings panel width draggable (#7566)

The chat run settings panel was fixed at 17rem. It now uses the same drag
handle as the sidebar, on its left edge, between 248px and 560px and capped
at 40% of the window. The width persists and syncs across tabs.

Reuses PanelResizeHandle and createPanelWidthStore, so behaviour matches the
sidebar exactly. The panel width key joins the preference reset list.

The system prompt overflow check now runs off a ResizeObserver attached
through a callback ref. A drag changes the width through a custom property
without re-rendering, and the collapsible section unmounts the textarea, so
a stored observer would miss both.
This commit is contained in:
Michael Han 2026-07-28 22:31:43 -07:00 committed by GitHub
commit 076c965723
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 108 additions and 18 deletions

View file

@ -18,6 +18,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { InfoHint } from "@/components/ui/info-hint";
import { PanelResizeHandle } from "@/components/ui/panel-resize-handle";
import {
InputGroup,
InputGroupAddon,
@ -44,7 +45,13 @@ import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { NumericValueInput, snapToStep } from "@/features/model-picker";
import { RetrievalSettingsSection } from "@/features/rag";
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
import {
CHAT_SETTINGS_WIDTH_MIN,
clampChatSettingsWidth,
useChatSettingsWidth,
} from "@/hooks/use-chat-settings-width";
import { useIsMobile } from "@/hooks/use-mobile";
import { useT } from "@/i18n";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
@ -52,7 +59,7 @@ import { Edit03Icon, LayoutAlignRightIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Braces, ChevronDown, ExternalLink } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Fragment, type ReactNode } from "react";
import { type CSSProperties, Fragment, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { OpenAICodeExecSection } from "./components/openai-code-exec-section";
import { PermissionModeDropdown } from "./permission-mode-select";
@ -363,6 +370,15 @@ export function ChatSettingsPanel({
onExternalProviderChange,
externalProviderType = null,
}: ChatSettingsPanelProps) {
const asideRef = useRef<HTMLElement>(null);
const t = useT();
const {
width: settingsWidth,
max: settingsMax,
stored: settingsStored,
setWidth: setSettingsWidth,
resetWidth: resetSettingsWidth,
} = useChatSettingsWidth();
// Local models show every knob; providerCapabilities is only consulted when
// isExternalModel. Unknown providers fall back to the OpenAI-compat shape via
// getProviderCapabilities, so these flags never undercount support.
@ -461,6 +477,23 @@ export function ChatSettingsPanel({
// When the prompt overflows the inline box, clicking opens the popup editor.
const systemPromptBoxRef = useRef<HTMLTextAreaElement>(null);
const [systemPromptOverflows, setSystemPromptOverflows] = useState(false);
const promptObserverRef = useRef<ResizeObserver | null>(null);
const measurePromptRef = useRef<() => void>(() => {});
// The section unmounts its textarea when collapsed, so observe through a
// callback ref: a stored observer would cling to the detached node and the
// remounted one would never be measured.
const attachPromptBox = useCallback((node: HTMLTextAreaElement | null) => {
systemPromptBoxRef.current = node;
promptObserverRef.current?.disconnect();
promptObserverRef.current = null;
if (!node || typeof ResizeObserver === "undefined") return;
// Resizing rewraps the prompt, and a drag changes the width through a
// custom property without re-rendering, so watch the box itself.
const observer = new ResizeObserver(() => measurePromptRef.current());
observer.observe(node);
promptObserverRef.current = observer;
measurePromptRef.current();
}, []);
const [activePresetBaseline, setActivePresetBaseline] = useState(params);
const presets = useMemo(() => {
return getOrderedPresets(customPresets);
@ -746,15 +779,20 @@ export function ChatSettingsPanel({
}, [open]);
useEffect(() => {
const el = systemPromptBoxRef.current;
setSystemPromptOverflows(
currentSystemPrompt.length > 0 &&
el != null &&
el.clientHeight > 0 &&
el.scrollHeight > el.clientHeight + 1,
);
measurePromptRef.current = () => {
const el = systemPromptBoxRef.current;
setSystemPromptOverflows(
currentSystemPrompt.length > 0 &&
el != null &&
el.clientHeight > 0 &&
el.scrollHeight > el.clientHeight + 1,
);
};
measurePromptRef.current();
}, [currentSystemPrompt, open]);
useEffect(() => () => promptObserverRef.current?.disconnect(), []);
const settingsScrollRef = useRef<HTMLDivElement>(null);
const settingsContent = (
@ -1124,7 +1162,7 @@ export function ChatSettingsPanel({
)}
>
<textarea
ref={systemPromptBoxRef}
ref={attachPromptBox}
value={currentSystemPrompt}
onChange={(e) => set("systemPrompt")(e.target.value)}
onMouseDown={(e) => {
@ -1433,17 +1471,47 @@ export function ChatSettingsPanel({
return (
<aside
ref={asideRef}
data-tour="chat-settings"
data-slot="chat-settings-panel"
className={cn(
"relative z-50 shrink-0 overflow-hidden bg-panel-surface text-panel-surface-fg font-heading",
open ? "w-[17rem] border-l border-sidebar-border" : "w-0",
"relative z-50 shrink-0 bg-panel-surface text-panel-surface-fg font-heading",
open
? "w-(--chat-settings-width) border-l border-sidebar-border"
: "w-0 overflow-hidden",
)}
style={{
height: "calc(100% - var(--studio-custom-titlebar-height, 0px))",
marginTop: "var(--studio-custom-titlebar-height, 0px)",
}}
style={
{
"--chat-settings-width": `${settingsWidth}px`,
height: "calc(100% - var(--studio-custom-titlebar-height, 0px))",
marginTop: "var(--studio-custom-titlebar-height, 0px)",
} as CSSProperties
}
>
<div className="h-full w-full">{settingsContent}</div>
{open ? (
<PanelResizeHandle
edge="left"
open={open}
width={settingsWidth}
stored={settingsStored}
min={CHAT_SETTINGS_WIDTH_MIN}
max={settingsMax}
clamp={clampChatSettingsWidth}
setWidth={setSettingsWidth}
resetWidth={resetSettingsWidth}
onToggle={() => onOpenChange?.(!open)}
target={() => asideRef.current}
cssVar="--chat-settings-width"
measure={() => asideRef.current?.getBoundingClientRect().width ?? 0}
label={t("shell.aria.resizeRunSettings")}
toggleLabel={t("shell.aria.openRunSettings")}
collapseHint={t("shell.resize.collapse")}
expandHint={t("shell.resize.expand")}
dragHint={t("shell.resize.drag")}
dataSlot="chat-settings-resize-handle"
/>
) : null}
<div className="h-full w-full overflow-hidden">{settingsContent}</div>
</aside>
);
}

View file

@ -75,6 +75,7 @@ const PREFS_KEYS: string[] = [
// UI state
"sidebar_pinned",
"sidebar_width",
"chat_settings_width",
"unsloth_sidebar_navigate_open",
"unsloth_settings_active_tab",
// Chat runtime prefs

View file

@ -0,0 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createPanelWidthStore } from "./use-panel-width.ts";
/** The previous fixed 17rem, at a 16px root font size. */
export const CHAT_SETTINGS_WIDTH_DEFAULT = 272;
/** Below this the sliders and their value pills start colliding. */
export const CHAT_SETTINGS_WIDTH_MIN = 248;
export const CHAT_SETTINGS_WIDTH_MAX = 560;
const store = createPanelWidthStore({
key: "chat_settings_width",
min: CHAT_SETTINGS_WIDTH_MIN,
max: CHAT_SETTINGS_WIDTH_MAX,
fallback: CHAT_SETTINGS_WIDTH_DEFAULT,
});
export const clampChatSettingsWidth = store.clamp;
export const useChatSettingsWidth = store.useWidth;

View file

@ -1374,7 +1374,8 @@ html[data-chat-font] .aui-root {
html[data-panel-resizing]
:is(
[data-slot="sidebar-inner"],
[data-slot="sidebar-inset"]
[data-slot="sidebar-inset"],
[data-slot="chat-settings-panel"] > div
) {
pointer-events: none;
}

View file

@ -6,7 +6,7 @@ import test from "node:test";
import { readFile } from "node:fs/promises";
// Every localStorage key written by a panel width store.
const PANEL_WIDTH_KEYS = ["sidebar_width"];
const PANEL_WIDTH_KEYS = ["sidebar_width", "chat_settings_width"];
// The store reads window at import time, so stub it before importing.
const stubWindow = {