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:
parent
5b73c9c5b5
commit
003e947c18
20 changed files with 751 additions and 11 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
317
studio/frontend/src/components/ui/panel-resize-handle.tsx
Normal file
317
studio/frontend/src/components/ui/panel-resize-handle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
138
studio/frontend/src/hooks/use-panel-width.ts
Normal file
138
studio/frontend/src/hooks/use-panel-width.ts
Normal 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 };
|
||||
}
|
||||
21
studio/frontend/src/hooks/use-sidebar-width.ts
Normal file
21
studio/frontend/src/hooks/use-sidebar-width.ts
Normal 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;
|
||||
|
|
@ -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: "خيارات التدريب",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: "रन विकल्प",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: "実行オプション",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: "학습 옵션",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: "Параметры запуска",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: "训练选项",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
72
studio/frontend/tests/sidebar-width.test.ts
Normal file
72
studio/frontend/tests/sidebar-width.test.ts
Normal 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`);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue