Studio: make the sidebar width draggable (#7561)

* Studio: make the sidebar width draggable

The sidebar was locked at 17.5rem. Long chat titles truncated early with
no way to trade content width for sidebar width.

Adds a drag handle on the sidebar edge. Drag to resize between 264px and
480px (also capped at 40% of the window), click to collapse or expand,
arrow keys to nudge, Home to restore the default. The width persists in
localStorage next to the existing pin flag and syncs across tabs.

Dragging in stops at the minimum rather than collapsing, so an overshoot
while resizing cannot snap the sidebar shut.

The minimum is set by the header: the logo lockup and the search and
collapse buttons need ~258px. The wordmark now truncates instead of
letting the search icon ride over the logo when the UI font scale pushes
the lockup wider.

Resizing relayouts the whole shell, so the live width is painted straight
to the wrapper's custom property once per animation frame instead of on
every pointermove, and only committed to the store on release.

* Studio: address review on the draggable sidebar

Four fixes from the review:

Re-clamp on viewport change. The 40% window cap was only evaluated on
load and on an explicit set, so a stored 480px stayed 480px after the
window narrowed. The store now keeps the preference whole and derives an
effective width from it, recomputed on resize, so narrowing shrinks the
sidebar and widening restores the preference instead of discarding it.

Keep the DOM and the store in step when a drag does not commit. On
pointercancel, and on a collapsed-rail drag that never reached the
minimum, the live width was left painted on the wrapper without being
stored. Since the provider does not re-render, React never rewrote the
property and the next expand could render at the rail's 48px. Drag end
now hands the property back to the committed value; a commit re-renders
with the new width.

Feed the resized width to the custom titlebar. WindowTitlebar sits
outside the sidebar wrapper so it cannot inherit --sidebar-width, and it
was positioning its seam and drag region from a fixed 17.5rem. It reads
the same store now.

Mirror the handle for side="right". Placement, cursor, tooltip side and
the pointer delta all follow the configured side. Measuring the rail from
the sidebar container makes the start width side-agnostic too.

Adds unit tests for the clamp, including the viewport cap and the floor
winning when 40% falls below it.

* Studio: keyboard, aria and titlebar fixes for the sidebar edge

Three more from review, and the handle is extracted so the run settings
panel can reuse it.

Keyboard activation. The handle advertises collapse and expand in its
label, but that only ran from pointer-up, and a button's synthesized
click is swallowed by the tooltip trigger. Enter and Space now toggle,
and the outward arrow reopens a collapsed rail rather than returning
early, so a focused handle is not a dead end.

Announced maximum. aria-valuemax was the absolute 480 even when the 40%
viewport cap put the real limit lower, so a screen reader offered
adjustment that could not happen. The store now exposes the effective
maximum and recomputes it on resize.

Titlebar during a drag. The custom titlebar reads the committed store
value, so its seam sat still while the sidebar moved. The drag now
mirrors the live width onto the root for it to read, and clears it on
release.

The drag mechanics move to PanelResizeHandle and the store to a
createPanelWidthStore factory. Behaviour is unchanged; both exist so the
run settings panel gets the same edge without a second copy.

* Studio: keep the stored width when a drag is viewport-capped

Dragging outward while the 40% cap is active committed the capped value,
so a 480px preference became 320px on a narrow window and never came
back when the window widened again.

The drag now commits what the pointer asked for rather than what was
painted. setWidth still clamps to the absolute range, so a deliberate
inward drag is honoured as before; only the capped case stops writing a
smaller preference than the user chose.

* Studio: review fixes for the panel resize handle

Five more from review.

Stale width after a resize with nothing mounted. With no subscribers
there is no resize listener, so a resize on /login or /onboarding left
the cached width and cap stale, and returning to the app restored the
old width past the viewport cap. The store now recomputes when a
subscriber attaches.

Capped outward drags no longer lower the stored preference. The drag
starts from the effective width, so with 480 stored in a capped window a
small outward pull committed a smaller number and discarded the
preference for good. The commit and the outward arrow now leave it alone
when the panel is already pinned at the cap. A deliberate inward drag
still commits.

Keyboard focus was invisible. The app zeroes the native outline on
buttons, so a tabbed handle showed nothing at all. It now paints its line
on focus-visible and opens the hint.

Collapsed aria. The separator reported 264 as its current value while the
rail renders at 48 and may restore to something else entirely. The range
attributes are dropped when collapsed, leaving the label to describe it.

Localised copy. The tooltip is visible text and was hardcoded English in
all eleven locales. The strings are props now, supplied through the
translation layer, with keys added across every locale.

* Studio: support click activation and fix collapsed role

Two more from review.

Switch and voice control activate a control by dispatching a bare click
with no pointer or key events. Everything here hung off pointer-up or
keydown, so those users could not toggle the panel at all. There is now a
click path, guarded so the click the browser sends after a real pointer
release does not toggle a second time.

The guard is set when any sequence ends, not only on release: a cancelled
drag also ends without a toggle, and its click would otherwise collapse
the panel. The suite caught that on the first attempt.

A focusable separator is an adjustable widget and needs a current value.
Dropping the range attributes while collapsed left an invalid range
control, so it reports as a button when closed and a separator with a
value when open.

* Studio: only suppress the click after a real pointer sequence

endDrag doubles as the effect cleanup, so setting the guard there
unconditionally swallowed the first click from switch or voice control
when no drag had happened. It now only arms after a sequence that
actually started, whether it ended in a release or a cancel.

* Studio: do not arm the click guard on keyboard toggles

preventDefault cancels the native synthesized click, so nothing followed
to guard against and the flag stayed set. The next switch or voice
activation was then read as a duplicate and ignored.

* Make the resize handle click guard self-healing

A canceled drag emits no compatibility click, so the boolean guard stayed
armed and swallowed the next click from a switch or voice control. Record
when the pointer sequence ended instead and ignore only a click that lands
inside the browser's compatibility window.

* Clear the stored sidebar width on preference reset

Reset all local preferences dropped every other UI key but left
sidebar_width, so the reload restored the old width instead of the
default.

* Guard that persisted panel widths stay in the reset list

* Tighten the sidebar header actions and lower the width floor

The search and collapse buttons carried 8px of padding each side of a 16px
icon, so the pair read as one wide block. Narrow them to 28px and close the
gap to 1px, which brings the glyphs from 18px apart to 13px.

That frees room in the header lockup, so the drag floor drops from 264px to
260px. 260 is the narrowest width that leaves the wordmark unclipped in
Firefox, which renders it ~3px wider than Chromium and WebKit.
This commit is contained in:
Michael Han 2026-07-28 22:16:32 -07:00 committed by GitHub
commit 003e947c18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 751 additions and 11 deletions

View file

@ -1226,7 +1226,9 @@ export function AppSidebar() {
openNewChat(null);
}}
className={cn(
"flex items-center gap-[6px] select-none transition-opacity",
// min-w-0 so a narrow sidebar truncates the wordmark
// instead of pushing the search icon over the logo.
"flex min-w-0 items-center gap-[6px] select-none transition-opacity",
chatDisabled && "pointer-events-none opacity-50",
)}
aria-label={t("shell.aria.home")}
@ -1238,17 +1240,17 @@ export function AppSidebar() {
<img
src="/circle-logo-small.png"
alt="Unsloth"
className="h-[calc(26px+0.5rem*var(--ui-font-scale,1))] w-[calc(26px+0.5rem*var(--ui-font-scale,1))] rounded-full object-cover"
className="h-[calc(26px+0.5rem*var(--ui-font-scale,1))] w-[calc(26px+0.5rem*var(--ui-font-scale,1))] shrink-0 rounded-full object-cover"
/>
<span className="font-heading text-[calc(13px+0.5rem*var(--ui-font-scale,1))] font-semibold tracking-[0em] leading-none text-black dark:text-white dark:tracking-[0.02em]">
<span className="truncate font-heading text-[calc(13px+0.5rem*var(--ui-font-scale,1))] font-semibold tracking-[0em] leading-none text-black dark:text-white dark:tracking-[0.02em]">
unsloth
</span>
<span className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[calc(0.5rem*var(--ui-font-scale,1))] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
<span className="nav-badge ml-0.5 inline-flex shrink-0 items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[calc(0.5rem*var(--ui-font-scale,1))] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
{t("shell.beta")}
</span>
</Link>
)}
<div className="flex items-center gap-0.5">
<div className="flex shrink-0 items-center gap-0.25">
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
@ -1257,7 +1259,7 @@ export function AppSidebar() {
useChatSearchStore.getState().open();
closeMobileIfOpen();
}}
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="inline-flex h-[33px] w-[28px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t("shell.navigation.search")}
>
<HugeiconsIcon icon={Search01Icon} strokeWidth={1.75} className="size-icon" />
@ -1281,7 +1283,7 @@ export function AppSidebar() {
<button
type="button"
onClick={togglePinned}
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="inline-flex h-[33px] w-[28px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t("shell.aria.closeSidebar")}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
import { useSidebarWidth } from "@/hooks/use-sidebar-width";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import {
@ -110,9 +111,13 @@ export function WindowTitlebar({
const [enabled] = useState(shouldUseCustomWindowTitlebar);
const [maximized, setMaximized] = useState(false);
const { pinned, togglePinned } = useSidebarPin();
// The titlebar sits outside the sidebar wrapper, so it cannot inherit
// --sidebar-width. Read the resized width from the same store instead.
const { width } = useSidebarWidth();
const sidebarWidth = showSidebarSurface
? pinned
? "var(--studio-sidebar-expanded-width,17.5rem)"
? // The live value only exists mid-drag; otherwise the committed width.
`var(--studio-sidebar-live-width, ${width}px)`
: "var(--studio-sidebar-collapsed-width,3rem)"
: "0px";
const contentBorderLeft = pinned ? `calc(${sidebarWidth} + 12px)` : "0px";

View file

@ -0,0 +1,317 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { getClientPlatform } from "@/components/tauri/window-titlebar"
/** Pointer travel (px) below which a drag counts as a plain click. */
const DRAG_SLOP = 4
/** A compatibility click lands immediately after pointer-up. */
const CLICK_COMPAT_WINDOW_MS = 300
/** Arrow-key resize step for keyboard users. */
const RESIZE_STEP = 16
type DragState = {
startX: number
startWidth: number
moved: boolean
}
export type PanelResizeHandleProps = {
/** Which edge of the panel the handle sits on. */
edge: "left" | "right"
open: boolean
width: number
/** Uncapped stored preference, so a capped drag does not lower it. */
stored: number
min: number
max: number
clamp: (px: number) => number
setWidth: (px: number) => void
resetWidth: () => void
onToggle: () => void
/** Element to paint the live width onto, and the property to paint. */
target: () => HTMLElement | null
cssVar: string
/** Measured to start a drag from the rendered size when collapsed. */
measure: () => number
label: string
toggleLabel: string
/** Translated tooltip copy; the caller owns the translation layer. */
collapseHint: string
expandHint: string
dragHint: string
/** Shown in the tooltip when the panel has a toggle shortcut. */
shortcut?: string
dataSlot?: string
className?: string
/** Mirrors the live width onto :root for chrome outside the panel. */
rootVar?: string
}
/**
* A draggable panel edge: drag to resize, click to collapse or expand. Arrow
* keys resize, Home restores the default. The width is painted straight to the
* target while dragging and only persisted on release.
*/
export function PanelResizeHandle({
edge,
open,
width,
stored,
min,
max,
clamp,
setWidth,
resetWidth,
onToggle,
target,
cssVar,
measure,
label,
toggleLabel,
collapseHint,
expandHint,
dragHint,
shortcut,
dataSlot = "panel-resize-handle",
className,
rootVar,
}: PanelResizeHandleProps) {
const ref = React.useRef<HTMLButtonElement>(null)
const dragRef = React.useRef<DragState | null>(null)
const [dragging, setDragging] = React.useState(false)
const [hovered, setHovered] = React.useState(false)
const [focused, setFocused] = React.useState(false)
const [isMacPlatform] = React.useState(() => getClientPlatform().includes("mac"))
const hint = shortcut ? shortcut.replace("Mod", isMacPlatform ? "⌘" : "Ctrl+") : null
// Cached on pointer down so no DOM walk per move.
const targetRef = React.useRef<HTMLElement | null>(null)
const frameRef = React.useRef(0)
const pendingRef = React.useRef(0)
// What the pointer asked for, before the viewport cap. Committing the capped
// value instead would quietly downgrade a stored preference on a narrow window.
const rawRef = React.useRef(0)
// When a pointer sequence last ended. The browser's compatibility click
// lands in the same tick, so only a click that close behind is a duplicate.
// A timestamp cannot go stale the way an armed flag does: a genuine cancel
// emits no click, and a later assistive-tech click still gets through.
const handledAtRef = React.useRef(0)
const committedRef = React.useRef(width)
React.useEffect(() => {
committedRef.current = width
}, [width])
const paint = React.useCallback(
(value: string) => {
targetRef.current?.style.setProperty(cssVar, value)
if (rootVar) {
document.documentElement.style.setProperty(rootVar, value)
}
},
[cssVar, rootVar],
)
// Resizing relayouts the whole shell, and pointermove fires faster than the
// display refreshes, so coalesce to one paint per frame.
const paintWidth = React.useCallback(
(px: number) => {
pendingRef.current = px
if (frameRef.current) return
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0
paint(`${pendingRef.current}px`)
})
},
[paint],
)
const endDrag = React.useCallback(() => {
// Only a sequence that actually started can produce a compatibility click.
// This also runs as the effect cleanup, where no drag happened.
if (dragRef.current) handledAtRef.current = Date.now()
dragRef.current = null
if (frameRef.current) {
cancelAnimationFrame(frameRef.current)
frameRef.current = 0
}
// Hand the property back to the committed value. A commit re-renders with
// the new width; a cancel or a no-commit drag keeps DOM and store in step.
paint(`${committedRef.current}px`)
if (rootVar) document.documentElement.style.removeProperty(rootVar)
targetRef.current?.removeAttribute("data-resizing")
document.documentElement.removeAttribute("data-panel-resizing")
targetRef.current = null
setDragging(false)
document.body.style.removeProperty("cursor")
document.body.style.removeProperty("user-select")
}, [paint, rootVar])
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
targetRef.current = target()
// Collapsed: grow from the rendered size so the edge tracks the pointer.
const start = open ? width : measure()
dragRef.current = { startX: event.clientX, startWidth: start, moved: false }
pendingRef.current = start
rawRef.current = start
targetRef.current?.setAttribute("data-resizing", "true")
document.documentElement.setAttribute("data-panel-resizing", "true")
setDragging(true)
document.body.style.setProperty("cursor", "col-resize")
document.body.style.setProperty("user-select", "none")
}
const handlePointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current
if (!drag) return
// A panel whose handle is on its left edge grows as the pointer moves left.
const delta = (edge === "left" ? -1 : 1) * (event.clientX - drag.startX)
if (!drag.moved && Math.abs(delta) < DRAG_SLOP) return
drag.moved = true
const next = drag.startWidth + delta
rawRef.current = next
if (!open) {
// Past the minimum, dragging the collapsed edge reopens it.
if (next >= min) {
paintWidth(clamp(next))
onToggle()
}
return
}
// Dragging inward stops at the minimum. Collapsing is click or the shortcut.
paintWidth(clamp(next))
}
const handlePointerUp = (event: React.PointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current
if (!drag) return
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
endDrag()
if (!drag.moved) {
onToggle()
return
}
// A drag below the minimum leaves the stored width alone.
if (!open) return
// Capped: the visible edge is already at the cap, so an outward pull cannot
// express intent beyond it. Committing would silently lower the larger
// hidden preference. A deliberate inward drag still commits.
if (stored > max && rawRef.current >= max) return
// Commit what was asked for, not the capped paint, so a drag on a narrow
// window cannot shrink a larger stored preference. setWidth clamps.
setWidth(rawRef.current)
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
// The collapse/expand the label advertises, for keyboard users. Pointer-up
// handles it for the mouse; a synthesized click never reaches it.
if (event.key === "Enter" || event.key === " ") {
// preventDefault cancels the native click, so nothing follows to guard
// against; arming here would swallow the next assistive-tech click.
event.preventDefault()
onToggle()
return
}
const outward = edge === "left" ? "ArrowLeft" : "ArrowRight"
const inward = edge === "left" ? "ArrowRight" : "ArrowLeft"
if (event.key === outward || event.key === inward) {
event.preventDefault()
if (!open) {
// Collapsed there is nothing to resize, so the outward arrow reopens.
if (event.key === outward) onToggle()
return
}
if (event.key === outward && stored > max && width >= max) return
setWidth(width + (event.key === outward ? RESIZE_STEP : -RESIZE_STEP))
return
}
if (event.key === "Home") {
event.preventDefault()
resetWidth()
}
}
// Clear a stuck cursor override if we unmount mid-drag.
React.useEffect(() => endDrag, [endDrag])
return (
<Tooltip open={(hovered || focused) && !dragging}>
<TooltipTrigger asChild>
<button
ref={ref}
type="button"
data-slot={dataSlot}
data-dragging={dragging || undefined}
aria-label={open ? label : toggleLabel}
{...(open ? { "aria-orientation": "vertical" as const } : {})}
{...(open
? { "aria-valuenow": width, "aria-valuemin": min, "aria-valuemax": max }
: {})}
role={open ? "separator" : "button"}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={endDrag}
onKeyDown={handleKeyDown}
onClick={() => {
// Switch and voice control activate by dispatching a bare click
// with no pointer or key events, which nothing else here catches.
if (Date.now() - handledAtRef.current < CLICK_COMPAT_WINDOW_MS) return
onToggle()
}}
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
onFocus={(event) => setFocused(event.target.matches(":focus-visible"))}
onBlur={() => setFocused(false)}
className={cn(
"absolute inset-y-0 z-30 hidden w-2 touch-none select-none sm:block",
edge === "left" ? "-left-1" : "-right-1",
// `!` overrides the app-wide hand cursor on buttons.
open
? "cursor-col-resize!"
: edge === "left"
? "cursor-w-resize!"
: "cursor-e-resize!",
// Sits exactly on the panel border so hover recolours one line.
"after:absolute after:inset-y-0 after:w-px after:bg-transparent after:transition-colors after:duration-150",
edge === "left" ? "after:left-1" : "after:right-1",
"hover:after:bg-sidebar-ring/25 data-dragging:after:bg-sidebar-ring/25",
// The app zeroes the native outline on buttons, so mark focus here.
"focus-visible:outline-none focus-visible:after:bg-sidebar-ring/60",
className,
)}
/>
</TooltipTrigger>
<TooltipContent
side={edge === "left" ? "left" : "right"}
align="center"
className="tooltip-compact"
>
<span className="flex flex-col gap-px">
<span>
{open ? collapseHint : expandHint}
{hint ? ` ${hint}` : ""}
</span>
<span className="opacity-70">{dragHint}</span>
</span>
</TooltipContent>
</Tooltip>
)
}

