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`); + } +});