Chat first onboarding (#5063)

* auth: default to chat

* settings: relaunch onboarding

* onboarding: return to launch page

* studio: stop auto guided tour

* ui: soften global radius

* cleanup: rename onboarding exit prop

* fix onboarding redirect safety

* Show real Unsloth version in settings

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Wasim Yousef Said 2026-04-16 18:58:10 +02:00 committed by GitHub
parent f4422b0a62
commit 7ef65bd2e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 131 additions and 38 deletions

View file

@ -27,6 +27,7 @@ import mimetypes
import shutil
import warnings
from contextlib import asynccontextmanager
from importlib.metadata import PackageNotFoundError, version as package_version
# Fix broken Windows registry MIME types. Some Windows installs map .js to
# "text/plain" in the registry (HKCR\.js\Content Type). Python's mimetypes
@ -78,6 +79,27 @@ import utils.hardware.hardware as _hw_module
from utils.cache_cleanup import clear_unsloth_compiled_cache
def get_unsloth_version() -> str:
try:
return package_version("unsloth")
except PackageNotFoundError:
pass
version_file = (
_Path(__file__).resolve().parents[2] / "unsloth" / "models" / "_utils.py"
)
try:
for line in version_file.read_text(encoding = "utf-8").splitlines():
if line.startswith("__version__ = "):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
return "dev"
UNSLOTH_VERSION = get_unsloth_version()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache."""
@ -140,7 +162,7 @@ async def lifespan(app: FastAPI):
# Create FastAPI app
app = FastAPI(
title = "Unsloth UI Backend",
version = "1.0.0",
version = UNSLOTH_VERSION,
description = "Backend API for Unsloth UI - Training and Model Management",
lifespan = lifespan,
)
@ -198,6 +220,7 @@ async def health_check():
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"service": "Unsloth UI Backend",
"version": UNSLOTH_VERSION,
"device_type": device_type,
"chat_only": _hw_module.CHAT_ONLY,
}

View file

@ -6,6 +6,8 @@ import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
export type OnboardingSearch = { redirectTo?: string };
const WizardLayout = lazy(() =>
import("@/features/onboarding/components/wizard-layout").then((m) => ({
default: m.WizardLayout,
@ -16,5 +18,8 @@ export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/onboarding",
beforeLoad: () => requireAuth(),
validateSearch: (search: Record<string, unknown>): OnboardingSearch => ({
redirectTo: typeof search.redirectTo === "string" ? search.redirectTo : undefined,
}),
component: WizardLayout,
});

View file

@ -8,7 +8,7 @@ export const AUTH_REFRESH_TOKEN_KEY = "unsloth_auth_refresh_token";
export const ONBOARDING_DONE_KEY = "unsloth_onboarding_done";
export const AUTH_MUST_CHANGE_PASSWORD_KEY = "unsloth_auth_must_change_password";
type PostAuthRoute = "/onboarding" | "/studio" | "/change-password" | "/chat";
type PostAuthRoute = "/change-password" | "/chat";
function canUseStorage(): boolean {
return typeof window !== "undefined";
@ -80,5 +80,5 @@ export function resetOnboardingDone(): void {
export function getPostAuthRoute(): PostAuthRoute {
if (mustChangePassword()) return "/change-password";
if (usePlatformStore.getState().isChatOnly()) return "/chat";
return isOnboardingDone() ? "/studio" : "/onboarding";
return "/chat";
}

View file

@ -7,12 +7,12 @@ import { motion } from "motion/react";
interface SplashScreenProps {
onStartOnboarding: () => void;
onGoToStudio: () => void;
onSkipOnboarding: () => void;
}
export function SplashScreen({
onStartOnboarding,
onGoToStudio,
onSkipOnboarding,
}: SplashScreenProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-b from-background via-background to-primary/5 p-6">
@ -65,7 +65,7 @@ export function SplashScreen({
<Button size="lg" onClick={onStartOnboarding}>
Start Onboarding
</Button>
<Button size="lg" variant="outline" onClick={onGoToStudio}>
<Button size="lg" variant="outline" onClick={onSkipOnboarding}>
Skip Onboarding
</Button>
</motion.div>

View file

@ -7,10 +7,15 @@ import { markOnboardingDone } from "@/features/auth";
import { useTrainingConfigStore } from "@/features/training";
import { ArrowLeft02Icon, ArrowRight02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
import { useShallow } from "zustand/react/shallow";
export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void }) {
export function WizardFooter({
returnTo,
onBackToSplash,
}: {
returnTo: string;
onBackToSplash: () => void;
}) {
const { currentStep, prevStep, nextStep, canProceed } = useTrainingConfigStore(
useShallow((s) => ({
currentStep: s.currentStep,
@ -19,7 +24,6 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
canProceed: s.canProceed(),
})),
);
const navigate = useNavigate();
const isFirst = currentStep === 1;
const isLast = currentStep === STEPS.length;
@ -41,7 +45,7 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
className="px-4"
onClick={() => {
markOnboardingDone();
navigate({ to: "/studio" });
window.location.assign(returnTo);
}}
>
Skip
@ -51,12 +55,12 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
<Button
onClick={() => {
markOnboardingDone();
navigate({ to: "/studio" });
window.location.assign(returnTo);
}}
disabled={!canProceed}
className="px-4 !pr-4"
>
Go to Studio
Finish onboarding
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
</Button>
) : (
@ -65,7 +69,7 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
if (currentStep === 1 && sessionStorage.getItem("unsloth_chat_only") === "1") {
sessionStorage.removeItem("unsloth_chat_only");
markOnboardingDone();
window.location.href = "/chat";
window.location.assign(returnTo);
} else {
nextStep();
}

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Card } from "@/components/ui/card";
import { useNavigate } from "@tanstack/react-router";
import { Route as OnboardingRoute } from "@/app/routes/onboarding";
import { motion } from "motion/react";
import { Suspense, lazy, useEffect, useRef, useState } from "react";
@ -19,13 +19,23 @@ const Confetti = lazy(() =>
import("@/components/ui/confetti").then((m) => ({ default: m.Confetti })),
);
function sanitizeRedirectTarget(value: string | undefined): string {
if (!value) return "/chat";
if (!value.startsWith("/")) return "/chat";
if (value.startsWith("//")) return "/chat";
if (value.includes("\\")) return "/chat";
return value;
}
export function WizardLayout() {
const navigate = useNavigate();
const search = OnboardingRoute.useSearch();
const [showSplash, setShowSplash] = useState(true);
const currentStep = useTrainingConfigStore((s) => s.currentStep);
const confettiRef = useRef<ConfettiRef>(null);
const hasFiredRef = useRef(false);
const isFinalStep = currentStep === STEPS.length;
const returnTo = sanitizeRedirectTarget(search.redirectTo);
const exitToReturnTo = () => window.location.assign(returnTo);
// Only redirect on initial mount — not on re-renders after markOnboardingDone()
// which would override explicit /chat navigation from skip buttons.
@ -34,10 +44,10 @@ export function WizardLayout() {
if (!checkedRef.current) {
checkedRef.current = true;
if (isOnboardingDone()) {
navigate({ to: "/studio" });
exitToReturnTo();
}
}
}, [navigate]);
}, [returnTo]);
useEffect(() => {
if (isFinalStep && !hasFiredRef.current) {
@ -67,9 +77,9 @@ export function WizardLayout() {
{showSplash && (
<SplashScreen
onStartOnboarding={() => setShowSplash(false)}
onGoToStudio={() => {
onSkipOnboarding={() => {
markOnboardingDone();
window.location.href = "/studio";
exitToReturnTo();
}}
/>
)}
@ -91,10 +101,10 @@ export function WizardLayout() {
}}
>
<Card className="relative z-10 w-full !gap-0 !m-0 !p-0 flex min-h-[560px] flex-col overflow-hidden shadow-border ring-1 ring-border md:min-h-[620px] md:flex-row lg:h-[660px]">
<WizardSidebar />
<WizardSidebar returnTo={returnTo} />
<div className="flex-1 flex flex-col">
<WizardContent />
<WizardFooter onBackToSplash={() => setShowSplash(true)} />
<WizardFooter returnTo={returnTo} onBackToSplash={() => setShowSplash(true)} />
</div>
</Card>
</motion.div>

View file

@ -10,7 +10,7 @@ import { ArrowRight02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { WizardStepItem } from "./wizard-step-item";
export function WizardSidebar() {
export function WizardSidebar({ returnTo }: { returnTo: string }) {
const currentStep = useTrainingConfigStore((s) => s.currentStep);
const progress = ((currentStep - 1) / (STEPS.length - 1)) * 100;
@ -38,10 +38,10 @@ export function WizardSidebar() {
className="mt-2 w-full md:hidden"
onClick={() => {
markOnboardingDone();
window.location.href = "/chat";
window.location.assign(returnTo);
}}
>
Skip to Chat
Skip onboarding
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
</Button>
<nav className="mt-3 hidden flex-col gap-1 md:flex">
@ -54,10 +54,10 @@ export function WizardSidebar() {
className="mt-3 hidden w-full md:flex"
onClick={() => {
markOnboardingDone();
window.location.href = "/chat";
window.location.assign(returnTo);
}}
>
Skip to Chat
Skip onboarding
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
</Button>
</aside>

View file

@ -13,17 +13,36 @@ import {
MessageNotification01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
const VERSION: string =
(import.meta.env.VITE_APP_VERSION as string | undefined) ?? "dev";
export function AboutTab() {
const deviceType = usePlatformStore((s) => s.deviceType);
const defaultShell = deviceType === "windows" ? "windows" : "unix";
const [shutdownOpen, setShutdownOpen] = useState(false);
const [version, setVersion] = useState("dev");
useEffect(() => {
let canceled = false;
(async () => {
try {
const res = await fetch("/api/health");
if (!res.ok) return;
const data = (await res.json()) as { version?: string };
if (!canceled && data.version) {
setVersion(data.version);
}
} catch {
// fall back to dev label
}
})();
return () => {
canceled = true;
};
}, []);
return (
<div className="flex flex-col gap-6">
@ -36,7 +55,7 @@ export function AboutTab() {
<SettingsSection title="Studio">
<SettingsRow label="Version">
<code className="font-mono text-xs text-muted-foreground">{VERSION}</code>
<code className="font-mono text-xs text-muted-foreground">{version}</code>
</SettingsRow>
</SettingsSection>

View file

@ -12,7 +12,10 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { resetOnboardingDone } from "@/features/auth";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { useSettingsDialogStore } from "@/features/settings";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { SettingsRow } from "../components/settings-row";
@ -75,10 +78,24 @@ function resetAllPrefs() {
}
export function GeneralTab() {
const navigate = useNavigate();
const closeDialog = useSettingsDialogStore((s) => s.closeDialog);
const { pathname, search } = useRouterState({
select: (s) => ({
pathname: s.location.pathname,
search:
"searchStr" in s.location
? (s.location as { searchStr?: string }).searchStr ?? ""
: typeof window !== "undefined"
? window.location.search
: "",
}),
});
const hfToken = useChatRuntimeStore((s) => s.hfToken);
const setHfToken = useChatRuntimeStore((s) => s.setHfToken);
const autoTitle = useChatRuntimeStore((s) => s.autoTitle);
const setAutoTitle = useChatRuntimeStore((s) => s.setAutoTitle);
const redirectTo = `${pathname}${search}`;
const [draftToken, setDraftToken] = useState(hfToken ?? "");
const [showToken, setShowToken] = useState(false);
@ -153,6 +170,25 @@ export function GeneralTab() {
</SettingsRow>
</SettingsSection>
<SettingsSection title="Getting started">
<SettingsRow
label="Start onboarding"
description="Open the setup wizard again without changing your account."
>
<Button
variant="outline"
size="sm"
onClick={() => {
resetOnboardingDone();
closeDialog();
navigate({ to: "/onboarding", search: { redirectTo } });
}}
>
Start onboarding
</Button>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Danger zone">
<SettingsRow
destructive

View file

@ -25,8 +25,6 @@ import { LiveTrainingView } from "./live-training-view";
import { HistoricalTrainingView } from "./historical-training-view";
import { HistoryCardGrid } from "./history-card-grid";
const STUDIO_TOUR_KEY = "tour:studio:v1";
export function StudioPage(): ReactElement {
useTrainingRuntimeLifecycle();
const showTrainingView = useTrainingRuntimeStore(shouldShowTrainingView);
@ -85,8 +83,6 @@ export function StudioPage(): ReactElement {
id: "studio",
steps: tourSteps,
enabled: tourEnabled,
autoKey: isConfigTour ? STUDIO_TOUR_KEY : undefined,
autoWhen: isConfigTour,
});
const setTourOpen = tour.setOpen;

View file

@ -56,7 +56,7 @@
--chart-3: oklch(0.7014 0.1193 197.5897);
--chart-4: oklch(0.6926 0.1112 346.5775);
--chart-5: oklch(0.7497 0.1003 85.0057);
--radius: 0.625rem;
--radius: 1.1rem;
--sidebar: oklch(0.99 0 0);
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
@ -132,7 +132,7 @@
--sidebar-border: oklch(0.38 0 0);
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
--destructive-foreground: oklch(1 0 0);
--radius: 0.625rem;
--radius: 1.1rem;
--font-sans: Geist, ui-sans-serif, sans-serif, system-ui;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
@ -198,7 +198,7 @@
--radius-4xl: calc(var(--radius) + 16px);
--font-mono: JetBrains Mono, monospace;
--font-serif: Source Serif 4, serif;
--radius: 0.625rem;
--radius: 1.1rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);