+ LLMs can make mistakes. Double-check all responses.
+
+
+
+
+ )}
);
};
const ThreadScrollToBottom: FC = () => {
+ // Scoped to the nearest ThreadPrimitive.Root via context, so in compare
+ // mode each pane reads its own viewport state.
+ //
+ // The button stays mounted and toggles visibility via CSS. Conditionally
+ // rendering (return null) unmounts a DOM node inside the viewport, which
+ // the assistant-ui autoscroll hook's MutationObserver sees as a content
+ // change — during streaming that triggered spurious scroll-to-bottom
+ // calls, especially in the narrower mobile stacked layout.
+ const isAtBottom = useThreadViewport((vp) => vp.isAtBottom);
return (
@@ -199,7 +232,7 @@ const SuggestionItem: FC = () => {
const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
return (
- If that fails or unsloth studio update is unavailable, run:
-
-
-
-
-
-
-
- Restart Studio after updating for changes to take effect.
-
-
- );
-}
-
-function getTourId(pathname: string): "studio" | "chat" | "export" | null {
- if (pathname === "/studio") return "studio";
- if (pathname === "/chat") return "chat";
- if (pathname === "/export") return "export";
- return null;
-}
+import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
export function Navbar() {
- const pathname = useRouterState({ select: (s) => s.location.pathname });
- const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
- const [mobileOpen, setMobileOpen] = useState(false);
- const [mobileUpdateOpen, setMobileUpdateOpen] = useState(false);
- const [shutdownOpen, setShutdownOpen] = useState(false);
-
- const deviceType = usePlatformStore((s) => s.deviceType);
- const chatOnly = usePlatformStore((s) => s.isChatOnly());
- const defaultUpdateShell = getDefaultUpdateShell(deviceType);
-
- // Warn before closing the tab only when training is running (data loss risk).
- // We store the handler in a ref so removeUnloadHandler() can clean it up
- // before the "Server stopped" page renders.
- const unloadHandlerRef = useRef<((e: BeforeUnloadEvent) => void) | null>(null);
-
- useEffect(() => {
- const handler = (e: BeforeUnloadEvent) => {
- if (!useTrainingRuntimeStore.getState().isTrainingRunning) return;
- e.preventDefault();
- e.returnValue = "";
- };
- unloadHandlerRef.current = handler;
- window.addEventListener("beforeunload", handler);
- return () => {
- window.removeEventListener("beforeunload", handler);
- };
- }, []);
-
- const removeUnloadHandler = () => {
- if (unloadHandlerRef.current) {
- window.removeEventListener("beforeunload", unloadHandlerRef.current);
- unloadHandlerRef.current = null;
- }
- };
-
- const tourId = getTourId(pathname);
-
- const openTour = () => {
- if (!tourId) return;
- window.dispatchEvent(
- new CustomEvent(TOUR_OPEN_EVENT, { detail: { id: tourId } }),
+ const { isMobile } = useSidebar();
+ if (!isMobile) {
+ return (
+
);
- };
-
+ }
return (
- <>
-
-
- {/* Left: logo */}
-
-
-
-
- BETA
-
-
-
- {/* Center: pill nav */}
-
-
- {/* Right: docs/tour desktop — one wrapper per control so flex gap is even (HoverCard roots can confuse flex spacing). */}
-
-
-
- >
);
}
diff --git a/studio/frontend/src/components/shutdown-dialog.tsx b/studio/frontend/src/components/shutdown-dialog.tsx
index dea738bf6b..dfeafb33eb 100644
--- a/studio/frontend/src/components/shutdown-dialog.tsx
+++ b/studio/frontend/src/components/shutdown-dialog.tsx
@@ -18,16 +18,17 @@ import {
interface ShutdownDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
- /** Called right before the shutdown API request so callers can remove the
- * beforeunload listener — otherwise the "Server stopped" page would still
- * trigger a "Leave site?" prompt when the user tries to close it. */
- onBeforeShutdown?: () => void;
+ /** Called after the shutdown API returns success, right before we replace
+ * document.body with the "Server stopped" page. Callers use this to remove
+ * their beforeunload listener — otherwise the browser would prompt
+ * "Leave site?" when the user tries to close the final tab. */
+ onAfterShutdown?: () => void;
}
export function ShutdownDialog({
open,
onOpenChange,
- onBeforeShutdown,
+ onAfterShutdown,
}: ShutdownDialogProps) {
const [stopping, setStopping] = useState(false);
@@ -49,7 +50,7 @@ export function ShutdownDialog({
return;
}
- onBeforeShutdown?.();
+ onAfterShutdown?.();
document.body.innerHTML = `
Unsloth Studio has stopped.
diff --git a/studio/frontend/src/components/ui/animated-theme-toggler.tsx b/studio/frontend/src/components/ui/animated-theme-toggler.tsx
index 24f3c68ec9..d83e278401 100644
--- a/studio/frontend/src/components/ui/animated-theme-toggler.tsx
+++ b/studio/frontend/src/components/ui/animated-theme-toggler.tsx
@@ -6,11 +6,73 @@ import { Moon, Sun } from "lucide-react"
import { flushSync } from "react-dom"
import { cn } from "@/lib/utils"
+import { setTheme } from "@/features/settings/stores/theme-store"
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
duration?: number
}
+export function useAnimatedThemeToggle(duration = 400) {
+ const [isDark, setIsDark] = useState(false)
+ const anchorRef = useRef(null)
+
+ useEffect(() => {
+ const updateTheme = () => {
+ setIsDark(document.documentElement.classList.contains("dark"))
+ }
+ updateTheme()
+ const observer = new MutationObserver(updateTheme)
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class"],
+ })
+ return () => observer.disconnect()
+ }, [])
+
+ const toggleTheme = useCallback(async () => {
+ const anchor = anchorRef.current
+ const applyTheme = () => {
+ flushSync(() => {
+ const newTheme = !isDark
+ setIsDark(newTheme)
+ setTheme(newTheme ? "dark" : "light")
+ })
+ }
+
+ if (!document.startViewTransition) {
+ applyTheme()
+ return
+ }
+
+ await document.startViewTransition(applyTheme).ready
+
+ if (anchor) {
+ const { top, left, width, height } = anchor.getBoundingClientRect()
+ const x = left + width / 2
+ const y = top + height / 2
+ const maxRadius = Math.hypot(
+ Math.max(left, window.innerWidth - left),
+ Math.max(top, window.innerHeight - top)
+ )
+ document.documentElement.animate(
+ {
+ clipPath: [
+ `circle(0px at ${x}px ${y}px)`,
+ `circle(${maxRadius}px at ${x}px ${y}px)`,
+ ],
+ },
+ {
+ duration,
+ easing: "ease-in-out",
+ pseudoElement: "::view-transition-new(root)",
+ }
+ )
+ }
+ }, [isDark, duration])
+
+ return { isDark, toggleTheme, anchorRef }
+}
+
export const AnimatedThemeToggler = ({
className,
duration = 400,
@@ -38,14 +100,20 @@ export const AnimatedThemeToggler = ({
const toggleTheme = useCallback(async () => {
if (!buttonRef.current) return
- await document.startViewTransition(() => {
+ const apply = () => {
flushSync(() => {
const newTheme = !isDark
setIsDark(newTheme)
- document.documentElement.classList.toggle("dark")
- localStorage.setItem("theme", newTheme ? "dark" : "light")
+ setTheme(newTheme ? "dark" : "light")
})
- }).ready
+ }
+
+ if (!document.startViewTransition) {
+ apply()
+ return
+ }
+
+ await document.startViewTransition(apply).ready
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect()
diff --git a/studio/frontend/src/components/ui/command.tsx b/studio/frontend/src/components/ui/command.tsx
index 6181d55ea1..a2340b62ed 100644
--- a/studio/frontend/src/components/ui/command.tsx
+++ b/studio/frontend/src/components/ui/command.tsx
@@ -1,6 +1,6 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-
+// 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 { Command as CommandPrimitive } from "cmdk";
@@ -39,12 +39,14 @@ function CommandDialog({
description = "Search for a command to run...",
children,
className,
+ overlayClassName,
showCloseButton = false,
...props
}: React.ComponentProps & {
title?: string;
description?: string;
className?: string;
+ overlayClassName?: string;
showCloseButton?: boolean;
}) {
return (
@@ -55,9 +57,10 @@ function CommandDialog({
{children}
diff --git a/studio/frontend/src/components/ui/sidebar.tsx b/studio/frontend/src/components/ui/sidebar.tsx
index 898972f1ee..967b0dcaac 100644
--- a/studio/frontend/src/components/ui/sidebar.tsx
+++ b/studio/frontend/src/components/ui/sidebar.tsx
@@ -1,6 +1,6 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-
+// 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"
@@ -26,12 +26,11 @@ import {
} from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/use-mobile"
import { HugeiconsIcon } from "@hugeicons/react"
-import { SidebarLeftIcon } from "@hugeicons/core-free-icons"
+import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons"
+
+const noop = () => {}
-const SIDEBAR_COOKIE_NAME = "sidebar_state"
-const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
-const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@@ -43,6 +42,10 @@ type SidebarContextProps = {
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
+ hasPinMode: boolean
+ pinned: boolean
+ setPinned: (value: boolean) => void
+ togglePinned: () => void
}
const SidebarContext = React.createContext(null)
@@ -60,6 +63,9 @@ function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
+ pinned: pinnedProp,
+ setPinned: setPinnedProp,
+ togglePinned: togglePinnedProp,
className,
style,
children,
@@ -68,33 +74,57 @@ function SidebarProvider({
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
+ pinned?: boolean
+ setPinned?: (value: boolean) => void
+ togglePinned?: () => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
+ const prevIsMobileRef = React.useRef(isMobile)
+ React.useEffect(() => {
+ if (prevIsMobileRef.current && !isMobile) {
+ setOpenMobile(false)
+ }
+ prevIsMobileRef.current = isMobile
+ }, [isMobile])
+
+ // Whether pin mode is active (caller provides pinned + setPinned + togglePinned).
+ const hasPinMode = pinnedProp !== undefined && setPinnedProp !== undefined && togglePinnedProp !== undefined
+
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
- const open = openProp ?? _open
+
+ // When pin mode is active, open is driven entirely by `pinned` (explicit
+ // user toggle). Otherwise fall back to the controlled/uncontrolled pattern.
+ const open = hasPinMode ? !!pinnedProp : (openProp ?? _open)
+
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
+
+ if (hasPinMode) {
+ // In pin mode, setOpen controls pinned state.
+ setPinnedProp?.(openState)
+ return
+ }
+
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
-
- // This sets the cookie to keep the sidebar state.
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
- [setOpenProp, open]
+ [setOpenProp, open, hasPinMode, setPinnedProp]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
- return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
- }, [isMobile, setOpen, setOpenMobile])
+ if (isMobile) return setOpenMobile((open) => !open)
+ if (hasPinMode && togglePinnedProp) return togglePinnedProp()
+ return setOpen((open) => !open)
+ }, [isMobile, setOpen, setOpenMobile, hasPinMode, togglePinnedProp])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@@ -116,6 +146,10 @@ function SidebarProvider({
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
+ const pinned = pinnedProp ?? false
+ const setPinned = setPinnedProp ?? noop
+ const togglePinned = togglePinnedProp ?? noop
+
const contextValue = React.useMemo(
() => ({
state,
@@ -125,8 +159,12 @@ function SidebarProvider({
openMobile,
setOpenMobile,
toggleSidebar,
+ hasPinMode,
+ pinned,
+ setPinned,
+ togglePinned,
}),
- [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned]
)
return (
@@ -165,7 +203,7 @@ function Sidebar({
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
- const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
+ const { isMobile, state, openMobile, setOpenMobile, hasPinMode, pinned } = useSidebar()
if (collapsible === "none") {
return (
@@ -190,12 +228,7 @@ function Sidebar({
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
- className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
- style={
- {
- "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
- } as React.CSSProperties
- }
+ className="bg-sidebar text-sidebar-foreground w-2/3 max-w-[18rem] p-0 [&>button]:hidden"
side={side}
>
@@ -210,7 +243,11 @@ function Sidebar({
return (