fix(search): a one-character skill query filters instead of clearing the filter - #298
Merged
Conversation
…the filter Settings → Skills and the composer's skill picker share one ranked matcher (PR #289), whose short-term rule exists precisely for a query like `R`: a term under three characters matches whole WORDS, so `R` finds the R skills and not every row holding the letter. Settings → Skills never saw that query. The shared `SearchBar` refused anything under two characters and reported an EMPTY term, which the view reads as "browsing" — so `R` drew the whole catalog under its provenance headings, which reads as a filter that matched everything. Measured in the running app on a two-skill catalog, before: #/skills, ⌘F, `R` 2 of 2 rows under FROM THIS PROJECT (2) `R ` (one space more) MATCHES (1): ggplot `z` 2 of 2 rows; `zz` -> "No matching skills" The floor is real, but it belongs to the surface, not to the component. A `SearchView` answers a term by walking its container's text nodes and building one positioned overlay element per match, each costing a forced layout in `range.getClientRects()`. Replaying that loop in the app over a SHORT chat (2,170 characters): `e` = 217 matches / 432 ms, `a` = 115 / 223 ms, against 40 ms for `er`. Linear at ~2 ms a match, and a real transcript is two orders of magnitude longer — so the chat and a saved transcript keep the two-character floor, and a surface that only filters a list of rows passes 1. `minSearchLength` is now a prop threaded `SkillsView → SearchView → SearchBar`, defaulting to `DEFAULT_MIN_SEARCH_LENGTH`. Two things came with it: * Below the floor the bar SAYS so, on every surface. A control that looks like it filtered and did not is the defect independent of the threshold, and the old bar was silent on both sides of it. The reveal is a `max-height` transition under `overflow: hidden`, so the two-row bar needed its ceiling raised or the sentence was clipped through its middle — measured on Settings → Extensions. * The `Aa` toggle was guarded on `if (searchTerm)`, so a below-floor term that typing had refused was searched anyway the moment case sensitivity was clicked. It now honours the same floor. After, same catalog: `R` → MATCHES (1): ggplot; `z` → "No matching skills"; empty → FROM THIS PROJECT (2) with both rows. The composer picker, which has no gate, is unchanged and now agrees with Settings on `R`. On a chat, `e` shows the sentence and 0 highlights, `er` still highlights 23. The `SkillsView` search tests were green while measuring nothing: their mocked `SearchView` handed every keystroke straight through, so the `R` cases passed in a test while being unreachable in the app. The mock carries the floor now, and its value is pinned to the component's own constant.
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.
The defect, reproduced
PR #289 pointed Settings → Skills and the composer's skill picker at one ranked
matcher whose short-term rule exists for exactly this query: a term under three
characters matches whole words, so
Rfinds the R skills and not every row holdingthe letter r.
Settings → Skills never saw a one-character query. The shared
SearchBarrefusedanything under two characters and reported an empty term, which the view reads as
"browsing" — so the control looked like it had filtered and had not.
Driven in the real app (dev GUI, sandboxed config) on a two-skill catalog:
FROM THIS PROJECT (2)— bb-cli, ggplotRFROM THIS PROJECT (2)— both rowsMATCHES (1)— ggplotR(one space more)MATCHES (1)— ggplotMATCHES (1)— ggplotzFROM THIS PROJECT (2)— both rowszzggplotMATCHES (1)— ggplotMATCHES (1)— ggplotFROM THIS PROJECT (2)FROM THIS PROJECT (2)The two
Rrows are the defect: a one-character query showed everything, and onemore character showed the right answer.
zis the same defect in its worst form —a query matching nothing silently rendered the whole catalog.
Why the floor stayed, and why it moved to the surface
SearchBaris shared. Its consumers areBaseChatandSessionHistoryView(whichhighlight a transcript through
SearchView's ownSearchHighlighter) andSessionListView,WorkflowsView,ExtensionsView,ApplicationsView,SkillsView(which filter a list of rows).
The highlighter walks the container's text nodes and builds one positioned overlay
element per match, each costing a forced layout in
range.getClientRects(). There isno cap. Replaying that exact loop in the running app over a short chat — 2,170
characters of transcript:
eaerthe~2 ms per match, linear, and a real transcript is two orders of magnitude longer than
that one. So a global drop to 1 was rejected on measurement, not taste: the gate is
load-bearing exactly where the report guessed it was.
A list surface pays none of it —
SearchViewcallsonSearchfirst and debounces thehighlight by 150 ms, so the highlighter only ever sees the rows the filter already kept
(with
Ron Skills the bar reports1/15highlights, not the catalog's worth).The choice: a per-surface
minSearchLengthprop, threadedSkillsView → SearchView → SearchBar, defaulting toDEFAULT_MIN_SEARCH_LENGTH = 2.Only
SkillsViewopts into 1 — the surface the defect was found on, and the one whosematcher has a rule written for one-character terms.
A one-character query never silently shows everything again
That is true independent of the threshold, so the honesty fix lives in
SearchBarandreaches every surface: while the typed term is below that surface's floor the bar says
so, in as many words, instead of reporting an empty term in silence.
An empty box is not a short query — that is browsing, and the full list is the honest
answer there, so nothing is said.
Two things fell out of it:
max-heighttransition underoverflow: hidden, so theceiling is also a clip: at the one-row 72px the new sentence was cut through its
middle (measured on Settings → Extensions).
search-bar-has-noteraises it onlywhile that row is there, so the ordinary open/close keeps the travel it was tuned for.
toggleCaseSensitivewas guarded onif (searchTerm), so a below-floor term thattyping had refused was searched anyway the moment
Aawas clicked — on a chat thatwas the 432 ms case, one click away. It honours the same floor now.
The other surfaces, and the composer
Every other
SearchViewconsumer keeps the two-character floor and now states itbelow that — verified on Settings → Extensions:
gshows the sentence and the fulllist,
gifilters (11 → 10 rows), clearing restores the list.The composer picker has its own plain input and no gate; it is untouched, and the two
surfaces now agree on the query that made them disagree —
R→ ggplot on both,z→ "No skills found" / "No matching skills", cleared → the full list with itsprovenance headings.
On a chat:
e→ the sentence, 0 highlights;er→ 23 highlights,1/21;cleared → nothing highlighted.
Tests
All new assertions fail on
origin/mainand pass here (Tests 7 failed | 25 passedbefore,
5356 passedafter across the whole renderer suite):SearchBar.test.tsx— a one-character term searches atminSearchLength={1}; belowthe floor the bar reports
''and renders the sentence; the case toggle nolonger searches a term typing refused; the note asks the animation for room.
SearchView.test.tsx— the prop actually reaches the bar.SkillsView.test.tsx—Randzas measured above.styles/searchBarNote.test.ts— the two-row ceiling clears the one-row ceiling by atleast the note's height (jsdom has no layout engine; the source is what is assertable).
⚠ The
SkillsViewsearch tests were green while measuring nothing: their mockedSearchViewhanded every keystroke straight through, soRpassed in a test whilebeing unreachable in the app. The mock carries the floor now, and its value is pinned
to
DEFAULT_MIN_SEARCH_LENGTHso it cannot drift back into agreeing with itself.Checked against mutation: deleting
minSearchLength={1}fromSkillsViewfails exactlythe two one-letter tests and nothing else; lowering the CSS ceiling fails the style test.
Gates:
npm run lint:checkclean,npx prettier --checkmatched all eight touched files(verified it flags a deliberate misformat rather than passing vacuously).
🤖 Generated with Claude Code