From 1df23079581584c9822fb778c2283f733db8c9c6 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:27:12 -0700 Subject: [PATCH 1/3] feat(ui): the export dialog chooses a target model, and the lossy consent names it The Format select becomes Target model: the catalog from GET /export-targets, grouped under Ultralytics YOLO, Community YOLO and Other formats, each option carrying the tasks it accepts or the geometries it carries. The choice is sent as target=, never format=, and the retired yolo alias is never offered. The consent banner reads the 409's report for the chosen target and opens with a sentence naming the target, what it accepts and how much would be dropped or degraded, with the per-class list beneath. The grouped picker is a shared pattern, ExportTargetSelect, for the preprocessing tab to take as well. The note denying a pre-export validation route is corrected: the route exists and this screen does not call it. --- frontend/ui-core/src/data/refusals.ts | 50 +++- frontend/ui-core/src/index.ts | 9 + .../src/patterns/ExportTargetSelect.tsx | 100 ++++++++ .../ui-core/src/screens/DatasetScreen.tsx | 114 +++++---- frontend/ui-core/src/screens/dataset.test.tsx | 226 +++++++++++++----- frontend/ui-core/src/screens/queries.ts | 37 ++- 6 files changed, 419 insertions(+), 117 deletions(-) create mode 100644 frontend/ui-core/src/patterns/ExportTargetSelect.tsx diff --git a/frontend/ui-core/src/data/refusals.ts b/frontend/ui-core/src/data/refusals.ts index 8dae8069..5410e4fc 100644 --- a/frontend/ui-core/src/data/refusals.ts +++ b/frontend/ui-core/src/data/refusals.ts @@ -87,6 +87,7 @@ */ import { asApiError } from "./errors.js"; +import { GEOMETRY_LABELS, GEOMETRY_PLURALS } from "./geometryCategory.js"; import type { components } from "../generated/api.js"; import { formatCount } from "../lib/format.js"; @@ -150,8 +151,9 @@ export const REFUSAL_PROSE: Record = { RELEASE_NOT_FOUND: "That release is no longer on record.", RELEASE_TAG_TAKEN: "A release with that tag already exists — tags are never reused.", NO_SPLIT_RECIPE: "This release was published without a split, so there are no folds to show.", - LOSSY_EXPORT_NOT_CONSENTED: "This format cannot express every shape in the dataset.", + LOSSY_EXPORT_NOT_CONSENTED: "This target cannot take every shape in the dataset.", EXPORT_FORMAT_NOT_FOUND: "No exporter for that format is installed on this server.", + EXPORT_TARGET_NOT_FOUND: "No installed exporter writes for that model.", UNSERIALIZABLE_MANIFEST: "This release's manifest cannot be read back — the workspace may be damaged.", EMPTY_RELEASE: "This dataset has no frames yet — promote a completed batch first.", // The dialog lists the classes beside this, from the refusal's own `blockers`. @@ -301,3 +303,49 @@ export function lostClasses(detail: Record | null): readonly Cl function isClassCompatibility(value: unknown): value is ClassCompatibility { return isClassCount(value) && typeof (value as Record)["status"] === "string"; } + +/** `"boxes"`, or `"box"` for exactly one; a geometry the build has no word for stays raw. */ +function geometryNoun(geometry: string, count: number): string { + const table: Record = count === 1 ? GEOMETRY_LABELS : GEOMETRY_PLURALS; + return table[geometry] ?? geometry; +} + +function listed(words: readonly string[]): string { + if (words.length <= 1) return words.join(""); + return `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`; +} + +/** + * The lossy consent as one sentence about the target: what it accepts, and how + * much of this release it would drop or degrade. + * + * `"YOLOv10 accepts boxes only — 1,204 polygons would be dropped."` The counts + * are the report's, summed per geometry across classes, so the sentence answers + * the question the per-class list beneath it then itemises. A target that + * accepts nothing the release holds still names what it does accept, so the + * remedy — pick another target — is readable off the sentence. + */ +export function describeTargetDrops( + target: { readonly label: string; readonly geometries: readonly string[] }, + lost: readonly ClassCompatibility[], +): string { + const accepted = target.geometries.map((one) => geometryNoun(one, 2)); + const accepts = + accepted.length === 0 + ? `${target.label} accepts none of the shapes here` + : accepted.length === 1 + ? `${target.label} accepts ${accepted[0]} only` + : `${target.label} accepts ${listed(accepted)}`; + + const totals = new Map(); + for (const one of lost) { + const key = `${one.status}:${one.geometry}`; + totals.set(key, (totals.get(key) ?? 0) + one.annotations); + } + const clauses = [...totals].map(([key, count]) => { + const [status, geometry] = key.split(":") as [string, string]; + const verb = status === "degraded" ? "would be degraded" : "would be dropped"; + return `${formatCount(count)} ${geometryNoun(geometry, count)} ${verb}`; + }); + return clauses.length === 0 ? `${accepts}.` : `${accepts} — ${listed(clauses)}.`; +} diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts index 695430c9..ed31f4ee 100644 --- a/frontend/ui-core/src/index.ts +++ b/frontend/ui-core/src/index.ts @@ -441,3 +441,12 @@ export { type ReleaseVerification, type SplitRecipe, } from "./screens/queries.js"; +export { useExportTargets, type ExportTarget } from "./screens/queries.js"; +export { + ExportTargetSelect, + exportTargetMeta, + groupExportTargets, + type ExportTargetGroup, + type ExportTargetSelectProps, +} from "./patterns/ExportTargetSelect.js"; +export { describeTargetDrops } from "./data/refusals.js"; diff --git a/frontend/ui-core/src/patterns/ExportTargetSelect.tsx b/frontend/ui-core/src/patterns/ExportTargetSelect.tsx new file mode 100644 index 00000000..42adb512 --- /dev/null +++ b/frontend/ui-core/src/patterns/ExportTargetSelect.tsx @@ -0,0 +1,100 @@ +/** + * The target picker: which model a release is exported for. + * + * Options are grouped by family — the trainer's own YOLO line, the community + * YOLO forks, and everything else — because a person choosing a model knows + * which of those they are training and reads the list that way. The family is + * a string on the wire and may grow; one this build has no heading for lands + * under *Other formats* rather than out of the list, so nothing declared is + * invisible. A group with nothing under it renders nothing. + * + * Each option's second line is what the target takes: the tasks it accepts for a + * model, and, for a self-named format with no task vocabulary, the geometries it + * carries. `SelectItem`'s `meta` puts the same two lines on the closed control. + */ + +import type { JSX } from "react"; + +import { GEOMETRY_LABELS } from "../data/geometryCategory"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "../primitives/Select"; +import type { ExportTarget } from "../screens/queries"; + +const FAMILY_HEADINGS: readonly (readonly [family: string, heading: string])[] = [ + ["ultralytics-yolo", "Ultralytics YOLO"], + ["community-yolo", "Community YOLO"], +]; +const OTHER_HEADING = "Other formats"; + +export interface ExportTargetGroup { + readonly heading: string; + readonly targets: readonly ExportTarget[]; +} + +/** The catalog under its headings, in heading order, groups with nothing omitted. */ +export function groupExportTargets(targets: readonly ExportTarget[]): readonly ExportTargetGroup[] { + const known = new Set(FAMILY_HEADINGS.map(([family]) => family)); + const groups = FAMILY_HEADINGS.map(([family, heading]) => ({ + heading, + targets: targets.filter((one) => one.family === family), + })); + groups.push({ heading: OTHER_HEADING, targets: targets.filter((one) => !known.has(one.family)) }); + return groups.filter((group) => group.targets.length > 0); +} + +/** The option's second line, or nothing when the target declares neither tasks nor geometries. */ +export function exportTargetMeta(target: ExportTarget): string | undefined { + if (target.tasks.length > 0) return target.tasks.join(" · "); + if (target.geometries.length > 0) { + return target.geometries + .map((one) => (GEOMETRY_LABELS as Record)[one] ?? one) + .join(" · "); + } + return undefined; +} + +export interface ExportTargetSelectProps { + readonly id?: string; + readonly targets: readonly ExportTarget[]; + /** The chosen target's `name`; `""` while nothing is chosen. */ + readonly value: string; + readonly onValueChange: (name: string) => void; + readonly placeholder?: string; + readonly "data-testid"?: string; +} + +export function ExportTargetSelect({ + id, + targets, + value, + onValueChange, + placeholder = "Choose a model", + "data-testid": testId, +}: ExportTargetSelectProps): JSX.Element { + return ( + + ); +} diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx index e1dda620..8ffee6eb 100644 --- a/frontend/ui-core/src/screens/DatasetScreen.tsx +++ b/frontend/ui-core/src/screens/DatasetScreen.tsx @@ -18,13 +18,15 @@ * and the UI keeps them apart too: the delete dialog, the schema dialog and this * one are three. * - * There is no pre-export validation route, so consent here is attempt-shaped: - * attempt, read `LOSSY_EXPORT_NOT_CONSENTED` off the 409, ask, retry with the flag. - * The schema editor does not have this shape — it previews first — and the - * difference is exactly the routed preview that export lacks. - * `FormatOut.lossy` is what makes the question predictable — it is declared by the - * *format*, because a bbox-only format loses a polygon whether or not today's - * dataset holds one. + * `GET /releases/{id}/export-compatibility` exists and would answer the question + * before anything is attempted; this screen does not call it. Consent here is + * attempt-shaped: attempt, read `LOSSY_EXPORT_NOT_CONSENTED` off the 409, ask, + * retry with the flag — and the 409 carries the same compatibility report the + * route would, judged for the chosen target, so the banner can say what the + * target accepts and how much would be lost. `FormatOut.lossy`, read through the + * target's format, is what makes the question predictable before the attempt — + * declared by the *format*, because a bbox-only format loses a polygon whether or + * not today's dataset holds one. * * ## The trunk's membership is on the screen now, and so is curation * @@ -65,19 +67,14 @@ import { FieldError, FieldHint, Input, Label } from "../primitives/Input"; import { classBlockers, describeClassCount, + describeTargetDrops, jobFailureProse, lostClasses, refusalProse, } from "../data/refusals"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../primitives/Select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/Table"; import { EmptyState, ErrorState } from "../patterns/AsyncStates"; +import { ExportTargetSelect } from "../patterns/ExportTargetSelect"; import { AssetThumbnail } from "./AssetThumbnail"; import { DatasetAssetDialog, trunkAssetLabel } from "./DatasetAssetDialog"; import { saveBlob } from "./download"; @@ -88,6 +85,7 @@ import { useDatasetStats, useDownloadManifest, useExportRelease, + useExportTargets, useFormats, useJobArtifact, useProjectDataset, @@ -931,10 +929,11 @@ function ExportDialog({ readonly open: boolean; readonly onClose: () => void; }): JSX.Element { + const targets = useExportTargets(); const formats = useFormats(); const exportRelease = useExportRelease(releaseId); const artifact = useJobArtifact(); - const [format, setFormat] = useState(""); + const [target, setTarget] = useState(""); const [consented, setConsented] = useState(false); // The job this dialog is watching. Null until a launch is accepted, and null // again once the archive has been saved — a finished download is not something @@ -947,10 +946,13 @@ function ExportDialog({ const [outcome, setOutcome] = useState(null); const job = useBackgroundJob(jobId); - const installed = formats.data?.items ?? []; - const chosen = installed.find((one) => one.name === format); - // **The refusals still arrive on the launch.** An unknown format is a 404 and a - // lossy format without consent is a 409, both answered by the request rather + const catalog = targets.data?.items ?? []; + const chosen = catalog.find((one) => one.name === target); + // Lossiness is the format's declaration, reached through the target's format; + // a formats read that failed leaves the hint out and the 409 still asks. + const lossy = formats.data?.items.find((one) => one.name === chosen?.format)?.lossy === true; + // **The refusals still arrive on the launch.** An unknown target is a 404 and a + // lossy export without consent is a 409, both answered by the request rather // than by the job — so the consent flow below is exactly the one that shipped // before export was queued. const failure = exportRelease.isError ? asApiError(exportRelease.error) : null; @@ -964,7 +966,7 @@ function ExportDialog({ setSaved(false); setOutcome(null); exportRelease.mutate( - { format, ...(allowLossy ? { allowLossy: true } : {}) }, + { target, ...(allowLossy ? { allowLossy: true } : {}) }, { onSuccess: (queued) => setJobId(queued.id) }, ); } @@ -982,11 +984,11 @@ function ExportDialog({ setOutcome("succeeded"); artifact.mutate(jobId, { onSuccess: (blob) => { - saveBlob(blob, `${tag}-${format}.zip`); + saveBlob(blob, `${tag}-${target}.zip`); setJobId(null); }, }); - }, [saved, jobId, job.data?.state, artifact, tag, format]); + }, [saved, jobId, job.data?.state, artifact, tag, target]); // The badge's subject: the live job while there is one, the outcome once the // archive has been handed over and the poll has stopped. @@ -997,65 +999,67 @@ function ExportDialog({ Export {tag} - Writes the release through an installed exporter and downloads the result. + Writes the release for the model you will train and downloads the result.
- + {/* Three renderings for three answers, because `?? []` above used to give the first two the same one — the swallowed-refusal pattern. - A failed `GET /formats` is not an answer at all; a + A failed `GET /export-targets` is not an answer at all; a successful empty page is an answer about this server's plugins; - and the combobox is for when there is something to choose. Rolling + and the picker is for when there is something to choose. Rolling the first two together produces a control offering nothing and saying nothing, which is also the visible signature of an install whose exporters are not discoverable — so the screen that should tell a broken install from a broken request is what makes the two indistinguishable. - Loading is deliberately not a fourth branch: `formats.data === - undefined` while pending, so the combobox stands empty for the one - tick it takes, exactly as it did before. + Loading is deliberately not a fourth branch: `targets.data === + undefined` while pending, so the picker stands empty for the one + tick it takes. */} - {formats.isError ? ( -
+ {targets.isError ? ( +
{/* No `code`: the identifier is not the half a person can act on, and the sibling refusal on this screen (`manifest-error-*`) already renders prose without one. */} void formats.refetch()} + message={`${refusalProse(targets.error)} Try again to choose a model.`} + onRetry={() => void targets.refetch()} />
- ) : formats.data !== undefined && installed.length === 0 ? ( -
+ ) : targets.data !== undefined && catalog.length === 0 ? ( +
) : ( - + )} {/* Declared by the format, never by the release: a bbox-only format loses a polygon whether or not today's dataset holds one. */} - {chosen?.lossy === true && ( + {lossy && ( - This format cannot express everything the schema allows. + {chosen?.label} cannot take everything the schema allows. + + )} + {/* A formats read that failed is said, not swallowed: the hint is + absent for a reason, and the launch still asks before dropping. */} + {chosen !== undefined && formats.isError && ( + + Whether {chosen.label} loses anything could not be read — the export asks before + dropping a shape. )}
@@ -1082,7 +1086,13 @@ function ExportDialog({ title="Some shapes cannot be exported" data-testid="lossy-consent" > -

{refusalProse(failure)}

+ {/* The sentence names the target and the counts when the 409 + carried its report; without one, the vocabulary's own line. */} +

+ {chosen !== undefined && lost !== null + ? describeTargetDrops(chosen, lost) + : refusalProse(failure)} +

{lost !== null && lost.length > 0 && (
    {lost.map((one) => ( @@ -1140,7 +1150,7 @@ function ExportDialog({ data-testid="export-submit" // The consent gate: while the API is asking, the button stays shut until // the box is ticked. It is `allow_lossy` and never `confirm`. - disabled={format === "" || running || (needsConsent && !consented)} + disabled={target === "" || running || (needsConsent && !consented)} onClick={() => run(needsConsent)} >