fix(annotator): the suggest tool survives a class switch; a select option is two lines - #473
Merged
Conversation
…ts facts The curated model select put the id, the download size and the hint on one line, inside a trigger measured for one. The text wrapped and read squashed. `SelectItem` takes a `meta`: the children stay the identifier at the label role, `meta` goes underneath at the meta role. Radix renders the selected item's own `ItemText` into the trigger, so the closed control and the open list are the same two lines by construction rather than by a second copy of the layout at the call site. The trigger is `min-h-9` with `py-1` instead of a fixed `h-9`: a one-line option still measures exactly 36px, so every select that shipped before this is unmoved, and a two-line one grows to fit. Nothing truncates and nothing ellipsises — half a model id is not a model id. The styleguide gains the specimen, and its e2e measures what jsdom cannot: the one-line trigger on 36px, the two-line one taller.
…ather than dying Selecting a different class silently disarmed the tool. That was an artifact of #451, which read "switching tools discards" (D2) and treated moving the active class as switching tools. Directed (Armando, 2026-08-09): arming is a decision about how to work and picking a class is the next thing somebody does, so a class switch ends what is pending and not the session. `withClass` in the core is the whole rule, and it is one function because the three readings are one transition over a different argument: - nothing pending — the class is swapped in, and the next click asks under it; - a preview showing — it is discarded with the clicks that produced it, since it was answered under the old class's `allowed_geometries` and accepting it under the new one could write a kind that class does not admit; - a class that can hold nothing — the session parks, `labelClass` is `null`, no press is diverted, and the armed intent is kept so that returning to a class which can hold an answer picks up with no second press. The serial keeps counting across all three, so an answer to the ask that was in flight when the class moved cannot repaint a preview nobody is on. Parked is a state with a sentence, not a control that goes quiet: the strip button is dimmed with its reason (lit and dimmed at once — both are true), and the panel names the class, says what to pick, says the tool is still armed, and carries the way out, since the button it would otherwise be is the one that is dimmed. The canvas is handed `null` while parked, because the class that parked it may still be drawable and a tool that swallowed those presses would have stopped being parked. Switching assets still disarms and discards, through the `key={asset.id}` remount, unchanged.
This was referenced Aug 10, 2026
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.
Closes #472.
Two things a person had to work around: a select that squashed the label it was showing, and a
tool that switched itself off when you picked a class to use it on. Frontend only — no wire, no
route, no kernel, no docker.
A select option is two lines when it is an identifier plus its facts
SelectItemtakes ameta. The children stay the identifier at the label role;metagoesunderneath at the meta role in
muted-foreground. Radix renders the selected item's ownItemTextinto the trigger, so the closed control and the open list are the same two lines byconstruction — there is no second copy of the layout to keep in step, which is the reason this
is a primitive variant rather than one screen's styling.
The trigger is
min-h-9withpy-1instead of a fixedh-9. A one-line option measures exactly36px still (22.4px of text plus 8px of padding plus the border is under the floor), so every
select that shipped before this is unmoved; a two-line one grows. Nothing truncates and nothing
ellipsises — half a model id is not a model id, so a long one wraps.
Custom model…gets a meta line too, so no row in the list is a different height from itsneighbours.
The styleguide gains the specimen and
DESIGN.mdgains the rule, next to the geometry picker'sgrouping rule it sits beside.
The suggest tool survives a class switch
This is #451's behaviour, deliberately reversed, and that is the part worth reading. Slice 3b
held the mode beside the active class and discarded it whenever that class moved, on D2's
"switching tools discards" — moving the active class being how this build spells switching
tools. Directed (Armando, 2026-08-09): arming is a decision about how to work and picking a class
is the next thing somebody does, so a class switch ends what is pending and not the session.
withClassincore/interaction/suggestion.tsis the whole rule, and it is one function becausethe three readings are one transition over a different argument:
labelClassisnull, no press is diverted, the armed intent is keptkey={asset.id}remount, unchangedThe preview is discarded because it was answered under the old class's
allowed_geometries;accepting it under the new one could write a kind that class does not admit, which is the same
argument D3 makes about which classes may be asked in the first place.
The serial keeps counting across all three, for
cleared's reason: an answer to the ask thatwas in flight when the class moved must not repaint a preview belonging to a class nobody is on.
withClassreturns the state by identity when the class did not really move, so the page's effectfolds through unconditionally without a render or a discarded preview per re-selection.
Parked is a state with a sentence, not a control going quiet
is armed, and it cannot act. Hidden is the schema answer (D3's third case, unchanged);
disabled-with-reason is the class answer, because that one comes back the moment the active
class moves, and a button that vanished and returned as somebody worked down the class list
would be describing a project that keeps changing.
armed, and carries the way out — since the control it would otherwise be is the dimmed one. No
Escchip beside it, because the chord is a substitution the canvas makes while something ispending and a parked session has nothing pending; printing the key would be printing one that
does nothing.
never hold the answer reports progress towards something that is not going to happen and hides
the one choice the person can change.
nullwhile parked. The class that parked it may still be drawable — alane is a
polyline— and a tool that swallowed those presses would have stopped being parkedand started being broken. There is a test that draws one.
Tests
Core (
suggestion.test.ts, +10): identity when the class did not move; armed survives the swap;the preview discards; the serial keeps counting and the in-flight answer is dropped; parking;
re-arming on the class that unparked it, not the one left behind; parked-to-parked is identity;
nothing is written without a class.
paint.test.ts: a parked session draws nothing.Adapter/product:
suggestFlow.test.tsxgains the whole class-switch block driven throughAnnotationPagewith a stubbed route table — including the asset switch, which needed a secondframe in the fixture, and a
polylineclass, which is what parks the tool.suggestPanel.test.tsxgains the parked reading, its way out, its precedence over the blocker, and the no-class-at-all
sentence.
toolPalette.test.tsxgains dimmed-with-reason and the press it refuses.primitives.test.tsxgains the two-line option;inference.test.tsxgains it on the real form,trigger and list;
styleguide.spec.tsmeasures in a browser what jsdom cannot — the one-linetrigger on 36px and the two-line one taller.
The pinned test that moved.
suggestFlow.test.tsx› disarms when another tool moves theactive class (D2) is gone, replaced by disarms when it is pressed again plus the block above.
It encoded the rule this PR was asked to change, and it is named here so the removal is a decision
rather than a disappearance.
Mutation verification
Each mutation applied to the committed tree, its anchor asserted present before and the
replacement after, run, then reverted by its exact edit with a clean tree confirmed.
setSession(null)on a class switchsuggestFlow.test.tsx, led by stays armed on a class switchwithClasskeeps the pending state —{ ...state, labelClass }instead of{ ...cleared(state), labelClass }suggestion.test.ts+ 1 red insuggestFlow.test.tsx, all discards a preview…SelectTriggerback to a fixedh-9suggesting, notdiverting)The two the issue asked to be named are the first two rows.
Gate
Run in stages under the harness's ~10-minute command ceiling; pytest split by the directories
ls tests/reported at run time. The machine was free (load average 1.45 on 20 cores), so this isthe full gate split and not the saturation fallback. Every exit code verbatim.
pytest tests/architecturepytest tests/clipytest tests/examplespytest tests/formatspytest tests/inferencepytest tests/jobspytest tests/kernelpytest tests/mcppytest tests/packagingpytest tests/server tests/test_versioning.pyruff check .ruff format --check .mypy src/visionsetlint-imports(4 kept, 0 broken)pnpm -r buildpnpm test(node script gates + vitest)pnpm -r lint(incl. all three annotator boundary gates)check.sh generated(openapi drift, client drift, mcp reference, version sync)playwright test(annotator + app e2e, chromium)playwright test -c playwright.cycle.config.tsFrontend totals: annotator 914 passed (34 files), ui-core 844 passed (46 files).
tests/scriptsholds only.mjsnode gates, which run underpnpm test, so it is absent aboverather than reported as exit 5. No baseline-proof exception was used or needed.
Boundaries
Nothing in
frontend/annotator/src/core/gained a React import, a DOM global or a DOM type in asignature — the mode change lives in the core because that is where the mode lives, and all three
gates are green. No route, tab, screen, nav entry or entry point moves, so the sitemap is
unchanged.
openapi.jsonand the generated client are untouched, and the drift gates say so.Found, not fixed
that parks before it ever asks. It is one request against a workspace-scoped list, and
un-parking is one click away, so prefetching is the better of the two — recorded because the
"a job nobody suggests on makes no inference request at all" discipline it sits under is worth
keeping visible.
#466(the four inference request models do not forbid unknown fields) is untouched.