View file

@ -24,13 +24,21 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelResizeHandle } from "@/components/ui/panel-resize-handle"
import { useT } from "@/i18n"
import { useIsMobile } from "@/hooks/use-mobile"
import {
SIDEBAR_WIDTH_DEFAULT,
SIDEBAR_WIDTH_MIN,
clampSidebarWidth,
useSidebarWidth,
} from "@/hooks/use-sidebar-width"
import { HugeiconsIcon } from "@hugeicons/react"
import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons"
const noop = () => {}
const SIDEBAR_WIDTH = "17.5rem"
const SIDEBAR_WIDTH = `${SIDEBAR_WIDTH_DEFAULT}px`
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@ -46,6 +54,11 @@ type SidebarContextProps = {
pinned: boolean
setPinned: (value: boolean) => void
togglePinned: () => void
width: number
storedWidth: number
maxWidth: number
setWidth: (value: number) => void
resetWidth: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
@ -80,6 +93,13 @@ function SidebarProvider({
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const {
width,
max: maxWidth,
stored: storedWidth,
setWidth,
resetWidth,
} = useSidebarWidth()
const prevIsMobileRef = React.useRef(isMobile)
React.useEffect(() => {
@ -163,8 +183,13 @@ function SidebarProvider({
pinned,
setPinned,
togglePinned,
width,
storedWidth,
maxWidth,
setWidth,
resetWidth,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned]
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned, width, storedWidth, maxWidth, setWidth, resetWidth]
)
return (
@ -173,7 +198,8 @@ function SidebarProvider({
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
// The drag handle writes this same property live while resizing.
"--sidebar-width": `${width}px`,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
@ -311,11 +337,64 @@ function Sidebar({
>
{children}
</div>
<SidebarResizeHandle side={side} />
</div>
</div>
)
}
/**
* The sidebar's draggable edge, over the shared panel handle.
*/
function SidebarResizeHandle({
className,
side = "left",
}: {
className?: string
side?: "left" | "right"
}) {
const { open, toggleSidebar, width, storedWidth, maxWidth, setWidth, resetWidth } =
useSidebar()
const ref = React.useRef<HTMLDivElement>(null)
const t = useT()
return (
<div ref={ref} className="contents">
<PanelResizeHandle
edge={side === "right" ? "left" : "right"}
open={open}
width={width}
stored={storedWidth}
min={SIDEBAR_WIDTH_MIN}
max={maxWidth}
clamp={clampSidebarWidth}
setWidth={setWidth}
resetWidth={resetWidth}
onToggle={toggleSidebar}
target={() =>
ref.current?.closest<HTMLElement>('[data-slot="sidebar-wrapper"]') ?? null
}
cssVar="--sidebar-width"
// The custom titlebar renders outside the wrapper and cannot inherit it.
rootVar="--studio-sidebar-live-width"
measure={() =>
ref.current
?.closest<HTMLElement>('[data-slot="sidebar-container"]')
?.getBoundingClientRect().width ?? SIDEBAR_WIDTH_MIN
}
label={t("shell.aria.resizeSidebar")}
toggleLabel={t("shell.aria.openSidebar")}
collapseHint={t("shell.resize.collapse")}
expandHint={t("shell.resize.expand")}
dragHint={t("shell.resize.drag")}
shortcut="ModB"
dataSlot="sidebar-resize-handle"
className={className}
/>
</div>
)
}
function SidebarTrigger({
className,
onClick,
@ -777,6 +856,7 @@ export {
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarResizeHandle,
SidebarSeparator,
SidebarTrigger,
useSidebar,

View file

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

View file

@ -0,0 +1,138 @@
// 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 { useCallback, useSyncExternalStore } from "react";
/** Never let one panel eat more than this share of a narrow window. */
const MAX_VIEWPORT_FRACTION = 0.4;
export type PanelWidthStore = {
/** Clamps to what the current viewport allows. */
clamp: (px: number) => number;
useWidth: () => {
width: number;
max: number;
/** The uncapped stored preference. */
stored: number;
setWidth: (value: number) => void;
resetWidth: () => void;
};
};
/**
* A persisted, viewport-aware width for a draggable panel. The preference is
* stored whole and an effective width is derived from it, so narrowing the
* window shrinks the panel without losing what the user picked.
*/
export function createPanelWidthStore({
key,
min,
max,
fallback,
}: {
key: string;
min: number;
max: number;
fallback: number;
}): PanelWidthStore {
function maxWidth(): number {
if (typeof window === "undefined") return max;
// The floor wins on a narrow window; collapsing is the escape.
return Math.max(min, Math.min(max, window.innerWidth * MAX_VIEWPORT_FRACTION));
}
/** Clamps to the absolute range, ignoring the viewport. */
function clampStored(px: number): number {
if (!Number.isFinite(px)) return fallback;
return Math.min(max, Math.max(min, Math.round(px)));
}
function clamp(px: number): number {
return Math.min(maxWidth(), clampStored(px));
}
function load(): number {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
if (raw === null) return fallback;
return clampStored(Number.parseFloat(raw));
} catch {
return fallback;
}
}
let storedWidth = load();
let effectiveWidth = clamp(storedWidth);
let effectiveMax = maxWidth();
const listeners = new Set<() => void>();
let lastStored = storedWidth;
function recompute() {
const nextWidth = clamp(storedWidth);
const nextMax = maxWidth();
if (
nextWidth === effectiveWidth &&
nextMax === effectiveMax &&
storedWidth === lastStored
) {
return;
}
effectiveWidth = nextWidth;
effectiveMax = nextMax;
lastStored = storedWidth;
listeners.forEach((cb) => cb());
}
function subscribe(cb: () => void) {
// With no subscribers there is no resize listener, so the cache can be
// stale after a resize on a route that hides every panel. Refresh first;
// useSyncExternalStore re-reads the snapshot right after subscribing.
recompute();
listeners.add(cb);
if (typeof window === "undefined") {
return () => listeners.delete(cb);
}
// Keep tabs in sync, same as the pin flag.
const onStorage = (e: StorageEvent) => {
if (e.key === key || e.key === null) {
storedWidth = load();
effectiveWidth = clamp(storedWidth);
effectiveMax = maxWidth();
cb();
}
};
window.addEventListener("storage", onStorage);
window.addEventListener("resize", recompute);
return () => {
listeners.delete(cb);
window.removeEventListener("storage", onStorage);
window.removeEventListener("resize", recompute);
};
}
function setWidthGlobal(next: number) {
const stored = clampStored(next);
if (stored !== storedWidth) {
storedWidth = stored;
try {
window.localStorage.setItem(key, String(stored));
} catch {}
}
recompute();
}
function useWidth() {
const width = useSyncExternalStore(subscribe, () => effectiveWidth, () => fallback);
// What the viewport actually allows right now, for aria-valuemax.
const panelMax = useSyncExternalStore(subscribe, () => effectiveMax, () => max);
// The uncapped preference, so a capped drag can avoid lowering it.
const preference = useSyncExternalStore(subscribe, () => storedWidth, () => fallback);
const setWidth = useCallback((value: number) => setWidthGlobal(value), []);
const resetWidth = useCallback(() => setWidthGlobal(fallback), []);
return { width, max: panelMax, stored: preference, setWidth, resetWidth };
}
return { clamp, useWidth };
}

View file

@ -0,0 +1,21 @@
// 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 17.5rem, at a 16px root font size. */
export const SIDEBAR_WIDTH_DEFAULT = 280;
/** Narrowest width that still fits the wordmark. Firefox is the constraint:
* it renders the heading ~3px wider than Chromium and WebKit. */
export const SIDEBAR_WIDTH_MIN = 260;
export const SIDEBAR_WIDTH_MAX = 480;
const store = createPanelWidthStore({
key: "sidebar_width",
min: SIDEBAR_WIDTH_MIN,
max: SIDEBAR_WIDTH_MAX,
fallback: SIDEBAR_WIDTH_DEFAULT,
});
export const clampSidebarWidth = store.clamp;
export const useSidebarWidth = store.useWidth;

View file

@ -27,10 +27,18 @@ export const ar = {
product: "Unsloth Studio",
accountMenu: "قائمة حساب {name}",
updateAvailable: "يتوفر تحديث",
resize: {
collapse: "انقر للطي",
expand: "انقر للتوسيع",
drag: "اسحب لتغيير الحجم",
},
aria: {
home: "الصفحة الرئيسية لـ Unsloth",
closeSidebar: "إغلاق الشريط الجانبي",
openSidebar: "فتح الشريط الجانبي",
resizeSidebar: "تغيير حجم الشريط الجانبي أو طيه",
resizeRunSettings: "تغيير حجم إعدادات التشغيل أو إغلاقها",
openRunSettings: "فتح إعدادات التشغيل",
chatOptions: "خيارات المحادثة",
runOptions: "خيارات التدريب",
},

View file

@ -27,10 +27,18 @@ export const de = {
product: "Unsloth Studio",
accountMenu: "Kontomenü von {name}",
updateAvailable: "Update verfügbar",
resize: {
collapse: "Zum Einklappen klicken",
expand: "Zum Ausklappen klicken",
drag: "Zum Ändern der Größe ziehen",
},
aria: {
home: "Unsloth Startseite",
closeSidebar: "Seitenleiste schließen",
openSidebar: "Seitenleiste öffnen",
resizeSidebar: "Seitenleiste anpassen oder einklappen",
resizeRunSettings: "Ausführungseinstellungen anpassen oder schließen",
openRunSettings: "Ausführungseinstellungen öffnen",
chatOptions: "Chat-Optionen",
runOptions: "Trainingslauf-Optionen",
},

View file

@ -24,10 +24,18 @@ export const en = {
product: "Unsloth Studio",
accountMenu: "{name} account menu",
updateAvailable: "Update available",
resize: {
collapse: "Click to collapse",
expand: "Click to expand",
drag: "Drag to resize",
},
aria: {
home: "Unsloth home",
closeSidebar: "Close sidebar",
openSidebar: "Open sidebar",
resizeSidebar: "Resize or collapse sidebar",
resizeRunSettings: "Resize or close run settings",
openRunSettings: "Open run settings",
chatOptions: "Chat options",
runOptions: "Run options",
},

View file

@ -27,10 +27,18 @@ export const es = {
product: "Unsloth Studio",
accountMenu: "Menú de cuenta de {name}",
updateAvailable: "Actualización disponible",
resize: {
collapse: "Haz clic para contraer",
expand: "Haz clic para expandir",
drag: "Arrastra para redimensionar",
},
aria: {
home: "Inicio de Unsloth",
closeSidebar: "Cerrar barra lateral",
openSidebar: "Abrir barra lateral",
resizeSidebar: "Redimensionar o contraer la barra lateral",
resizeRunSettings: "Redimensionar o cerrar los ajustes de ejecución",
openRunSettings: "Abrir los ajustes de ejecución",
chatOptions: "Opciones de chat",
runOptions: "Opciones de ejecución",
},

View file

@ -27,10 +27,18 @@ export const fr = {
product: "Unsloth Studio",
accountMenu: "Menu du compte de {name}",
updateAvailable: "Mise à jour disponible",
resize: {
collapse: "Cliquez pour réduire",
expand: "Cliquez pour développer",
drag: "Faites glisser pour redimensionner",
},
aria: {
home: "Accueil Unsloth",
closeSidebar: "Fermer la barre latérale",
openSidebar: "Ouvrir la barre latérale",
resizeSidebar: "Redimensionner ou réduire la barre latérale",
resizeRunSettings: "Redimensionner ou fermer les paramètres d'exécution",
openRunSettings: "Ouvrir les paramètres d'exécution",
chatOptions: "Options de discussion",
runOptions: "Options d'exécution",
},

View file

@ -27,10 +27,18 @@ export const hi = {
product: "Unsloth Studio",
accountMenu: "{name} खाता मेनू",
updateAvailable: "अपडेट उपलब्ध है",
resize: {
collapse: "छोटा करने के लिए क्लिक करें",
expand: "विस्तार के लिए क्लिक करें",
drag: "आकार बदलने के लिए खींचें",
},
aria: {
home: "Unsloth होम",
closeSidebar: "साइडबार बंद करें",
openSidebar: "साइडबार खोलें",
resizeSidebar: "साइडबार का आकार बदलें या छोटा करें",
resizeRunSettings: "रन सेटिंग्स का आकार बदलें या बंद करें",
openRunSettings: "रन सेटिंग्स खोलें",
chatOptions: "चैट विकल्प",
runOptions: "रन विकल्प",
},

View file

@ -28,10 +28,18 @@ export const ja = {
product: "Unsloth Studio",
accountMenu: "{name} のアカウントメニュー",
updateAvailable: "アップデートが利用可能です",
resize: {
collapse: "クリックで折りたたむ",
expand: "クリックで展開",
drag: "ドラッグでサイズ変更",
},
aria: {
home: "Unsloth ホーム",
closeSidebar: "サイドバーを閉じる",
openSidebar: "サイドバーを開く",
resizeSidebar: "サイドバーのサイズ変更または折りたたみ",
resizeRunSettings: "実行設定のサイズ変更または閉じる",
openRunSettings: "実行設定を開く",
chatOptions: "チャットオプション",
runOptions: "実行オプション",
},

View file

@ -27,10 +27,18 @@ export const ko = {
product: "Unsloth Studio",
accountMenu: "{name} 계정 메뉴",
updateAvailable: "업데이트 사용 가능",
resize: {
collapse: "클릭하여 접기",
expand: "클릭하여 펼치기",
drag: "드래그하여 크기 조절",
},
aria: {
home: "Unsloth 홈",
closeSidebar: "사이드바 닫기",
openSidebar: "사이드바 열기",
resizeSidebar: "사이드바 크기 조절 또는 접기",
resizeRunSettings: "실행 설정 크기 조절 또는 닫기",
openRunSettings: "실행 설정 열기",
chatOptions: "채팅 옵션",
runOptions: "학습 옵션",
},

View file

@ -27,10 +27,18 @@ export const ptBR = {
product: "Unsloth Studio",
accountMenu: "Menu de conta {name}",
updateAvailable: "Atualização disponível",
resize: {
collapse: "Clique para recolher",
expand: "Clique para expandir",
drag: "Arraste para redimensionar",
},
aria: {
home: "Início do Unsloth",
closeSidebar: "Fechar barra lateral",
openSidebar: "Abrir barra lateral",
resizeSidebar: "Redimensionar ou recolher a barra lateral",
resizeRunSettings: "Redimensionar ou fechar as configurações de execução",
openRunSettings: "Abrir as configurações de execução",
chatOptions: "Opções de chat",
runOptions: "Opções de execução",
},

View file

@ -27,10 +27,18 @@ export const ru = {
product: "Unsloth Studio",
accountMenu: "Меню аккаунта {name}",
updateAvailable: "Доступно обновление",
resize: {
collapse: "Нажмите, чтобы свернуть",
expand: "Нажмите, чтобы развернуть",
drag: "Потяните, чтобы изменить размер",
},
aria: {
home: "Главная Unsloth",
closeSidebar: "Закрыть боковую панель",
openSidebar: "Открыть боковую панель",
resizeSidebar: "Изменить размер или свернуть боковую панель",
resizeRunSettings: "Изменить размер или закрыть настройки запуска",
openRunSettings: "Открыть настройки запуска",
chatOptions: "Параметры чата",
runOptions: "Параметры запуска",
},

View file

@ -27,10 +27,18 @@ export const zhCN = {
product: "Unsloth Studio",
accountMenu: "{name} 账号菜单",
updateAvailable: "有可用更新",
resize: {
collapse: "点击折叠",
expand: "点击展开",
drag: "拖动调整大小",
},
aria: {
home: "Unsloth 首页",
closeSidebar: "关闭侧边栏",
openSidebar: "打开侧边栏",
resizeSidebar: "调整或折叠侧边栏",
resizeRunSettings: "调整或关闭运行设置",
openRunSettings: "打开运行设置",
chatOptions: "聊天选项",
runOptions: "训练选项",
},

View file

@ -1363,6 +1363,22 @@ html[data-chat-font] .aui-root {
cursor: pointer;
}
/* While a panel edge is dragged, keep the resize cursor even as the pointer
travels over buttons and text that would claim their own. */
html[data-panel-resizing],
html[data-panel-resizing] * {
cursor: col-resize !important;
user-select: none !important;
}
html[data-panel-resizing]
:is(
[data-slot="sidebar-inner"],
[data-slot="sidebar-inset"]
) {
pointer-events: none;
}
/* Model selector: pointer cursor on every clickable element. */
.unsloth-model-selector-trigger,
.unsloth-model-selector-menu button {

View file

@ -0,0 +1,72 @@
// 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 assert from "node:assert/strict";
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"];
// The store reads window at import time, so stub it before importing.
const stubWindow = {
innerWidth: 1440,
localStorage: {
getItem: () => null,
setItem: () => {},
},
addEventListener: () => {},
removeEventListener: () => {},
};
(globalThis as { window?: unknown }).window = stubWindow;
const {
clampSidebarWidth,
SIDEBAR_WIDTH_DEFAULT,
SIDEBAR_WIDTH_MAX,
SIDEBAR_WIDTH_MIN,
} = await import("../src/hooks/use-sidebar-width.ts");
test("clamps to the absolute range on a roomy window", () => {
stubWindow.innerWidth = 1440;
assert.equal(clampSidebarWidth(320), 320);
assert.equal(clampSidebarWidth(SIDEBAR_WIDTH_MAX + 200), SIDEBAR_WIDTH_MAX);
assert.equal(clampSidebarWidth(10), SIDEBAR_WIDTH_MIN);
assert.equal(clampSidebarWidth(Number.NaN), SIDEBAR_WIDTH_DEFAULT);
});
test("caps at 40% of a narrow window", () => {
stubWindow.innerWidth = 800;
assert.equal(clampSidebarWidth(SIDEBAR_WIDTH_MAX), 320);
assert.equal(clampSidebarWidth(300), 300);
});
test("the floor still wins when 40% falls below it", () => {
stubWindow.innerWidth = 500;
assert.equal(clampSidebarWidth(SIDEBAR_WIDTH_MAX), SIDEBAR_WIDTH_MIN);
});
test("re-evaluates the cap per call, so a resize can re-clamp", () => {
stubWindow.innerWidth = 1440;
assert.equal(clampSidebarWidth(SIDEBAR_WIDTH_MAX), SIDEBAR_WIDTH_MAX);
stubWindow.innerWidth = 900;
assert.equal(clampSidebarWidth(SIDEBAR_WIDTH_MAX), 360);
stubWindow.innerWidth = 1440;
assert.equal(clampSidebarWidth(SIDEBAR_WIDTH_MAX), SIDEBAR_WIDTH_MAX);
});
// The reset action promises to clear every stored preference, so a persisted
// panel width that is missing from the list survives the reload.
test("persisted panel widths are cleared by the preference reset", async () => {
const source = await readFile(
new URL("../src/features/settings/tabs/general-tab.tsx", import.meta.url),
"utf8",
);
const keys = source.slice(
source.indexOf("const PREFS_KEYS"),
source.indexOf("];", source.indexOf("const PREFS_KEYS")),
);
for (const key of PANEL_WIDTH_KEYS) {
assert.ok(keys.includes(`"${key}"`), `${key} missing from PREFS_KEYS`);
}
});