From 003e947c18368c55f0e7257763448638a295077a Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:16:32 -0700 Subject: [PATCH] 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. --- .../frontend/src/components/app-sidebar.tsx | 16 +- .../src/components/tauri/window-titlebar.tsx | 7 +- .../src/components/ui/panel-resize-handle.tsx | 317 ++++++++++++++++++ studio/frontend/src/components/ui/sidebar.tsx | 86 ++++- .../features/settings/tabs/general-tab.tsx | 1 + studio/frontend/src/hooks/use-panel-width.ts | 138 ++++++++ .../frontend/src/hooks/use-sidebar-width.ts | 21 ++ studio/frontend/src/i18n/locales/ar.ts | 8 + studio/frontend/src/i18n/locales/de.ts | 8 + studio/frontend/src/i18n/locales/en.ts | 8 + studio/frontend/src/i18n/locales/es.ts | 8 + studio/frontend/src/i18n/locales/fr.ts | 8 + studio/frontend/src/i18n/locales/hi.ts | 8 + studio/frontend/src/i18n/locales/ja.ts | 8 + studio/frontend/src/i18n/locales/ko.ts | 8 + studio/frontend/src/i18n/locales/pt-br.ts | 8 + studio/frontend/src/i18n/locales/ru.ts | 8 + studio/frontend/src/i18n/locales/zh-CN.ts | 8 + studio/frontend/src/index.css | 16 + studio/frontend/tests/sidebar-width.test.ts | 72 ++++ 20 files changed, 751 insertions(+), 11 deletions(-) create mode 100644 studio/frontend/src/components/ui/panel-resize-handle.tsx create mode 100644 studio/frontend/src/hooks/use-panel-width.ts create mode 100644 studio/frontend/src/hooks/use-sidebar-width.ts create mode 100644 studio/frontend/tests/sidebar-width.test.ts diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index d263a6a739..849460dc7d 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -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() { Unsloth - + unsloth - + {t("shell.beta")} )} -
+
+
) } +/** + * 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(null) + const t = useT() + + return ( +
+ + ref.current?.closest('[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('[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} + /> +
+ ) +} + function SidebarTrigger({ className, onClick, @@ -777,6 +856,7 @@ export { SidebarMenuSubItem, SidebarProvider, SidebarRail, + SidebarResizeHandle, SidebarSeparator, SidebarTrigger, useSidebar, diff --git a/studio/frontend/src/features/settings/tabs/general-tab.tsx b/studio/frontend/src/features/settings/tabs/general-tab.tsx index 1d5c533a25..0ea7b21945 100644 --- a/studio/frontend/src/features/settings/tabs/general-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/general-tab.tsx @@ -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 diff --git a/studio/frontend/src/hooks/use-panel-width.ts b/studio/frontend/src/hooks/use-panel-width.ts new file mode 100644 index 0000000000..d753c3a1bb --- /dev/null +++ b/studio/frontend/src/hooks/use-panel-width.ts @@ -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 }; +} diff --git a/studio/frontend/src/hooks/use-sidebar-width.ts b/studio/frontend/src/hooks/use-sidebar-width.ts new file mode 100644 index 0000000000..c670e254a8 --- /dev/null +++ b/studio/frontend/src/hooks/use-sidebar-width.ts @@ -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; diff --git a/studio/frontend/src/i18n/locales/ar.ts b/studio/frontend/src/i18n/locales/ar.ts index e5c709de60..a0ea2a1ee2 100644 --- a/studio/frontend/src/i18n/locales/ar.ts +++ b/studio/frontend/src/i18n/locales/ar.ts @@ -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: "خيارات التدريب", }, diff --git a/studio/frontend/src/i18n/locales/de.ts b/studio/frontend/src/i18n/locales/de.ts index a0ea20999c..8a5921c623 100644 --- a/studio/frontend/src/i18n/locales/de.ts +++ b/studio/frontend/src/i18n/locales/de.ts @@ -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", }, diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index 88baa480a5..10e571f4da 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -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", }, diff --git a/studio/frontend/src/i18n/locales/es.ts b/studio/frontend/src/i18n/locales/es.ts index 713417ab27..6edc33f9de 100644 --- a/studio/frontend/src/i18n/locales/es.ts +++ b/studio/frontend/src/i18n/locales/es.ts @@ -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", }, diff --git a/studio/frontend/src/i18n/locales/fr.ts b/studio/frontend/src/i18n/locales/fr.ts index d271587406..789e89079c 100644 --- a/studio/frontend/src/i18n/locales/fr.ts +++ b/studio/frontend/src/i18n/locales/fr.ts @@ -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", }, diff --git a/studio/frontend/src/i18n/locales/hi.ts b/studio/frontend/src/i18n/locales/hi.ts index e7ac8863a6..410318983b 100644 --- a/studio/frontend/src/i18n/locales/hi.ts +++ b/studio/frontend/src/i18n/locales/hi.ts @@ -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: "रन विकल्प", }, diff --git a/studio/frontend/src/i18n/locales/ja.ts b/studio/frontend/src/i18n/locales/ja.ts index 01c012109c..1653bad76e 100644 --- a/studio/frontend/src/i18n/locales/ja.ts +++ b/studio/frontend/src/i18n/locales/ja.ts @@ -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: "実行オプション", }, diff --git a/studio/frontend/src/i18n/locales/ko.ts b/studio/frontend/src/i18n/locales/ko.ts index dfca8bfa8c..b0da314896 100644 --- a/studio/frontend/src/i18n/locales/ko.ts +++ b/studio/frontend/src/i18n/locales/ko.ts @@ -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: "학습 옵션", }, diff --git a/studio/frontend/src/i18n/locales/pt-br.ts b/studio/frontend/src/i18n/locales/pt-br.ts index 1f0df15666..e9f23623f8 100644 --- a/studio/frontend/src/i18n/locales/pt-br.ts +++ b/studio/frontend/src/i18n/locales/pt-br.ts @@ -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", }, diff --git a/studio/frontend/src/i18n/locales/ru.ts b/studio/frontend/src/i18n/locales/ru.ts index 798d640e65..364f224802 100644 --- a/studio/frontend/src/i18n/locales/ru.ts +++ b/studio/frontend/src/i18n/locales/ru.ts @@ -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: "Параметры запуска", }, diff --git a/studio/frontend/src/i18n/locales/zh-CN.ts b/studio/frontend/src/i18n/locales/zh-CN.ts index 4292b2a394..777bfbc4dd 100644 --- a/studio/frontend/src/i18n/locales/zh-CN.ts +++ b/studio/frontend/src/i18n/locales/zh-CN.ts @@ -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: "训练选项", }, diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index 4797401ead..87c7e56e92 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -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 { diff --git a/studio/frontend/tests/sidebar-width.test.ts b/studio/frontend/tests/sidebar-width.test.ts new file mode 100644 index 0000000000..2e0a3b6f6b --- /dev/null +++ b/studio/frontend/tests/sidebar-width.test.ts @@ -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`); + } +});