feat: implement guided tours and refactor model selector components

This commit is contained in:
Shine1i 2026-02-15 23:43:10 +01:00
commit c7ff1687e6
24 changed files with 778 additions and 454 deletions

View file

@ -1,39 +1,26 @@
"use client";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks";
import { cn, formatCompact } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
ArrowDown01Icon,
Logout01Icon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactNode, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import type {
LoraModelOption,
ModelOption,
ModelSelectorChangeMeta,
} from "./model-selector/types";
import { HubModelPicker, LoraModelPicker } from "./model-selector/pickers";
export interface ModelOption {
id: string;
name: string;
description?: string;
icon?: ReactNode;
}
export interface LoraModelOption extends ModelOption {
baseModel?: string;
updatedAt?: number;
}
export interface ModelSelectorChangeMeta {
source: "hub" | "lora";
isLora: boolean;
}
export type { LoraModelOption, ModelOption, ModelSelectorChangeMeta } from "./model-selector/types";
interface ModelSelectorProps {
models: ModelOption[];
@ -46,10 +33,10 @@ interface ModelSelectorProps {
size?: "sm" | "default" | "lg";
className?: string;
contentClassName?: string;
}
function dedupe(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
open?: boolean;
onOpenChange?: (open: boolean) => void;
triggerDataTour?: string;
contentDataTour?: string;
}
function ModelSelectorTrigger({
@ -58,17 +45,20 @@ function ModelSelectorTrigger({
variant = "outline",
size = "default",
className,
dataTour,
}: {
currentModel?: ModelOption;
isLoaded: boolean;
variant?: "outline" | "ghost" | "muted";
size?: "sm" | "default" | "lg";
className?: string;
dataTour?: string;
}) {
return (
<PopoverTrigger asChild={true}>
<button
type="button"
data-tour={dataTour}
className={cn(
"flex items-center gap-2 transition-colors",
variant === "outline" &&
@ -99,268 +89,6 @@ function ModelSelectorTrigger({
);
}
function ListLabel({ children }: { children: ReactNode }) {
return (
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</div>
);
}
function ModelRow({
label,
meta,
selected,
onClick,
}: {
label: string;
meta?: string;
selected?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent",
selected && "bg-accent/60",
)}
>
<span className="min-w-0 flex-1 truncate">{label}</span>
{meta ? (
<span className="shrink-0 text-[10px] text-muted-foreground">{meta}</span>
) : null}
</button>
);
}
function HubModelPicker({
models,
value,
onSelect,
}: {
models: ModelOption[];
value?: string;
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
}) {
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query);
const { results, isLoading, isLoadingMore, fetchMore } = useHfModelSearch(
debouncedQuery,
);
const recommendedIds = useMemo(
() => dedupe([...models.map((model) => model.id), value ?? ""]),
[models, value],
);
const showHfSection = debouncedQuery.trim().length > 0;
const recommendedSet = useMemo(
() => new Set(recommendedIds),
[recommendedIds],
);
const hfIds = useMemo(() => {
if (!showHfSection) {
return [];
}
return results
.map((result) => result.id)
.filter((id) => !recommendedSet.has(id));
}, [recommendedSet, results, showHfSection]);
const metricsById = useMemo(
() =>
new Map(
results.map((result) => [
result.id,
result.totalParams
? formatCompact(result.totalParams)
: `${formatCompact(result.downloads)}`,
]),
),
[results],
);
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore, results.length);
return (
<div className="space-y-2">
<div className="relative">
<HugeiconsIcon
icon={Search01Icon}
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search Hugging Face models"
className="h-9 pl-8 pr-8"
/>
{isLoading && (
<Spinner className="pointer-events-none absolute right-2.5 top-2.5 size-4 text-muted-foreground" />
)}
</div>
<div
ref={scrollRef}
className="max-h-64 overflow-y-auto"
>
<div className="p-1">
{!showHfSection ? (
<>
<ListLabel>Recommended</ListLabel>
{recommendedIds.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No default models.
</div>
) : (
recommendedIds.map((id) => (
<ModelRow
key={id}
label={id}
selected={value === id}
onClick={() => onSelect(id, { source: "hub", isLora: false })}
/>
))
)}
</>
) : null}
{showHfSection ? (
<>
<ListLabel>Hugging Face</ListLabel>
{hfIds.length === 0 && !isLoading ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No matching models.
</div>
) : (
hfIds.map((id) => (
<ModelRow
key={id}
label={id}
meta={metricsById.get(id)}
selected={value === id}
onClick={() => onSelect(id, { source: "hub", isLora: false })}
/>
))
)}
<div ref={sentinelRef} className="h-px" />
{isLoadingMore ? (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
) : null}
</>
) : null}
</div>
</div>
</div>
);
}
function LoraModelPicker({
loraModels,
value,
onSelect,
}: {
loraModels: LoraModelOption[];
value?: string;
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
}) {
const [query, setQuery] = useState("");
const normalized = useMemo(
() =>
loraModels
.map((model) => ({
...model,
baseModel: model.baseModel || model.description || "Unknown base model",
}))
.sort((a, b) => {
const aTime = a.updatedAt ?? -1;
const bTime = b.updatedAt ?? -1;
if (aTime !== bTime) {
return bTime - aTime;
}
const baseCmp = a.baseModel.localeCompare(b.baseModel);
if (baseCmp !== 0) {
return baseCmp;
}
return a.name.localeCompare(b.name);
}),
[loraModels],
);
const grouped = useMemo(() => {
const needle = query.trim().toLowerCase();
const out = new Map<string, LoraModelOption[]>();
for (const model of normalized) {
const searchText = `${model.name} ${model.baseModel} ${model.id}`.toLowerCase();
if (needle && !searchText.includes(needle)) {
continue;
}
const key = model.baseModel || "Unknown base model";
const prev = out.get(key) ?? [];
prev.push(model);
out.set(key, prev);
}
return [...out.entries()].sort((a, b) => {
const aLatest = Math.max(...a[1].map((model) => model.updatedAt ?? -1));
const bLatest = Math.max(...b[1].map((model) => model.updatedAt ?? -1));
if (aLatest !== bLatest) {
return bLatest - aLatest;
}
return a[0].localeCompare(b[0]);
});
}, [normalized, query]);
return (
<div className="space-y-2">
<div className="relative">
<HugeiconsIcon
icon={Search01Icon}
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search local adapters"
className="h-9 pl-8"
/>
</div>
<div className="max-h-64 overflow-y-auto">
<div className="p-1">
{grouped.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">No adapters found.</div>
) : (
grouped.map(([baseModel, adapters], index) => (
<div key={baseModel}>
{index > 0 ? <div className="my-1" /> : null}
<ListLabel>{baseModel}</ListLabel>
{adapters.map((adapter) => (
<ModelRow
key={adapter.id}
label={adapter.name}
meta="LoRA"
selected={value === adapter.id}
onClick={() => onSelect(adapter.id, { source: "lora", isLora: true })}
/>
))}
</div>
))
)}
</div>
</div>
</div>
);
}
function ModelSelectorContent({
models,
loraModels,
@ -368,6 +96,7 @@ function ModelSelectorContent({
onSelect,
onEject,
className,
dataTour,
}: {
models: ModelOption[];
loraModels: LoraModelOption[];
@ -375,12 +104,14 @@ function ModelSelectorContent({
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
onEject?: () => void;
className?: string;
dataTour?: string;
}) {
const hasSelection = Boolean(value);
return (
<PopoverContent
align="start"
data-tour={dataTour}
className={cn("w-[440px] min-w-[440px] gap-0 p-2", className)}
>
<Tabs defaultValue="hub" className="w-full">
@ -430,8 +161,14 @@ export function ModelSelector({
size = "default",
className,
contentClassName,
open: controlledOpen,
onOpenChange,
triggerDataTour,
contentDataTour,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = onOpenChange ?? setUncontrolledOpen;
const [uncontrolled, setUncontrolled] = useState(defaultValue ?? "");
const selected = value ?? uncontrolled;
@ -477,6 +214,7 @@ export function ModelSelector({
variant={variant}
size={size}
className={className}
dataTour={triggerDataTour}
/>
<ModelSelectorContent
models={models}
@ -485,6 +223,7 @@ export function ModelSelector({
onSelect={handleSelect}
onEject={onEject ? handleEject : undefined}
className={contentClassName}
dataTour={contentDataTour}
/>
</Popover>
);

View file

@ -0,0 +1,265 @@
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks";
import { cn, formatCompact } from "@/lib/utils";
import { Search01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo, useState, type ReactNode } from "react";
import type {
LoraModelOption,
ModelOption,
ModelSelectorChangeMeta,
} from "./types";
function dedupe(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
function ListLabel({ children }: { children: ReactNode }) {
return (
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</div>
);
}
function ModelRow({
label,
meta,
selected,
onClick,
}: {
label: string;
meta?: string;
selected?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent",
selected && "bg-accent/60",
)}
>
<span className="min-w-0 flex-1 truncate">{label}</span>
{meta ? (
<span className="shrink-0 text-[10px] text-muted-foreground">{meta}</span>
) : null}
</button>
);
}
export function HubModelPicker({
models,
value,
onSelect,
}: {
models: ModelOption[];
value?: string;
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
}) {
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query);
const { results, isLoading, isLoadingMore, fetchMore } = useHfModelSearch(
debouncedQuery,
);
const recommendedIds = useMemo(
() => dedupe([...models.map((model) => model.id), value ?? ""]),
[models, value],
);
const showHfSection = debouncedQuery.trim().length > 0;
const recommendedSet = useMemo(() => new Set(recommendedIds), [recommendedIds]);
const hfIds = useMemo(() => {
if (!showHfSection) return [];
return results
.map((result) => result.id)
.filter((id) => !recommendedSet.has(id));
}, [recommendedSet, results, showHfSection]);
const metricsById = useMemo(
() =>
new Map(
results.map((result) => [
result.id,
result.totalParams
? formatCompact(result.totalParams)
: `${formatCompact(result.downloads)}`,
]),
),
[results],
);
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore, results.length);
return (
<div className="space-y-2">
<div className="relative">
<HugeiconsIcon
icon={Search01Icon}
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search Hugging Face models"
className="h-9 pl-8 pr-8"
/>
{isLoading && (
<Spinner className="pointer-events-none absolute right-2.5 top-2.5 size-4 text-muted-foreground" />
)}
</div>
<div ref={scrollRef} className="max-h-64 overflow-y-auto">
<div className="p-1">
{!showHfSection ? (
<>
<ListLabel>Recommended</ListLabel>
{recommendedIds.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No default models.
</div>
) : (
recommendedIds.map((id) => (
<ModelRow
key={id}
label={id}
selected={value === id}
onClick={() => onSelect(id, { source: "hub", isLora: false })}
/>
))
)}
</>
) : null}
{showHfSection ? (
<>
<ListLabel>Hugging Face</ListLabel>
{hfIds.length === 0 && !isLoading ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No matching models.
</div>
) : (
hfIds.map((id) => (
<ModelRow
key={id}
label={id}
meta={metricsById.get(id)}
selected={value === id}
onClick={() => onSelect(id, { source: "hub", isLora: false })}
/>
))
)}
<div ref={sentinelRef} className="h-px" />
{isLoadingMore ? (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
) : null}
</>
) : null}
</div>
</div>
</div>
);
}
export function LoraModelPicker({
loraModels,
value,
onSelect,
}: {
loraModels: LoraModelOption[];
value?: string;
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
}) {
const [query, setQuery] = useState("");
const normalized = useMemo(
() =>
loraModels
.map((model) => ({
...model,
baseModel: model.baseModel || model.description || "Unknown base model",
}))
.sort((a, b) => {
const aTime = a.updatedAt ?? -1;
const bTime = b.updatedAt ?? -1;
if (aTime !== bTime) return bTime - aTime;
const baseCmp = a.baseModel.localeCompare(b.baseModel);
if (baseCmp !== 0) return baseCmp;
return a.name.localeCompare(b.name);
}),
[loraModels],
);
const grouped = useMemo(() => {
const needle = query.trim().toLowerCase();
const out = new Map<string, LoraModelOption[]>();
for (const model of normalized) {
const searchText = `${model.name} ${model.baseModel} ${model.id}`.toLowerCase();
if (needle && !searchText.includes(needle)) continue;
const key = model.baseModel || "Unknown base model";
const prev = out.get(key) ?? [];
prev.push(model);
out.set(key, prev);
}
return [...out.entries()].sort((a, b) => {
const aLatest = Math.max(...a[1].map((model) => model.updatedAt ?? -1));
const bLatest = Math.max(...b[1].map((model) => model.updatedAt ?? -1));
if (aLatest !== bLatest) return bLatest - aLatest;
return a[0].localeCompare(b[0]);
});
}, [normalized, query]);
return (
<div className="space-y-2">
<div className="relative">
<HugeiconsIcon
icon={Search01Icon}
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search local adapters"
className="h-9 pl-8"
/>
</div>
<div className="max-h-64 overflow-y-auto">
<div className="p-1">
{grouped.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No adapters found.
</div>
) : (
grouped.map(([baseModel, adapters], index) => (
<div key={baseModel}>
{index > 0 ? <div className="my-1" /> : null}
<ListLabel>{baseModel}</ListLabel>
{adapters.map((adapter) => (
<ModelRow
key={adapter.id}
label={adapter.name}
meta="LoRA"
selected={value === adapter.id}
onClick={() => onSelect(adapter.id, { source: "lora", isLora: true })}
/>
))}
</div>
))
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
import type { ReactNode } from "react";
export interface ModelOption {
id: string;
name: string;
description?: string;
icon?: ReactNode;
}
export interface LoraModelOption extends ModelOption {
baseModel?: string;
updatedAt?: number;
}
export interface ModelSelectorChangeMeta {
source: "hub" | "lora";
isLora: boolean;
}

View file

@ -9,6 +9,7 @@ import {
Analytics01Icon,
ArrowRight01Icon,
Book03Icon,
InformationCircleIcon,
PackageIcon,
ZapIcon,
} from "@hugeicons/core-free-icons";
@ -16,6 +17,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { Link, useRouterState } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { TOUR_OPEN_EVENT } from "@/features/tour";
const NAV_ITEMS = [
{ label: "Studio", href: "/studio", icon: ZapIcon, enabled: true },
@ -28,6 +30,15 @@ export function Navbar() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const [logoHovered, setLogoHovered] = useState(false);
const tourId =
pathname === "/studio"
? "studio"
: pathname === "/chat"
? "chat"
: pathname === "/export"
? "export"
: null;
return (
<header className="top-0 z-40 h-16 w-full">
<div className="mx-auto flex h-full max-w-7xl items-center justify-between px-6">
@ -128,39 +139,56 @@ export function Navbar() {
</nav>
{/* Right: docs link */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 hover:text-emerald-700 transition-colors"
<div className="flex items-center gap-2">
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 hover:text-emerald-700 transition-colors"
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
Learn more
</a>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-80 p-0">
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="group/card flex flex-col gap-1 p-4 no-underline"
>
<p className="text-sm font-semibold font-heading">
Unsloth Documentation
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Guides on fine-tuning LLMs 2x faster with 70% less memory.
Covers LoRA, QLoRA, data formatting, and deployment.
</p>
<span className="mt-1 flex items-center gap-1 text-xs font-medium text-emerald-600 group-hover/card:underline">
Visit docs
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
</span>
</a>
</HoverCardContent>
</HoverCard>
{tourId ? (
<button
type="button"
onClick={() => {
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, { detail: { id: tourId } }),
);
}}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Tour"
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
Learn more
</a>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-80 p-0">
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="group/card flex flex-col gap-1 p-4 no-underline"
>
<p className="text-sm font-semibold font-heading">
Unsloth Documentation
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Guides on fine-tuning LLMs 2x faster with 70% less memory.
Covers LoRA, QLoRA, data formatting, and deployment.
</p>
<span className="mt-1 flex items-center gap-1 text-xs font-medium text-emerald-600 group-hover/card:underline">
Visit docs
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
</span>
</a>
</HoverCardContent>
</HoverCard>
<HugeiconsIcon icon={InformationCircleIcon} className="size-4" />
</button>
) : null}
</div>
</div>
</header>
);

View file

@ -5,16 +5,8 @@ import {
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import { Button } from "@/components/ui/button";
import {
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { SidebarProvider, SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import {
ColumnInsertIcon,
@ -33,6 +25,7 @@ import {
useRef,
useState,
} from "react";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { db } from "./db";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
@ -46,6 +39,7 @@ import {
} from "./shared-composer";
import { ThreadSidebar } from "./thread-sidebar";
import type { ChatView } from "./types";
import { buildChatTourSteps } from "./tour";
const SingleContent = memo(function SingleContent({
threadId,
@ -92,7 +86,7 @@ const CompareContent = memo(function CompareContent({
return (
<CompareHandlesProvider handlesRef={handlesRef}>
<div className="flex min-h-0 flex-1 flex-col">
<div className="grid min-h-0 flex-1 grid-cols-2 px-0">
<div data-tour="chat-compare-view" className="grid min-h-0 flex-1 grid-cols-2 px-0">
<div className="flex min-h-0 flex-col">
<div className="px-3 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
@ -210,6 +204,8 @@ export function ChatPage(): ReactElement {
newThreadNonce: crypto.randomUUID(),
});
const [settingsOpen, setSettingsOpen] = useState(false);
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const viewBeforeCompareRef = useRef<ChatView | null>(null);
const inferenceParams = useChatRuntimeStore((state) => state.params);
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
@ -242,6 +238,25 @@ export function ChatPage(): ReactElement {
[],
);
const openModelSelector = useCallback(() => setModelSelectorOpen(true), []);
const closeModelSelector = useCallback(() => setModelSelectorOpen(false), []);
const openSettings = useCallback(() => setSettingsOpen(true), []);
const closeSettings = useCallback(() => setSettingsOpen(false), []);
const enterCompare = useCallback(() => {
if (viewBeforeCompareRef.current == null) {
viewBeforeCompareRef.current = view;
}
setView({ mode: "compare", pairId: crypto.randomUUID() });
}, [view]);
const exitCompare = useCallback(() => {
const prev = viewBeforeCompareRef.current;
if (!prev) return;
viewBeforeCompareRef.current = null;
setView(prev);
}, []);
const models = useMemo<ModelOption[]>(
() =>
modelsFromStore.map((model) => ({
@ -267,8 +282,36 @@ export function ChatPage(): ReactElement {
void refresh();
}, [refresh]);
const tourSteps = useMemo(
() =>
buildChatTourSteps({
canCompare,
openModelSelector,
closeModelSelector,
openSettings,
closeSettings,
enterCompare,
exitCompare,
}),
[
canCompare,
closeModelSelector,
closeSettings,
enterCompare,
exitCompare,
openModelSelector,
openSettings,
],
);
const tour = useGuidedTourController({
id: "chat",
steps: tourSteps,
});
return (
<div className="h-[calc(100vh-4rem)] bg-background overflow-hidden">
<GuidedTour {...tour.tourProps} />
<SidebarProvider
defaultOpen={true}
className="!min-h-0 h-full max-w-7xl mx-auto px-4"
@ -305,6 +348,10 @@ export function ChatPage(): ReactElement {
onValueChange={handleCheckpointChange}
onEject={handleEject}
variant="ghost"
open={modelSelectorOpen}
onOpenChange={setModelSelectorOpen}
triggerDataTour="chat-model-selector"
contentDataTour="chat-model-selector-popover"
/>
</div>
{modelsError && (
@ -318,6 +365,7 @@ export function ChatPage(): ReactElement {
onClick={() => setSettingsOpen((o) => !o)}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Inference settings"
data-tour="chat-settings"
>
<HugeiconsIcon icon={Settings04Icon} className="size-5" />
</button>

View file

@ -116,7 +116,7 @@ export function ThreadSidebar({
</SidebarMenuItem>
{showCompare ? (
<SidebarMenuItem>
<SidebarMenuButton onClick={onNewCompare}>
<SidebarMenuButton data-tour="chat-compare" onClick={onNewCompare}>
<HugeiconsIcon icon={ColumnInsertIcon} />
<span>Compare</span>
</SidebarMenuButton>

View file

@ -0,0 +1,2 @@
export { buildChatTourSteps } from "./steps";

View file

@ -0,0 +1,66 @@
import type { TourStep } from "@/features/tour";
export function buildChatTourSteps({
canCompare,
openModelSelector,
closeModelSelector,
openSettings,
closeSettings,
enterCompare,
exitCompare,
}: {
canCompare: boolean;
openModelSelector: () => void;
closeModelSelector: () => void;
openSettings: () => void;
closeSettings: () => void;
enterCompare: () => void;
exitCompare: () => void;
}): TourStep[] {
const steps: TourStep[] = [
{
id: "model",
target: "chat-model-selector",
title: "Pick a model",
body: <>Hub models vs fine-tuned adapters live here.</>,
},
{
id: "model-tabs",
target: "chat-model-selector-popover",
title: "Two tabs",
body: <>Hub: search HF. Fine-tuned: your local LoRA adapters.</>,
onEnter: openModelSelector,
onExit: closeModelSelector,
},
{
id: "settings",
target: "chat-settings",
title: "Settings sidebar",
body: <>Sampling + system prompt live in the right sidebar.</>,
onEnter: openSettings,
onExit: closeSettings,
},
];
if (canCompare) {
steps.push(
{
id: "compare-btn",
target: "chat-compare",
title: "Compare mode",
body: <>When a LoRA is selected, you can compare base vs fine-tuned.</>,
},
{
id: "compare-view",
target: "chat-compare-view",
title: "Side-by-side threads",
body: <>Same prompt, 2 threads. Compose at bottom.</>,
onEnter: enterCompare,
onExit: exitCompare,
},
);
}
return steps;
}

View file

@ -19,7 +19,7 @@ interface MethodPickerProps {
export function MethodPicker({ value, onChange }: MethodPickerProps) {
return (
<div className="flex flex-col gap-3">
<div data-tour="export-method" className="flex flex-col gap-3">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Export Method
<Tooltip>

View file

@ -31,6 +31,8 @@ import {
METHOD_LABELS,
getEstimatedSize,
} from "./constants";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { exportTourSteps } from "./tour";
export function ExportPage() {
const {
@ -89,6 +91,11 @@ export function ExportPage() {
const [modelName, setModelName] = useState("");
const [privateRepo, setPrivateRepo] = useState(false);
const tour = useGuidedTourController({
id: "export",
steps: exportTourSteps,
});
const handleMethodChange = (method: ExportMethod) => {
setExportMethod(method);
if (method !== "gguf") {
@ -106,6 +113,8 @@ export function ExportPage() {
return (
<div className="min-h-screen bg-background">
<main className="mx-auto max-w-7xl px-6 py-4">
<GuidedTour {...tour.tourProps} />
<div className="mb-8 flex flex-col gap-0.5">
<h1 className="text-2xl font-semibold tracking-tight">
Export Model
@ -156,7 +165,7 @@ export function ExportPage() {
</Tooltip>
</label>
<Select value={checkpoint ?? ""} onValueChange={setCheckpoint}>
<SelectTrigger className="w-full">
<SelectTrigger data-tour="export-checkpoint" className="w-full">
<SelectValue
placeholder={
isAdapter ? "Select a checkpoint…" : "Select model…"
@ -250,7 +259,11 @@ export function ExportPage() {
/>
<span>Est. size: {estimatedSize} · Free disk space: 120 GB</span>
</div>
<Button disabled={!canExport} onClick={() => setDialogOpen(true)}>
<Button
data-tour="export-cta"
disabled={!canExport}
onClick={() => setDialogOpen(true)}
>
Export Model
</Button>
</div>

View file

@ -0,0 +1,2 @@
export { exportTourSteps } from "./steps";

View file

@ -0,0 +1,23 @@
import type { TourStep } from "@/features/tour";
export const exportTourSteps: TourStep[] = [
{
id: "checkpoint",
target: "export-checkpoint",
title: "Pick checkpoint",
body: <>Choose which checkpoint (or final model) to export.</>,
},
{
id: "method",
target: "export-method",
title: "Export method",
body: <>Select GGUF vs safetensors, then quant if needed.</>,
},
{
id: "cta",
target: "export-cta",
title: "Export",
body: <>When ready, export to local or HF Hub.</>,
},
];

View file

@ -0,0 +1,41 @@
import type { TrainingPhase } from "@/features/training";
export const phaseLabel: Record<TrainingPhase, string> = {
idle: "Idle",
loading_model: "Loading model",
loading_dataset: "Loading dataset",
configuring: "Configuring",
training: "Training",
completed: "Completed",
error: "Error",
stopped: "Stopped",
};
export const phaseColors: Record<TrainingPhase, string> = {
idle: "bg-muted text-muted-foreground",
loading_model:
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
loading_dataset:
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
configuring: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
training:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
completed:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
error: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
stopped: "bg-muted text-muted-foreground",
};
export function formatDuration(seconds: number | null): string {
if (seconds == null || seconds < 0) return "--";
const total = Math.floor(seconds);
const min = Math.floor(total / 60);
const sec = total % 60;
return `${min}m ${sec}s`;
}
export function formatNumber(value: number | null | undefined, digits: number): string {
if (value == null || !Number.isFinite(value)) return "--";
return value.toFixed(digits);
}

View file

@ -19,7 +19,6 @@ import {
useTrainingConfigStore,
useTrainingActions,
useTrainingRuntimeStore,
type TrainingPhase,
} from "@/features/training";
import {
ChartAverageIcon,
@ -33,48 +32,7 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
import { useShallow } from "zustand/react/shallow";
const phaseLabel: Record<TrainingPhase, string> = {
idle: "Idle",
loading_model: "Loading model",
loading_dataset: "Loading dataset",
configuring: "Configuring",
training: "Training",
completed: "Completed",
error: "Error",
stopped: "Stopped",
};
const phaseColors: Record<TrainingPhase, string> = {
idle: "bg-muted text-muted-foreground",
loading_model: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
loading_dataset:
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
configuring: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
training:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
completed:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
error: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
stopped: "bg-muted text-muted-foreground",
};
function formatDuration(seconds: number | null): string {
if (seconds == null || seconds < 0) {
return "--";
}
const total = Math.floor(seconds);
const min = Math.floor(total / 60);
const sec = total % 60;
return `${min}m ${sec}s`;
}
function formatNumber(value: number | null | undefined, digits: number): string {
if (value == null || !Number.isFinite(value)) {
return "--";
}
return value.toFixed(digits);
}
import { formatDuration, formatNumber, phaseColors, phaseLabel } from "./progress-section-lib";
export function ProgressSection(): ReactElement {
const runtime = useTrainingRuntimeStore(
@ -238,6 +196,7 @@ export function ProgressSection(): ReactElement {
</Popover>
<AlertDialog open={stopDialogOpen} onOpenChange={setStopDialogOpen}>
<Button
data-tour="studio-training-stop"
variant="destructive"
size="sm"
className="h-7 cursor-pointer px-3 text-xs"

View file

@ -5,11 +5,11 @@ import {
useTrainingRuntimeLifecycle,
useTrainingRuntimeStore,
} from "@/features/training";
import { GuidedTour } from "@/features/tour";
import { studioTourSteps } from "@/features/studio/tour";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { studioTourSteps, studioTrainingTourSteps } from "@/features/studio/tour";
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useEffect, useState } from "react";
import { type ReactElement, useEffect } from "react";
import { DatasetSection } from "./sections/dataset-section";
import { ModelSection } from "./sections/model-section";
import { ParamsSection } from "./sections/params-section";
@ -28,25 +28,25 @@ export function StudioPage(): ReactElement {
const { dismissTrainingRun } = useTrainingActions();
const canGoBack = runtimePhase === "stopped" || runtimePhase === "error";
const tourEnabled = hasHydratedRuntime && !isHydratingRuntime && !showTrainingView;
const [tourOpen, setTourOpen] = useState(false);
const tourEnabled = hasHydratedRuntime && !isHydratingRuntime;
const isConfigTour = !showTrainingView;
const tourSteps = showTrainingView ? studioTrainingTourSteps : studioTourSteps;
const tour = useGuidedTourController({
id: "studio",
steps: tourSteps,
enabled: tourEnabled,
autoKey: isConfigTour ? STUDIO_TOUR_KEY : undefined,
autoWhen: isConfigTour,
});
useEffect(() => {
if (!tourEnabled) return;
if (localStorage.getItem(STUDIO_TOUR_KEY)) return;
setTourOpen(true);
}, [tourEnabled]);
tour.setOpen(false);
}, [showTrainingView, tour.setOpen]);
return (
<div className="min-h-screen bg-background">
<main className="mx-auto max-w-7xl px-6 py-4">
<GuidedTour
open={tourOpen}
onOpenChange={setTourOpen}
steps={studioTourSteps}
onSkip={() => localStorage.setItem(STUDIO_TOUR_KEY, "skipped")}
onComplete={() => localStorage.setItem(STUDIO_TOUR_KEY, "done")}
/>
<GuidedTour {...tour.tourProps} />
{canGoBack && (
<Button

View file

@ -1,2 +1,2 @@
export { studioTourSteps } from "./steps";
export { studioTrainingTourSteps } from "./training";

View file

@ -0,0 +1,2 @@
export { studioTrainingTourSteps } from "./steps";

View file

@ -0,0 +1,23 @@
import type { TourStep } from "@/features/tour";
export const studioTrainingTourSteps: TourStep[] = [
{
id: "nav",
target: "navbar",
title: "Training view",
body: <>Live run status + metrics. You can stop anytime.</>,
},
{
id: "progress",
target: "studio-training-progress",
title: "Progress + ETA",
body: <>Phase, steps, loss, speed, ETA. This card updates live.</>,
},
{
id: "stop",
target: "studio-training-stop",
title: "Stop / save",
body: <>Stop training, optionally save adapters/checkpoints.</>,
},
];

View file

@ -33,7 +33,9 @@ export function TrainingView(): ReactElement {
<div
className={cn("flex flex-col gap-6 transition-[filter]", showOverlay && "blur")}
>
<ProgressSection />
<div data-tour="studio-training-progress">
<ProgressSection />
</div>
<ChartsSection />
</div>
{showOverlay ? (

View file

@ -20,64 +20,9 @@ import {
import { cssEscape, toRect } from "../lib/dom";
import { fireConfettiFireworks } from "../lib/confetti-fireworks";
import { computeCardPos, padded, pickPlacement } from "../lib/layout";
import { SpotlightOverlay } from "./spotlight-overlay";
import type { Placement, Rect, TourStep } from "../types";
// (types + layout/dom helpers live in ../types and ../lib)
type SpotlightOverlayProps = {
rect: Rect | null;
vw: number;
vh: number;
maskId: string;
};
function SpotlightOverlay({
rect,
vw,
vh,
maskId,
}: SpotlightOverlayProps) {
const hole = rect ?? { x: vw / 2 - 140, y: vh / 2 - 90, w: 280, h: 180 };
const r = 22;
return (
<svg
className="absolute inset-0 size-full"
viewBox={`0 0 ${vw} ${vh}`}
preserveAspectRatio="none"
aria-hidden={true}
>
<defs>
<radialGradient id={`${maskId}-v`} cx="50%" cy="45%" r="80%">
<stop offset="0%" stopColor="rgba(6, 9, 15, 0.35)" />
<stop offset="55%" stopColor="rgba(6, 9, 15, 0.65)" />
<stop offset="100%" stopColor="rgba(6, 9, 15, 0.88)" />
</radialGradient>
<mask id={maskId}>
<rect x="0" y="0" width={vw} height={vh} fill="white" />
<motion.rect
x={hole.x}
y={hole.y}
width={hole.w}
height={hole.h}
rx={r}
fill="black"
transition={{ type: "spring", stiffness: 260, damping: 30 }}
/>
</mask>
</defs>
<rect
x="0"
y="0"
width={vw}
height={vh}
fill={`url(#${maskId}-v)`}
mask={`url(#${maskId})`}
/>
</svg>
);
}
type GuidedTourProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -107,6 +52,7 @@ export function GuidedTour({
const closeLockRef = useRef(false);
const rafRef = useRef<number | null>(null);
const lastRectRef = useRef<Rect | null>(null);
const activeStepRef = useRef<TourStep | null>(null);
const step = steps[idx] ?? null;
const total = steps.length;
@ -118,6 +64,27 @@ export function GuidedTour({
return padded(targetRect, pad, vw, vh);
}, [step?.target, targetRect, vw, vh]);
useEffect(() => {
if (!open) return;
const prev = activeStepRef.current;
if (prev && prev.id !== step?.id) {
void prev.onExit?.();
}
activeStepRef.current = step;
if (step) {
void step.onEnter?.();
}
}, [open, step?.id]); // run before target lookup effect below
useEffect(() => {
if (open) return;
const prev = activeStepRef.current;
activeStepRef.current = null;
if (prev) {
void prev.onExit?.();
}
}, [open]);
useEffect(() => {
if (!open) return;
setIdx(0);

View file

@ -0,0 +1,52 @@
import { motion } from "motion/react";
import type { Rect } from "../types";
type SpotlightOverlayProps = {
rect: Rect | null;
vw: number;
vh: number;
maskId: string;
};
export function SpotlightOverlay({ rect, vw, vh, maskId }: SpotlightOverlayProps) {
const hole = rect ?? { x: vw / 2 - 140, y: vh / 2 - 90, w: 280, h: 180 };
const r = 22;
return (
<svg
className="absolute inset-0 size-full"
viewBox={`0 0 ${vw} ${vh}`}
preserveAspectRatio="none"
aria-hidden={true}
>
<defs>
<radialGradient id={`${maskId}-v`} cx="50%" cy="45%" r="80%">
<stop offset="0%" stopColor="rgba(6, 9, 15, 0.35)" />
<stop offset="55%" stopColor="rgba(6, 9, 15, 0.65)" />
<stop offset="100%" stopColor="rgba(6, 9, 15, 0.88)" />
</radialGradient>
<mask id={maskId}>
<rect x="0" y="0" width={vw} height={vh} fill="white" />
<motion.rect
x={hole.x}
y={hole.y}
width={hole.w}
height={hole.h}
rx={r}
fill="black"
transition={{ type: "spring", stiffness: 260, damping: 30 }}
/>
</mask>
</defs>
<rect
x="0"
y="0"
width={vw}
height={vh}
fill={`url(#${maskId}-v)`}
mask={`url(#${maskId})`}
/>
</svg>
);
}

View file

@ -0,0 +1,71 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { TourStep } from "../types";
export const TOUR_OPEN_EVENT = "omx:tour:open";
export type TourOpenDetail = {
id?: string;
};
export function useGuidedTourController({
id,
steps,
enabled = true,
autoKey,
autoWhen = false,
}: {
id: string;
steps: TourStep[];
enabled?: boolean;
autoKey?: string;
autoWhen?: boolean;
}) {
const [open, setOpen] = useState(false);
const [hasRuntime, setHasRuntime] = useState(false);
useEffect(() => setHasRuntime(true), []);
useEffect(() => {
if (!hasRuntime || !enabled) return;
if (!autoKey || !autoWhen) return;
if (steps.length === 0) return;
if (localStorage.getItem(autoKey)) return;
setOpen(true);
}, [autoKey, autoWhen, enabled, hasRuntime, steps.length]);
useEffect(() => {
if (!hasRuntime || !enabled) return;
function onOpen(e: Event) {
const ce = e as CustomEvent<TourOpenDetail>;
if (ce.detail?.id && ce.detail.id !== id) return;
if (steps.length === 0) return;
setOpen(true);
}
window.addEventListener(TOUR_OPEN_EVENT, onOpen);
return () => window.removeEventListener(TOUR_OPEN_EVENT, onOpen);
}, [enabled, hasRuntime, id, steps.length]);
const onSkip = useCallback(() => {
if (!autoKey) return;
localStorage.setItem(autoKey, "skipped");
}, [autoKey]);
const onComplete = useCallback(() => {
if (!autoKey) return;
localStorage.setItem(autoKey, "done");
}, [autoKey]);
const tourProps = useMemo(
() => ({
open,
onOpenChange: setOpen,
steps,
onSkip,
onComplete,
}),
[onComplete, onSkip, open, steps],
);
return { open, setOpen, onSkip, onComplete, tourProps };
}

View file

@ -1,3 +1,4 @@
export { GuidedTour } from "./components/guided-tour";
export { ReadMore } from "./components/read-more";
export { TOUR_OPEN_EVENT, useGuidedTourController } from "./hooks/use-guided-tour-controller";
export type { TourStep } from "./types";

View file

@ -5,9 +5,10 @@ export type TourStep = {
target: string; // data-tour="<target>"
title: string;
body: ReactNode;
onEnter?: () => void | Promise<void>;
onExit?: () => void | Promise<void>;
};
export type Rect = { x: number; y: number; w: number; h: number };
export type Placement = "right" | "left" | "top" | "bottom";