diff --git a/.agents/skills/domain/batch-lifecycle/SKILL.md b/.agents/skills/domain/batch-lifecycle/SKILL.md index f344fcbd..262386b6 100644 --- a/.agents/skills/domain/batch-lifecycle/SKILL.md +++ b/.agents/skills/domain/batch-lifecycle/SKILL.md @@ -36,7 +36,7 @@ The kernel tables are authoritative. Quote them; never re-derive them. 9. **Closure is a job-level fact, and the workspace reads it at job level.** Read-only is a *transition*, not only an entry state: finishing a job flips the open workspace in place — same window, every frame, from the re-read declaration. Two rulings come with it, and both look like untidiness to a later reader: - **Frame-verb gating is job-level, never frame-level.** `Skip` / `Un-skip` and the flow verb stop rendering when the *job* is closed (or its batch is), not when the *frame* is read-only. A `skipped` frame is read-only per-frame and still needs its `Un-skip` — the one edge back out of `skipped` — and the navigation cluster is measured to one width, so a slot that emptied and refilled as somebody walked a mixed job would move the arrows under their cursor. - **`complete` is the job's declaration, not the last frame's.** `Finish job` stays reachable on a frame that is itself settled and read-only: a job whose last frame is `annotated` is precisely the job that is ready to finish. Withdrawing it along with the frame's verbs strands the job with no way to close it. -10. **Unattended writing enters its own editable state through one door, never through `add`.** `AnnotationService.enter_unreviewed` is the only path a model's unattended prediction takes: every annotation must carry `provenance="model"`, the asset must be exactly `unannotated` (narrower than `WRITABLE_PROGRESS`), and the labels commit with the move to `pre_labeled` in the same transaction — never a two-step path through `annotated` that a crash could catch mid-way. The same door may **supersede its own earlier writes**: asked to replace, it removes the `provenance="model"` rows on a frame that is still `pre_labeled` and lands the new ones in the same transaction, leaving the frame `pre_labeled` (or `unannotated` if nothing landed); a frame anybody edited, confirmed or skipped is refused (`AssetNotWritable`). Eligibility is progress, never provenance (decided on #683). `unannotated → pre_labeled` is therefore a **derived unnamed edge**, the same shape as the existing `unannotated ↔ annotated` pair: nobody clicks it, `UNNAMED_EDGES` (`kernel/domain/capabilities.py`) computes it from `progress_after_annotating`, and it is the consequence of a batch-level pre-label action, not a per-asset control. `add` never lands a write at `pre_labeled` — accepting a model's *suggestion* by hand is still a person's write and still only ever produces `annotated`. +10. **Unattended writing enters its own editable state through one door, never through `add`.** `AnnotationService.enter_unreviewed` is the only path a model's unattended prediction takes: every annotation must carry `provenance="model"`, the asset must be exactly `unannotated` (narrower than `WRITABLE_PROGRESS`), and the labels commit with the move to `pre_labeled` in the same transaction — never a two-step path through `annotated` that a crash could catch mid-way. The same door may **supersede its own earlier writes**: asked to replace, it removes the `provenance="model"` rows on a frame that is still `pre_labeled` and lands the new ones in the same transaction, leaving the frame `pre_labeled` (or `unannotated` if nothing landed); a frame anybody edited, confirmed or skipped is refused (`AssetNotWritable`). Eligibility is progress, never provenance (decided on #683). `unannotated → pre_labeled` is therefore a **derived unnamed edge**, the same shape as the existing `unannotated ↔ annotated` pair: nobody clicks it, `UNNAMED_EDGES` (`kernel/domain/capabilities.py`) computes it from `progress_after_annotating`, and it is the consequence of a **job-level** pre-label action (`JobAction.PRE_LABEL`, declared on an open job of an `in_annotation` batch), never a per-asset control. A batch-level or project-level launch is a fan-out over open jobs — one `annotation.pre_label` row per job, keyed by the job — and reports one row per job; the batch's `pre_label_run` is the newest of its jobs'. Decided 2026-08-25. `add` never lands a write at `pre_labeled` — accepting a model's *suggestion* by hand is still a person's write and still only ever produces `annotated`. - `pre_labeled` means labels exist, a model wrote them, and no person has touched them — a state of its own rather than `review_pending`, which means a person finished this and asked for review, and cannot be reused for unattended output without inverting every affordance a reviewer's read-only frame relies on. Its three memberships each carry a reason. It is in `WRITABLE_PROGRESS`, because correcting a prediction is the normal path here and must cost nothing. It is **not** in `PROMOTABLE_PROGRESS` — the reason that matters most: an asset at `annotated` may enter the Dataset when its batch completes, so a model's labels that no person has ever seen must never sit there; provenance already records who wrote a label, and this is what stops that record from also being the thing that lets it into the curated trunk unseen. It is not in `SETTLED_PROGRESS` either, because a batch where every frame carries an unreviewed guess is not done — that is exactly the work still outstanding — so it cannot let a job, and through it a batch, complete. `pre_labeled → annotated` is derived the same way `unannotated → annotated` is — the moment a person edits the frame, `progress_after_annotating` takes it over — and it is also the one derived edge with a name: `AssetAction.CONFIRM` lets a person keep a model's labels as the frame's own without editing them (decided on #676). Labels exist either way; the marker records judgment, and the non-promotable protection is unchanged. `pre_labeled → skipped` is claimed by `AssetAction.SKIP`, the same act that already skips an `unannotated` or `annotated` frame. 11. **The orphan invariant is per-annotation, and releases gate content.** An annotation is valid against **its own batch's pin**, and the trunk may legitimately hold annotations naming a class the *active* schema version no longer declares. That state is reachable without a race: publish a narrowing version while a batch pinned to the older one is still open, then write a label under the class the newer version dropped. It is tolerated in the trunk and refused at the boundary where a frozen artifact is produced — `ReleaseService.publish` revalidates every candidate annotation against the **active** schema and raises `ReleaseContentWouldViolateSchema` (409 `RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA`, per-class blockers in `detail`) before a manifest is written. The active-version alternative — a publish that refuses, closes or re-pins open batches — was considered and declined: it turns a schema decision into an administrative one and inverts what the pin exists for. Decided on #585 and shipped with the release-publish compatibility gate (#694). diff --git a/.agents/skills/frontend/information-architecture/SKILL.md b/.agents/skills/frontend/information-architecture/SKILL.md index 353693d4..44211a2b 100644 --- a/.agents/skills/frontend/information-architecture/SKILL.md +++ b/.agents/skills/frontend/information-architecture/SKILL.md @@ -54,6 +54,8 @@ column carries are in `docs/content/ui/navigation.md`, *Inside a project*. Rules: +- **Annotate enters by job.** The project's `Annotate` control opens the chosen `in_annotation` batch's one job directly (`/jobs/:jobId`) when the batch has exactly one, and the batch gallery otherwise — the gallery's job panel is where a job is chosen, and it is the only door: the gallery header carries the `approved → in_annotation` transition and nothing that opens the editor. With several open batches the dropdown stays batch-level and the same rule applies to the pick. A panel's `Annotate` takes a `pending` job to `in_progress` before opening it; `Continue` and `View` only open. Panel controls are secondary — the header's transition is the page's one filled control. +- **A batch with jobs shows one job's frames at a time.** The gallery is an accordion of jobs with at most one panel open and every panel closable; the open panel's counts, timeline and frames are that job's, and the panel open on arrival is the first job with work left. A draft batch, having no jobs, keeps the flat grid. The rule is observability: a batch-wide grid beneath a per-job control puts two scopes for the same frames on one screen. Nothing about it changes an address — the gallery route is unchanged and the open panel is not in the URL. - **A correction batch is reached from the batch that needs correcting**, never from a "new batch" form: the gallery header and the Batches row both offer it on a `completed` batch, capability-gated on `create_correction`. The annotator's read-only banner and the gallery's bulk bar *link* to it rather than duplicating it — creating a batch is a curation act, curation lives on the batch view, and a second place batches are made is a second place the rules can drift. - **Dataset is first-class.** It is the product's central object and must be reachable in ≤1 click from any project section. It is never gated behind, or discoverable only through, onboarding UI. Promotion success links onward to it; the gallery links to it once a batch is `completed`. - **The frames blocking a narrowing are a subsection of Schema, not a screen.** They are a *view of* the draft on the editor above them, the same relation version history has to the schema. A row links to **every** batch holding its frame rather than to one: an annotation carries an `asset_id` and no batch, so there is no single annotator address to prefer. The section is omitted entirely when the host wires no batch route, on the rule the Batches section already follows. It shows a window of the frames and states the total as text rather than a "see all": the destination that control would need is a project-wide asset view, and there is none — the count is a property of the proposal, not the length of a list somebody can open. diff --git a/docs/content/api.md b/docs/content/api.md index bc135ce2..87d46783 100644 --- a/docs/content/api.md +++ b/docs/content/api.md @@ -71,19 +71,20 @@ DELETE /batches/{batch_id} ?confirm=true POST /batches/{batch_id}/approve with a partition spec POST /batches/{batch_id}/start GET /batches/{batch_id}/pre-label?connection_id=&geometries= the classes a run would ask for, and the shapes it writes -POST /batches/{batch_id}/pre-label launch; replace_model_labels redoes an earlier pass, geometries narrows the shapes -POST /projects/{project_id}/batches/pre-label launch over every open batch, or the named ones; one job per batch +POST /batches/{batch_id}/pre-label launch over every open job of the batch; one row per open job +POST /projects/{project_id}/batches/pre-label launch over every open batch, or the named ones; one row per open job POST /batches/{batch_id}/repin ?allow_destructive= POST /batches/{batch_id}/complete POST /batches/{batch_id}/corrections a new batch over a completed one GET /batches/{batch_id}/jobs -GET /batches/{batch_id}/assets paged; ?progress=&sort= +GET /batches/{batch_id}/assets paged; ?progress=&sort=&job= POST /batches/{batch_id}/assets draft only DELETE /batches/{batch_id}/assets?id=&id= draft only GET /jobs/{job_id} GET /jobs/{job_id}/progress POST /jobs/{job_id}/start POST /jobs/{job_id}/complete +POST /jobs/{job_id}/pre-label launch over one job; replace_model_labels redoes an earlier pass, geometries narrows the shapes GET /jobs/{job_id}/next the next n waiting assets PUT /jobs/{job_id}/assets/{asset_id}/progress GET /jobs/{job_id}/assets/{asset_id}/annotations @@ -148,6 +149,13 @@ property of the schema rather than a version number a client could guess. `GET /datasets/{id}/assets` is the curated trunk's. A project page reads the first, a gallery the second, a release the third. +**One job's frames are the batch listing narrowed, not a listing of their own.** `?job=` keeps +the assets that job carries, composing with `progress` and `sort`, and `total` is the size of +what matched; a job the batch does not have is 404 `JOB_NOT_FOUND`, resolved after the batch +itself, and a draft - which has no jobs - matches nothing rather than refusing. A +`/jobs/{id}/assets` of its own would be a fourth window onto assets a batch already indexes by +job, and two addresses for one page is two places paging, sorting and filtering can drift. + The project listing is **ordered by arrival, newest first**, which `Asset.ingested_at` is what made possible: until that field existed nothing recorded when an asset arrived, and the listing was deterministic but arbitrary. A whole ingest run shares one timestamp, so *within* a run the diff --git a/docs/content/architecture/backend/jobs.md b/docs/content/architecture/backend/jobs.md index 19e60df7..36ac37a7 100644 --- a/docs/content/architecture/backend/jobs.md +++ b/docs/content/architecture/backend/jobs.md @@ -2,7 +2,7 @@ [`src/visionset/jobs/`](../../../../src/visionset/jobs/) holds the handlers for work that outlives the request that asked for it: an ingest run, an export, a weights -download, an integrity check, a batch pre-label run. +download, an integrity check, a pre-label run over one annotation job. ## Launch and poll diff --git a/docs/content/background-jobs.md b/docs/content/background-jobs.md index 3391b76c..cebbd41e 100644 --- a/docs/content/background-jobs.md +++ b/docs/content/background-jobs.md @@ -32,27 +32,35 @@ handling for one GIL. | `export.release` | `visionset/jobs/export.py` | `POST /releases/{id}/export` | | `inference.download_weights` | `visionset/jobs/weights.py` | `POST /inference/connections/{id}/download` | | `inference.check_integrity` | `visionset/jobs/integrity.py` | `POST /inference/connections/{id}/check-integrity` | -| `annotation.pre_label` | `visionset/jobs/prelabel.py` | `POST /batches/{id}/pre-label`, `POST /projects/{id}/batches/pre-label` (one row per batch) | +| `annotation.pre_label` | `visionset/jobs/prelabel.py` | `POST /jobs/{id}/pre-label`, `POST /batches/{id}/pre-label`, `POST /projects/{id}/batches/pre-label` (one row per job) | The last three are why the throttled progress below matters: a weight download is gigabytes, an integrity check reads every byte of them, and pre-labeling is one forward pass per untouched asset - or, where its payload carries `replace_model_labels`, per pre-labeled one as well - so all three report for minutes rather than seconds. `processed` and `total` are counted in **assets** for -pre-labeling, where the other two count bytes and files. None of the four CLI +pre-labeling, where the other two count bytes and files. None of the five CLI equivalents queues: `visionset inference download`, `visionset inference -check-integrity`, `visionset batch pre-label`, and `visionset project pre-label` run -their shared bodies inline because a terminal has no worker. - -They are also the only four launchers that **join a run instead of starting a -second one** — the project launch (`POST /projects/{id}/batches/pre-label`) joins -per batch too: asked for a kind this connection or this batch already has queued -or running, the route answers with that job. It is the route's own read of the -queue rather than anything `enqueue` does, so every other launcher above queues a -fresh job each time it is asked — and because nothing brackets the read and the -enqueue, even these four coalesce the ordinary repetition rather than -guaranteeing uniqueness. `docs/content/inference.md` and `docs/content/batches.md` say what that -buys and what it deliberately does not refuse. +check-integrity`, `visionset job pre-label`, `visionset batch pre-label`, and +`visionset project pre-label` run their shared bodies inline because a terminal has +no worker. + +**A pre-labeling row is keyed by the annotation job it is over**, `annotation_job_id` +in its payload beside the `batch_id` that leads to the pinned schema. A batch is +partitioned into jobs and a run reaches one job's assets, so the batch launches are +a fan-out: `POST /batches/{id}/pre-label` queues one row per open job of the batch, +`POST /projects/{id}/batches/pre-label` one per open job of every selected batch, +and each row is polled and cancelled on its own. + +The three pre-labeling launches, the weight download and the integrity check are +the only launchers that **join a run instead of starting a second one** — and both +fan-outs join per job: asked for a kind this connection or this job already has +queued or running, the route answers with that job. It is the +route's own read of the queue rather than anything `enqueue` does, so every other +launcher above queues a fresh job each time it is asked — and because nothing +brackets the read and the enqueue, even these coalesce the ordinary repetition +rather than guaranteeing uniqueness. `docs/content/inference.md` and +`docs/content/batches.md` say what that buys and what it deliberately does not refuse. Verify, publish, promote and thumbnail backfill are still synchronous. They are future job types, not an oversight - each answers inside its request today and diff --git a/docs/content/batches.md b/docs/content/batches.md index 8b12266c..98f07394 100644 --- a/docs/content/batches.md +++ b/docs/content/batches.md @@ -303,7 +303,9 @@ is why those sets are named rather than written inline. Promotion is the cleares assets into the trunk and leaves the batch exactly where it was. `pre_label` is declared from the batch's state alone, on `complete`'s precedent - whether the local runtime is installed and whether the pinned schema has a class a detection can land on are not facts about the batch, and -hiding the control on either ground would leave their refusals nowhere to be shown. +hiding the control on either ground would leave their refusals nowhere to be shown. A `JobOut` +declares `pre_label` on the same precedent, from the job's own openness and the batch's - it is +the declaration a control that launches a run reads, since the run is over a job. **`delete` is declared last, and it is the one action that ends the batch rather than moving it along.** It was withdrawn in #331, when the rule and `BatchService.delete` were real but nothing @@ -324,12 +326,15 @@ declaring differently depending on which endpoint answered - is worse than one h ## Pre-labeling -A batch that is `in_annotation` can ask a text-prompt model to label its **untouched** assets - -`unannotated`, and carrying no annotations at all. An asset already `pre_labeled` - unless the run +A job of a batch that is `in_annotation` can ask a text-prompt model to label its **untouched** +assets - `unannotated`, and carrying no annotations at all. The job is the unit a run is over: a +batch's frames are partitioned into jobs, so the set of frames a run reaches is a question only a +job answers, and `JobAction.PRE_LABEL` is declared on an open job of an `in_annotation` batch. +An asset already `pre_labeled` - unless the run is asked to replace, below - `annotated`, `skipped`, `review_pending` or `accepted` is passed over, and so is an `unannotated` one that still carries a person's boxes from an earlier round that was skipped and then restored - progress alone does not prove untouched, since that sequence deletes -nothing. Either way, a run never writes over what a person did in this batch, and it supersedes an +nothing. Either way, a run never writes over what a person did in this job, and it supersedes an earlier run's own labels only where it is asked to - the second-run paragraph below. **An asset somebody starts working while a run is still going is passed over too, not fatal.** The @@ -360,10 +365,13 @@ lists. It takes the connection (`?connection_id=`), because the prompt is a prop detector — and answers `produces`, the shapes the run will write; the dialog re-reads it when the model changes. A batch whose schema has no askable class at all is refused with the same `SCHEMA_HAS_NO_DETECTABLE_CLASS` the launch answers, rather than reported as an empty prompt. -At a terminal `visionset batch pre-label` writes the same two lines to stderr before the first -forward pass. The MCP tool `get_pre_label_plan` answers the same two halves, and there alone the -plan also travels *in* the outcome: `pre_label_batch` blocks until the run is done and returns it -under `plan`, so an agent that asked for nothing it expected never needs a second call. +The plan is a property of the batch's pin and the model, so it is the same for every job of the +batch and the route stays batch-scoped. At a terminal `visionset batch pre-label` writes the same +two lines to stderr once, before the first job's first forward pass. The MCP tool +`get_pre_label_plan` answers the same two halves, and there alone the plan also travels *in* the +outcome: `pre_label_job` returns it under `plan`, and `pre_label_batch` returns one `items` entry +per job each carrying its own, so an agent that asked for nothing it expected never needs a second +call. **What a run writes is every shape the model produces, unless the request says which.** A model declares the shapes it answers in (`ConnectionOut.produces`); a model declaring both a box and a @@ -401,7 +409,7 @@ nothing on what already landed. `replace_model_labels` - the request field, the and `--replace-model-labels` at a terminal - widens that reach to every frame still `pre_labeled`: the model's earlier labels there are deleted and the new ones land in the same transaction, one frame at a time, so no frame is ever briefly bare. A frame anybody edited, -confirmed or skipped in this batch is passed over whatever the flag says, and a `pre_labeled` +confirmed or skipped in this job is passed over whatever the flag says, and a `pre_labeled` frame the second run finds nothing on returns to `unannotated`. Both the outcome and `pre_label_run` carry `annotations_replaced`, the count of earlier model labels the run superseded. @@ -416,31 +424,39 @@ leaves only whole assets entered: an asset's labels and progress state commit to later run considers only assets still untouched - plus, where it is asked to replace, those still `pre_labeled`. -**The batch remembers its own run.** `BatchService.latest_pre_label_job` reads the queue for the -most recent `annotation.pre_label` job naming this batch - live or settled - and projects it as +**The job remembers its own run.** `JobService.latest_pre_label_run` reads the queue for the most +recent `annotation.pre_label` row naming this job - live or settled - and projects it as `PreLabelRun`, on `ConnectionJob`'s reasoning: a run outlives the request that launched it, so a -reload, a second tab or a run started at a terminal can only be shown by the batch itself saying +reload, a second tab or a run started at a terminal can only be shown by the job itself saying so. Counted in assets, the unit this handler works in, and carrying the outcome `prelabel.py`'s `run` returns once the job has settled - `stopped_early`, `assets_labeled`, `annotations_replaced`, `regions_discarded`, `regions_out_of_bounds` - so a client can tell a -cancelled run from an untouched batch. Derived, never stored, and published on `BatchOut` as -`pre_label_run`, `null` where none ever ran. - -**Beyond one batch, the batch is still the unit.** `POST /projects/{id}/batches/pre-label` fans a -launch out over the project's batches that are `in_annotation` - every one of them, or exactly -the `batch_ids` it names - and queues, or joins, the same `annotation.pre_label` row per batch -that the single-batch launch does. The answer is one row per batch (`job`, and `joined` when a -run was already in flight for it); each is polled, cancelled and remembered per batch, and -`BatchOut.pre_label_run` reads it afterwards exactly as if that batch had been launched alone. -There is no project-level total because there is no project-level run. The request is refused -whole, up front, and no refusal creates a row: a named batch outside the project (404), a named -batch that is not open or a project with no open batch (409 `BATCH_NOT_IN_ANNOTATION`; an empty -`batch_ids` names nothing and is refused the same way), or any selected batch whose pin holds no -class a shape the model produces can be written as (409 `SCHEMA_HAS_NO_DETECTABLE_CLASS`, naming -the batch, so it can be left out by name). Assets that sit in no batch are not reached: a model's -labels are written through an open job, so the answer there is to cut a batch first. `visionset -project pre-label` and the MCP tool `pre_label_project` run the same selection inline, one batch -after another. +cancelled run from an untouched job. Derived, never stored, and published on `JobOut` as +`pre_label_run`, `null` where none ever ran; the row names both ids, `annotation_job_id` for the +job it is over and `job_id` for the queue row to poll. `BatchOut.pre_label_run` is the newest run +across the batch's jobs, read from the same queue in one pass by +`BatchService.pre_label_runs`, and it is what a batch listing shows a `pre-labeling…` mark from. + +**Beyond one job, the job is still the unit.** `POST /batches/{id}/pre-label` fans a launch out +over the batch's jobs that are still open, and `POST /projects/{id}/batches/pre-label` fans out +over the project's batches that are `in_annotation` - every one of them, or exactly the +`batch_ids` it names - and within each over that batch's open jobs. Both queue, or join, per job +the same `annotation.pre_label` row `POST /jobs/{job_id}/pre-label` queues, and both answer +`PreLabelFanOutOut`: one item per open job, naming `batch_id`, `batch_name`, `annotation_job_id`, +the queue row under `job`, and `joined` where a run was already in flight for that job. A +finished job is passed over, so a batch whose every job is complete contributes no item. Each row +is polled, cancelled and remembered per job, and `JobOut.pre_label_run` reads it afterwards +exactly as if that job had been launched alone. There is no batch-level or project-level total, +because neither is one run. A batch launch is refused on the batch (409 `BATCH_NOT_IN_ANNOTATION` +for one that is not open); the project launch is refused whole, up front, and no refusal creates +a row: a named batch outside the project (404), a named batch that is not open or a project with +no open batch (409 `BATCH_NOT_IN_ANNOTATION`; an empty `batch_ids` names nothing and is refused +the same way), or any selected batch whose pin holds no class a shape the model produces can be +written as (409 `SCHEMA_HAS_NO_DETECTABLE_CLASS`, naming the batch, so it can be left out by +name). Assets that sit in no batch are not reached: a model's labels are written through an open +job, so the answer there is to cut a batch first. `visionset batch pre-label` and `visionset +project pre-label`, and the MCP tools `pre_label_batch` and `pre_label_project`, run the same +selection inline, one job after another. ## What approval and completion announce @@ -525,7 +541,7 @@ The [API](api.md) is this service with the curation half left off. ``` GET /projects/{id}/batches → 200 BatchPage GET /batches/{id} → 200 BatchOut, with per-state counts - and the batch's own pre_label_run + and the newest pre_label_run of its jobs POST /batches/{id}/approve { "partition": … } → 200 BatchOut POST /batches/{id}/start → 200 BatchOut POST /batches/{id}/repin?allow_destructive= → 200 BatchOut @@ -535,7 +551,9 @@ GET /batches/{id}/pre-label?connection_id=&geometries= → 200 PreLabelPlanOu `produces` (the selection, if one) POST /batches/{id}/pre-label { "connection_id": …, "minimum_confidence": …, "replace_model_labels": …, "geometries": … } - → 202 BackgroundJobOut + → 202 PreLabelFanOutOut, one row per open + job; `POST /jobs/{id}/pre-label` is the + same launch over a single job POST /batches/{id}/promote → 200 AssetPage, the assets that entered GET /batches/{id}/jobs → 200 JobPage GET /batches/{id}/assets?limit=&offset=&progress=&sort= → 200 BatchAssetPage diff --git a/docs/content/cli.md b/docs/content/cli.md index 516276e2..dc952224 100644 --- a/docs/content/cli.md +++ b/docs/content/cli.md @@ -30,6 +30,7 @@ visionset job list --batch BATCH_ID visionset job next JOB_ID [-n COUNT] visionset job progress|start|complete JOB_ID visionset job mark JOB_ID ASSET_ID --progress STATE +visionset job pre-label JOB_ID CONNECTION [--minimum-confidence FLOAT] [--replace-model-labels] [--geometry SHAPE]... visionset release publish --tag T --project P [--split TRAIN,VAL,TEST] [--seed N] visionset release list --project P @@ -364,7 +365,10 @@ and touching ranges merged. The run is **synchronous**, and there is no `pre-label BATCH_ID CONNECTION [--minimum-confidence FLOAT] [--replace-model-labels] [--geometry SHAPE]...` blocks and calls `visionset.inference.pre_label` inline because a terminal has no -dispatcher. Before the first forward pass it names the classes it is about to ask for, the shape +dispatcher, once per open job of the batch: a run is over one job's assets, so the batch command +is a loop, each job announced by id on stderr and reported on its own line, with the total +`annotations_written` on stdout. A batch whose every job is finished says so and writes `0`. +Before the first job's first forward pass it names the classes it is about to ask for, the shape or shapes what it finds will land as, and every class of the pinned schema it is leaving out with the reason - a class the prompt omits labels nothing, and afterwards there is only the silence to explain. `--replace-model-labels` widens the run to frames a previous run labeled and nobody has @@ -373,18 +377,20 @@ label(s)`. `--geometry SHAPE`, repeated for several, writes only those of the sh produces - a model answering a box and a polygon for every region writes both unless told otherwise - and a shape the model does not produce is refused before anything runs; omitted, the run writes every shape the model produces. Progress and the summary are written to stderr; normal -stdout contains `annotations_written`. With `--json`, the command prints the complete outcome -instead, including `regions_discarded` for unmappable model labels and regions in a shape left -out, and `regions_out_of_bounds` for mapped regions without overlap with a measured asset. +stdout contains `annotations_written`. With `--json`, `items` holds one outcome per job, each +under its own `job_id`, and `annotations_written` the total - every outcome including +`regions_discarded` for unmappable model labels and regions in a shape left out, and +`regions_out_of_bounds` for mapped regions without overlap with a measured asset. `project pre-label PROJECT CONNECTION [--batch BATCH_ID]... [--minimum-confidence FLOAT] [--geometry SHAPE]...` is the same run over every batch of the project that is open for -annotation - or exactly the `--batch` ids given - one after another, each announced and reported -by name on stderr, with the total `annotations_written` on stdout, and `--geometry` applying to -every one of them. The selection is refused whole before the first forward pass: a batch outside -the project, a named batch that is not open, a project with no open batch, or a pin with no class -a shape the run writes can be written as (the message names the batch). With `--json`, `items` -holds one outcome per batch and `annotations_written` the total. +annotation - or exactly the `--batch` ids given - one after another and, within each, one open +job after another; every batch is announced and reported by name on stderr, with the total +`annotations_written` on stdout, and `--geometry` applies to all of them. The selection is refused +whole before the first forward pass: a batch outside the project, a named batch that is not open, +a project with no open batch, or a pin with no class a shape the run writes can be written as (the +message names the batch). With `--json`, `items` holds one outcome per job, each carrying its +`batch_id`, `batch_name` and `job_id`, and `annotations_written` the total. `--jobs-of N` is the `BySize` partition; with no flag the batch becomes one job. There is no `batch create` and no membership editing: a batch is born from an ingest. See @@ -395,8 +401,15 @@ holds one outcome per batch and `annotations_written` the total. `list --batch B`, `next JOB [-n N]`, `progress JOB`, `start JOB`, `mark JOB ASSET --progress STATE`, `complete JOB`. Each is one `JobService` call. -**`--progress annotated` records that somebody labeled an asset, and the CLI writes no labels** - -geometry comes from a canvas or a model, not from typing. A release published off a batch driven +`pre-label JOB_ID CONNECTION [--minimum-confidence FLOAT] [--replace-model-labels] [--geometry +SHAPE]...` is `batch pre-label`'s run over the one job named, on the same options and the same +inline execution, and it is what that command loops. A `completed` job is refused rather than +passed over - naming one job is a decision to run it. With `--json` the outcome carries the +`job_id` beside the counts. + +**`--progress annotated` records that somebody labeled an asset, and no command here but +`pre-label` writes a label** - geometry comes from a canvas or a model, not from typing. A +release published off a batch driven this way reports `annotation_count: 0`, and its manifest says so. These commands exist because the lifecycle must be drivable from a script, not because this is how labelling happens. See [jobs.md](jobs.md#at-a-terminal). diff --git a/docs/content/inference.md b/docs/content/inference.md index f56a75be..78540d43 100644 --- a/docs/content/inference.md +++ b/docs/content/inference.md @@ -76,8 +76,8 @@ marks each card by it (see [The Models page](#the-models-page)). ## Running a model here needs the `local-inference` extra -Both auto-labeling modes - suggesting a shape from a click, and pre-labeling a batch from its -schema - are always present. What is optional is the runtime that executes a model +Both auto-labeling modes - suggesting a shape from a click, and pre-labeling a job from its +batch's pinned schema - are always present. What is optional is the runtime that executes a model *on this machine*: ```bash @@ -415,7 +415,7 @@ pre-label plan reports, before any batch narrows it to what one schema can take. | Capability | Means | Consumed by | | --- | --- | --- | | `point_suggest` | Give me the thing under these points | [suggesting a shape from a click](#suggesting-a-shape-from-a-click), below | -| `text_detect` | Find everything these words name | [pre-labeling a batch](batches.md#pre-labeling) | +| `text_detect` | Find everything these words name | [pre-labeling a job](batches.md#pre-labeling) | **The vocabulary is closed and the set of models answering to it is not.** These two are the whole of what a connection can declare, and each exists because a surface renders it. Which diff --git a/docs/content/jobs.md b/docs/content/jobs.md index 01598bae..14af40db 100644 --- a/docs/content/jobs.md +++ b/docs/content/jobs.md @@ -176,12 +176,23 @@ the per-asset rows from the map it read. `JobOut` and `BatchAssetOut` carry `allowed_actions`, derived in `kernel/domain/capabilities.py` from the tables on this page. -**Both job actions need the batch open.** `JobService` runs `require_open_batch` before it +**Every job action needs the batch open.** `JobService` runs `require_open_batch` before it consults `JOB_TRANSITIONS`, so a `pending` job inside an `approved` batch declares *nothing* even though the table alone would call it startable. That dimension is exactly what a client re-deriving the rules from `JOB_TRANSITIONS` would drop. `complete` is refined by `SETTLED_PROGRESS` as well, which costs nothing: a job carries its own per-asset map. +`start` and `complete` are the two that move the job. **`pre_label` is the third, and it moves no +state at all**: a job that is `pending` or `in_progress` +(`OPEN_JOB_STATES`) inside an `in_annotation` batch declares it, and a `completed` job declares +nothing whatever its batch is. It is the declaration behind +[pre-labeling](batches.md#pre-labeling) — a run is over one job's assets, so the job is what +offers it — and it is declared from those two states alone, on `BatchAction.PRE_LABEL`'s +precedent: whether +the local runtime is installed and whether the pinned schema has a class a detection can land on +are not facts about the job, and hiding the control on either ground would leave those refusals +nowhere to be shown. + **Per asset, inside an open job in an `in_annotation` batch:** | Progress | Declares | @@ -208,7 +219,7 @@ model's unattended write makes in the same transaction as its labels, `pre_label a person's edit taking the frame over, and `pre_labeled → unannotated` is that edit deleting the model's last label. Four of the five have no name at all, and nobody clicks them: they are the consequence of `annotate`, which is declared on both `unannotated` and `pre_labeled`, and the entry -edge into `pre_labeled` is the consequence of a batch-level pre-label action rather than a +edge into `pre_labeled` is the consequence of a job-level pre-label action rather than a per-asset one. `pre_labeled → annotated` is the fifth, and the one edge with two ways to ride it: an edit, which is derived like the other four, and `confirm`, which a person clicks to keep a model's labels as the frame's own without editing them - labels exist either way, the marker @@ -292,15 +303,20 @@ visionset job start "$JOB" visionset job next "$JOB" -n 50 visionset job mark "$JOB" "$ASSET" --progress annotated visionset job progress "$JOB" +visionset job pre-label "$JOB" "$CONNECTION" visionset job complete "$JOB" ``` -Each is one `JobService` call. `next` and `mark` are what make the lifecycle drivable from a script +Each is one `JobService` call, save `pre-label`, which calls shared inference inline because a +terminal has no dispatcher to claim a queued run - `visionset batch pre-label`'s pattern, one +level down, and the one command here that writes labels rather than recording that somebody +else did. `next` and `mark` are what make the lifecycle drivable from a script at all - a batch cannot be completed until every asset has settled, and nothing else settles one. `JobService.mark`'s own docstring invites the second by name. -**Say the wart out loud: `--progress annotated` records that somebody labeled an asset, and the CLI -writes no labels.** Geometry comes from a canvas or a model, not from typing. A release published +**Say the wart out loud: `--progress annotated` records that somebody labeled an asset, and no +command here but `pre-label` writes a label.** Geometry comes from a canvas or a model, not from +typing. A release published off a batch driven entirely this way carries `annotation_count: 0`, and its manifest honestly says so. These commands exist because the *lifecycle* must be reachable from a terminal, not because this is how labelling is meant to happen. @@ -324,14 +340,29 @@ POST /jobs/{id}/start → 200 JobOut POST /jobs/{id}/complete → 200 JobOut GET /jobs/{id}/next?n= → 200 AssetPage PUT /jobs/{id}/assets/{asset_id}/progress → 200 AssetProgressOut +POST /jobs/{id}/pre-label → 202 BackgroundJobOut, Location: the row ``` +`POST /jobs/{id}/pre-label` is the launch [pre-labeling](batches.md#pre-labeling) describes, +over this one job: 202 with the `annotation.pre_label` row to poll and a `Location` header naming +it, or that row's id again where a run for this job is already queued or running. A `completed` +job is refused with 409 `JOB_FINISHED` rather than passed over — naming one job is a decision to +run it — and a job whose batch is not `in_annotation` is 409 `BATCH_NOT_IN_ANNOTATION`. The batch +and project launches fan the same row out over open jobs; there is no `GET /jobs/{id}/pre-label`, +because the plan is a property of the batch's pin and the model, and +`GET /batches/{id}/pre-label` answers it for every job of the batch alike. + `JobOut` carries **`batch_id`**, which an `AnnotationJob` does not: the model records only its task group, and a client holding a job id would otherwise have no route to the schema version its work is judged against. `JobService.batch` is the read behind it. `task_group_id` is deliberately absent - no route reaches a task group, so publishing the id would be contract surface that could never be removed. +It also carries **`pre_label_run`**, this job's most recent pre-labeling run - live or settled, +`null` where none ever ran - so a reload, a second tab or a run launched at a terminal is read +off the job rather than off an id some component happened to keep. `PreLabelRunOut` names both +ids: `annotation_job_id` is the job the run is over, `job_id` the queue row to poll. + `ProgressCounts` is five named integers plus a total rather than an open map, so a generated client gets a real type instead of a `Record`. Every state is a field, including the ones nobody is in. diff --git a/docs/content/mcp-tools.md b/docs/content/mcp-tools.md index 2202d2e6..57783601 100644 --- a/docs/content/mcp-tools.md +++ b/docs/content/mcp-tools.md @@ -11,7 +11,7 @@ error envelope, and the three gate words. ## Always offered -52 tools, in the order an agent meets them: make a project, give it a schema, put images in it, work through them, promote, publish, export. +53 tools, in the order an agent meets them: make a project, give it a schema, put images in it, work through them, promote, publish, export. | Tool | Takes | What it does | | --- | --- | --- | @@ -34,10 +34,11 @@ error envelope, and the three gate words. | `approve_batch` | `batch_id`, `jobs_of`? | Freeze a batch, pin the project's active schema, and cut it into jobs. | | `start_batch` | `batch_id` | Open an approved batch for annotation. | | `get_pre_label_plan` | `batch_id`, `connection`, `geometries`? | Which classes a pre-labeling run of that connection over this batch would ask about, which it would leave out, and what shapes it would write. | -| `pre_label_batch` | `batch_id`, `connection`, `minimum_confidence`?, `replace_model_labels`?, `geometries`? | Ask a model to label every untouched asset in a batch. This blocks until it is done. | +| `pre_label_batch` | `batch_id`, `connection`, `minimum_confidence`?, `replace_model_labels`?, `geometries`? | Ask a model to label every untouched asset in a batch, one run per open job. | | `pre_label_project` | `project`, `connection`, `minimum_confidence`?, `batch_ids`?, `geometries`? | Ask a model to label untouched assets across a project's open batches. Blocks until done. | +| `pre_label_job` | `job_id`, `connection`, `minimum_confidence`?, `replace_model_labels`?, `geometries`? | Ask a model to label every untouched asset in one job. This blocks until it is done. | | `repin_batch` | `batch_id`, `allow_destructive`? | Move a batch's schema pin onto the project's *current* active version. | -| `list_batch_assets` | `batch_id`, `limit`?, `offset`?, `progress`?, `sort`? | List a batch's assets, with the job, progress and label summary each carries. | +| `list_batch_assets` | `batch_id`, `limit`?, `offset`?, `progress`?, `sort`?, `job_id`? | List a batch's assets, with the job, progress and label summary each carries. | | `create_batch` | `project`, `name`, `asset_ids`? | Start a draft batch over a chosen set of a project's assets. | | `add_batch_assets` | `batch_id`, `asset_ids` | Put assets into a draft batch. | | `remove_batch_assets` | `batch_id`, `asset_ids` | Take assets out of a draft batch. This does not delete anything. | diff --git a/docs/content/mcp.md b/docs/content/mcp.md index c4497bdc..943667cb 100644 --- a/docs/content/mcp.md +++ b/docs/content/mcp.md @@ -121,10 +121,10 @@ page groups them by what they are for. | `approve_batch` | Freeze it, pin the schema, cut it into jobs. | | `start_batch` | Open it for annotation. | | `get_pre_label_plan` | Which classes a run of a connection would ask about, which it would leave out, and what shapes it writes. | -| `pre_label_batch` | Ask a model to label every untouched asset. Blocks until it is done. | -| `pre_label_project` | The same, over every open batch of a project or the named ones; one outcome per batch. Blocks until done. | +| `pre_label_batch` | Ask a model to label every untouched asset of a batch, one run per open job; one outcome per job. Blocks until it is done. | +| `pre_label_project` | The same, over every open batch of a project or the named ones; one outcome per open job. Blocks until done. | | `repin_batch` | Move its schema pin onto the current active version. | -| `list_batch_assets` | What is in it, paged, with each asset's job and progress. | +| `list_batch_assets` | What is in it, paged, with each asset's job and progress. `job_id` narrows it to one job's frames. | | `complete_batch` | Close it, once every job is complete. | | `promote_batch` | Move the finished assets into the dataset. | | `create_correction_batch` | Start a draft that corrects a completed one. | @@ -138,8 +138,9 @@ is already cut into jobs — see [batches.md](batches.md). | | | | --- | --- | -| `get_job` | State, counts, and the batch and schema it answers to. | +| `get_job` | State, counts, the batch and schema it answers to, and its own most recent pre-label run. | | `next_pending_assets` | The loop primitive: what is left to annotate. | +| `pre_label_job` | Ask a model to label every untouched asset of the one job you hold. Blocks until it is done. | | `get_asset_image` | **Look at the pixels.** See below. | | `list_asset_annotations` | What is already on an asset, with ids for editing. | | `add_annotations` | Write labels. All or none. | @@ -163,6 +164,11 @@ refusal behind an `InvalidTransition` of its own. A job whose batch is not `in_a refuses exactly as it always did: the batch gate is checked first, so a closed batch is not quietly marked as being worked on. +**`pre_label_job` is the one write that starts nothing**, and the two fan-outs built on it do not +either. Auto-start records that somebody is working the job, and a model's unattended pass is +nobody working it: the labels land `pre_labeled` for a person to take over, so a job pre-labeled +and not yet opened is still `pending`, and the first annotation write starts it as it always did. + `complete_job` starts a job too, which is not redundant: a correction batch cut over already-labeled assets opens fully settled (see [batches.md](batches.md)), so its job can be finished with no edits at all and no other write would ever have reached it. @@ -304,22 +310,28 @@ guard is that a batch which is no longer `in_annotation` refuses every write. **Ingest, export, weight downloads, integrity checks and pre-labeling are synchronous.** A stdio server has no background worker: something has to do the decode, and an agent driving a "resume" loop would block for exactly as long as doing the work in the first place. A long video -makes `ingest` a long call, a large model makes `download_connection_weights` one, and a batch of -untouched assets makes `pre_label_batch` one — minutes, with nothing to poll from here; -`pre_label_project` runs the same over every open batch of a project, so the wait is that many -batches' worth of minutes. A cut-off +makes `ingest` a long call, a large model makes `download_connection_weights` one, and a job of +untouched assets makes `pre_label_job` one — minutes, with nothing to poll from here. +`pre_label_batch` runs that once per open job of a batch and `pre_label_project` once per open +job of every batch it selects, so the wait is that many jobs' worth of minutes. A cut-off download changed nothing (the connection is only marked ready once every file is here) and the retry resumes the cache rather than starting over; a cut-off pre-labeling call has written only the assets it fully entered, one commit per asset, so calling it again resumes with whatever is still untouched - plus, where `replace_model_labels` is set, the frames still `pre_labeled`. -`pre_label_batch` reports unmappable model labels as `regions_discarded`, mapped regions +`pre_label_job` reports unmappable model labels as `regions_discarded`, mapped regions without overlap with a measured asset as `regions_out_of_bounds`, the model labels a replacing run superseded as `annotations_replaced`, and the prompt it ran under as `plan` — `asked_classes` beside `excluded_classes` and `produces`, so a run that labeled nothing says which classes it never asked about, and what shapes it was answering in, rather than leaving -that to a second call. `get_pre_label_plan` takes the same connection and answers the same -thing before the wait. All three tools take an optional `geometries`: omitted, a run writes +that to a second call. **The job is the unit, so the fan-outs report per job**: `pre_label_batch` +answers `items`, one entry in exactly that shape per open job with its own `job_id` and `plan`, +and `pre_label_project` the same with `batch_id` and `batch_name` beside them; a finished job is +passed over, and `annotations_written` at the top level is the total across the items. Naming one +job with `pre_label_job` is instead a decision to run that job, so a `completed` one is refused +rather than passed over. `get_pre_label_plan` takes the same connection and answers the same +thing before the wait — it stays batch-scoped, since the prompt comes from the batch's pin and is +the same for every job of it. All four tools take an optional `geometries`: omitted, a run writes every shape the model produces, which is what every call did before the parameter existed; named, it writes only those, a region in any other shape counts as discarded, and `produces` in the plan is the selection. A shape the model does not produce is refused before anything runs. @@ -334,7 +346,7 @@ The API's upload staging exists because HTTP has bytes where the kernel has path beside the workspace and has the filesystem. **One workspace per server.** No tool takes a workspace parameter — threading one through -fifty-two tools would put a path an agent has no way to know into every call. The workspace is +fifty-six tools would put a path an agent has no way to know into every call. The workspace is opened and closed per tool call rather than held, so the file is never kept from `visionset server` or a second agent between calls. @@ -346,14 +358,15 @@ out of the object to pick the variant, and omitting it fails. Always send ## What is not here, and why Fifty candidate tools were recorded across the four REST tasks; thirty of them shipped and -twenty did not. Twenty have been added since, each because a surface grew a capability an -agent had no way to reach: `check_export`, the plan-before-apply half of an export on the -`preview_schema_change` precedent; the four batch-composition tools above; the seven -inference-connection tools, closing the Models page's SDK-first parity; the four -schema-draft tools above, because composing a schema across several calls needs somewhere to -hold a class before it is finished; the three deletions, which are advertised only on -request; and `pre_label_batch`, closing the last capability declared with no consumer. That -is forty-nine offered by default and fifty-two in all. The parity rule means +twenty did not. Twenty-six have been added since, every one of them because a surface grew a +capability an agent had no way to reach. The larger groups say what that looks like: the four +batch-composition tools above; the seven inference-connection tools, closing the Models page's +SDK-first parity; the four schema-draft tools above, because composing a schema across several +calls needs somewhere to hold a class before it is finished; the three deletions, which are +advertised only on request; the pre-labeling trio, `pre_label_job` beside the two fan-outs, +closing the last capability declared with no consumer; and `check_export`, the plan-before-apply +half of an export on the `preview_schema_change` precedent. That is fifty-three offered by +default and fifty-six in all. The parity rule means *evaluated*, not *implemented* — tool-selection accuracy degrades with count, so a tool ships only when an agent has a reason to reach for it that no neighbour covers. diff --git a/docs/content/ui.md b/docs/content/ui.md index 90272490..34a65e30 100644 --- a/docs/content/ui.md +++ b/docs/content/ui.md @@ -186,7 +186,8 @@ A project has four sections - Overview, Schema, Batches, Dataset - and they are **navigation**, not four things stacked in one column (#171): at `lg` and above a column between the rail and the content, below `lg` a tab strip above it, one component (`ProjectNav`) drawing whichever layout `ProjectShell`'s `matchMedia` -answer hands it. The navigation carries the one filled control (Annotate, or Ingest in its +answer hands it. The navigation carries the one filled control (Annotate - straight into a +batch's one job, or onto the gallery to pick one - or Ingest in its place) and the overflow, and is only as wide as those; the project's identity is an eyebrow above the content - the name and the active-version chip, identity and not navigation - and the content opens under the section's own header: its title as the page `h1`, one @@ -718,23 +719,93 @@ was lost. It draws a placeholder, and offers no button: the remedy, **Paging and virtualization are two problems and both are solved.** `limit`/`offset` bound the *response*, so the network side is `useInfiniteQuery` - and "have I seen -everything" is `seen < total`, because `total` is the size of the whole batch and -does not move. Ten pages fetched is still ten pages in the DOM, so the render side +everything" is `seen < total`, because `total` is the size of what the request +matched - the whole batch, or one job's frames where `job=` narrows it - and does +not move while you page it. Ten pages fetched is still ten pages in the DOM, so the render side virtualizes **rows** (a row is what the browser lays out; virtualizing tiles inside a CSS grid means reimplementing the grid). The column count is measured with a `ResizeObserver` rather than guessed from a second breakpoint list. -#### The jobs strip - -One row per job - ordinal, frame count, state, and who is working it - rendered -only once jobs exist, the same `showsProgress` gate the progress bar above it uses, -so a draft needs no empty state of its own. The assignee is a plain editable name, -not an account: `JobService.assign` gates on nothing, so the control is always +#### The jobs accordion + +**Once a batch has jobs, its frames are shown per job, and at most one job is open at a +time.** A batch is partitioned, and a person works one part of it: a batch-wide grid beneath a +per-job control would put two scopes for the same frames on one screen, where the control +names a job and the grid, the counts and the timeline answer for everybody. One open panel is +one scope - its chips, its timeline and its tiles all count the same job - which is why +opening a panel closes the one that was open. **Every panel may be closed**: clicking the open +header collapses it, and an accordion with nothing open is the batch read as an index of its +jobs. + +The panel open on arrival is **the first job with work left** - frames still unannotated +or only pre-labeled - and the first job otherwise, so landing on a batch lands on +something to do rather than on a job somebody has finished. Nothing is remembered across +reloads: the rule recomputes from counts that are read anyway, and a remembered panel is +stale the moment somebody else works the job. + +**A collapsed header is the overview**, so a job is picked without opening it: ordinal, +frame count, state, `A of F annotated`, who is working it, and a thin progress bar. The +accordion is rendered only once jobs exist, the same `showsProgress` gate the progress bar +above it uses, so a draft needs no empty state of its own. The assignee is a plain editable +name, not an account: `JobService.assign` gates on nothing, so the control is always live, and clearing it is the same operation with `null`. A failed read shows its -error instead of the strip silently vanishing - an empty list and a failed one look +error instead of the accordion silently vanishing - an empty list and a failed one look identical to the naive `undefined`-or-zero-items check, and only one of them means there is nothing to assign. +**The open panel holds, in order:** the way into the annotator, `Pre-label` and the +assignee; the segment chips, counting *that job* from `GET /jobs/{id}/progress` rather +than the batch; the order select; that job's timeline; and only that job's frames, from +`GET /batches/{id}/assets?job=`. Frame numbers stay batch-wide, because a frame's number +is its place in the batch and renumbering per job would give one picture two names. + +**Thumbnail size is one setting and is rendered outside the panels** - it is a property +of how a grid is read rather than of a job, and it is the same persisted preference +either way. It sits on the batch's own progress row in the header, right of the progress bar +and on the line of its `A of F annotated` readout: the last row above the accordion that is +about the batch rather than about one job. A draft, which has no progress row, keeps it in the +toolbar over its flat grid. The segment filter is the opposite: it belongs to the panel and **resets to +`All` when the open job changes**, because a filter carried across shows an empty panel +for a job with nothing in that state, which reads as a job with no frames. + +**A draft batch has no accordion** - one flat grid, with the membership tools and +selection over it - because it has no jobs, and there is nothing to partition its frames +by. + +Each header is a ` @@ -287,7 +312,7 @@ function AnnotateAction({ onOpen(batch.id)} + onSelect={() => go(batch)} >
{batch.name} diff --git a/frontend/ui-core/src/patterns/projectNav.test.tsx b/frontend/ui-core/src/patterns/projectNav.test.tsx index 0aa9b18e..75cdeeb1 100644 --- a/frontend/ui-core/src/patterns/projectNav.test.tsx +++ b/frontend/ui-core/src/patterns/projectNav.test.tsx @@ -25,7 +25,7 @@ function props(overrides: Partial = {}): ProjectNavProps { hrefFor: (section) => `/projects/p/${section}`, onNavigate: vi.fn(), annotate: { - targets: [{ id: "b1", name: "drive-01", remaining: 12, schemaVersion: 4 }], + targets: [{ id: "b1", name: "drive-01", remaining: 12, schemaVersion: 4, jobIds: ["j1"] }], onOpen: vi.fn(), }, onIngest: vi.fn(), @@ -135,6 +135,72 @@ describe("ProjectNav", () => { expect(onOpen).toHaveBeenCalledWith("b1"); }); + it("jumps into the annotator when the one open batch has one job", async () => { + const onOpen = vi.fn(); + const onOpenJob = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByTestId("go-annotate")); + expect(onOpenJob).toHaveBeenCalledWith("j1"); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("lands on the gallery to pick a job when the batch has several, or while they are unknown", async () => { + for (const jobIds of [["j1", "j2"], undefined]) { + const onOpen = vi.fn(); + const onOpenJob = vi.fn(); + const { unmount } = render( + , + ); + await userEvent.click(screen.getByTestId("go-annotate")); + expect(onOpen).toHaveBeenCalledWith("b1"); + expect(onOpenJob).not.toHaveBeenCalled(); + unmount(); + } + }); + + it("applies the job rule to the batch picked from the dropdown", async () => { + const onOpen = vi.fn(); + const onOpenJob = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByTestId("go-annotate")); + await userEvent.click(await screen.findByTestId("annotate-batch-drive-02")); + expect(onOpenJob).toHaveBeenCalledWith("j2"); + await userEvent.click(screen.getByTestId("go-annotate")); + await userEvent.click(await screen.findByTestId("annotate-batch-drive-01")); + expect(onOpen).toHaveBeenCalledWith("b1"); + }); + it("keeps rename and delete behind the overflow", async () => { const onRename = vi.fn(); render(); diff --git a/frontend/ui-core/src/screens/BatchLifecycle.tsx b/frontend/ui-core/src/screens/BatchLifecycle.tsx index 75acbf04..ff03f71b 100644 --- a/frontend/ui-core/src/screens/BatchLifecycle.tsx +++ b/frontend/ui-core/src/screens/BatchLifecycle.tsx @@ -23,7 +23,7 @@ */ import { useState, type JSX } from "react"; -import { Play, SquareCheck } from "lucide-react"; +import { SquareCheck } from "lucide-react"; import { asApiError } from "../data/errors"; import { refusalProse } from "../data/refusals"; @@ -333,9 +333,8 @@ export function ApproveDialog({ * gallery send the identical mutation rather than two spellings of it. * * Deliberately does not navigate. Landing back on the gallery re-reads the - * batch as `in_annotation`, and the header then offers Pre-label beside Open - * annotator — the choice a batch of any size is worth making explicitly, not - * one a jump straight into the annotator would skip past. + * batch as `in_annotation`, and the job panels then offer Start annotating and + * Pre-label per job. */ export function StartAnnotatingButton({ batch, @@ -356,7 +355,6 @@ export function StartAnnotatingButton({ disabled={start.isPending} onClick={() => start.mutate()} > -
diff --git a/frontend/ui-core/src/screens/ProjectFrame.tsx b/frontend/ui-core/src/screens/ProjectFrame.tsx index 6d1eb94f..4d093237 100644 --- a/frontend/ui-core/src/screens/ProjectFrame.tsx +++ b/frontend/ui-core/src/screens/ProjectFrame.tsx @@ -16,7 +16,7 @@ * second filled control beside it would be two answers to "what now?". */ -import { useState, type JSX, type ReactNode } from "react"; +import { useMemo, useState, type JSX, type ReactNode } from "react"; import { refusalProse } from "../data/refusals"; import { asApiError } from "../data/errors"; @@ -38,10 +38,12 @@ import { useActiveSchema, useBatches, useDeleteProject, + useJobsOfBatches, useProject, useProjectStats, useRenameProject, type Batch, + type Job, } from "./queries"; import type { FormEvent } from "react"; @@ -68,6 +70,7 @@ export interface ProjectFrameProps { */ readonly cta?: { readonly onOpenBatch?: (batchId: string) => void; + readonly onOpenJob?: (jobId: string) => void; readonly onIngest?: () => void; readonly contentOwnsTheAction?: boolean; }; @@ -92,7 +95,10 @@ export interface ProjectFrameProps { * direction. The copy is not decoration — the array belongs to the query cache, * and `reverse` mutates in place. */ -export function openForAnnotation(batches: readonly Batch[] | undefined): readonly AnnotateTarget[] { +export function openForAnnotation( + batches: readonly Batch[] | undefined, + jobs?: ReadonlyMap, +): readonly AnnotateTarget[] { return [...(batches ?? [])] .filter((batch) => batch.state === "in_annotation") .reverse() @@ -101,6 +107,7 @@ export function openForAnnotation(batches: readonly Batch[] | undefined): readon name: batch.name, remaining: batch.progress.unannotated, schemaVersion: batch.schema_version ?? null, + jobIds: jobs?.get(batch.id)?.map((job) => job.id), })); } @@ -121,14 +128,22 @@ export function ProjectFrame({ const [renaming, setRenaming] = useState(false); const [deleting, setDeleting] = useState(false); - const open = cta?.onOpenBatch === undefined ? [] : openForAnnotation(batches.data?.items); + // Hooks cannot be conditional, so this always runs — with an empty list, and + // therefore no request, whenever the host wired no `onOpenJob` to apply the + // job rule with. + const openIds = useMemo( + () => (batches.data?.items ?? []).filter((batch) => batch.state === "in_annotation").map((batch) => batch.id), + [batches.data], + ); + const jobs = useJobsOfBatches(cta?.onOpenJob === undefined ? [] : openIds); + const open = cta?.onOpenBatch === undefined ? [] : openForAnnotation(batches.data?.items, jobs); const nav: ProjectNavData = { sections, active, onNavigate, ...(hrefFor === undefined ? {} : { hrefFor }), ...(open.length > 0 && cta?.onOpenBatch !== undefined - ? { annotate: { targets: open, onOpen: cta.onOpenBatch } } + ? { annotate: { targets: open, onOpen: cta.onOpenBatch, ...(cta.onOpenJob === undefined ? {} : { onOpenJob: cta.onOpenJob }) } } : {}), ...(cta?.onIngest === undefined ? {} : { onIngest: cta.onIngest }), contentOwnsTheAction: cta?.contentOwnsTheAction ?? false, diff --git a/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx b/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx index bb390b1f..1cf530d1 100644 --- a/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx +++ b/frontend/ui-core/src/screens/ProjectPreLabelDialog.tsx @@ -43,9 +43,29 @@ import { usePreLabelProject, type Batch, type GeometryType, - type ProjectPreLabelOut, + type PreLabelFanOutOut, } from "./queries"; +/** + * One row per job. A batch that fanned out to several jobs would otherwise + * repeat its name over indistinguishable rows, so a row past the first for + * that batch is suffixed by its 1-based position among them. + */ +function resultRows( + items: PreLabelFanOutOut["items"], +): readonly { item: PreLabelFanOutOut["items"][number]; label: string }[] { + const counts = new Map(); + for (const item of items) counts.set(item.batch_id, (counts.get(item.batch_id) ?? 0) + 1); + const seen = new Map(); + return items.map((item) => { + const position = (seen.get(item.batch_id) ?? 0) + 1; + seen.set(item.batch_id, position); + const label = + (counts.get(item.batch_id) ?? 0) > 1 ? `${item.batch_name} · job ${position}` : item.batch_name; + return { item, label }; + }); +} + export interface ProjectPreLabelButtonProps { readonly projectId: string; readonly batches: readonly Batch[]; @@ -111,7 +131,7 @@ function ProjectPreLabelDialog({ const [checked, setChecked] = useState>( () => new Set(batches.filter((one) => one.progress.unannotated > 0).map((one) => one.id)), ); - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); const launch = usePreLabelProject(projectId); const active = candidates.find((row) => row.id === connectionId) ?? candidates[0]; const shapes = active?.produces ?? []; @@ -249,13 +269,13 @@ function ProjectPreLabelDialog({ ) : (
    - {result.items.map((item) => ( -
  • + {resultRows(result.items).map(({ item, label }) => ( +
  • {item.joined ? "already running — joined" : "queued"} diff --git a/frontend/ui-core/src/screens/ProjectScreen.tsx b/frontend/ui-core/src/screens/ProjectScreen.tsx index 42ec78f7..124d53ed 100644 --- a/frontend/ui-core/src/screens/ProjectScreen.tsx +++ b/frontend/ui-core/src/screens/ProjectScreen.tsx @@ -149,6 +149,7 @@ export interface ProjectScreenProps { /** Route changes, supplied by the app. See `ProjectsScreen`'s note. */ readonly onIngest?: () => void; readonly onOpenBatch?: (batchId: string) => void; + readonly onOpenJob?: (jobId: string) => void; /** * Where to go once the project is gone. Absent means the overflow menu still * deletes, and the caller is left on a screen whose subject no longer exists — @@ -170,6 +171,7 @@ export function ProjectScreen({ projectId, onIngest, onOpenBatch, + onOpenJob, onDeleted, tab, onTabChange, @@ -456,6 +458,7 @@ export function ProjectScreen({ chain="frame" cta={{ ...(onOpenBatch === undefined ? {} : { onOpenBatch }), + ...(onOpenJob === undefined ? {} : { onOpenJob }), ...(onIngest === undefined ? {} : { onIngest }), contentOwnsTheAction: overviewOwnsTheAction, }} diff --git a/frontend/ui-core/src/screens/gallery.test.tsx b/frontend/ui-core/src/screens/gallery.test.tsx index d7cc10bd..8acdff7f 100644 --- a/frontend/ui-core/src/screens/gallery.test.tsx +++ b/frontend/ui-core/src/screens/gallery.test.tsx @@ -130,6 +130,54 @@ function batch(overrides: Record = {}): Record }; } +/** + * The jobs a non-draft batch has, and each one's counts. + * + * Every fixture past `draft` needs both, because the gallery shows a batch's + * frames **inside a job**: with no roster and no per-job progress there is no + * open panel, and therefore no grid, no segments and no timeline to assert + * against. The counts are the *job's* — the segment chips read them, not the + * batch's. + */ +function oneJob( + options: { + state?: JobState; + batchState?: BatchState; + assetCount?: number; + assignee?: string | null; + counts?: Record; + } = {}, +): void { + const { + state = "in_progress", + batchState = "in_annotation", + assetCount = 3, + assignee = null, + counts = {}, + } = options; + on("GET", /\/jobs$/, { + status: 200, + body: { + items: [ + { + id: JOB, + batch_id: BATCH, + state, + asset_count: assetCount, + assignee, + pre_label_run: null, + allowed_actions: jobActions(state, { batchState }), + }, + ], + total: 1, + }, + }); + on("GET", /\/jobs\/[^/]+\/progress$/, { + status: 200, + body: { ...NO_PROGRESS, total: assetCount, unannotated: assetCount, ...counts }, + }); +} + describe("the batch table", () => { it("shows one action per state, and the last one is promote", async () => { on("GET", /\/batches$/, { @@ -348,6 +396,7 @@ describe("the gallery", () => { } it("asks for the first window with the page size", async () => { + on("GET", /\/batches\/[^/]+$/, { status: 200, body: batch() }); on("GET", /\/assets$/, { status: 200, body: assets(100, 0, 250) }); render(mount()); @@ -432,6 +481,7 @@ describe("the gallery", () => { // Null means *unknown*, not "never" — an asset ingested before the column // existed is legitimately unstamped, and inventing a date would be worse than // the omission. + on("GET", /\/batches\/[^/]+$/, { status: 200, body: batch() }); on("GET", /\/assets$/, { status: 200, body: { total: 1, items: [asset(0, { ingested_at: null, source_id: null })] }, @@ -472,24 +522,24 @@ describe("the gallery", () => { expect(screen.queryByTestId("approve-batch")).toBeNull(); }); - it("counts the segments off the batch rather than off the loaded page", async () => { + it("counts the segments off the open job rather than off the loaded page", async () => { on("GET", /\/batches\/[^/]+$/, { status: 200, - body: batch({ - state: "in_annotation", - asset_count: 48, - progress: { - total: 48, - unannotated: 30, - pre_labeled: 2, - annotated: 8, - review_pending: 5, - accepted: 1, - skipped: 4, - }, - }), + body: batch({ state: "in_annotation", asset_count: 48 }), + }); + oneJob({ + assetCount: 48, + counts: { + total: 48, + unannotated: 30, + pre_labeled: 2, + annotated: 8, + review_pending: 5, + accepted: 1, + skipped: 4, + }, }); - // Five loaded out of forty-eight: the counts must describe the batch, not the + // Five loaded out of forty-eight: the counts must describe the job, not the // window. A filter whose numbers described the page would be a filter that // lies about the collection it is filtering. on("GET", /\/assets$/, { status: 200, body: mixed() }); @@ -514,6 +564,7 @@ describe("the gallery", () => { body: batch({ state: "in_annotation", schema_version: 1, progress: { ...NO_PROGRESS, total: 3, unannotated: 2, pre_labeled: 1 } }), }); + oneJob({ counts: { total: 3, unannotated: 2, pre_labeled: 1 } }); on("GET", /\/assets$/, { status: 200, body: assets(3) }); render(mount()); await screen.findByTestId("segment-pre_labeled"); @@ -542,6 +593,7 @@ describe("the gallery", () => { body: batch({ state: "in_annotation", schema_version: 1, progress: { ...NO_PROGRESS, total: 2, pre_labeled: 2 } }), }); + oneJob({ assetCount: 2, counts: { total: 2, pre_labeled: 2 } }); on("GET", /\/assets$/, { status: 200, body: assets(2) }); render(mount()); const order = (await screen.findByTestId("sort-order")) as HTMLSelectElement; @@ -557,6 +609,7 @@ describe("the gallery", () => { }); it("shows a draft no sort control, because a draft has no scores", async () => { + on("GET", /\/batches\/[^/]+$/, { status: 200, body: batch() }); on("GET", /\/assets$/, { status: 200, body: assets(2) }); render(mount()); await screen.findByTestId("tile-asset-0"); @@ -569,6 +622,7 @@ describe("the gallery", () => { body: batch({ state: "in_annotation", schema_version: 1, progress: { ...NO_PROGRESS, total: 2, annotated: 1, pre_labeled: 1 } }), }); + oneJob({ assetCount: 2, counts: { total: 2, annotated: 1, pre_labeled: 1 } }); on("GET", /\/assets$/, { status: 200, body: { @@ -594,6 +648,7 @@ describe("the gallery", () => { body: batch({ state: "in_annotation", schema_version: 1, progress: { ...NO_PROGRESS, total: 3, unannotated: 3 } }), }); + oneJob({ counts: { total: 3, unannotated: 3 } }); handlers.push((request) => { const url = new URL(request.url); if (request.method === "GET" && url.pathname.endsWith("/assets")) { @@ -710,13 +765,14 @@ describe("the gallery", () => { status: 200, body: batch({ state: "approved", progress: { ...NO_PROGRESS, total: 5, unannotated: 5 } }), }); + oneJob({ state: "pending", batchState: "approved", assetCount: 5, counts: { total: 5, unannotated: 5 } }); on("GET", /\/assets$/, { status: 200, body: mixed() }); render(mount()); await waitFor(() => expect(screen.queryByTestId("segments")).not.toBeNull()); // The other half of the claim above: hidden *before* approval, not removed. - expect(screen.queryByTestId("timeline")).not.toBeNull(); + expect(await screen.findByTestId("timeline")).not.toBeNull(); expect(screen.queryByTestId("select-asset-0")).not.toBeNull(); expect(screen.queryByTestId("state-asset-0")).not.toBeNull(); }); @@ -729,6 +785,7 @@ describe("the gallery", () => { status: 200, body: batch({ state: "in_annotation", progress: { ...NO_PROGRESS, total: 5, annotated: 5 } }), }); + oneJob({ assetCount: 5, counts: { total: 5, annotated: 5 } }); on("GET", /\/assets$/, { status: 200, body: mixed() }); render(mount()); @@ -761,6 +818,7 @@ describe("the gallery", () => { }); it("keeps the empty state for a batch with nothing in it", async () => { + on("GET", /\/batches\/[^/]+$/, { status: 200, body: batch() }); on("GET", /\/assets$/, { status: 200, body: assets(0, 0, 0) }); render(mount()); await waitFor(() => expect(screen.queryByText("This batch is empty")).not.toBeNull()); @@ -854,6 +912,7 @@ describe("the gallery", () => { // render here for the intended layout. expect(globalThis.ResizeObserver).toBeUndefined(); + on("GET", /\/batches\/[^/]+$/, { status: 200, body: batch() }); on("GET", /\/assets$/, { status: 200, body: assets(6, 0, 6) }); render(mount()); const grid = await screen.findByTestId("gallery-grid"); @@ -861,6 +920,7 @@ describe("the gallery", () => { }); it("has no scrollable box of its own any more", async () => { + on("GET", /\/batches\/[^/]+$/, { status: 200, body: batch() }); on("GET", /\/assets$/, { status: 200, body: assets(6, 0, 6) }); render(mount()); @@ -981,6 +1041,7 @@ describe("finishing a batch", () => { asset_count: 48, allowed_actions: jobActions(state as JobState), assignee: null, + pre_label_run: null, }; } @@ -1192,6 +1253,9 @@ describe("the bulk bar", () => { progress: { ...NO_PROGRESS, total: states.length, unannotated: states.length }, }), }); + // Unused by a draft, which has no jobs and keeps its flat grid — and the one + // thing without which every other state renders no grid at all. + oneJob({ batchState, assetCount: states.length, counts: { total: states.length, unannotated: states.length } }); on("GET", /\/assets$/, { status: 200, body: { total: states.length, items: states.map((one, at) => tile(at, one, batchState)) }, @@ -1368,6 +1432,7 @@ describe("the bulk bar", () => { progress: { ...NO_PROGRESS, total: 2, pre_labeled: 2 }, }), }); + oneJob({ assetCount: 2, counts: { total: 2, pre_labeled: 2 } }); let swept = false; handlers.push((request) => { const url = new URL(request.url); @@ -1416,6 +1481,7 @@ describe("the bulk bar", () => { progress: { ...NO_PROGRESS, total: 2, pre_labeled: 2 }, }), }); + oneJob({ assetCount: 2, counts: { total: 2, pre_labeled: 2 } }); let moved = false; handlers.push((request) => { const url = new URL(request.url); @@ -1587,6 +1653,7 @@ describe("the bulk bar", () => { progress: { ...NO_PROGRESS, total: 2, review_pending: 1, annotated: 1 }, }), }); + oneJob({ assetCount: 2, counts: { total: 2, review_pending: 1, annotated: 1 } }); handlers.push((request) => { const url = new URL(request.url); if (request.method === "GET" && url.pathname.endsWith("/assets")) { @@ -1926,13 +1993,14 @@ describe("the bulk bar", () => { }); /** - * The way into the annotator, which must not close behind you. + * The way into the annotator, and it is a job's panel that holds it. * - * Drawing `Start annotating` only while some frame is `unannotated` leaves a batch - * whose work is finished with no action in its header at all — while the badge - * beside the empty space goes on saying `in progress`. + * A batch's frames are partitioned into jobs, so "start annotating" is a question + * about one job: which frames, and taking them (`start`) before anyone else does. + * The header's single door could answer neither — it guessed a frame and sent no + * mutation at all — so the door moved into the panel that knows. */ -describe("the gallery header's way into the annotator", () => { +describe("the job panel's way into the annotator", () => { function frames(batchState: BatchState, ...states: string[]): Record { return { total: states.length, @@ -1958,12 +2026,9 @@ describe("the gallery header's way into the annotator", () => { }; } - async function open(...states: string[]): Promise> { - return openIn("in_annotation", ...states); - } - - async function openIn( + async function openWith( batchState: BatchState, + jobState: JobState, ...states: string[] ): Promise> { on("GET", /\/batches\/[^/]+$/, { @@ -1976,64 +2041,126 @@ describe("the gallery header's way into the annotator", () => { }); on("GET", /\/assets$/, { status: 200, body: frames(batchState, ...states) }); on("GET", /\/annotations$/, { status: 200, body: [] }); - - const opened = vi.fn(); - render(mount()); + on("GET", /\/jobs$/, { + status: 200, + body: { + items: [ + { + id: JOB, + batch_id: BATCH, + state: jobState, + asset_count: states.length, + assignee: null, + pre_label_run: null, + allowed_actions: jobActions(jobState, { batchState }), + }, + ], + total: 1, + }, + }); + on("GET", /\/jobs\/[^/]+\/progress$/, { + status: 200, + body: { ...NO_PROGRESS, total: states.length, unannotated: states.length }, + }); + + const openedJob = vi.fn(); + render( + mount( + , + ), + ); + // The accordion first: the frames are *inside* a panel now, so there are no + // tiles at all until the job roster and its counts have both landed. + await screen.findByTestId("job-panels"); await screen.findByTestId("tile-asset-0"); - return opened; + return openedJob; } - it("still offers a way in when every frame is settled", async () => { - await open("annotated", "skipped", "skipped"); - // The defect: this was absent, and nothing else in the header offered one. - expect(screen.getByTestId("start-annotating").textContent).toContain("Open annotator"); - }); - - it("opens a skipped frame, which the annotator can un-skip from", async () => { - const opened = await open("skipped", "skipped"); - await userEvent.click(screen.getByTestId("start-annotating")); - // Not filtered out. The annotator lists a job's assets with no progress - // filter and carries `Un-skip` on its toolbar, so a skipped frame is a - // legitimate thing to open — and with everything skipped it is the only thing. - expect(opened).toHaveBeenCalledWith(expect.objectContaining({ id: "asset-0" })); - }); - - it("still starts on the first waiting frame when there is one", async () => { - const opened = await open("annotated", "unannotated", "unannotated"); - expect(screen.getByTestId("start-annotating").textContent).toContain("Start annotating"); - await userEvent.click(screen.getByTestId("start-annotating")); - expect(opened).toHaveBeenCalledWith(expect.objectContaining({ id: "asset-1" })); - }); - - /** - * The third question the label had to start asking (F2). - * - * "Whether there is a frame to open" and "whether any is still waiting" were - * the only two, so a completed batch read `Open annotator` and opened a fully - * live editor whose every save the kernel refuses. The door is the same; the - * word on it is now honest about what is behind it. - */ - it("says View when the frames cannot be written to", async () => { - await openIn("completed", "annotated", "skipped"); - expect(screen.getByTestId("start-annotating").textContent).toContain("View frames"); - expect(screen.getByTestId("start-annotating").textContent).not.toContain("annotator"); + it("offers no door in the header once the batch is open", async () => { + await openWith("in_annotation", "pending", "unannotated"); + expect(screen.queryByTestId("start-annotating")).toBeNull(); + // Pre-label is a job's action now, so every trigger on the page is inside the + // accordion. Asserting a batch-keyed testid is absent would pass on a page + // that still had a header mount, because the testid is keyed by job. + const panels = screen.getByTestId("job-panels"); + const triggers = [...document.querySelectorAll('[data-testid^="pre-label-"]')]; + expect(triggers.length).toBeGreaterThan(0); + expect(triggers.every((node) => panels.contains(node))).toBe(true); }); - it("says View on the tiles too, since a tile is the other door", async () => { - await openIn("completed", "annotated", "skipped"); + it("starts a pending job, then opens it", async () => { + on("POST", /\/jobs\/[^/]+\/start$/, { + status: 200, + body: { + id: JOB, + batch_id: BATCH, + state: "in_progress", + asset_count: 1, + assignee: null, + pre_label_run: null, + allowed_actions: jobActions("in_progress"), + }, + }); + const openedJob = await openWith("in_annotation", "pending", "unannotated"); + const door = screen.getByTestId(`start-job-${JOB}`); + expect(door.textContent).toContain("Annotate"); + expect(door.querySelector("svg")).not.toBeNull(); + expect(door.dataset.variant).toBe("secondary"); + await userEvent.click(door); + await waitFor(() => expect(openedJob).toHaveBeenCalledWith(JOB)); + expect(sent.filter((r) => r.method === "POST").map((r) => new URL(r.url).pathname)).toEqual([ + `/jobs/${JOB}/start`, + ]); + }); + + it("continues an in-progress job without starting it again", async () => { + const openedJob = await openWith("in_annotation", "in_progress", "unannotated"); + const door = screen.getByTestId(`start-job-${JOB}`); + expect(door.textContent).toContain("Continue"); + await userEvent.click(door); + expect(openedJob).toHaveBeenCalledWith(JOB); + expect(sent.some((r) => r.method === "POST")).toBe(false); + }); + + it("says View on a pending job the batch has not started yet", async () => { + // An `approved` batch's jobs are `pending` and declare nothing, so there is + // no taking them from here — and "Continue" over a job nobody has opened is + // the label promising a state that does not exist. + const openedJob = await openWith("approved", "pending", "unannotated"); + const door = screen.getByTestId(`start-job-${JOB}`); + expect(door.textContent).toContain("View"); + await userEvent.click(door); + expect(openedJob).toHaveBeenCalledWith(JOB); + expect(sent.some((r) => r.method === "POST")).toBe(false); + }); + + it("says View on a finished job and on a finished batch", async () => { + await openWith("completed", "completed", "annotated", "skipped"); + expect(screen.getByTestId(`start-job-${JOB}`).textContent).toContain("View"); expect(screen.getByTestId("open-asset-0").textContent).toBe("View"); - expect(screen.getByTestId("open-asset-0").getAttribute("aria-label")).toMatch(/^View frame/); }); - it("still opens, because looking at finished work is the point of the door", async () => { - const opened = await openIn("completed", "annotated", "skipped"); - await userEvent.click(screen.getByTestId("start-annotating")); - expect(opened).toHaveBeenCalledWith(expect.objectContaining({ id: "asset-0" })); + it("shows the refusal when the start is refused, and does not navigate", async () => { + on("POST", /\/jobs\/[^/]+\/start$/, { + status: 409, + body: { code: "BATCH_NOT_IN_ANNOTATION", message: "batch 'drive-01' is 'completed'" }, + }); + const openedJob = await openWith("in_annotation", "pending", "unannotated"); + await userEvent.click(screen.getByTestId(`start-job-${JOB}`)); + + const said = await screen.findByText("This batch is not open for annotation any more."); + expect(said.textContent).not.toContain("BATCH_NOT_IN_ANNOTATION"); + expect(openedJob).not.toHaveBeenCalled(); }); - it("says Open on a batch that can be written to", async () => { - await open("annotated", "skipped"); - expect(screen.getByTestId("open-asset-0").textContent).toBe("Open"); + it("offers Pre-label in the job's panel, gated on the job's declaration", async () => { + await openWith("in_annotation", "pending", "unannotated"); + expect(screen.getByTestId(`pre-label-${JOB}`)).toBeTruthy(); }); /** @@ -2044,14 +2171,14 @@ describe("the gallery header's way into the annotator", () => { * link to the dataset either, which is where a promotion's evidence lives. */ it("offers Promote once the batch is completed", async () => { - await openIn("completed", "annotated", "skipped"); + await openWith("completed", "completed", "annotated", "skipped"); expect(screen.queryByTestId("promote-drive-01")).not.toBeNull(); }); it("offers no Promote before the batch is completed", async () => { // Capability-gated, not state-guessed: `PROMOTABLE_STATES` is the kernel's // and the wire declares it. - await open("annotated", "unannotated"); + await openWith("in_annotation", "in_progress", "annotated", "unannotated"); expect(screen.queryByTestId("promote-drive-01")).toBeNull(); }); }); @@ -2103,6 +2230,7 @@ describe("the gallery header's own next step", () => { }); on("GET", /\/assets$/, { status: 200, body: frames(batchState, ...states) }); on("GET", /\/annotations$/, { status: 200, body: [] }); + oneJob({ batchState, assetCount: states.length, counts: { total: states.length, unannotated: states.length } }); render(mount()); await screen.findByTestId("tile-asset-0"); @@ -2111,21 +2239,26 @@ describe("the gallery header's own next step", () => { it("offers Start annotating on an approved batch, not View frames", async () => { await openIn("approved", "unannotated", "unannotated"); expect(screen.getByTestId("start-batch").textContent).toContain("Start annotating"); - // The per-frame door is not offered beside it — a batch that has not - // started has nothing settled yet for that door to show. expect(screen.queryByTestId("start-annotating")).toBeNull(); }); - it("offers no Start on a completed batch, only View frames", async () => { + it("draws no icon on the approved batch's Start annotating", async () => { + // The header is a row of word-only controls; a glyph on one of them reads as + // a different kind of control rather than as emphasis. + await openIn("approved", "unannotated", "unannotated"); + expect(screen.getByTestId("start-batch").querySelector("svg")).toBeNull(); + }); + + it("offers no Start on a completed batch, and no header door either", async () => { await openIn("completed", "annotated", "skipped"); expect(screen.queryByTestId("start-batch")).toBeNull(); - expect(screen.getByTestId("start-annotating").textContent).toContain("View frames"); + expect(screen.queryByTestId("start-annotating")).toBeNull(); }); - it("offers no Start on an in_annotation batch, only the annotator entry", async () => { + it("offers no Start on an in_annotation batch, whose door is in the job's panel", async () => { await openIn("in_annotation", "annotated", "unannotated", "unannotated"); expect(screen.queryByTestId("start-batch")).toBeNull(); - expect(screen.getByTestId("start-annotating")).toBeTruthy(); + expect(screen.queryByTestId("start-annotating")).toBeNull(); }); it("performs the batch's own start rather than navigating into the annotator", async () => { @@ -2218,7 +2351,7 @@ describe("the gallery header's own next step", () => { }); }); -describe("the jobs strip", () => { +describe("the jobs accordion", () => { const OTHER_JOB = "88888888-8888-4888-8888-888888888888"; function jobRow(assignee: string | null, id: string = JOB) { @@ -2229,16 +2362,32 @@ describe("the jobs strip", () => { asset_count: 3, allowed_actions: jobActions("in_progress"), assignee, + pre_label_run: null, }; } function renderGallery(): void { on("GET", /\/batches\/[^/]+$/, { status: 200, body: batch({ state: "in_annotation" }) }); on("GET", /\/assets$/, { status: 200, body: { items: [], total: 0 } }); + // Registered last, so a test that wants its own answer for a job's counts + // still wins: handlers are consulted in registration order. + on("GET", /\/jobs\/[^/]+\/progress$/, { + status: 200, + body: { ...NO_PROGRESS, total: 3, unannotated: 3 }, + }); render(mount()); } - it("shows each job's assignee, and an Assign control when there is none", async () => { + /** + * The assignee editor is in the **open panel**, not on every row: a collapsed + * header is an overview and names who has the job, and the control that changes + * that is one of the things opening a panel is for. + */ + async function openPanel(): Promise> { + return within(await screen.findByTestId(`job-panel-${JOB}`)); + } + + it("names each job's assignee on its own header, open or not", async () => { handlers.push((request) => { const url = new URL(request.url); if (url.pathname === `/batches/${BATCH}/jobs`) @@ -2252,9 +2401,16 @@ describe("the jobs strip", () => { return undefined; }); renderGallery(); - const strip = within(await screen.findByTestId("jobs-strip")); - expect(strip.getByText("Dana Reyes")).toBeTruthy(); - expect(strip.getByRole("button", { name: "Assign" })).toBeTruthy(); + + // The overview half of the claim: who has a job is legible without opening + // it, and an unassigned one says so rather than saying nothing. + expect((await screen.findByTestId(`job-row-${JOB}`)).textContent).toContain("Dana Reyes"); + expect(screen.getByTestId(`job-row-${OTHER_JOB}`).textContent).toContain("—"); + + // ...and the editor is in whichever panel is open, once. + const panel = await openPanel(); + expect(panel.getByRole("button", { name: "Dana Reyes" })).toBeTruthy(); + expect(screen.queryAllByLabelText(/Assignee for job/)).toHaveLength(0); }); it("says a missing batch in words when the jobs cannot be read", async () => { @@ -2290,10 +2446,10 @@ describe("the jobs strip", () => { return undefined; }); renderGallery(); - const strip = within(await screen.findByTestId("jobs-strip")); - await userEvent.click(strip.getByRole("button", { name: /assign/i })); + const panel = await openPanel(); + await userEvent.click(panel.getByRole("button", { name: /assign/i })); await userEvent.keyboard("Dana Reyes{Enter}"); - const said = await strip.findByRole("alert"); + const said = await panel.findByRole("alert"); expect(said.textContent).toContain("That job is no longer on record."); expect(said.textContent).not.toContain(JOB); expect(said.textContent).not.toContain("JOB_NOT_FOUND"); @@ -2315,13 +2471,13 @@ describe("the jobs strip", () => { return undefined; }); renderGallery(); - const strip = within(await screen.findByTestId("jobs-strip")); - await userEvent.click(strip.getByRole("button", { name: /assign/i })); + const panel = await openPanel(); + await userEvent.click(panel.getByRole("button", { name: /assign/i })); await userEvent.keyboard("Dana Reyes{Enter}"); const put = sent.find((request) => request.method === "PUT"); expect(put).toBeTruthy(); expect(JSON.parse(bodies.get(put!) ?? "")).toEqual({ assignee: "Dana Reyes" }); - expect(await strip.findByText("Dana Reyes")).toBeTruthy(); + expect(await panel.findByText("Dana Reyes")).toBeTruthy(); }); it("commits the typed name on blur, not only on Enter", async () => { @@ -2334,9 +2490,9 @@ describe("the jobs strip", () => { return undefined; }); renderGallery(); - const strip = within(await screen.findByTestId("jobs-strip")); - await userEvent.click(strip.getByRole("button", { name: /assign/i })); - await userEvent.type(strip.getByLabelText(/Assignee for job/), "Dana Reyes"); + const panel = await openPanel(); + await userEvent.click(panel.getByRole("button", { name: /assign/i })); + await userEvent.type(panel.getByLabelText(/Assignee for job/), "Dana Reyes"); await userEvent.tab(); const put = sent.find((request) => request.method === "PUT"); expect(put).toBeTruthy(); @@ -2351,11 +2507,11 @@ describe("the jobs strip", () => { return undefined; }); renderGallery(); - const strip = within(await screen.findByTestId("jobs-strip")); - await userEvent.click(strip.getByRole("button", { name: /assign/i })); - await userEvent.type(strip.getByLabelText(/Assignee for job/), "Dana Reyes"); + const panel = await openPanel(); + await userEvent.click(panel.getByRole("button", { name: /assign/i })); + await userEvent.type(panel.getByLabelText(/Assignee for job/), "Dana Reyes"); await userEvent.keyboard("{Escape}"); - expect(await strip.findByRole("button", { name: "Assign" })).toBeTruthy(); + expect(await panel.findByRole("button", { name: "Assign" })).toBeTruthy(); expect(sent.some((request) => request.method === "PUT")).toBe(false); }); @@ -2367,10 +2523,10 @@ describe("the jobs strip", () => { return undefined; }); renderGallery(); - const strip = within(await screen.findByTestId("jobs-strip")); - await userEvent.click(strip.getByRole("button", { name: /assign/i })); + const panel = await openPanel(); + await userEvent.click(panel.getByRole("button", { name: /assign/i })); await userEvent.tab(); - expect(await strip.findByRole("button", { name: "Assign" })).toBeTruthy(); + expect(await panel.findByRole("button", { name: "Assign" })).toBeTruthy(); expect(sent.some((request) => request.method === "PUT")).toBe(false); }); @@ -2383,6 +2539,6 @@ describe("the jobs strip", () => { }); renderGallery(); expect(await screen.findByText("jobs are unreachable")).toBeTruthy(); - expect(screen.queryByTestId("jobs-strip")).toBeNull(); + expect(screen.queryByTestId("job-panels")).toBeNull(); }); }); diff --git a/frontend/ui-core/src/screens/jobPanels.test.tsx b/frontend/ui-core/src/screens/jobPanels.test.tsx new file mode 100644 index 00000000..a33c7cb5 --- /dev/null +++ b/frontend/ui-core/src/screens/jobPanels.test.tsx @@ -0,0 +1,582 @@ +/** + * The gallery as an accordion of jobs. + * + * The claim under all of these is one sentence: **once a batch has jobs, its + * frames are shown per job, and at most one job's are on screen.** A screen that + * showed the batch's frames beside a strip of jobs had two truths for the same + * pictures — the strip's job and the grid's batch — and a person working one job + * saw everybody else's frames. + * + * The fetch-stub harness is `gallery.test.tsx`'s, reused rather than reinvented: + * `handlers` consulted in registration order, `on()` for a path, `mount()` for the + * provider. What is new here is that `/assets` **answers by the `job` query + * parameter**, because a stub that handed every job the same page could not tell + * a request that carries `job=` from one that does not. + */ + +import { QueryClient } from "@tanstack/react-query"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { JSX, ReactNode } from "react"; + +import { ApiProvider } from "../data/ApiProvider"; +import { writeToken } from "../data/session"; +import { GalleryScreen } from "./GalleryScreen"; +import { defaultOpenJob } from "./JobPanels"; +import { assetActions, batchActions, jobActions } from "../testing/wire.fixtures.js"; +import type { Job } from "./queries"; +import type { components } from "../generated/api.js"; + +type BatchState = components["schemas"]["BatchState"]; +type JobState = components["schemas"]["AnnotationJobState"]; + +const API = "http://visionset.test"; +const PROJECT = "11111111-1111-4111-8111-111111111111"; +const BATCH = "55555555-5555-4555-8555-555555555555"; +const JOB_A = "77777777-7777-4777-8777-777777777777"; +const JOB_B = "88888888-8888-4888-8888-888888888888"; + +type Answer = { status: number; body?: unknown }; +let handlers: ((request: Request) => Answer | undefined)[] = []; +const sent: Request[] = []; + +const NO_PROGRESS = { + unannotated: 0, + pre_labeled: 0, + annotated: 0, + skipped: 0, + review_pending: 0, + accepted: 0, + total: 0, +}; + +/** Each job's counts, keyed by job id — what `/jobs/{id}/progress` answers. */ +let progress: Map>; + +/** The jobs `/batches/{id}/jobs` answers with. Mutable, so a job can vanish mid-test. */ +let roster: readonly Job[]; + +/** The screen's own client, so a test can force the re-fetch a mutation would. */ +let client: QueryClient; + +beforeEach(() => { + handlers = []; + sent.length = 0; + roster = []; + progress = new Map([ + [JOB_A, { ...NO_PROGRESS, total: 2, unannotated: 2 }], + [JOB_B, { ...NO_PROGRESS, total: 1, unannotated: 1 }], + ]); + writeToken("a-token"); + vi.stubGlobal("fetch", async (request: Request) => { + sent.push(request); + for (const handler of handlers) { + const answer = handler(request); + if (answer !== undefined) { + return new Response(answer.status === 204 ? null : JSON.stringify(answer.body ?? null), { + status: answer.status, + headers: { "content-type": "application/json" }, + }); + } + } + return new Response(JSON.stringify({ code: "NO_STUB", message: request.url }), { + status: 500, + headers: { "content-type": "application/json" }, + }); + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + globalThis.sessionStorage.clear(); + globalThis.localStorage.clear(); +}); + +/** A thunk rather than a value where the answer has to change mid-test. */ +function on(method: string, pattern: RegExp, answer: Answer | (() => Answer)): void { + handlers.push((request) => + request.method === method && pattern.test(new URL(request.url).pathname) + ? typeof answer === "function" + ? answer() + : answer + : undefined, + ); +} + +function mount(node: ReactNode): JSX.Element { + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {node} + + ); +} + +/** Re-read what a mutation would have invalidated, and let the answers land. */ +async function refetch(queryKey: readonly unknown[]): Promise { + await act(async () => { + await client.invalidateQueries({ queryKey: [...queryKey] }); + }); +} + +function progressIs(jobId: string, counts: Record): void { + progress.set(jobId, { ...NO_PROGRESS, ...counts }); +} + +function batch(overrides: Record = {}): Record { + const state = (overrides.state as BatchState | undefined) ?? "in_annotation"; + return { + id: BATCH, + project_id: PROJECT, + name: "drive-01", + state, + schema_version: 2, + asset_count: 3, + progress: { ...NO_PROGRESS, total: 3, unannotated: 3 }, + allowed_actions: batchActions(state), + promoted_asset_count: 0, + parent_batch_id: null, + pre_label_run: null, + ...overrides, + }; +} + +function job(id: string, assetCount: number, over: Record = {}): Job { + const state = (over.state as JobState | undefined) ?? "in_progress"; + return { + id, + batch_id: BATCH, + state, + asset_count: assetCount, + assignee: null, + pre_label_run: null, + allowed_actions: jobActions(state), + ...over, + } as Job; +} + +function asset(index: number, jobId: string): Record { + return { + id: `asset-${index}`, + project_id: PROJECT, + modality: "image", + content_hash: `${index}`.padStart(8, "0") + "deadbeef", + width: 1280, + height: 720, + format: "jpeg", + // Null, so nothing here reaches `GET /sources/{id}`: the provenance line is + // `gallery.test.tsx`'s subject, not this file's. + source_id: null, + frame_index: index, + frame_timestamp: index, + thumbnail_hash: "cafebabe", + ingested_at: "2026-08-01T09:00:00Z", + job_id: jobId, + progress: "unannotated", + allowed_actions: assetActions("unannotated"), + annotation_count: 0, + min_confidence: null, + }; +} + +/** Which frames each job carries. Frame numbers stay in batch order. */ +const FRAMES: Record = { [JOB_A]: [0, 1], [JOB_B]: [2] }; + +/** + * Every read this screen makes, with two jobs behind it. + * + * `/assets` answers **by the `job` parameter** rather than one fixed page: a + * request that forgot the filter would otherwise be indistinguishable from one + * that carried it. + */ +function stubs(batchOverrides: Record = {}, jobs?: readonly Job[]): void { + on("GET", /\/batches\/[^/]+$/, { status: 200, body: batch(batchOverrides) }); + on("GET", /\/batches$/, { status: 200, body: { items: [batch(batchOverrides)], total: 1 } }); + roster = jobs ?? [job(JOB_A, 2), job(JOB_B, 1)]; + on("GET", /\/jobs$/, () => ({ + status: 200, + body: { items: roster, total: roster.length }, + })); + handlers.push((request) => { + const url = new URL(request.url); + if (request.method !== "GET") return undefined; + const forProgress = /\/jobs\/([^/]+)\/progress$/.exec(url.pathname); + if (forProgress !== null) { + const counts = progress.get(forProgress[1] as string); + return counts === undefined + ? { status: 404, body: { code: "JOB_NOT_FOUND", message: "no such job" } } + : { status: 200, body: counts }; + } + if (url.pathname.endsWith("/assets")) { + const jobId = url.searchParams.get("job"); + const indexes = jobId === null ? [0, 1, 2] : (FRAMES[jobId] ?? []); + return { + status: 200, + body: { total: indexes.length, items: indexes.map((at) => asset(at, jobId ?? JOB_A)) }, + }; + } + return undefined; + }); +} + +function renderGallery(): void { + render( + mount( + , + ), + ); +} + +/** The `/assets` requests, most recent last. */ +function assetRequests(): URL[] { + return sent + .filter((one) => new URL(one.url).pathname.endsWith("/assets")) + .map((one) => new URL(one.url)); +} + +describe("which panel opens", () => { + it("opens exactly one panel, the first job with work left", async () => { + progressIs(JOB_A, { total: 2, annotated: 2 }); + progressIs(JOB_B, { total: 1, unannotated: 1 }); + stubs(); + renderGallery(); + + await screen.findByTestId(`job-panel-${JOB_B}`); + expect(screen.queryByTestId(`job-panel-${JOB_A}`)).toBeNull(); + expect(screen.getByTestId(`job-header-${JOB_B}`).getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByTestId(`job-header-${JOB_A}`).getAttribute("aria-expanded")).toBe("false"); + }); + + it("counts a model's first pass as work left, not as work done", async () => { + // `pre_labeled` frames are the ones somebody has to look at next, which is + // the whole reason the rule sums them with `unannotated`. + progressIs(JOB_A, { total: 2, annotated: 2 }); + progressIs(JOB_B, { total: 1, pre_labeled: 1 }); + stubs(); + renderGallery(); + + await screen.findByTestId(`job-panel-${JOB_B}`); + }); + + it("falls back to the first job when nothing is left", async () => { + progressIs(JOB_A, { total: 2, annotated: 2 }); + progressIs(JOB_B, { total: 1, skipped: 1 }); + stubs(); + renderGallery(); + + await screen.findByTestId(`job-panel-${JOB_A}`); + expect(screen.queryByTestId(`job-panel-${JOB_B}`)).toBeNull(); + }); + + it("opening another job closes the open one, and clicking the open header closes it", async () => { + stubs(); + renderGallery(); + await screen.findByTestId(`job-panel-${JOB_A}`); + + await userEvent.click(screen.getByTestId(`job-header-${JOB_B}`)); + await screen.findByTestId(`job-panel-${JOB_B}`); + expect(screen.queryByTestId(`job-panel-${JOB_A}`)).toBeNull(); + + // Every panel closes: the accordion read as an index of the batch's jobs is + // a state somebody asks for, and a control that cannot undo itself is not a + // toggle. + await userEvent.click(screen.getByTestId(`job-header-${JOB_B}`)); + expect(screen.queryByTestId(`job-panel-${JOB_B}`)).toBeNull(); + for (const id of [JOB_A, JOB_B]) { + expect(screen.getByTestId(`job-header-${id}`).getAttribute("aria-expanded")).toBe("false"); + } + + await userEvent.click(screen.getByTestId(`job-header-${JOB_B}`)); + expect(await screen.findByTestId(`job-panel-${JOB_B}`)).toBeTruthy(); + }); + + it("stops describing frames once nothing is open", async () => { + stubs(); + renderGallery(); + await screen.findByTestId(`job-panel-${JOB_A}`); + // The header's provenance line is assembled from the open panel's window. + await waitFor(() => expect(screen.getByTestId("batch-facts").textContent).toContain("1280×720")); + + await userEvent.click(screen.getByTestId(`job-header-${JOB_A}`)); + await waitFor(() => + expect(screen.getByTestId("batch-facts").textContent).not.toContain("1280×720"), + ); + }); + + it("keeps the open panel open when finishing its last frame moves the default on", async () => { + // The default is a *latch*, not a derivation: re-read every render, finishing + // the open job's last frame would make the next job the first with work left + // and shut the panel under the person still looking at it. + progressIs(JOB_A, { total: 2, unannotated: 2 }); + progressIs(JOB_B, { total: 1, unannotated: 1 }); + stubs(); + renderGallery(); + await screen.findByTestId(`job-panel-${JOB_A}`); + + progressIs(JOB_A, { total: 2, annotated: 2 }); + await refetch(["jobs"]); + + expect(screen.queryByTestId(`job-panel-${JOB_A}`)).not.toBeNull(); + expect(screen.queryByTestId(`job-panel-${JOB_B}`)).toBeNull(); + }); + + it("falls back to the default when the open job is no longer in the batch", async () => { + stubs(); + renderGallery(); + await screen.findByTestId(`job-panel-${JOB_A}`); + await userEvent.click(screen.getByTestId(`job-header-${JOB_B}`)); + await screen.findByTestId(`job-panel-${JOB_B}`); + + // A job that has stopped existing cannot stay open, and holding its id would + // leave the accordion closed over a batch that has jobs. + roster = [job(JOB_A, 2)]; + await refetch(["batches"]); + + expect(await screen.findByTestId(`job-panel-${JOB_A}`)).toBeTruthy(); + expect(screen.queryByTestId(`job-header-${JOB_B}`)).toBeNull(); + }); + + it("opens nothing until every job's progress has answered", async () => { + // A panel opened off half-read counts is a panel that flips to a different + // job once the rest land — the wrong job's frames, then a jump. The gate sits + // under `globalThis.fetch` *before* the client is built, because + // `openapi-fetch` reads that reference once at `createClient()` time. + progressIs(JOB_A, { total: 2, annotated: 2 }); + progressIs(JOB_B, { total: 1, unannotated: 1 }); + const inner = globalThis.fetch; + let release: (() => void) | undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + vi.stubGlobal("fetch", async (request: Request) => { + if (new URL(request.url).pathname === `/jobs/${JOB_B}/progress`) await held; + return inner(request); + }); + + stubs(); + renderGallery(); + await screen.findByTestId(`job-header-${JOB_A}`); + expect(screen.queryByTestId(`job-panel-${JOB_A}`)).toBeNull(); + expect(screen.queryByTestId(`job-panel-${JOB_B}`)).toBeNull(); + + release?.(); + // And it is B that opens — the job the withheld counts turn out to name. + await screen.findByTestId(`job-panel-${JOB_B}`); + }); +}); + +describe("what the open panel asks for", () => { + it("asks for the open job's frames only, and counts its segments off its own progress", async () => { + progressIs(JOB_A, { total: 2, annotated: 2 }); + progressIs(JOB_B, { total: 1, unannotated: 1 }); + stubs(); + renderGallery(); + + const panel = within(await screen.findByTestId(`job-panel-${JOB_B}`)); + await waitFor(() => expect(assetRequests().at(-1)?.searchParams.get("job")).toBe(JOB_B)); + // One, not three: the batch holds three frames and this job holds one, so a + // count off the batch would be the filter lying about what it filters. + expect(panel.getByTestId("segment-all").textContent).toContain("All (1)"); + expect(panel.getByTestId(`tile-asset-2`)).toBeTruthy(); + expect(screen.queryByTestId("tile-asset-0")).toBeNull(); + }); + + it("resets the segment filter when the open job changes", async () => { + progressIs(JOB_A, { total: 2, unannotated: 1, annotated: 1 }); + progressIs(JOB_B, { total: 1, unannotated: 1 }); + stubs(); + renderGallery(); + + const first = within(await screen.findByTestId(`job-panel-${JOB_A}`)); + fireEvent.click(first.getByTestId("segment-done")); + expect(first.getByTestId("segment-done").getAttribute("aria-pressed")).toBe("true"); + + await userEvent.click(screen.getByTestId(`job-header-${JOB_B}`)); + const second = within(await screen.findByTestId(`job-panel-${JOB_B}`)); + // The filter is about the job you were looking at, and carrying it over is + // how a person opens a job and is told it has no frames. + expect(second.getByTestId("segment-all").getAttribute("aria-pressed")).toBe("true"); + await waitFor(() => + expect(assetRequests().at(-1)?.searchParams.getAll("progress")).toEqual([]), + ); + }); +}); + +describe("the collapsed header is the overview", () => { + it("renders the frames, the state, the annotated count, the assignee and a bar", async () => { + progressIs(JOB_A, { total: 2, annotated: 1, unannotated: 1 }); + stubs({}, [job(JOB_A, 2, { assignee: "Dana Reyes" }), job(JOB_B, 1)]); + renderGallery(); + + const row = await screen.findByTestId(`job-row-${JOB_A}`); + await waitFor(() => expect(row.textContent).toContain("1 of 2 annotated")); + expect(row.textContent).toContain("Job 1"); + expect(row.textContent).toContain("2 frames"); + expect(row.textContent).toContain("in progress"); + expect(row.textContent).toContain("Dana Reyes"); + // The bar, and only the bar: `BatchProgressBar` would draw its readout under + // the track and say "1 of 2 annotated" a second time. + const bar = within(row).getByRole("progressbar"); + expect(bar.getAttribute("aria-valuenow")).toBe("50"); + expect(row.textContent?.match(/1 of 2 annotated/g)).toHaveLength(1); + // The bar and the assignee are siblings of the control: a `progressbar` is + // content a `