diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 849e017ea8..58a2d316c9 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -42,7 +42,6 @@ import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler"; import { cn } from "@/lib/utils"; import { ChefHatIcon, - ColumnInsertIcon, CursorInfo02Icon, Delete02Icon, DownloadSquare01Icon, @@ -58,13 +57,16 @@ import { TestTube01Icon, ZapIcon, } from "@hugeicons/core-free-icons"; -import { - Tooltip, - TooltipContent, -} from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { HugeiconsIcon } from "@hugeicons/react"; -import { ChevronDown, ChevronsUpDown, MoreHorizontalIcon, Moon, Sun } from "lucide-react"; +import { + ChevronDown, + ChevronsUpDown, + MoreHorizontalIcon, + Moon, + Sun, +} from "lucide-react"; import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; import { ChatSearchDialog, @@ -130,10 +132,7 @@ function getTourId(pathname: string): string | null { // keeps the test-tube outline + horizontal cap + liquid line, dropping // the bubbles. The original export stays untouched, and HugeiconsIcon // renders this trimmed array exactly the same way. -const TestTubeOutlineIcon = TestTube01Icon.slice( - 0, - 3, -) as typeof TestTube01Icon; +const TestTubeOutlineIcon = TestTube01Icon.slice(0, 3) as typeof TestTube01Icon; function runStatusDotClass(status: TrainingRunSummary["status"]): string { switch (status) { @@ -199,8 +198,14 @@ function NavItem({ data-tour={dataTour} className="sidebar-nav-btn h-[35px] rounded-[10px] gap-[8.5px] px-2.5 font-medium group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[10px] group-data-[collapsible=icon]:mx-auto" > - - {label} + + + {label} + {children} @@ -230,17 +235,33 @@ export function AppSidebar() { const [shutdownOpen, setShutdownOpen] = useState(false); const isChatRoute = pathname.startsWith("/chat"); - const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/"); + const isStudioRoute = + pathname === "/studio" || pathname.startsWith("/studio/"); const scrollRef = useRef(null); const [scrolled, setScrolled] = useState(false); + const [canScrollDown, setCanScrollDown] = useState(false); useEffect(() => { const el = scrollRef.current; if (!el) return; - const handler = () => setScrolled(el.scrollTop > 0); - handler(); - el.addEventListener("scroll", handler, { passive: true }); - return () => el.removeEventListener("scroll", handler); + + const updateScrollState = () => { + setScrolled(el.scrollTop > 0); + setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 1); + }; + + updateScrollState(); + el.addEventListener("scroll", updateScrollState, { passive: true }); + const resizeObserver = new ResizeObserver(updateScrollState); + resizeObserver.observe(el); + const mutationObserver = new MutationObserver(updateScrollState); + mutationObserver.observe(el, { childList: true, subtree: true }); + + return () => { + el.removeEventListener("scroll", updateScrollState); + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; }, []); const isRecipesRoute = pathname.startsWith("/data-recipes"); @@ -250,10 +271,10 @@ export function AppSidebar() { const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId); const activeThreadId = isChatRoute - ? (search.thread as string | undefined) ?? + ? ((search.thread as string | undefined) ?? (search.compare as string | undefined) ?? storeThreadId ?? - undefined + undefined) : undefined; // Training runs @@ -261,12 +282,18 @@ export function AppSidebar() { !chatOnly && isStudioRoute, ); const activeJobId = useTrainingRuntimeStore((s) => s.jobId); - const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId); - const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId); + const selectedHistoryRunId = useTrainingRuntimeStore( + (s) => s.selectedHistoryRunId, + ); + const setSelectedHistoryRunId = useTrainingRuntimeStore( + (s) => s.setSelectedHistoryRunId, + ); const chatDisabled = isTrainingRunning; - async function handleDeleteThread(item: Parameters[0]) { + async function handleDeleteThread( + item: Parameters[0], + ) { await deleteChatItem(item, activeThreadId, (view) => { navigate({ to: "/chat", @@ -316,7 +343,10 @@ export function AppSidebar() { return; } try { - const updated = await renameTrainingRun(target.run.id, nextRunDisplayName); + const updated = await renameTrainingRun( + target.run.id, + nextRunDisplayName, + ); emitTrainingRunUpdated(updated); } catch (err) { toast.error(translate("shell.toast.failedToRenameRun"), { @@ -328,8 +358,9 @@ export function AppSidebar() { type DeleteTarget = | { kind: "chat"; item: SidebarItem } | { kind: "run"; run: TrainingRunSummary }; - const [confirmingDelete, setConfirmingDelete] = - useState(null); + const [confirmingDelete, setConfirmingDelete] = useState( + null, + ); async function commitDelete() { const target = confirmingDelete; @@ -364,560 +395,665 @@ export function AppSidebar() { return ( <> - - - {/* Expanded: compact logo + close toggle */} -
- { - event.preventDefault(); - if (chatDisabled) return; - setActiveThreadId(null); - closeMobileIfOpen(); - void navigate({ - to: "/chat", - search: { new: createNavigationNonce() }, - }); - }} - className="flex items-center gap-[6px] select-none" - aria-label={t("shell.aria.home")} - > - Unsloth - - unsloth - - - {t("shell.beta")} - - - {!isMobile && ( - - - + + - - - - - {t("shell.aria.closeSidebar")} - - - )} -
- - {/* Collapsed: panel icon doubles as expand trigger */} - {!isMobile && ( -
- - - - - - {t("shell.aria.openSidebar")} - - + {t("shell.aria.closeSidebar")} + + + )}
- )} -
- - + {/* Collapsed: panel icon doubles as expand trigger */} + {!isMobile && ( +
+ + + + + + {t("shell.aria.openSidebar")} + + +
+ )} + + + + + + { + if (chatDisabled) return; + setActiveThreadId(null); + navigate({ + to: "/chat", + search: { new: createNavigationNonce() }, + }); + closeMobileIfOpen(); + }} + /> + { + if (chatDisabled) return; + useChatSearchStore.getState().open(); + closeMobileIfOpen(); + }} + /> + + + + + + + + { + if (chatOnly) return; + navigate({ to: "/studio" }); + closeMobileIfOpen(); + }} + /> + + { + navigate({ to: "/data-recipes" }); + closeMobileIfOpen(); + }} + /> + + { + if (chatOnly) return; + navigate({ to: "/export" }); + closeMobileIfOpen(); + }} + /> + + + + + + {!isStudioRoute && chatItems.length > 0 && ( + + + + + {t("shell.navigation.recents")} + + + + + + + {chatItems.map((item) => ( + + { + navigate({ + to: "/chat", + search: + item.type === "single" + ? { thread: item.id } + : { compare: item.id }, + }); + closeMobileIfOpen(); + }} + > + {item.title} + + + + + + + openRenameChat(item)} + > + + {t("common.rename")} + + + setConfirmingDelete({ kind: "chat", item }) + } + > + + {t("common.delete")} + + + + + ))} + + + + + + )} + + {isStudioRoute && runItems.length > 0 && !chatOnly && ( + + + + + {t("shell.navigation.recents")} + + + + + + + {runItems.map((run) => { + const isActiveRun = + selectedHistoryRunId === run.id || + activeJobId === run.id; + return ( + + { + setSelectedHistoryRunId(run.id); + closeMobileIfOpen(); + }} + > +
+ + + {run.display_name ?? run.model_name} + + + {formatRelativeShort(run.started_at)} + +
+ + {run.dataset_name} + +
+ + + + + + openRenameRun(run)} + > + + {t("common.rename")} + + + setConfirmingDelete({ kind: "run", run }) + } + > + + {t("common.delete")} + + + +
+ ); + })} +
+
+
+
+
+ )} +
+ + - { - if (chatDisabled) return; - setActiveThreadId(null); - navigate({ to: "/chat", search: { new: createNavigationNonce() } }); - closeMobileIfOpen(); - }} - /> - i.id === search.compare)} - disabled={chatDisabled} - dataTour="chat-compare" - onClick={() => { - if (chatDisabled) return; - setActiveThreadId(null); - navigate({ to: "/chat", search: { compare: createNavigationNonce() } }); - closeMobileIfOpen(); - }} - /> - { - if (chatDisabled) return; - useChatSearchStore.getState().open(); - closeMobileIfOpen(); - }} - /> - -
-
- - - - - { - if (chatOnly) return; - navigate({ to: "/studio" }); - closeMobileIfOpen(); - }} - /> - - { - navigate({ to: "/data-recipes" }); - closeMobileIfOpen(); - }} - /> - - { - if (chatOnly) return; - navigate({ to: "/export" }); - closeMobileIfOpen(); - }} - /> - - - - - - {!isStudioRoute && chatItems.length > 0 && ( - - - - - {t("shell.navigation.recents")} - - - - - - - {chatItems.map((item) => ( - - { - navigate({ - to: "/chat", - search: - item.type === "single" - ? { thread: item.id } - : { compare: item.id }, - }); - closeMobileIfOpen(); + + + + +
+ +
+
+ + {displayTitle} + + + Unsloth + +
+ +
+
+ + + + useSettingsDialogStore.getState().openDialog() + } + > + + {t("shell.navigation.settings")} + ⌘, + + + useSettingsDialogStore.getState().openDialog("api-keys") + } + > + + {t("shell.navigation.api")} + + {t("common.new")} + + + } + onSelect={(e) => { + e.preventDefault(); + toggleTheme(); }} > - {item.title} -
- - - - - - openRenameChat(item)}> - - {t("common.rename")} - - setConfirmingDelete({ kind: "chat", item })} - > - - {t("common.delete")} - - - -
- ))} -
-
-
-
-
- )} - - {isStudioRoute && runItems.length > 0 && !chatOnly && ( - - - - - {t("shell.navigation.recents")} - - - - - - - {runItems.map((run) => { - const isActiveRun = - selectedHistoryRunId === run.id || activeJobId === run.id; - return ( - - { - setSelectedHistoryRunId(run.id); - closeMobileIfOpen(); - }} - > -
- - - {run.display_name ?? run.model_name} - - - {formatRelativeShort(run.started_at)} - -
- - {run.dataset_name} - -
- - - - - - openRenameRun(run)}> - - {t("common.rename")} - - - setConfirmingDelete({ kind: "run", run }) - } - > - - {t("common.delete")} - - - -
- ); - })} -
-
-
-
-
- )} -
- - - - - - - -
- + ) : ( + + )} + + {isDark + ? t("shell.navigation.lightMode") + : t("shell.navigation.darkMode")} + + + { + const tourId = getTourId(pathname); + if (!tourId) return; + window.dispatchEvent( + new CustomEvent(TOUR_OPEN_EVENT, { + detail: { id: tourId }, + }), + ); + }} + > + + {t("shell.navigation.guidedTour")} + + + + + useSettingsDialogStore.getState().openDialog("about") + } + > + -
-
- {displayTitle} - Unsloth -
- -
-
- - - useSettingsDialogStore.getState().openDialog()} - > - - {t("shell.navigation.settings")} - ⌘, + {t("common.help")} useSettingsDialogStore.getState().openDialog("api-keys")} - > - - {t("shell.navigation.api")} - - {t("common.new")} - - - } - onSelect={(e) => { e.preventDefault(); toggleTheme(); }} - > - {isDark ? : } - - {isDark - ? t("shell.navigation.lightMode") - : t("shell.navigation.darkMode")} - - - { - const tourId = getTourId(pathname); - if (!tourId) return; - window.dispatchEvent( - new CustomEvent(TOUR_OPEN_EVENT, { - detail: { id: tourId }, - }), - ); + onSelect={async () => { + // Best-effort server-side revocation; ignore network errors + // so the local clear path still runs and the user lands on /login. + try { + await logout(); + } catch { + clearAuthTokens(); + } + void navigate({ to: "/login" }); }} > - - {t("shell.navigation.guidedTour")} + + {t("shell.navigation.logOut")} - - - useSettingsDialogStore.getState().openDialog("about")} - > - - {t("common.help")} - - { - // Best-effort server-side revocation; ignore network errors - // so the local clear path still runs and the user lands on /login. - try { - await logout(); - } catch { - clearAuthTokens(); - } - void navigate({ to: "/login" }); - }} - > - - {t("shell.navigation.logOut")} - - setShutdownOpen(true)}> - - {t("common.shutdown")} - - -
-
-
-
-
- - - { - if (!open) setConfirmingDelete(null); - }} - > - - - - {confirmingDelete?.kind === "run" - ? t("shell.dialog.deleteRun.title") - : t("shell.dialog.deleteChat.title")} - - - {confirmingDelete?.kind === "run" ? ( - renderEmphasizedTranslation( - t, - "shell.dialog.deleteRun.description", - confirmingDelete.run.display_name ?? - confirmingDelete.run.model_name, - ) - ) : confirmingDelete?.kind === "chat" ? ( - renderEmphasizedTranslation( - t, - "shell.dialog.deleteChat.description", - confirmingDelete.item.title, - ) - ) : null} - - - - - - - - - { - if (!open) setRenamingTarget(null); - }} - > - - - - {renamingTarget?.kind === "run" - ? t("shell.dialog.renameRun.title") - : t("shell.dialog.renameChat.title")} - - - setRenameDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void commitRename(); + setShutdownOpen(true)}> + + {t("common.shutdown")} + + + + + + + + + + { + if (!open) setConfirmingDelete(null); + }} + > + + + + {confirmingDelete?.kind === "run" + ? t("shell.dialog.deleteRun.title") + : t("shell.dialog.deleteChat.title")} + + + {confirmingDelete?.kind === "run" + ? renderEmphasizedTranslation( + t, + "shell.dialog.deleteRun.description", + confirmingDelete.run.display_name ?? + confirmingDelete.run.model_name, + ) + : confirmingDelete?.kind === "chat" + ? renderEmphasizedTranslation( + t, + "shell.dialog.deleteChat.description", + confirmingDelete.item.title, + ) + : null} + + + + + + + + + { + if (!open) setRenamingTarget(null); + }} + > + + + + {renamingTarget?.kind === "run" + ? t("shell.dialog.renameRun.title") + : t("shell.dialog.renameChat.title")} + + + setRenameDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void commitRename(); + } + }} + autoFocus + maxLength={120} + placeholder={ + renamingTarget?.kind === "run" + ? t("shell.dialog.renameRun.placeholder") + : t("shell.dialog.renameChat.placeholder") } - }} - autoFocus - maxLength={120} - placeholder={ - renamingTarget?.kind === "run" - ? t("shell.dialog.renameRun.placeholder") - : t("shell.dialog.renameChat.placeholder") - } - aria-label={ - renamingTarget?.kind === "run" - ? t("shell.dialog.renameRun.placeholder") - : t("shell.dialog.renameChat.placeholder") - } - className="focus-visible:border-input focus-visible:ring-0" - /> - - - - - - + aria-label={ + renamingTarget?.kind === "run" + ? t("shell.dialog.renameRun.placeholder") + : t("shell.dialog.renameChat.placeholder") + } + className="focus-visible:border-input focus-visible:ring-0" + /> + + + + + + ); } diff --git a/studio/frontend/src/components/ui/sidebar.tsx b/studio/frontend/src/components/ui/sidebar.tsx index 6be77d01b9..9c3da94cd2 100644 --- a/studio/frontend/src/components/ui/sidebar.tsx +++ b/studio/frontend/src/components/ui/sidebar.tsx @@ -30,7 +30,7 @@ import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons" const noop = () => {} -const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH = "18rem" const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_KEYBOARD_SHORTCUT = "b"