feat(annotator): one dialog session is one version, a pin that answers, and a grouped ledger (WS4) (#368) - #379
Merged
Merged
Conversation
…rs back (WS4) WS4 of the annotation workspace redesign — cf. #368. - `AddClassDialog` accumulates: `Create and add another` (⌘↵) banks a class and clears the form, the primary publishes the whole session as one schema version under one auto-written description naming them all. Cancelling with classes banked asks, and asks on Escape and the overlay too, because everything a session holds lives in the browser. The F23 `canRepin` preflight, the save→publish→repin order and the partial-failure table are unchanged. - The class field's create row carries the typed name into the dialog. WS2 had handed it over already; the page dropped it because the dialog had nowhere to put it. The row is also no longer gated on `onOpenGallery`, which had made it absent for exactly the callers whose tool-strip `+` still worked. - The pinned `v{n}` badge opens a disclosure: whether the batch's version is still the project's current one and, if not, what arrived since. Both reads are gated on it being open, so opening a job still asks for no `/schema`. - The project's version history collapses consecutive `annotation`-provenance versions into one expandable row. `curated` and null always render individually; a run of one is not a run. The rule is a pure function with its own boundary tests. Found in scope and fixed: the drawing class was `Workspace` state, and `usePinnedSchema`'s query key names the version — so a re-pin sent the page through `LoadingState`, unmounting the component that held the class somebody had just created. #233's `activateClass(declared.name)` had therefore never worked. It now lives in `JobScreen` beside the clipboard, so it also survives moving to the next frame.
A session that replaced rather than appended published the same two names when only one class had been banked, so the test could not fail on it. Caught by the mutation pass, not by review.
…uld not see A comparison left enabled while the popover is closed keeps refetching over a cached active version, and the earlier tests never closed it — so removing the `open &&` on the bounds turned nothing red. A disabled query ignores an invalidation; an enabled one answers it.
The shape helper rendered a one-element run as `v2`, identical to an ungrouped version, so lowering RUN_MINIMUM to 1 turned no assertion red. The brackets are what make the two kinds of row distinguishable.
… of them Every version in the fixture declared the same contract, so a run summarising its oldest member instead of its newest read identically.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
WS4 of the annotation workspace redesign, and the last of the four.
Closes #368.Three deliverables and one prefill, plus a defect the tests exposed.
What changed
AddClassDialogis a session.Create and add another(⌘↵) banks the class and clears the form; the primary publishes everything banked plus whatever is still in the form, as one schema version. Banked classes show as removable chips, the auto-written description names them all (Added classes "cone", "barrier" and "crossing" from the annotation view), and the primary says how many it will publish. The collision check now covers the session as well as the published version, becausecreate_versionjudges the whole contract and a 409 after the save is the worst place to learn about a name typed twice — the two cases get different sentences, since one means "pick the class that exists" and the other means "you already added that".Cancelling with classes banked asks, and the ask lives in the close handler rather than on the Cancel button: Radix routes Escape and the overlay through
onOpenChange, so a guard the button owned would protect one of three ways out. It is the only question this dialog asks — everything a session holds lives in the browser, so closing loses exactly what somebody typed and nothing else.Unchanged, deliberately: the save → publish → repin order and its test, the F23
canRepinpreflight, the two-word button (Add class/Publish without re-pinning), the partial-failure table in the module docstring, and theDESTRUCTIVE_SCHEMA_CHANGErefusal that names the Schema tab.runAddClasstookdeclared: LabelClassBodyand now takesadded: readonly LabelClassBody[]; nothing else about it moved.The
v{n}badge answers the question it raises. Pressing it opens a panel saying whether the batch's pin is still the project's current version and, when it is not, what arrived since — the kernel's own words for each change, the same payload the Schema tab's ledger renders. Nothing is fetched until it is opened:useActiveSchemaby itsenabled, the comparison by being handednullbounds. That is the rulee2e/annotate.spec.tspins from the other side — a page that read the active version on arrival would be one refactor from offering classes the API then refuses.A hand-built disclosure rather than Radix's
Popover, for the reasonCombobox.tsxalready writes down: a Popover owns focus on open and restores it on close, and the annotator reads the keyboard off its own root.The version history groups what the annotator published. Consecutive versions whose
provenanceisannotationcollapse into one expandable row —v2–v3, how many, when the run ended, and the contract it left behind — so the curated milestones somebody opened the ledger to read are not buried under a run ofAdded class "cone" from the annotation view.curatedand null always render individually; a run of one is not a run. Expanding gives back exactly the rows a flat table would have had, with the samedata-testids.The rule is
screens/schemaHistory.ts, a pure function with its own boundary tests, because every way it goes wrong is at an edge and none of them is a thing a rendering test isolates.The create row's typed name reaches the dialog.
ClassFieldhas handed it over since WS2 and the page dropped it, becauseAddClassDialoghad nowhere to put it. The tool strip's+still opens empty — that press means "I want a class", not a particular one.Found in scope, and fixed
#233's "you are now drawing with the class you just made" had never worked.
activeClasswasWorkspacestate,Workspaceis keyed on the asset, andJobScreenreturnsLoadingStatewhenever any of its four queries is pending — whileusePinnedSchema's query key names the version. So the re-pin at the end of the chain moved the key, the page fell through to the loading state, the component holding the freshly-armed class unmounted, and the class field readSelectagain a moment later. No error, no refusal, nothing on screen to notice.The state moved up to
JobScreen, beside the clipboard, which lives there for the same reason one frame over (#123). Deliberate consequence: the drawing class now also survives moving to the next frame, where it used to reset — which is what somebody labelling one class across a clip wants, and is the same scope a paste already has. It still stops at the job's edge.Also fixed, because it blocked this PR's own deliverable: the class field's create row was spread in only when
onOpenGallerywas supplied. Nothing about knowing where the gallery is bears on whether a class can be created, and the tool strip's+was never gated on it — so the row was absent for exactly the callers whose+still worked, and the prefill would have been unreachable behind it.Deviations, stated rather than silent
ProjectScreen'sVersionHistorytable, not inSchemaEditor'sVersionNavigator. Annotation workspace redesign — class system, top bar, objects panel #368 §WS4 names the latter; decision 7 says "collapses consecutiveannotationruns into one expandable row" and the issue's own screenshot note points at "the flat Version history table (Version / Published / Why / Classes, one row per version)".VersionNavigatoris a<Select>whose items are options, not rows, and it is the reader — one version at a time with its diff — where every version must stay individually selectable; grouping it would remove the ability to read one. The ledger is what decision 7 describes, so that is what grouped. The rule is exported from its own module, so pointing a second surface at it is one import.no-repin-notice's subject is the whole session, not the form field. The mechanism —canRepin, the two sentences, the testid, the trigger — is untouched; but by the time somebody presses, the field is usually empty and the classes are banked, so“this class”would have named nothing at all.frontend/app/cycle/cycle.spec.tsis in the diff for four lines: the pin popover is the one WS4 surface with a real project behind it, and the demo has none. The session flow stays inui-core, where the assertions are about the request body that actually leaves.Test plan
621 ui-core vitest (up from 566), 820 annotator unchanged, 198 e2e unchanged, the real-server cycle.
Full local gate, in stages under the harness's ~10-minute ceiling. Every exit code:
pytest tests/kernelpytest tests/server tests/cli tests/mcppytest tests/architecture tests/examples tests/formats tests/jobs tests/packaging tests/scripts tests/test_versioning.pyruff check ./ruff format --check .mypy src/visionset— 133 fileslint-imports— 3 contracts keptpnpm -r buildpnpm -r lintpnpm -r testpnpm test:scriptscheck.sh generatedcheck.sh browser—e2e (chromium)198 in 2.2m,browser cycle, real serverin 30sThe pytest split was derived from
ls tests/at run time. No Python source changed; the backend stages are the regression check.Twenty-three mutations, each on an anchor asserted unique before and after, each reverted by its exact inverse. Twenty turned a named test red on the first attempt. Three did not, and each was a real hole in a test rather than a mutation not worth making — all three are fixed in their own commits and re-verified:
8f4a70d)publishes the banked classes when the form is emptyasks before it discards themasks on Escape too, which is the route a button guard walks pastrefuses a name already banked in this sessionpublishes the banked classes and the form's ownstarts from what the create row was typed withasks for nothing about the active version until it is opened9cb3410)arms the last class written and names itkeeps the drawing class when the next frame opensopens the dialog on the name the class field's create row was typed withcloses a run that reaches the end of the list411e071)annotationnever joins a run with a version that recorded no provenancecollapses the run and leaves the milestones alonegives back every row when it is expanded63222e1)names every class of the sessionlets a banked class be taken back outpublishes the banked classes when the form is emptybanks on ⌘Enter, so a session is typed without leaving the keyboardpin-popoverassertion — one browser repeat, because focus and key handling are what a hand-built disclosure is forThe mutation harness itself needed two fixes mid-run, and both are worth writing down:
grep -cFwith an embedded newline counts lines, not occurrences, so a multi-line anchor reports a bogus uniqueness count; and a harness that aborts between applying and reverting leaves the mutation in the tree for the next one to stack on — which happened once here, was caught by agit diff --quietafter the revert, and is why every run now asserts a clean tree before it starts.No CI job was added, renamed or removed, so the
mainruleset is untouched. No Python, no migration, no wire change —openapi.jsonandfrontend/ui-core/src/generated/are byte-identical.