Images: flat Create and Train panes, hover-only scrollbars, tidier Train dataset step

Both Images tabs now sit on the page background like the Hub: no card, no
shadow, no bounding box. A single rule divides the controls rail from the
preview canvas (Create) and from the run area (Train), and the settings and
previous-runs sections read as panes rather than nested cards.

Also:
- Scrollbars in these panes use the existing hover-scrollbar recipe, so the
  thumb only shows while the pane is hovered.
- Workflow rows explain themselves with a tooltip after a short hover, which
  also works on disabled rows, and the descriptions are much shorter.
- Training images rows are name plus image count; the license stays on the
  example card.
- The upload step loses its dashed box, the buttons match the sizes around
  them, and Upload only appears once files are picked.
- The empty preview uses the same icon as the Images nav item.
This commit is contained in:
Unsloth 2026-07-25 03:13:41 -07:00
commit 3f7c2e5f00
6 changed files with 123 additions and 105 deletions

View file

@ -11,6 +11,7 @@ import {
Delete02Icon,
Download01Icon,
Edit03Icon,
Image03Icon,
ImageAdd02Icon,
ImageUpload01Icon,
InformationCircleIcon,
@ -22,6 +23,9 @@ import {
ZoomInAreaIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
// The raw Radix trigger: the app's TooltipTrigger adds a click-to-toggle handler that
// has no business inside a menu row.
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { TestTubeOutlineIcon } from "@/lib/hugeicons-derived";
import { Button } from "@/components/ui/button";
@ -48,6 +52,7 @@ import { Slider } from "@/components/ui/slider";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { InfoHint } from "@/components/ui/info-hint";
import { ModelSelector } from "@/features/model-picker/components/model-selector";
import { IMAGE_GEN_TASKS } from "@/features/model-picker/components/model-selector/pickers";
@ -120,42 +125,42 @@ const WORKFLOW_TABS: Array<{
label: "Transform",
icon: MagicWand01Icon,
requires: "img2img",
hint: "Redraw an uploaded image guided by your prompt (img2img)",
hint: "Redraw an image from your prompt",
},
{
id: "inpaint",
label: "Inpaint",
icon: PaintBrush02Icon,
requires: "inpaint",
hint: "Paint over a region to regenerate just that area, keeping the rest",
hint: "Regenerate a painted region",
},
{
id: "extend",
label: "Extend",
icon: ArrowExpand01Icon,
requires: "outpaint",
hint: "Outpaint: grow the canvas and fill the new edges from your prompt",
hint: "Grow the canvas and fill the edges",
},
{
id: "upscale",
label: "Upscale",
icon: ZoomInAreaIcon,
requires: "upscale",
hint: "Hires fix: enlarge an uploaded image and re-detail it at higher resolution",
hint: "Enlarge and re-detail an image",
},
{
id: "reference",
label: "Reference",
icon: ImageUpload01Icon,
requires: "reference",
hint: "Generate a new image guided by a reference image + your prompt (FLUX.2)",
hint: "Generate guided by a reference image",
},
{
id: "edit",
label: "Edit",
icon: Edit03Icon,
requires: "edit",
hint: "Instruction editing: change an image with a prompt (Qwen-Image-Edit)",
hint: "Change an image with an instruction",
},
];
@ -2312,20 +2317,16 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
onDeploy={handleDeployAdapter}
/>
) : (
/* Controls rail + preview canvas. Padding mirrors the other tabs
(Export, Data Recipes): px-5 / sm:px-9, with a roomy bottom. pt-3 keeps the
cards off the model selector row. */
<div className="flex min-h-0 min-w-0 flex-1 gap-4 overflow-hidden px-5 pb-8 pt-6 sm:px-9">
{/* Controls and preview share one card, split by a divider, so the page
reads as a single surface instead of two floating boxes. */}
<div className="bg-card corner-squircle flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-3xl panel-soft-surface">
<div className="flex w-[340px] shrink-0 flex-col overflow-hidden border-r border-border/60">
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto p-5">
{/* Workflow picker. Seven workflows don't fit a segmented strip in a 340px
rail, so it's a dropdown: the trigger carries the current workflow and
its hint, and each row explains itself. A row is disabled until the
loaded model supports it (status.workflows), with the reason in place of
the hint. New workflows slot in without shrinking anything. */}
/* Controls rail + preview canvas. No card: both sit on the page background
like the Hub, divided by a rule, so nothing is spent on box chrome. Padding
mirrors the other tabs (Export, Data Recipes): px-5 / sm:px-9. */
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden px-5 pb-8 pt-6 sm:px-9">
<div className="flex w-[330px] shrink-0 flex-col overflow-hidden border-r border-border/60">
{/* pl-0.5 keeps focus rings off the scroll container's edge. */}
<div className="hover-scrollbar flex min-h-0 flex-col gap-4 overflow-y-auto pb-1 pl-0.5 pr-6">
{/* Workflow picker. Seven workflows don't fit a segmented strip in this rail,
so it's a dropdown; a row stays disabled until the loaded model supports
it (status.workflows). New workflows slot in without shrinking anything. */}
<div className="grid gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
@ -2347,40 +2348,45 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
/>
</button>
</DropdownMenuTrigger>
{/* DropdownMenuContent already tracks the trigger width, so the rows get
the full rail width for their hints. */}
<DropdownMenuContent align="start">
{WORKFLOW_TABS.map((t) => {
const enabled = workflowEnabled(t);
return (
<DropdownMenuItem
key={t.id}
disabled={!enabled}
onSelect={() => setWorkflow(t.id)}
// The selected row's description shows under the trigger, and a
// disabled row explains itself on hover, so rows stay one line.
title={
enabled
// Rows stay one line; what the workflow does (or why it is
// unavailable) arrives as a tooltip after a short hover. The
// trigger wraps the row rather than being the row: a disabled item
// has pointer-events: none, so it would never hover.
<Tooltip key={t.id} delayDuration={550}>
<TooltipPrimitive.Trigger asChild={true}>
<div>
<DropdownMenuItem
disabled={!enabled}
onSelect={() => setWorkflow(t.id)}
className="gap-2"
>
<HugeiconsIcon
icon={t.icon}
className="size-4 shrink-0 text-muted-foreground"
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{t.label}
</span>
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
className={cn(
"size-3.5 shrink-0",
workflow === t.id ? "text-foreground" : "invisible",
)}
/>
</DropdownMenuItem>
</div>
</TooltipPrimitive.Trigger>
<TooltipContent side="right" sideOffset={6}>
{enabled
? t.hint
: `Needs a loaded model that supports ${t.label.toLowerCase()}`
}
className="gap-2"
>
<HugeiconsIcon
icon={t.icon}
className="size-4 shrink-0 text-muted-foreground"
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{t.label}
</span>
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
className={cn(
"size-3.5 shrink-0",
workflow === t.id ? "text-foreground" : "invisible",
)}
/>
</DropdownMenuItem>
: `Needs a loaded model that supports ${t.label.toLowerCase()}`}
</TooltipContent>
</Tooltip>
);
})}
</DropdownMenuContent>
@ -2881,8 +2887,8 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
</div>
</div>
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="relative flex flex-1 items-center justify-center overflow-auto p-6">
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden pl-2">
<div className="hover-scrollbar relative flex flex-1 items-center justify-center overflow-auto p-6">
{selected && selectedSrc ? (
<>
<img
@ -2940,7 +2946,8 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
</div>
) : busy === "generating" ? null : (
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<HugeiconsIcon icon={ImageAdd02Icon} className="size-12" strokeWidth={1.5} />
{/* Same icon as the Images nav item. */}
<HugeiconsIcon icon={Image03Icon} className="size-12" strokeWidth={1.5} />
<p className="text-sm">
{status?.loaded
? "Enter a prompt and hit Generate."
@ -2980,7 +2987,7 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
{(images.length > 0 || busy === "generating") && (
<div
className="flex shrink-0 gap-2 overflow-x-auto border-t border-foreground/10 p-3"
className="hover-scrollbar flex shrink-0 gap-2 overflow-x-auto border-t border-foreground/10 p-3"
onScroll={(e) => {
// Near the right edge: pull the next older page (infinite scroll).
const el = e.currentTarget;
@ -3028,21 +3035,20 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
</div>
)}
</div>
</div>
{/* Right-docked Advanced panel (mirrors Chat's settings panel): closed by default,
opened by the single fixed top-bar toggle above (which never moves between states,
like Chat's run-settings toggle), so the optimisation controls are discoverable
without being docked open or buried at the bottom of the left rail. */}
{advancedOpen && (
<div className="bg-card corner-squircle flex w-[300px] shrink-0 flex-col overflow-hidden rounded-3xl panel-soft-surface">
<div className="flex h-[52px] shrink-0 items-center border-b border-border/60 px-4">
<div className="ml-4 flex w-[300px] shrink-0 flex-col overflow-hidden border-l border-border/60 pl-4">
<div className="flex h-[52px] shrink-0 items-center border-b border-border/60">
<span className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<HugeiconsIcon icon={Settings02Icon} className="size-4" />
Advanced
</span>
</div>
<div className="flex flex-col gap-3 overflow-y-auto p-4">
<div className="hover-scrollbar flex flex-col gap-3 overflow-y-auto py-4 pr-2">
<p className="text-xs text-muted-foreground">
Load-time tuning. Changes apply on the next load; Reapply reloads the current model.
</p>

View file

@ -259,7 +259,7 @@ export function DatasetLabelingGrid({
</div>
)}
</div>
<div className="group grid max-h-[420px] grid-cols-2 gap-2 overflow-y-auto pr-1 sm:grid-cols-3">
<div className="hover-scrollbar group grid max-h-[420px] grid-cols-2 gap-2 overflow-y-auto pr-1 sm:grid-cols-3">
{pageRecords.map((r) => (
<LabelTile
key={r.filename}

View file

@ -103,7 +103,7 @@ export function DatasetShowcase({
type="button"
onClick={onBrowse}
title="Browse and caption these images"
className="flex w-full items-center gap-1.5 overflow-x-auto rounded-lg border border-border bg-muted/20 p-1.5 text-left transition-colors hover:border-foreground/20"
className="hover-scrollbar flex w-full items-center gap-1.5 overflow-x-auto rounded-lg border border-border bg-muted/20 p-1.5 text-left transition-colors hover:border-foreground/20"
>
{names === null ? (
<div className="flex h-14 items-center gap-2 px-2 text-[11px] text-muted-foreground">

View file

@ -53,7 +53,11 @@ import {
import { DatasetLabelingGrid, LabelingGridToggle } from "./dataset-labeling-grid";
import { DatasetShowcase } from "./dataset-showcase";
import { DiffusionCharts } from "./diffusion-charts";
import { ExampleDatasetCards, runExampleImport } from "./example-dataset-cards";
import {
ExampleDatasetCards,
runExampleImport,
shortExampleLabel,
} from "./example-dataset-cards";
// The families the Train tab can train, in the popularity order the user asked for. This is
// the fallback used when the backend's /info does not yet report families (older backend);
@ -1020,10 +1024,12 @@ export function DiffusionTrainPanel({
);
return (
<div className="flex min-h-0 min-w-0 flex-1 gap-4 overflow-hidden px-5 pb-8 pt-6 sm:px-9">
{/* Left: configure */}
<div className="bg-card corner-squircle flex w-[380px] min-w-0 shrink-0 flex-col overflow-hidden rounded-3xl panel-soft-surface">
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto overflow-x-hidden p-5">
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden px-5 pb-8 pt-6 sm:px-9">
{/* Left: configure. No card here or on the right: both panes sit on the page
background like the Hub, divided by a rule. */}
<div className="flex w-[370px] min-w-0 shrink-0 flex-col overflow-hidden border-r border-border/60">
{/* pl-0.5 keeps focus rings off the scroll container's edge. */}
<div className="hover-scrollbar flex min-h-0 flex-col gap-4 overflow-y-auto overflow-x-hidden pb-1 pl-0.5 pr-6">
<div>
<h2 className="text-sm font-semibold">Train a LoRA</h2>
<p className="mt-1 text-[11px] leading-snug text-muted-foreground">
@ -1098,18 +1104,19 @@ export function DiffusionTrainPanel({
<SelectValue />
</SelectTrigger>
<SelectContent>
{/* Rows stay short: name plus image count. Caption counts show under the
picker, and an example's license shows on its card below. */}
{(info?.datasets ?? []).map((d) => (
<SelectItem key={d.name} value={d.name}>
{d.name} ({d.image_count} image{d.image_count === 1 ? "" : "s"}
{d.caption_count > 0 ? `, ${d.caption_count} captions` : ""})
{d.name} - {d.image_count} image{d.image_count === 1 ? "" : "s"}
</SelectItem>
))}
{pendingExamples.length > 0 && (
<SelectGroup>
<SelectLabel>Examples (one-click import)</SelectLabel>
<SelectLabel>Examples</SelectLabel>
{pendingExamples.map((ex) => (
<SelectItem key={ex.id} value={`${EXAMPLE_PREFIX}${ex.id}`}>
{ex.label} ({ex.image_cap} images, {ex.license})
{shortExampleLabel(ex.label)} - {ex.image_cap} images
</SelectItem>
))}
</SelectGroup>
@ -1124,7 +1131,7 @@ export function DiffusionTrainPanel({
)}
{dataset === UPLOAD_DATASET ? (
<div className="grid gap-1.5 rounded-md border border-dashed border-border p-2">
<div className="grid gap-1.5">
<Input
value={uploadName}
placeholder="my-style-photos"
@ -1149,29 +1156,33 @@ export function DiffusionTrainPanel({
type="button"
size="sm"
variant="outline"
className="h-8 shrink-0"
className="h-7 shrink-0 px-3 text-xs"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
Choose images
</Button>
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{pickedFileCount > 0
? `${pickedFileCount} file${pickedFileCount === 1 ? "" : "s"} selected`
: "No files selected"}
</span>
<Button
type="button"
size="sm"
variant="secondary"
className="h-8 shrink-0"
onClick={onUpload}
disabled={uploading}
>
{uploading ? "Uploading..." : "Upload"}
</Button>
{/* Nothing to upload until files are picked, so the count and Upload
only appear then. */}
{pickedFileCount > 0 && (
<>
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
{pickedFileCount} file{pickedFileCount === 1 ? "" : "s"} selected
</span>
<Button
type="button"
size="sm"
variant="secondary"
className="h-7 shrink-0 px-3 text-xs"
onClick={onUpload}
disabled={uploading}
>
{uploading ? "Uploading..." : "Upload"}
</Button>
</>
)}
</div>
<p className="text-[11px] text-muted-foreground">
<p className="text-[11px] leading-snug text-muted-foreground">
10-50 images are plenty. Captions are optional: without them, the trigger
prompt below describes every image.
</p>
@ -1271,12 +1282,12 @@ export function DiffusionTrainPanel({
front) plus the previous-runs history; during/after a run the live view takes
over (progress with Stop, then the saved-adapter card ABOVE the charts).
Selecting a previous run re-plots its persisted logs read-only. */}
{/* p-1.5 so the cards' shadow (drawn outside the box) isn't clipped by this
scroll container. */}
<div className="relative flex min-w-0 flex-1 flex-col gap-4 overflow-y-auto p-1.5">
{/* Sections here carry no card of their own: spacing and a rule separate them.
p-1.5 keeps the chart cards' outer ring from being clipped. */}
<div className="hover-scrollbar relative flex min-w-0 flex-1 flex-col gap-5 overflow-y-auto p-1.5 pl-6">
{viewRun && !hasRun ? (
<>
<div className="bg-card corner-squircle flex flex-col gap-3 rounded-3xl p-5 panel-soft-surface">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">
Previous run: {viewRun.adapter || viewRun.job_id.slice(0, 8)}
@ -1339,7 +1350,7 @@ export function DiffusionTrainPanel({
</>
) : !hasRun ? (
<>
<div className="bg-card corner-squircle flex flex-col gap-4 rounded-3xl p-5 panel-soft-surface">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-semibold">
<HugeiconsIcon icon={Settings02Icon} className="size-4" />
@ -1357,7 +1368,7 @@ export function DiffusionTrainPanel({
</div>
{prevRuns.length > 0 && (
<div className="bg-card corner-squircle flex flex-col gap-2 rounded-3xl p-5 panel-soft-surface">
<div className="flex flex-col gap-2 border-t border-border/60 pt-4">
<span className="text-sm font-semibold">Previous runs</span>
<div className="flex flex-col divide-y divide-border/60">
{prevRuns.map((r) => (
@ -1397,7 +1408,7 @@ export function DiffusionTrainPanel({
</>
) : (
<>
<div className="bg-card corner-squircle flex flex-col gap-3 rounded-3xl p-5 panel-soft-surface">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold capitalize">
{status?.status === "completed" ? "Training complete \u{1F389}" : status?.status}
@ -1473,7 +1484,7 @@ export function DiffusionTrainPanel({
</div>
{(completed || stoppedWithAdapter) && (
<div className="bg-card corner-squircle flex flex-col gap-2 rounded-3xl p-5 panel-soft-surface">
<div className="flex flex-col gap-2 border-t border-border/60 pt-4">
<span className="text-sm font-semibold">
{completed ? "Adapter ready" : "Partial adapter saved"}
</span>

View file

@ -50,6 +50,12 @@ async function fetchPreviews(repo: string): Promise<string[]> {
return p;
}
// "Dog (DreamBooth subject)" -> "Dog". The parenthetical is context, not a name, so
// one-line picker rows and card titles drop it.
export function shortExampleLabel(label: string): string {
return label.replace(/\s*\(.*$/, "");
}
function ExamplePreviews({ repo }: { repo: string }) {
const [urls, setUrls] = useState<string[] | null>(null);
useEffect(() => {
@ -105,7 +111,12 @@ export function ExampleDatasetCards({
<div className="flex min-w-0 items-center gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-xs font-medium">{ex.label}</span>
<span
className="min-w-0 flex-1 truncate text-xs font-medium"
title={ex.label}
>
{shortExampleLabel(ex.label)}
</span>
<span
className="max-w-[110px] shrink truncate rounded-full bg-secondary px-2 py-0.5 text-[10px] font-normal text-secondary-foreground"
title={ex.license}

View file

@ -1505,16 +1505,6 @@ html[data-chat-font] .aui-root {
box-shadow: none;
}
/* Panel cards: no outline, flat in dark. Lighter than the composer's shadow
but spread wider, so a large card reads as lifted without a hard edge. */
.panel-soft-surface {
box-shadow: 0 4px 22px -6px rgba(0, 0, 0, 0.1);
}
.dark .panel-soft-surface {
box-shadow: none;
}
.composer-pill-btn {
@apply flex cursor-pointer items-center gap-1.5 rounded-full py-1.5 pl-2 pr-2.5 text-ui-14 font-medium text-muted-foreground/70 transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.1] disabled:cursor-not-allowed disabled:opacity-40;
}