From 076c965723be8f2cd2ff561ddb183b8b989f7983 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:31:43 -0700 Subject: [PATCH] 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. --- .../src/features/chat/chat-settings-sheet.tsx | 100 +++++++++++++++--- .../features/settings/tabs/general-tab.tsx | 1 + .../src/hooks/use-chat-settings-width.ts | 20 ++++ studio/frontend/src/index.css | 3 +- studio/frontend/tests/sidebar-width.test.ts | 2 +- 5 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 studio/frontend/src/hooks/use-chat-settings-width.ts diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 6070bd2e40..fd41e558f9 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -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(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(null); const [systemPromptOverflows, setSystemPromptOverflows] = useState(false); + const promptObserverRef = useRef(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(null); const settingsContent = ( @@ -1124,7 +1162,7 @@ export function ChatSettingsPanel({ )} >