diff --git a/.agents/skills/frontend/information-architecture/SKILL.md b/.agents/skills/frontend/information-architecture/SKILL.md index ff2ed188..823f2574 100644 --- a/.agents/skills/frontend/information-architecture/SKILL.md +++ b/.agents/skills/frontend/information-architecture/SKILL.md @@ -10,6 +10,9 @@ description: The canonical sitemap and navigation rules for the VisionSet app. C Navigation maps 1:1 to domain objects. This is the target structure; if implementation differs, implementation is what's wrong. ``` +/ Home — the workspace dashboard (rail destination) + └─ deep-links out to: /jobs/:jobId?asset= (resume), /projects/:id/batches/:id + (review rows and the resume fallback), /projects/:id, /projects /projects Projects list /inference Inference — model connections (workspace-scoped) /projects/:id Project — tabs, in this order: @@ -42,6 +45,17 @@ Rules derived from the 2026-08 audit (§6): - **The 4-step checklist is onboarding, not navigation.** It retires itself twice over: when the journey is finished (`hasReleases` makes `done` derivable) and when somebody dismisses it. Dismissal is **per project** and persisted — finishing one project does not teach you the pipeline for the next. It gates nothing and is never the sole path to a screen. `hasReleases` is derived in `useProjectReadiness` from the two-hop read (project → dataset → releases) rather than added to the project-stats wire model: the Overview dashboard already makes both requests for its own cards, so a third spelling of the fact on the server would be the drift this audit was about. - **Inference is a rail destination, not a project tab.** Model connections carry no `project_id`: one workspace is one SQLite file, every project uses the same connections, and navigation maps 1:1 to domain objects — so a project tab would state a scope the object does not have. The decision is recorded on #421 (2026-08-08) and **supersedes #58's rail rule** ("logo, collapse toggle, Home, Projects, account avatar — nothing else"); the rail now carries Home, Projects, Inference and the account control, and `DESIGN.md` carries the same membership in both places it states it. What earns a rail entry is a workspace-level object with nowhere else to live, never mere frequency of use. +- **Home is the workspace's dashboard, and Overview is the project's.** They do not + overlap, because they answer different questions: Home asks *what is waiting on me, + anywhere*, which no single project can answer, and Overview asks *what does this + project hold*. So Home carries nothing project-scoped — no class distribution, no + samples, no schema state — and every row on it is a deep link into the screen that + owns the thing. It earns its rail entry on the same test Inference passes: a + workspace-level object with nowhere else to live. + **Its resume target is derived, never persisted**, and ranked by progress rather + than recency because no timestamp exists on a batch, an annotation or an asset's + progress. The one visible consequence is the CTA label: with no unlabeled frame + left it reads *Open batch* and goes to the gallery instead of the editor. - **Overview is a dashboard**: pipeline state of batches, trunk size, latest release, active schema version — each card links to its tab (`StatCard`'s `onGo`, which renders the card as a **button** so it is keyboard-reachable and announced as an action). Overview never duplicates a tab's full function: every number on it is a *pointer* at the section that owns it, and a section with nothing yet says so in words rather than showing a zero. ## Structural invariants diff --git a/DESIGN.md b/DESIGN.md index 28581bf5..794a8c05 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -434,6 +434,84 @@ Focus is **not** styled here at all: `styles.css`'s base layer gives every so it never depended on the segmented chip's fill. The trigger adds `focus-visible:bg-muted` only so the ring encloses a fill rather than the page. +## Home + +The workspace's front page, and the rail's first entry. It was a redirect to the project +list until there were numbers worth showing; what makes it a page rather than a second +list is that it answers a different question. A list answers *what exists*. Home answers +**what is waiting on me, and where do I carry on** — which is a question no single project +can answer, because the state that matters is spread across all of them. + +It carries nothing project-scoped. No class distribution, no samples, no schema state: +those belong to the project's own Overview, and a second copy here is the drift this +document exists to prevent. Every row is a *pointer*, and pressing it goes to the screen +that owns the thing. + +**Two states, and the first one is gone for good.** A workspace with no projects renders +one invitation and nothing else — a headline, a line of body, the filled **Create +project**, and three quiet cards naming the cycle (ingest, annotate, release) with no +controls on them. The cards are there so somebody can see what the tool does before +committing to it; a button on each would be three more things competing with the one that +matters. Once a project exists this state never returns, and the dashboard renders with +real zeros wherever the numbers are genuinely zero. + +**Layout** is the two-column detail contract, `1fr / 320px`, stacking below `lg`. The main +column is the resume card, then what needs attention, then a short list of recent +projects. The 320px aside is four stat cards in a 2×2 grid — projects, images, +annotations, releases — and beneath them the activity feed. + +**A section with nothing in it is not rendered.** Not as a placeholder, not as a zero, not +as "nothing here yet" — absent. A dashboard whose every section is an apology has taught +the reader nothing and taken a screen to do it. The stat cards are the deliberate +exception: a count of zero is a *measurement*, and four cards that came and went would +make the aside jump on every visit. + +### Continue where you left off + +The card names a project, a batch, `n / m annotated` in tabular figures, and a picture of +the frame it would open. It holds the view's one filled button. + +**It is ranked by progress, not by recency, and that is forced rather than chosen.** There +is no timestamp anywhere on a batch, an annotation, or an asset's progress, so *the batch +I touched last* has no source in the storage format and deriving one would mean a +migration. What the rows can answer is which batch is furthest through and not yet +finished, which is what the card offers. It degrades correctly — one open batch is offered +whatever its progress — and the accepted limit is that two batches part-way through +resolve to the further-along one rather than the more recent. + +**The label is load-bearing.** With a frame left to label the control reads **Continue +annotating** and opens the editor at that frame. With none left it reads **Open batch** and +goes to the gallery, because there is no frame to open and a button claiming otherwise +would land somewhere empty. Same card, same slot, different promise. + +### Needs your attention, and activity + +Attention rows are one line each: a batch holding frames awaiting review, a background job +that failed, a background job still running. A review row links to its batch. **A job row +links nowhere and says so by not being a link** — a background job names an ingest run or a +release in its payload, never a project, and no screen shows one. That is the rule for a +section whose consuming surface does not exist yet, and inventing a destination would be +principle 9's dead button with a friendlier label. + +Activity is a projection over timestamps that already exist, never an event log, capped at +about eight and newest first. Two of its four kinds say less than they might, and the copy +must not overstate them: an ingest row reports *the last data that arrived in this +project*, not one run finishing, because an ingest records no time at all; and a schema row +reports a version being **created**, because which version is active is derived — it is the +highest — so there is no activation to date. + +### The one filled button, in all three states + +Principle 8 is a count, so Home answers it in every state and there is never a fourth: +first run offers **Create project**, a workspace with somewhere to carry on offers +**Continue annotating**, and one with nothing open offers **New project** — because when +every batch is finished, starting the next piece of work genuinely is what comes next. +When the resume card renders, the header's own New project steps back to `secondary`. + +The first-run CTA opens the **same dialog** the project list's button opens, rather than +navigating to the screen that carries it. A filled button labelled *Create project* that +only moved somewhere else would promise an action it does not perform. + ## Project surfaces The project view is the face of a project, and principle 6 is the rule it kept failing: it diff --git a/docs/api.md b/docs/api.md index 5bb7a73f..c777e4c6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -367,6 +367,21 @@ have to fake. written out field by field, so a field reaches a client because somebody published it and never because somebody added it to an entity. +**One endpoint is a projection rather than a resource, and it is the exception that states the +rule.** `GET /home` composes the workspace's front page - totals across every project, the batch +to carry on with, what is waiting, and a feed derived from timestamps that already exist. It takes +no path parameters, has no verb but `GET`, and carries **no `allowed_actions`**: there is nothing +here to act on, only rows pointing at resources that declare their own capabilities. It exists +because the page it answers asks four questions that each span every project, and answering them +as separate resources would be a request per project per question. + +Two of its fields are honest about limits the storage format imposes, and a client should render +them as they are described rather than as it might wish them. `resume` is ranked by **progress, +not recency** - nothing records when a batch was last worked on - and a null `next_asset_id` means +the batch has no unlabeled frame left, so the caller opens its gallery rather than the editor. An +`ingest` activity entry is the newest asset arrival in a project rather than one run finishing, +because an ingest job records no times. + ## Where the UI lives The compiled application is mounted at **`/app`** and `/` redirects to it. `visionset server` starts diff --git a/docs/ui.md b/docs/ui.md index 54efe4db..e3d40299 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -36,7 +36,7 @@ handler is not an operation. | route | what | behind the token gate | | --- | --- | --- | -| `/` | Home | yes | +| `/` | Home - the workspace dashboard | yes | | `/projects`, `/projects/:id` (`?tab=overview\|schema\|batches\|dataset`), `/projects/:id/ingest`, `/projects/:id/batches/:id`, `/projects/:id/dataset` | the product | yes | | `/inference` | model connections, workspace-scoped | yes | | `/jobs/:jobId` (`?asset=`) | the annotation page | yes | @@ -119,6 +119,38 @@ Query keys are hierarchical - `["projects"]` → `["projects", id]` → invalidating `["projects", id]` after a rename refreshes the project, its schema and its version list, and the mutation never has to enumerate what it affected. +### Home, and the one screen the server composes + +`/` is the workspace dashboard. It was a redirect to the project list until there +were numbers worth showing, and what makes it a page rather than a second list is +that it answers a different question: a list says what exists, this says what is +waiting and where to carry on - which spans every project, so no project-scoped +screen can answer it. + +**One query, `useHome`, over one endpoint.** `GET /home` returns the whole page in +one response: totals, the batch to resume, what needs attention, a short list of +recent projects and a derived activity feed. Composed on the server because the +alternative is a request per project per question with the browser doing the +joining, which is slower and renders in pieces as they land. + +The summary is a **read-only projection**. It declares no `allowed_actions` and no +mutation takes it as input; every row deep-links to a resource whose own wire shape +says what may be done to it. A second copy of those declarations here would be the +hand-mirrored table the capabilities contract forbids, one layer up. + +Two things about it are consequences of the storage format rather than choices, and +both are stated on the endpoint as well as here. The resume target is ranked by +**progress, not recency** - no timestamp exists on a batch, an annotation or an +asset's progress row, so "most recently worked on" has no source - and when its +`next_asset_id` is null the batch has no unlabeled frame left, which is the client's +signal to open the gallery and to say *Open batch* rather than *Continue annotating*. +The activity feed's `ingest` entry is the newest `Asset.ingested_at` in a project +rather than a run finishing, because an ingest job records no times at all. + +A workspace with no projects reads zeros, nulls and empty lists. That is the +first-run state, and `totals.projects` is how the screen recognises it - not a flag, +which would be a second spelling of a fact the response already carries. + ### The project view, and the one screen whose section is in the URL A project has three sections - its schema, its batches, its version history - and diff --git a/frontend/app/cycle/cycle.spec.ts b/frontend/app/cycle/cycle.spec.ts index 2702438b..b17df500 100644 --- a/frontend/app/cycle/cycle.spec.ts +++ b/frontend/app/cycle/cycle.spec.ts @@ -167,7 +167,13 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa }); await test.step("create a project", async () => { - await page.getByTestId("new-project").click(); + // From Home's first-run invitation rather than from the project list, and + // that is the honest route here: this workspace was created seconds ago, so + // `/` is exactly the state that invitation exists for. It opens the same + // dialog the list's own button does — one component, two callers — which is + // why every field below is unchanged. + await expect(page.getByTestId("home-first-run")).toBeVisible(); + await page.getByTestId("home-create-project").click(); await page.getByTestId("project-name").fill(PROJECT); await page.getByTestId("project-description").fill("Driven by #59"); await page.getByTestId("create-submit").click(); diff --git a/frontend/app/e2e/inference.spec.ts b/frontend/app/e2e/inference.spec.ts index 953d1a99..aa29f66a 100644 --- a/frontend/app/e2e/inference.spec.ts +++ b/frontend/app/e2e/inference.spec.ts @@ -99,6 +99,22 @@ function connection( * it the way it reads a server. */ async function serveApi(page: Page, next: () => unknown): Promise { + // These scenarios reach Inference by signing in at `/`, which is a real page + // now rather than a redirect to the project list — so it makes this request on + // the way past. Empty totals: nothing here is about the dashboard, and an + // unrouted request would leave the page waiting on a network that is not there. + await page.route("**/api/home", (route) => + route.fulfill({ + status: 200, + json: { + totals: { projects: 0, assets: 0, annotations: 0, releases: 0 }, + resume: null, + attention: [], + projects: [], + activity: [], + }, + }), + ); await page.route("**/api/session", (route) => route.fulfill({ json: { issued: false } })); await page.route("**/api/inference/connections*", (route) => route.fulfill({ status: 200, json: { items: [next()], total: 1 } }), diff --git a/frontend/app/e2e/shell.spec.ts b/frontend/app/e2e/shell.spec.ts index 3ca1cd2b..b03627ad 100644 --- a/frontend/app/e2e/shell.spec.ts +++ b/frontend/app/e2e/shell.spec.ts @@ -35,6 +35,22 @@ async function serveApi(page: Page, { session = false } = {}): Promise { // because the gate and the sign-out button are what it is about and both are // only reachable when the server declines to sign the browser in by itself. await page.route("**/api/session", (route) => route.fulfill({ json: { issued: session } })); + // Home reads the workspace summary on every page load now, because the rail's + // Home entry lands on a real page rather than redirecting. Empty totals: this + // suite is about the rail and the router, and what the dashboard does with + // rows belongs to `ui-core`'s `home.test.tsx`. + await page.route("**/api/home", (route) => + route.fulfill({ + status: 200, + json: { + totals: { projects: 0, assets: 0, annotations: 0, releases: 0 }, + resume: null, + attention: [], + projects: [], + activity: [], + }, + }), + ); // The Inference section's one read. Empty, because this suite is about the // rail and the router: what the screen does with rows is `ui-core`'s // `inference.test.tsx`, and an unrouted request here would leave the page @@ -190,15 +206,21 @@ test("the Inference entry goes to the section, and is current once you are on it test("navigation is real links, and the active one is the one you are on", async ({ page }) => { await signIn(page); - // Signing in at `/` lands on `/projects`, because Home redirects there — so - // **Projects** is the current page and Home is not. Asserting Home first would - // be racing the redirect, which is exactly what it did until this comment - // existed: the scenario passed alone and failed under parallel load. + // Signing in at `/` stays at `/`, because Home is a page now rather than a + // redirect to the project list — so **Home** is the current page and Projects + // is not. This assertion used to read the other way round, and had a comment + // warning that asserting Home first raced the redirect. There is no redirect + // to race any more, which is the whole of what changed. + await expect(page).toHaveURL(/\/$/); + await expect(page.getByTestId("rail-home")).toHaveAttribute("aria-current", "page"); + await expect(page.getByTestId("rail-projects")).not.toHaveAttribute("aria-current", "page"); + + // `end` on the Home link is what keeps *the other direction* true: without it + // `NavLink` treats `/` as a prefix of every route and Home stays active + // everywhere. So the claim is checked from a page that is not Home. + await page.getByTestId("rail-projects").click(); await expect(page).toHaveURL(/\/projects$/); await expect(page.getByTestId("rail-projects")).toHaveAttribute("aria-current", "page"); - - // `end` on the Home link is what keeps this true: without it `NavLink` treats - // `/` as a prefix of every route and Home is active on every page. await expect(page.getByTestId("rail-home")).not.toHaveAttribute("aria-current", "page"); // A real ``, so middle-click and "open in new tab" work — which on a @@ -284,9 +306,11 @@ test("a client route nobody defined answers inside the shell, not with a blank p // a broken application. await expect(page.getByTestId("app-rail")).toBeVisible(); await page.getByRole("link", { name: "Back to Home" }).click(); - // Home redirects to the project list: there is nothing else a workspace's front - // page could honestly be until a dashboard has numbers to show. - await expect(page).toHaveURL(/\/projects$/); + // The link keeps its own promise now. It used to land on the project list, + // because Home was a redirect there; Home is a page, so "Back to Home" goes to + // Home and the dashboard is what answers. + await expect(page).toHaveURL(/\/$/); + await expect(page.getByTestId("home-first-run")).toBeVisible(); }); /** diff --git a/frontend/app/src/routes.tsx b/frontend/app/src/routes.tsx index d159843f..25881b73 100644 --- a/frontend/app/src/routes.tsx +++ b/frontend/app/src/routes.tsx @@ -42,6 +42,7 @@ import { AnnotationPage, assetParamFor, GalleryScreen, + HomeScreen, InferenceScreen, resolveProjectTab, IngestScreen, @@ -67,10 +68,15 @@ export function AppRoutes(): JSX.Element { }> {/* Lists and forms: the padded, `max-w-7xl` column. */} }> - {/* Home is the project list. There is nothing else a workspace's front - page could honestly be until a dashboard has numbers to show, and a - redirect keeps one screen rather than two that drift. */} - } /> + {/* Home was a redirect to the project list, on the reasoning that + there was nothing else a workspace's front page could honestly be + until a dashboard had numbers to show. `GET /home` is those + numbers: what is waiting across every project, and which batch to + carry on with. The two screens do not drift because they answer + different questions — this one is *what needs me*, the list is + *what exists* — and Home links to the list rather than repeating + it. */} + } /> } /> {/* A top-level destination rather than a project route, per the @@ -141,6 +147,31 @@ function Projects(): JSX.Element { return void navigate(`/projects/${projectId}`)} />; } +/** + * The workspace's front page, and the four edges it hands out. + * + * `onContinue` is the one that carries a decision rather than a path. The screen + * passes a null asset when the batch it is offering has no unlabeled frame left, + * and the annotator's `?asset=` is *where to start* rather than part of its + * identity — so a null simply means "open the job wherever it opens", which is + * what a deep link nobody parameterised already does. + */ +function Home(): JSX.Element { + const navigate = useNavigate(); + return ( + + void navigate(assetId === null ? `/jobs/${jobId}` : `/jobs/${jobId}?asset=${assetId}`) + } + onOpenBatch={(projectId, batchId) => + void navigate(`/projects/${projectId}/batches/${batchId}`) + } + onOpenProject={(projectId) => void navigate(PARENT.project(projectId))} + onOpenProjects={() => void navigate(PARENT.projects)} + /> + ); +} + /** * Every sub-view's parent, in one place. * @@ -159,6 +190,9 @@ function Projects(): JSX.Element { * default Schema tab after leaving a batch is landing somewhere you were not. */ const PARENT = { + //: A rail destination, like `inference` below, so nothing declares it as a + //: parent either. It is here because this table is the route map's own index. + home: "/", projects: "/projects", //: A rail destination, so nothing declares it as a parent — it is here because //: this table is the route map's own index, and an entry point missing from it diff --git a/frontend/ui-core/src/generated/api.ts b/frontend/ui-core/src/generated/api.ts index 25cb0c2a..234cd4cc 100644 --- a/frontend/ui-core/src/generated/api.ts +++ b/frontend/ui-core/src/generated/api.ts @@ -694,6 +694,44 @@ export interface paths { patch?: never; trace?: never; }; + "/home": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Home + * @description Everything the workspace's front page shows, in one response. + * + * `totals` counts the whole workspace. `projects` is a short shortcut into the + * project list, not a copy of it, and `activity` is capped — both have a screen + * that owns them in full. + * + * `resume` is the batch to carry on with: the one furthest through that still + * has an unannotated frame. It is **derived on every call and never stored**, + * and it is ranked by progress rather than by recency because nothing in the + * workspace records when a batch was last worked on. When `next_asset_id` is + * null the batch has no unlabeled frame left — open its gallery rather than the + * editor. `resume` itself is null when no batch is open for annotation. + * + * `attention` carries batches with frames awaiting review, and background jobs + * that failed or are still running. A job row has no `project_id`: a job names + * an ingest run or a release, never a project. + * + * An empty workspace answers zeros, nulls and empty lists. That is the + * first-run state, and `totals.projects` is how a client recognises it. + */ + get: operations["get_home"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/inference/connections": { parameters: { query?: never; @@ -2059,6 +2097,43 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** + * ActivityEntryOut + * @description One thing that happened, derived from a timestamp that already existed. + */ + ActivityEntryOut: { + /** Count */ + count: number | null; + kind: components["schemas"]["ActivityKind"]; + /** Label */ + label: string | null; + /** + * Occurred At + * Format: date-time + */ + occurred_at: string; + /** + * Project Id + * Format: uuid + */ + project_id: string; + /** Project Name */ + project_name: string; + /** + * Subject Id + * Format: uuid + */ + subject_id: string; + }; + /** + * ActivityKind + * @description What sort of thing a row of the activity feed is. + * + * Four kinds, and two of them are approximations the interface must not + * overstate. See :class:`ActivityEntry` for which and why. + * @enum {string} + */ + ActivityKind: "release_published" | "batch_promoted" | "ingest" | "schema_version"; /** * AnnotationCreate * @description One annotation to store, judged against the version its batch pinned. @@ -2271,6 +2346,43 @@ export interface components { AssetProgressSet: { progress: components["schemas"]["AssetProgress"]; }; + /** + * AttentionItemOut + * @description One thing in the workspace that is waiting on somebody. + */ + AttentionItemOut: { + /** Count */ + count: number | null; + /** Detail */ + detail: string | null; + kind: components["schemas"]["AttentionKind"]; + /** Label */ + label: string; + /** Processed */ + processed: number | null; + /** Project Id */ + project_id: string | null; + /** Project Name */ + project_name: string | null; + /** + * Subject Id + * Format: uuid + */ + subject_id: string; + /** Total */ + total: number | null; + }; + /** + * AttentionKind + * @description What sort of thing a row of the attention list is. + * + * A ``StrEnum`` rather than a plain ``str`` on the ``SourceKind`` test: no + * writer outside this build produces one, the value decides how a row renders, + * and the set grows deliberately. Contrast ``DatasetChange.operation``, which + * is a plain ``str`` precisely because a log outlives the build that wrote it. + * @enum {string} + */ + AttentionKind: "review_pending" | "job_failed" | "job_running"; /** * AttributeBody * @description A typed attribute on a label class. @@ -3001,6 +3113,20 @@ export interface components { * @enum {string} */ GeometryType: "bbox" | "polygon" | "mask" | "polyline" | "keypoints" | "cuboid_3d" | "polyline_3d" | "classification_tag"; + /** + * HomeOut + * @description Everything the workspace's front page asks for, in one answer. + */ + HomeOut: { + /** Activity */ + activity: components["schemas"]["ActivityEntryOut"][]; + /** Attention */ + attention: components["schemas"]["AttentionItemOut"][]; + /** Projects */ + projects: components["schemas"]["ProjectSummaryOut"][]; + resume: components["schemas"]["ResumeTargetOut"] | null; + totals: components["schemas"]["WorkspaceTotalsOut"]; + }; /** * ImageFormat * @description Every still-image encoding VisionSet accepts. See the module docstring. @@ -3411,6 +3537,23 @@ export interface components { */ project_id: string; }; + /** + * ProjectSummaryOut + * @description One project, as a shortcut rather than as the project list. + */ + ProjectSummaryOut: { + /** Annotated Fraction */ + annotated_fraction: number; + /** Asset Count */ + asset_count: number; + /** Name */ + name: string; + /** + * Project Id + * Format: uuid + */ + project_id: string; + }; /** * ReleaseCreate * @description What publishing a release needs. @@ -3489,6 +3632,39 @@ export interface components { */ release_id: string; }; + /** + * ResumeTargetOut + * @description The batch to carry on with, and where inside it to land. + */ + ResumeTargetOut: { + /** Annotated */ + annotated: number; + /** + * Batch Id + * Format: uuid + */ + batch_id: string; + /** Batch Name */ + batch_name: string; + /** + * Job Id + * Format: uuid + */ + job_id: string; + /** Next Asset Id */ + next_asset_id: string | null; + /** + * Project Id + * Format: uuid + */ + project_id: string; + /** Project Name */ + project_name: string; + /** Thumbnail Asset Id */ + thumbnail_asset_id: string | null; + /** Total */ + total: number; + }; /** * SchemaChangeOut * @description One difference between two schema versions, already judged. @@ -3832,6 +4008,20 @@ export interface components { job_id: string; state: components["schemas"]["BackgroundJobState"]; }; + /** + * WorkspaceTotalsOut + * @description Four counts over the whole workspace. + */ + WorkspaceTotalsOut: { + /** Annotations */ + annotations: number; + /** Assets */ + assets: number; + /** Projects */ + projects: number; + /** Releases */ + releases: number; + }; }; responses: never; parameters: never; @@ -5610,6 +5800,62 @@ export interface operations { }; }; }; + get_home: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HomeOut"]; + }; + }; + /** @description Missing or invalid bearer token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description The request payload is not processable */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description Unhandled server error, with an incident id */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description The workspace is busy; retry after the header says */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + }; + }; list_inference_connections: { parameters: { query?: never; diff --git a/frontend/ui-core/src/generated/checks.ts b/frontend/ui-core/src/generated/checks.ts index d4efe1f3..348b43cf 100644 --- a/frontend/ui-core/src/generated/checks.ts +++ b/frontend/ui-core/src/generated/checks.ts @@ -173,6 +173,30 @@ export const checkFormatOut: Check = export const checkFormatPage: Check = /*#__PURE__*/ object({ "items": [true, arrayOf(checkFormatOut)], "total": [true, isInteger] } as const); +export const checkActivityKind: Check = + /*#__PURE__*/ oneOf(["release_published", "batch_promoted", "ingest", "schema_version"] as const); + +export const checkActivityEntryOut: Check = + /*#__PURE__*/ object({ "count": [true, either([isInteger, isNull] as const)], "kind": [true, checkActivityKind], "label": [true, either([isString, isNull] as const)], "occurred_at": [true, isString], "project_id": [true, isString], "project_name": [true, isString], "subject_id": [true, isString] } as const); + +export const checkAttentionKind: Check = + /*#__PURE__*/ oneOf(["review_pending", "job_failed", "job_running"] as const); + +export const checkAttentionItemOut: Check = + /*#__PURE__*/ object({ "count": [true, either([isInteger, isNull] as const)], "detail": [true, either([isString, isNull] as const)], "kind": [true, checkAttentionKind], "label": [true, isString], "processed": [true, either([isInteger, isNull] as const)], "project_id": [true, either([isString, isNull] as const)], "project_name": [true, either([isString, isNull] as const)], "subject_id": [true, isString], "total": [true, either([isInteger, isNull] as const)] } as const); + +export const checkProjectSummaryOut: Check = + /*#__PURE__*/ object({ "annotated_fraction": [true, isNumber], "asset_count": [true, isInteger], "name": [true, isString], "project_id": [true, isString] } as const); + +export const checkResumeTargetOut: Check = + /*#__PURE__*/ object({ "annotated": [true, isInteger], "batch_id": [true, isString], "batch_name": [true, isString], "job_id": [true, isString], "next_asset_id": [true, either([isString, isNull] as const)], "project_id": [true, isString], "project_name": [true, isString], "thumbnail_asset_id": [true, either([isString, isNull] as const)], "total": [true, isInteger] } as const); + +export const checkWorkspaceTotalsOut: Check = + /*#__PURE__*/ object({ "annotations": [true, isInteger], "assets": [true, isInteger], "projects": [true, isInteger], "releases": [true, isInteger] } as const); + +export const checkHomeOut: Check = + /*#__PURE__*/ object({ "activity": [true, arrayOf(checkActivityEntryOut)], "attention": [true, arrayOf(checkAttentionItemOut)], "projects": [true, arrayOf(checkProjectSummaryOut)], "resume": [true, either([checkResumeTargetOut, isNull] as const)], "totals": [true, checkWorkspaceTotalsOut] } as const); + export const checkIngestFailureKind: Check = /*#__PURE__*/ oneOf(["unsupported", "corrupt", "partial"] as const); @@ -322,6 +346,7 @@ export const checkGetBackgroundJob = checkBackgroundJobOut; export const checkGetBackgroundJobArtifact = checkBlob; export const checkGetBatch = checkBatchOut; export const checkGetDataset = checkDatasetOut; +export const checkGetHome = checkHomeOut; export const checkGetInferenceConnection = checkConnectionOut; export const checkGetIngestJob = checkIngestJobOut; export const checkGetJob = checkJobOut; diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts index 024791a0..66bfedb7 100644 --- a/frontend/ui-core/src/index.ts +++ b/frontend/ui-core/src/index.ts @@ -193,6 +193,7 @@ export { } from "./screens/SchemaEditor.js"; export { IngestScreen, type IngestScreenProps } from "./screens/IngestScreen.js"; export { BatchesScreen, type BatchesScreenProps } from "./screens/BatchesScreen.js"; +export { HomeScreen, type HomeScreenProps } from "./screens/HomeScreen"; export { GalleryScreen, type GalleryScreenProps } from "./screens/GalleryScreen.js"; export { ApproveDialog, BatchProgressBar } from "./screens/BatchLifecycle.js"; export { PromoteButton, promotionSummary, type PromoteButtonProps } from "./screens/PromoteButton.js"; diff --git a/frontend/ui-core/src/screens/HomeScreen.tsx b/frontend/ui-core/src/screens/HomeScreen.tsx new file mode 100644 index 00000000..f59f2483 --- /dev/null +++ b/frontend/ui-core/src/screens/HomeScreen.tsx @@ -0,0 +1,554 @@ +/** + * The workspace's front page: what needs attention, and where to carry on. + * + * Home used to be a redirect to the project list, and the route table said why — + * *there is nothing else a workspace's front page could honestly be until a + * dashboard has numbers to show*. `GET /home` is those numbers. + * + * ## The question this answers is not the project list's + * + * A project list answers *what exists*. This answers *what is waiting on me*, + * which is a different question and spans every project: a batch part-way + * through in one, frames awaiting review in another, an export that failed in a + * third. None of that is visible from a list of names. + * + * ## One request, because the server composes it + * + * `useHome` is a single query. The alternative is a request per project per + * question with this component doing the joining, which is both slower and a + * page that renders in pieces as they land. + * + * ## Sections with nothing in them are not rendered + * + * Not rendered as a placeholder, not rendered as a zero, not rendered as "no + * items yet" — absent. A dashboard whose every section is an apology is a page + * that has taught you nothing and taken a screen to do it. The stat cards are + * the deliberate exception: a count of zero is a *measurement*, and four cards + * that come and go would make the aside jump on every visit. + * + * ## Exactly one filled button, in every state + * + * `DESIGN.md` states the rule as a count, tested in both directions, so zero + * filled buttons fails it exactly as two would. The three states each have their + * own answer and there is never a fourth: first run offers **Create project**, + * a workspace with somewhere to carry on offers **Continue annotating**, and one + * with nothing open offers **New project** — because when every batch is + * finished, starting the next piece of work genuinely is what comes next. + * + * ## The resume card is ranked by progress, not by recency + * + * There is no timestamp on a batch, an annotation, or an asset's progress + * anywhere in the storage format, so *the batch I touched last* has no source. + * The card offers the batch furthest through that still has an unlabeled frame. + * Its one visible consequence is here: with nothing left to label the control + * reads **Open batch** and goes to the gallery, because there is no frame to + * open and a button claiming otherwise would land somewhere empty. + */ + +import { + ArrowRight, + CircleAlert, + FolderGit2, + Layers, + Loader, + Play, + Plus, + Rocket, + Tags, + Upload, +} from "lucide-react"; +import { useState, type JSX, type ReactNode } from "react"; + +import { formatCount, formatPercent, formatWhen } from "../lib/format"; +import { ErrorState } from "../patterns/AsyncStates"; +import { StatCard } from "../patterns/DataDisplay"; +import { Badge } from "../primitives/Badge"; +import { Button } from "../primitives/Button"; +import { Skeleton } from "../primitives/Feedback"; +import { CreateProjectDialog } from "./ProjectsScreen"; +import { AssetThumbnail } from "./AssetThumbnail"; +import { + useHome, + type ActivityEntry, + type AttentionItem, + type ProjectSummary, + type ResumeTarget, +} from "./queries"; + +export interface HomeScreenProps { + /** + * Into the annotator, at the frame the card names. + * + * `assetId` is null when the batch has no unlabeled frame left, which is also + * when the control stops saying "Continue annotating" — a host that cannot + * honour the distinction renders neither. + */ + readonly onContinue?: (jobId: string, assetId: string | null) => void; + /** The batch gallery, for the fallback and for a review row. */ + readonly onOpenBatch?: (projectId: string, batchId: string) => void; + readonly onOpenProject?: (projectId: string) => void; + /** The project list, behind the recent-projects header link. */ + readonly onOpenProjects?: () => void; +} + +export function HomeScreen({ + onContinue, + onOpenBatch, + onOpenProject, + onOpenProjects, +}: HomeScreenProps): JSX.Element { + const home = useHome(); + const [creating, setCreating] = useState(false); + + if (home.isPending) return ; + if (home.isError) { + return void home.refetch()} />; + } + + const page = home.data; + const dialog = ( + setCreating(false)} + onCreated={(projectId) => onOpenProject?.(projectId)} + /> + ); + + // Zero projects is the whole first-run condition, and it needs no flag of its + // own — a count the page already carries answers it. + if (page.totals.projects === 0) { + return ( +
+ setCreating(true)} /> + {dialog} +
+ ); + } + + return ( +
+
+
+

Home

+

+ What is waiting, across every project in this workspace. +

+
+ {/* + The page's one filled button when there is nothing to carry on with. + With a resume card on screen this steps back to `secondary`, because + two filled buttons is the same rule broken from the other side. + */} + +
+ +
+
+ {page.resume !== null && ( + + )} + + +
+ + +
+ {dialog} +
+ ); +} + +/** + * The whole page on a workspace nobody has used yet. + * + * An invitation rather than an apology, and one invitation rather than three: + * the retired onboarding checklist showed a project three seconds old two + * competing next steps, and whichever a person followed the page was also + * telling them to do something else. + * + * The three cards beneath name the cycle and carry no controls at all. They are + * there so somebody can see what this tool does before committing to it — a + * button on each would be three more things competing with the one that matters. + */ +function FirstRun({ onCreate }: { readonly onCreate: () => void }): JSX.Element { + return ( +
+
+
+ +
+ } title="Ingest"> + Register images or a video and let it become frames. + + } title="Annotate"> + Declare the classes you will draw, then label a batch. + + } title="Release"> + Freeze what is finished and export it to train on. + +
+
+ ); +} + +function Stage({ + icon, + title, + children, +}: { + readonly icon: ReactNode; + readonly title: string; + readonly children: ReactNode; +}): JSX.Element { + return ( +
+ + {title} + {children} +
+ ); +} + +/** + * Where to carry on, and the page's one filled button when it renders. + * + * The label is not decoration. With a frame to open it says **Continue + * annotating** and goes into the editor; with none left it says **Open batch** + * and goes to the gallery, because the batch is still worth opening and there is + * no frame to open it at. + */ +function Resume({ + resume, + onContinue, + onOpenBatch, +}: { + readonly resume: ResumeTarget; + readonly onContinue?: (jobId: string, assetId: string | null) => void; + readonly onOpenBatch?: (projectId: string, batchId: string) => void; +}): JSX.Element { + const hasFrame = resume.next_asset_id !== null; + const share = resume.total === 0 ? 0 : (resume.annotated / resume.total) * 100; + const act = hasFrame + ? onContinue === undefined + ? undefined + : () => onContinue(resume.job_id, resume.next_asset_id) + : onOpenBatch === undefined + ? undefined + : () => onOpenBatch(resume.project_id, resume.batch_id); + + return ( +
+

Continue where you left off

+
+ {resume.thumbnail_asset_id !== null && ( +
+ +
+ )} +
+ {resume.project_name} + {resume.batch_name} + + {formatCount(resume.annotated)} / {formatCount(resume.total)} annotated ·{" "} + {formatPercent(share)} + +
+ {act !== undefined && ( + + )} +
+
+ ); +} + +/** + * What is waiting, and nothing at all when nothing is. + * + * A job row carries no project — a background job names an ingest run or a + * release in its payload, never a project — and there is no background-job + * screen to link to either, so those rows state what happened and go nowhere. + * `DESIGN.md`'s rule for a section whose consuming surface does not exist yet. + */ +function Attention({ + items, + onOpenBatch, +}: { + readonly items: readonly AttentionItem[]; + readonly onOpenBatch?: (projectId: string, batchId: string) => void; +}): JSX.Element | null { + if (items.length === 0) return null; + return ( +
+

Needs your attention

+
    + {items.map((item) => ( +
  • + +
  • + ))} +
+
+ ); +} + +function AttentionRow({ + item, + onOpenBatch, +}: { + readonly item: AttentionItem; + readonly onOpenBatch?: (projectId: string, batchId: string) => void; +}): JSX.Element { + const body = ( + <> + + {attentionLine(item)} + {item.project_name !== null && ( + {item.project_name} + )} + {item.kind === "job_failed" && Failed} + + ); + const shared = "flex w-full items-center gap-2 px-3 py-2 text-left"; + + if (item.kind === "review_pending" && item.project_id !== null && onOpenBatch !== undefined) { + return ( + + ); + } + return ( +
+ {body} +
+ ); +} + +function AttentionIcon({ kind }: { readonly kind: AttentionItem["kind"] }): JSX.Element { + const shape = "size-4 shrink-0"; + if (kind === "job_failed") { + return