Images: center the mode switch, flip the arrow with the orientation, app tooltips everywhere

The Create/Train switch is centered on the page instead of trailing the model
selector, with wider buttons. The flip control's arrows now rotate with the
orientation and its label says which way the flip goes. Every native title
tooltip on the page is now the app's tooltip, so they all get the rounded
surface instead of the OS box.
This commit is contained in:
Unsloth 2026-07-25 04:17:05 -07:00
commit 7e5f4d87de
4 changed files with 188 additions and 118 deletions

View file

@ -52,7 +52,11 @@ 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 {
Tooltip,
TooltipContent,
TooltipTrigger,
} 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";
@ -508,14 +512,18 @@ function ResolvedBadge({
}) {
const resolved = status?.resolved?.[controlKey];
if (!resolved || resolved.source !== "auto") return null;
return (
<span
title={resolved.reason || undefined}
className="shrink-0 rounded-sm bg-muted px-1 py-px text-[9px] font-medium uppercase tracking-wider text-muted-foreground"
>
const badge = (
<span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[9px] font-medium uppercase tracking-wider text-muted-foreground">
Auto: {formatResolvedValue(controlKey, resolved.value)}
</span>
);
if (!resolved.reason) return badge;
return (
<Tooltip>
<TooltipTrigger asChild={true}>{badge}</TooltipTrigger>
<TooltipContent>{resolved.reason}</TooltipContent>
</Tooltip>
);
}
// A compact labeled Select row for the Advanced Options panel.
@ -596,20 +604,24 @@ function ImageDropzone({
return (
<div className="relative overflow-hidden rounded-[10px] border border-border">
<img src={value} alt="Source" className="max-h-44 w-full object-contain bg-muted/30" />
<Button
type="button"
variant="secondary"
size="icon"
aria-label="Remove source image"
title="Remove"
className="absolute right-1.5 top-1.5 size-7"
onClick={() => {
onChange(null);
if (inputRef.current) inputRef.current.value = "";
}}
>
<HugeiconsIcon icon={Delete02Icon} className="size-3.5" />
</Button>
<Tooltip>
<TooltipTrigger asChild={true}>
<Button
type="button"
variant="secondary"
size="icon"
aria-label="Remove source image"
className="absolute right-1.5 top-1.5 size-7"
onClick={() => {
onChange(null);
if (inputRef.current) inputRef.current.value = "";
}}
>
<HugeiconsIcon icon={Delete02Icon} className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
</div>
);
}
@ -2214,16 +2226,20 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
wires lastLoad for it), so it keeps Reapply even before any user-initiated
load; GGUF/single_file residents have no reload target and hide the button. */}
{status?.loaded && (canReapply || status?.model_kind === "pipeline") && (
<Button
variant="secondary"
size="sm"
disabled={busy !== null}
onClick={handleReapply}
title="Reload the current model with these advanced options"
>
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="mr-2 size-3.5" />
Reapply to loaded model
</Button>
<Tooltip>
<TooltipTrigger asChild={true}>
<Button
variant="secondary"
size="sm"
disabled={busy !== null}
onClick={handleReapply}
>
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="mr-2 size-3.5" />
Reapply to loaded model
</Button>
</TooltipTrigger>
<TooltipContent>Reload the current model with these advanced options</TooltipContent>
</Tooltip>
)}
</>
);
@ -2233,7 +2249,7 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
{/* Top: the model selector, kept at the chat tab's exact position so the
shared element matches. The load progress shows in a chat-style toast,
not here. */}
<div className="flex h-[48px] shrink-0 items-start justify-between pl-2 pr-2 pt-[11px]">
<div className="relative flex h-[48px] shrink-0 items-start justify-between pl-2 pr-2 pt-[11px]">
<div className="flex items-center gap-2">
<ModelSelector
models={MODELS}
@ -2248,17 +2264,18 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
open={active && selectorOpen}
onOpenChange={(o) => setSelectorOpen(active && o)}
/>
{/* Create | Train page-mode switch, next to the model selector (which stays
leftmost: its position is shared with Chat's). PillTabs is the app's
segmented control, so this matches the picker and Hub toggles. Icons match
the sidebar's New chat / Train rows. Height pinned to the row's 34px. */}
</div>
{/* Create | Train page-mode switch, centered on the page rather than tied to the
selector's width (the selector stays leftmost: its position is shared with
Chat's). PillTabs is the app's segmented control, so this matches the picker
and Hub toggles. Icons match the sidebar's New chat / Train rows. */}
<div className="pointer-events-none absolute inset-x-0 top-[11px] flex justify-center">
<PillTabs
ariaLabel="Page mode"
value={pageMode}
onValueChange={(v) => setPageMode(v as "create" | "train")}
fit={true}
// ml-5 sets it off from the selector rather than sitting against it.
className="ml-5 h-[34px] [&>button]:h-[34px] [&>button]:px-4"
className="pointer-events-auto h-[34px] [&>button]:h-[34px] [&>button]:px-7"
tabs={[
{
value: "create",
@ -2285,21 +2302,25 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
toggle, same icon in both states so it never moves). Highlighted when open.
Only meaningful in Create mode (load-time tuning), so hidden while training. */}
{pageMode === "create" && (
<button
type="button"
onClick={() => setAdvancedOpen((o) => !o)}
aria-label={advancedOpen ? "Hide advanced options" : "Show advanced options"}
aria-pressed={advancedOpen}
title="Advanced options"
className={cn(
"flex h-[34px] w-[34px] items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
advancedOpen
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<HugeiconsIcon icon={LayoutAlignRightIcon} className="size-4" />
</button>
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
onClick={() => setAdvancedOpen((o) => !o)}
aria-label={advancedOpen ? "Hide advanced options" : "Show advanced options"}
aria-pressed={advancedOpen}
className={cn(
"flex h-[34px] w-[34px] items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
advancedOpen
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<HugeiconsIcon icon={LayoutAlignRightIcon} className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>Advanced options</TooltipContent>
</Tooltip>
)}
</div>
</div>
@ -2821,16 +2842,30 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
))}
</SelectContent>
</Select>
<Button
type="button"
variant="secondary"
size="icon"
aria-label="Flip width and height"
title="Flip orientation"
onClick={flipDimensions}
>
<HugeiconsIcon icon={ArrowLeftRightIcon} className="size-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild={true}>
<Button
type="button"
variant="secondary"
size="icon"
aria-label="Flip width and height"
onClick={flipDimensions}
>
{/* The arrows turn with the orientation, so the button shows
which way the flip goes. */}
<HugeiconsIcon
icon={ArrowLeftRightIcon}
className={cn(
"size-4 transition-transform duration-200",
portrait && "rotate-90",
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{portrait ? "Switch to landscape" : "Switch to portrait"}
</TooltipContent>
</Tooltip>
</div>
</Field>
<SliderField label="Width" value={width} min={MIN_DIM} max={MAX_DIM} step={16} onChange={changeWidth} />
@ -2929,16 +2964,20 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
size="sm"
variant="ghost"
aria-label="Delete image"
title="Delete"
className="text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(selected.id)}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild={true}>
<Button
size="sm"
variant="ghost"
aria-label="Delete image"
className="text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(selected.id)}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
</>
) : selected ? (

View file

@ -9,6 +9,11 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { toast } from "@/lib/toast";
@ -114,15 +119,19 @@ function LabelTile({
<Spinner className="size-4 text-muted-foreground" />
</div>
)}
<button
type="button"
onClick={remove}
disabled={deleting}
title="Remove this image from the dataset"
className="absolute right-1 top-1 rounded-md bg-background/80 px-1.5 py-0.5 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:bg-background hover:text-destructive group-hover:opacity-100 focus:opacity-100"
>
{deleting ? "..." : "Remove"}
</button>
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
onClick={remove}
disabled={deleting}
className="absolute right-1 top-1 rounded-md bg-background/80 px-1.5 py-0.5 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:bg-background hover:text-destructive group-hover:opacity-100 focus:opacity-100"
>
{deleting ? "..." : "Remove"}
</button>
</TooltipTrigger>
<TooltipContent>Remove this image from the dataset</TooltipContent>
</Tooltip>
</div>
<Textarea
value={caption}
@ -135,9 +144,12 @@ function LabelTile({
aria-label={`Caption for ${record.filename}`}
/>
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span className="truncate" title={record.filename}>
{record.filename}
</span>
<Tooltip>
<TooltipTrigger asChild={true}>
<span className="truncate">{record.filename}</span>
</TooltipTrigger>
<TooltipContent>{record.filename}</TooltipContent>
</Tooltip>
{saving ? (
<span>Saving...</span>
) : savedTick ? (

View file

@ -4,6 +4,11 @@
import { useEffect, useState } from "react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
diffusionDatasetImageUrl,
@ -99,29 +104,33 @@ export function DatasetShowcase({
const remaining = imageCount - (names?.length ?? 0);
return (
<button
type="button"
onClick={onBrowse}
title="Browse and caption these images"
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">
<Spinner className="size-3.5" /> Loading preview...
</div>
) : (
<>
{names.map((n) => (
<ShowcaseTile key={n} dataset={dataset} filename={n} />
))}
{remaining > 0 && (
<div className="flex size-14 shrink-0 flex-col items-center justify-center rounded-md bg-muted text-muted-foreground">
<span className="text-sm font-medium">+{remaining}</span>
<span className="text-[9px]">more</span>
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
onClick={onBrowse}
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">
<Spinner className="size-3.5" /> Loading preview...
</div>
) : (
<>
{names.map((n) => (
<ShowcaseTile key={n} dataset={dataset} filename={n} />
))}
{remaining > 0 && (
<div className="flex size-14 shrink-0 flex-col items-center justify-center rounded-md bg-muted text-muted-foreground">
<span className="text-sm font-medium">+{remaining}</span>
<span className="text-[9px]">more</span>
</div>
)}
</>
)}
</>
)}
</button>
</button>
</TooltipTrigger>
<TooltipContent>Browse and caption these images</TooltipContent>
</Tooltip>
);
}

View file

@ -4,6 +4,11 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { toast } from "@/lib/toast";
import {
@ -111,18 +116,23 @@ 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"
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}
>
{ex.license}
</span>
{/* Both are truncated, so the full text lives in a tooltip. */}
<Tooltip>
<TooltipTrigger asChild={true}>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{shortExampleLabel(ex.label)}
</span>
</TooltipTrigger>
<TooltipContent>{ex.label}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild={true}>
<span className="max-w-[110px] shrink truncate rounded-full bg-secondary px-2 py-0.5 text-[10px] font-normal text-secondary-foreground">
{ex.license}
</span>
</TooltipTrigger>
<TooltipContent>{ex.license}</TooltipContent>
</Tooltip>
</div>
<p className="line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{ex.description}