Merge pull request #140 from unslothai/feature/uxui-heuristics

ux: improve training-to-chat flow, param defaults UX, and guided onboarding/export polish
This commit is contained in:
Wasim Yousef Said 2026-02-17 12:10:27 -08:00 committed by GitHub
commit d9869dece1
15 changed files with 412 additions and 121 deletions

View file

@ -42,19 +42,21 @@ function AlertDialogOverlay({
);
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
function AlertDialogContent({
className,
size = "default",
overlayClassName,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
overlayClassName?: string;
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay className={overlayClassName} />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/5 gap-6 rounded-4xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-md group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className,

View file

@ -1,4 +1,5 @@
import { LightRays } from "@/components/ui/light-rays";
import { Card } from "@/components/ui/card";
import { AuthForm } from "./components/auth-form";
export function LoginPage() {
@ -12,9 +13,9 @@ export function LoginPage() {
length="70vh"
style={{ opacity: 0.4 }}
/>
<div className="relative z-10 w-full max-w-sm">
<Card className="relative z-10 w-full max-w-sm px-6 py-8 shadow-border ring-1 ring-border">
<AuthForm mode="login" />
</div>
</Card>
</div>
);
}

View file

@ -1,4 +1,5 @@
import { LightRays } from "@/components/ui/light-rays";
import { Card } from "@/components/ui/card";
import { AuthForm } from "./components/auth-form";
export function SignupPage() {
@ -12,9 +13,9 @@ export function SignupPage() {
length="70vh"
style={{ opacity: 0.4 }}
/>
<div className="relative z-10 w-full max-w-sm">
<Card className="relative z-10 w-full max-w-sm px-6 py-8 shadow-border ring-1 ring-border">
<AuthForm mode="signup" />
</div>
</Card>
</div>
);
}

View file

@ -29,6 +29,10 @@ import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { db } from "./db";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
import {
clearTrainingCompareHandoff,
getTrainingCompareHandoff,
} from "./lib/training-compare-handoff";
import { ChatRuntimeProvider } from "./runtime-provider";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import {
@ -41,6 +45,43 @@ import { ThreadSidebar } from "./thread-sidebar";
import type { ChatView } from "./types";
import { buildChatTourSteps } from "./tour";
type LoraCandidate = {
id: string;
baseModel: string;
updatedAt?: number;
};
function normalizeModelRef(value: string | null | undefined): string {
return value?.trim().toLowerCase() ?? "";
}
function pickBestLoraForBase(
loras: LoraCandidate[],
baseModel: string | null,
): LoraCandidate | null {
if (loras.length === 0) return null;
const sorted = [...loras].sort(
(a, b) => (b.updatedAt ?? -1) - (a.updatedAt ?? -1),
);
const normalizedBase = normalizeModelRef(baseModel);
if (!normalizedBase) return sorted[0];
const exact = sorted.find(
(lora) => normalizeModelRef(lora.baseModel) === normalizedBase,
);
if (exact) return exact;
const partial = sorted.find((lora) => {
const normalizedLoraBase = normalizeModelRef(lora.baseModel);
if (!normalizedLoraBase) return false;
return (
normalizedLoraBase.includes(normalizedBase) ||
normalizedBase.includes(normalizedLoraBase)
);
});
return partial ?? sorted[0];
}
const SingleContent = memo(function SingleContent({
threadId,
newThreadNonce,
@ -207,7 +248,9 @@ export function ChatPage(): ReactElement {
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const viewBeforeCompareRef = useRef<ChatView | null>(null);
const [viewBeforeCompare, setViewBeforeCompare] = useState<ChatView | null>(
null,
);
const inferenceParams = useChatRuntimeStore((state) => state.params);
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
@ -216,6 +259,13 @@ export function ChatPage(): ReactElement {
const lorasFromStore = useChatRuntimeStore((state) => state.loras);
const modelsError = useChatRuntimeStore((state) => state.modelsError);
const { refresh, selectModel, ejectModel } = useChatModelRuntime();
const refreshRef = useRef(refresh);
const selectModelRef = useRef(selectModel);
useEffect(() => {
refreshRef.current = refresh;
selectModelRef.current = selectModel;
}, [refresh, selectModel]);
const canCompare = useMemo(() => {
const selected = inferenceParams.checkpoint;
if (!selected) return false;
@ -262,18 +312,15 @@ export function ChatPage(): ReactElement {
const openSidebar = useCallback(() => setSidebarOpen(true), []);
const enterCompare = useCallback(() => {
if (viewBeforeCompareRef.current == null) {
viewBeforeCompareRef.current = view;
}
setViewBeforeCompare((prev) => prev ?? view);
setView({ mode: "compare", pairId: crypto.randomUUID() });
}, [view]);
const exitCompare = useCallback(() => {
const prev = viewBeforeCompareRef.current;
if (!prev) return;
viewBeforeCompareRef.current = null;
setView(prev);
}, []);
if (!viewBeforeCompare) return;
setView(viewBeforeCompare);
setViewBeforeCompare(null);
}, [viewBeforeCompare]);
const models = useMemo<ModelOption[]>(
() =>
@ -297,9 +344,69 @@ export function ChatPage(): ReactElement {
);
useEffect(() => {
if (getTrainingCompareHandoff()) return;
void refresh();
}, [refresh]);
useEffect(() => {
const handoff = getTrainingCompareHandoff();
if (!handoff) return;
console.info("[chat-handoff] received", handoff);
function clearHandoff(): void {
clearTrainingCompareHandoff();
}
let canceled = false;
void (async () => {
try {
console.info("[chat-handoff] refreshing models+loras");
await refreshRef.current();
if (canceled) return;
const state = useChatRuntimeStore.getState();
const targetLora = pickBestLoraForBase(state.loras, handoff.baseModel);
if (targetLora) {
console.info("[chat-handoff] loading lora", {
id: targetLora.id,
baseModel: targetLora.baseModel,
});
await selectModelRef.current({ id: targetLora.id, isLora: true });
if (canceled) return;
setView({ mode: "compare", pairId: crypto.randomUUID() });
clearHandoff();
console.info("[chat-handoff] loaded lora + opened compare");
return;
}
if (
handoff.baseModel &&
state.models.some((model) => model.id === handoff.baseModel)
) {
console.info("[chat-handoff] no lora match, loading base", {
id: handoff.baseModel,
});
await selectModelRef.current({ id: handoff.baseModel, isLora: false });
if (canceled) return;
} else {
console.warn("[chat-handoff] no lora/base match found", {
requestedBaseModel: handoff.baseModel,
loraCount: state.loras.length,
modelCount: state.models.length,
});
}
clearHandoff();
console.info("[chat-handoff] completed");
} catch (error) {
console.error("[chat-handoff] failed", error);
clearHandoff();
}
})();
return () => {
canceled = true;
};
}, []);
const tourSteps = useMemo(
() =>
buildChatTourSteps({
@ -332,8 +439,11 @@ export function ChatPage(): ReactElement {
useEffect(() => {
if (tour.open) return;
if (!modelSelectorLocked) return;
setModelSelectorLocked(false);
setModelSelectorOpen(false);
const timeoutId = window.setTimeout(() => {
setModelSelectorLocked(false);
setModelSelectorOpen(false);
}, 0);
return () => window.clearTimeout(timeoutId);
}, [modelSelectorLocked, tour.open]);
return (

View file

@ -7,3 +7,4 @@ export {
} from "./chat-settings-sheet";
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";

View file

@ -0,0 +1,53 @@
const TRAINING_COMPARE_HANDOFF_KEY = "chat:training-compare-handoff:v1";
const HANDOFF_MAX_AGE_MS = 15 * 60 * 1000;
export type TrainingCompareHandoff = {
intent: "compare";
baseModel: string | null;
requestedAt: number;
};
export function setTrainingCompareHandoff(baseModel: string | null): void {
if (typeof window === "undefined") return;
const payload: TrainingCompareHandoff = {
intent: "compare",
baseModel,
requestedAt: Date.now(),
};
window.sessionStorage.setItem(
TRAINING_COMPARE_HANDOFF_KEY,
JSON.stringify(payload),
);
}
export function getTrainingCompareHandoff(): TrainingCompareHandoff | null {
if (typeof window === "undefined") return null;
const raw = window.sessionStorage.getItem(TRAINING_COMPARE_HANDOFF_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<TrainingCompareHandoff>;
if (parsed.intent !== "compare") return null;
if (typeof parsed.requestedAt !== "number") return null;
if (Date.now() - parsed.requestedAt > HANDOFF_MAX_AGE_MS) {
clearTrainingCompareHandoff();
return null;
}
return {
intent: "compare",
baseModel:
typeof parsed.baseModel === "string" ? parsed.baseModel : null,
requestedAt: parsed.requestedAt,
};
} catch {
clearTrainingCompareHandoff();
return null;
}
}
export function clearTrainingCompareHandoff(): void {
if (typeof window === "undefined") return;
window.sessionStorage.removeItem(TRAINING_COMPARE_HANDOFF_KEY);
}

View file

@ -278,7 +278,7 @@ export function ExportPage() {
<div className="grid grid-cols-2 gap-8">
<div className="flex flex-col gap-4">
{/* Training run dropdown */}
<div className="flex flex-col gap-2">
<div data-tour="export-training-run" className="flex flex-col gap-2">
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Training Run
<Tooltip>
@ -344,7 +344,7 @@ export function ExportPage() {
</div>
{/* Checkpoint dropdown */}
<div className="flex flex-col gap-2">
<div data-tour="export-checkpoint" className="flex flex-col gap-2">
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Checkpoint
<Tooltip>
@ -378,7 +378,7 @@ export function ExportPage() {
onValueChange={setCheckpoint}
disabled={!selectedModelIdx}
>
<SelectTrigger data-tour="export-checkpoint" className="w-full">
<SelectTrigger className="w-full">
<SelectValue
placeholder={
!selectedModelIdx

View file

@ -1,6 +1,17 @@
import type { TourStep } from "@/features/tour";
export const exportTourSteps: TourStep[] = [
{
id: "training-run",
target: "export-training-run",
title: "Pick training run",
body: (
<>
Start by selecting the training run. Each run groups the checkpoints
produced by that specific fine-tuning job.
</>
),
},
{
id: "checkpoint",
target: "export-checkpoint",

View file

@ -1,4 +1,5 @@
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { motion } from "motion/react";
interface SplashScreenProps {
@ -11,57 +12,61 @@ export function SplashScreen({
onGoToStudio,
}: SplashScreenProps) {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-gradient-to-b from-background via-background to-primary/5">
{/* Mascot */}
<motion.img
src="/Sloth emojis/Sloth loca pc.png"
alt="Sloth mascot"
className="size-30"
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
type: "spring",
duration: 0.7,
bounce: 0.3,
delay: 0.1,
}}
/>
<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">
<Card className="w-full max-w-md px-8 py-8 shadow-border ring-1 ring-border">
{/* Mascot */}
<div className="flex justify-center">
<motion.img
src="/Sloth emojis/Sloth loca pc.png"
alt="Sloth mascot"
className="size-30"
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
type: "spring",
duration: 0.7,
bounce: 0.3,
delay: 0.1,
}}
/>
</div>
{/* Brand text */}
<motion.div
className="flex flex-col items-center gap-1 mt-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: [0.165, 0.84, 0.44, 1],
delay: 0.4,
}}
>
<h1 className="text-2xl font-semibold tracking-tight">
Unsloth Studio
</h1>
<p className="text-sm text-muted-foreground">Fine-tune LLMs faster</p>
</motion.div>
{/* Brand text */}
<motion.div
className="mt-4 flex flex-col items-center gap-1"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: [0.165, 0.84, 0.44, 1],
delay: 0.4,
}}
>
<h1 className="text-2xl font-semibold tracking-tight">
Unsloth Studio
</h1>
<p className="text-sm text-muted-foreground">Fine-tune LLMs faster</p>
</motion.div>
{/* Buttons */}
<motion.div
className="flex flex-col gap-3 mt-8"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: [0.165, 0.84, 0.44, 1],
delay: 0.8,
}}
>
<Button size="lg" onClick={onStartOnboarding}>
Start Onboarding
</Button>
<Button size="lg" variant="outline" onClick={onGoToStudio}>
Skip Onboarding
</Button>
</motion.div>
{/* Buttons */}
<motion.div
className="mt-8 flex flex-col gap-3"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: [0.165, 0.84, 0.44, 1],
delay: 0.8,
}}
>
<Button size="lg" onClick={onStartOnboarding}>
Start Onboarding
</Button>
<Button size="lg" variant="outline" onClick={onGoToStudio}>
Skip Onboarding
</Button>
</motion.div>
</Card>
</div>
);
}

View file

@ -28,6 +28,8 @@ import { useShallow } from "zustand/react/shallow";
export function HyperparametersStep() {
const {
trainingMethod,
maxSteps,
setMaxSteps,
epochs,
setEpochs,
contextLength,
@ -43,6 +45,8 @@ export function HyperparametersStep() {
} = useTrainingConfigStore(
useShallow((s) => ({
trainingMethod: s.trainingMethod,
maxSteps: s.maxSteps,
setMaxSteps: s.setMaxSteps,
epochs: s.epochs,
setEpochs: s.setEpochs,
contextLength: s.contextLength,
@ -60,6 +64,8 @@ export function HyperparametersStep() {
const showLoraParams =
trainingMethod === "lora" || trainingMethod === "qlora";
const maxStepsSliderMax = Math.max(500, maxSteps, 30);
const epochsSliderMax = Math.max(10, epochs, 1);
return (
<FieldGroup>
@ -68,7 +74,7 @@ export function HyperparametersStep() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<FieldLabel className="flex items-center gap-1.5 !text-sm text-muted-foreground">
Epochs
Max Steps
<Tooltip>
<TooltipTrigger asChild={true}>
<button
@ -82,7 +88,7 @@ export function HyperparametersStep() {
</button>
</TooltipTrigger>
<TooltipContent>
Number of times to iterate over the entire dataset.{" "}
Override total steps. Set 0 to use epochs instead.{" "}
<a
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
target="_blank"
@ -96,21 +102,21 @@ export function HyperparametersStep() {
</FieldLabel>
<div className="flex items-center gap-3">
<Slider
value={[epochs]}
onValueChange={([v]) => setEpochs(v)}
min={1}
max={10}
value={[Math.min(maxStepsSliderMax, Math.max(0, maxSteps))]}
onValueChange={([v]) => setMaxSteps(v)}
min={0}
max={maxStepsSliderMax}
step={1}
className="w-40"
/>
<input
type="number"
value={epochs}
onChange={(e) => setEpochs(Number(e.target.value))}
min={1}
max={10}
value={maxSteps}
onChange={(e) => setMaxSteps(Number(e.target.value))}
min={0}
max={maxStepsSliderMax}
step={1}
className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
className="w-16 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
@ -196,6 +202,56 @@ export function HyperparametersStep() {
className="w-32 font-mono"
/>
</div>
<div className="flex items-center justify-between">
<FieldLabel className="flex items-center gap-1.5 !text-sm text-muted-foreground">
Epochs
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
className="text-muted-foreground/50 hover:text-muted-foreground"
>
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3.5"
/>
</button>
</TooltipTrigger>
<TooltipContent>
Number of full passes over the dataset. Set 0 to run by max
steps.{" "}
<a
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Read more
</a>
</TooltipContent>
</Tooltip>
</FieldLabel>
<div className="flex items-center gap-3">
<Slider
value={[Math.min(epochsSliderMax, Math.max(0, epochs))]}
onValueChange={([v]) => setEpochs(v)}
min={0}
max={epochsSliderMax}
step={1}
className="w-40"
/>
<input
type="number"
value={epochs}
onChange={(e) => setEpochs(Number(e.target.value))}
min={0}
max={epochsSliderMax}
step={1}
className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
</div>
</FieldSet>

View file

@ -81,7 +81,7 @@ export function WizardLayout() {
ease: [0.165, 0.84, 0.44, 1],
}}
>
<Card className="relative z-10 w-full !gap-0 h-[640px] flex flex-row overflow-hidden !p-0 !m-0 shadow-border ring-1 ring-border">
<Card className="relative z-10 h-[660px] w-full !gap-0 !m-0 !p-0 flex flex-row overflow-hidden shadow-border ring-1 ring-border">
<WizardSidebar />
<div className="flex-1 flex flex-col">
<WizardContent />

View file

@ -112,6 +112,8 @@ export function ParamsSection(): ReactElement {
const showVisionLora = store.isVisionModel && store.isDatasetMultimodal === true;
const [loraOpen, setLoraOpen] = useState(false);
const [hyperOpen, setHyperOpen] = useState(false);
const maxStepsSliderMax = Math.max(500, store.maxSteps, 30);
const epochsSliderMax = Math.max(20, store.epochs, 1);
return (
<div data-tour="studio-params" className="lg:col-span-4">
@ -123,11 +125,11 @@ export function ParamsSection(): ReactElement {
className="min-h-[450px]"
>
<div className="flex flex-col gap-4">
{/* Epochs */}
{/* Max Steps */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Epochs
Max Steps
<Tooltip>
<TooltipTrigger asChild={true}>
<button
@ -141,8 +143,7 @@ export function ParamsSection(): ReactElement {
</button>
</TooltipTrigger>
<TooltipContent>
How many times the model sees the entire dataset during
training.{" "}
Override total steps. Set 0 to use epochs instead.{" "}
<a
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
target="_blank"
@ -156,23 +157,23 @@ export function ParamsSection(): ReactElement {
</span>
<input
type="number"
value={store.epochs}
onChange={(e) => store.setEpochs(Number(e.target.value))}
min={1}
max={20}
value={store.maxSteps}
onChange={(e) => store.setMaxSteps(Number(e.target.value))}
min={0}
max={maxStepsSliderMax}
step={1}
className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
className="w-16 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
<Slider
value={[store.epochs]}
onValueChange={([v]) => store.setEpochs(v)}
min={1}
max={20}
value={[Math.min(maxStepsSliderMax, Math.max(0, store.maxSteps))]}
onValueChange={([v]) => store.setMaxSteps(v)}
min={0}
max={maxStepsSliderMax}
step={1}
/>
<p className="text-[10px] text-muted-foreground">
Number of full passes over the training dataset
Total optimizer steps. Use 0 to run by epochs.
</p>
</div>
@ -602,11 +603,12 @@ export function ParamsSection(): ReactElement {
max={100}
step={1}
/>
<Row
label="Max Steps"
<SliderRow
label="Epochs"
tooltip={
<>
Override total steps. 0 means use epochs instead.{" "}
Number of full passes over the dataset. Set 0 to run by
max steps.{" "}
<a
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
target="_blank"
@ -617,14 +619,12 @@ export function ParamsSection(): ReactElement {
</a>
</>
}
>
<Input
type="number"
value={store.maxSteps}
onChange={(e) => store.setMaxSteps(Number(e.target.value))}
className="w-28 font-mono"
/>
</Row>
value={store.epochs}
onChange={store.setEpochs}
min={0}
max={epochsSliderMax}
step={1}
/>
<Row
label="Save Steps"
tooltip={

View file

@ -31,11 +31,14 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useState, type ReactElement, type ReactNode } from "react";
import { Link, useNavigate } from "@tanstack/react-router";
import { useShallow } from "zustand/react/shallow";
import { useGpuUtilization } from "@/hooks";
import { setTrainingCompareHandoff } from "@/features/chat";
import { formatDuration, formatNumber, phaseColors, phaseLabel } from "./progress-section-lib";
export function ProgressSection(): ReactElement {
const navigate = useNavigate();
const runtime = useTrainingRuntimeStore(
useShallow((state) => ({
phase: state.phase,
@ -101,6 +104,13 @@ export function ProgressSection(): ReactElement {
elapsed != null && elapsed > 0
? runtime.currentStep / elapsed
: null;
const showHalfwayHint =
runtime.phase === "training" && pct >= 50 && pct < 100;
const showCompletedHint = runtime.phase === "completed";
const handleCompareInChat = () => {
setTrainingCompareHandoff(config.selectedModel);
void navigate({ to: "/chat" });
};
const stoppedLoss = getDisplayMetric(
runtime.isTrainingRunning,
@ -198,7 +208,7 @@ export function ProgressSection(): ReactElement {
>
<HugeiconsIcon icon={StopIcon} className="size-3" /> Stop
</Button>
<AlertDialogContent>
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
<AlertDialogHeader>
<AlertDialogTitle>Stop Training</AlertDialogTitle>
<AlertDialogDescription>
@ -252,6 +262,26 @@ export function ProgressSection(): ReactElement {
</div>
</div>
{(showHalfwayHint || showCompletedHint) && (
<div className="rounded-xl border border-emerald-500/25 bg-emerald-500/8 p-3">
<p className="text-xs font-medium text-emerald-900 dark:text-emerald-200">
{showCompletedHint
? "Training done. Next step: compare base vs fine-tuned outputs."
: "Halfway done. Training is past 50%."}
</p>
{showCompletedHint && (
<div className="mt-2 flex flex-wrap gap-2">
<Button size="xs" onClick={handleCompareInChat}>
Compare in Chat
</Button>
<Button asChild={true} size="xs" variant="outline">
<Link to="/export">Export Model</Link>
</Button>
</div>
)}
</div>
)}
{runtime.error && (
<p className="text-xs text-red-500 leading-relaxed">{runtime.error}</p>
)}

View file

@ -1,4 +1,5 @@
import { Button } from "@/components/ui/button";
import { LightRays } from "@/components/ui/light-rays";
import {
shouldShowTrainingView,
useDatasetPreviewDialogStore,
@ -62,8 +63,16 @@ export function StudioPage(): ReactElement {
}, [selectedModel, ensureModelDefaultsLoaded]);
return (
<div className="min-h-screen bg-background">
<main className="mx-auto max-w-7xl px-6 py-4">
<div className="relative min-h-screen overflow-hidden bg-background">
<LightRays
count={6}
color="rgba(34, 197, 94, 0.14)"
blur={30}
speed={18}
length="62vh"
style={{ opacity: 0.45 }}
/>
<main className="relative z-10 mx-auto max-w-7xl px-6 py-4">
<GuidedTour {...tour.tourProps} celebrate={isConfigTour} />
<DatasetPreviewDialog

View file

@ -1,4 +1,5 @@
import { cn } from "@/lib/utils";
import { LightRays } from "@/components/ui/light-rays";
import { useTrainingRuntimeStore } from "@/features/training";
import type { ReactElement } from "react";
import { useShallow } from "zustand/react/shallow";
@ -30,8 +31,19 @@ export function TrainingView(): ReactElement {
return (
<div className={cn("relative", showOverlay && "min-h-[72vh]")}>
<LightRays
count={4}
color="rgba(16, 185, 129, 0.13)"
blur={28}
speed={20}
length="52vh"
style={{ opacity: 0.35 }}
/>
<div
className={cn("flex flex-col gap-6 transition-[filter]", showOverlay && "blur")}
className={cn(
"relative z-10 flex flex-col gap-6 transition-[filter]",
showOverlay && "blur",
)}
>
<div data-tour="studio-training-progress">
<ProgressSection />