Skip to content

fix(search): a one-character skill query filters instead of clearing the filter - #298

Merged
Broccolito merged 1 commit into
mainfrom
fix/skill-search-one-char
Sep 12, 2026
Merged

fix(search): a one-character skill query filters instead of clearing the filter#298
Broccolito merged 1 commit into
mainfrom
fix/skill-search-one-char

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

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 R finds the R skills and not every row holding
the letter r.

Settings → Skills never saw a one-character query. The shared SearchBar refused
anything 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:

query before after
(empty) FROM THIS PROJECT (2) — bb-cli, ggplot unchanged
R FROM THIS PROJECT (2) — both rows MATCHES (1) — ggplot
R (one space more) MATCHES (1) — ggplot MATCHES (1) — ggplot
z FROM THIS PROJECT (2) — both rows "No matching skills"
zz "No matching skills" "No matching skills"
ggplot MATCHES (1) — ggplot MATCHES (1) — ggplot
(cleared) FROM THIS PROJECT (2) FROM THIS PROJECT (2)

The two R rows are the defect: a one-character query showed everything, and one
more character showed the right answer. z is 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

SearchBar is shared. Its consumers are BaseChat and SessionHistoryView (which
highlight a transcript through SearchView's own SearchHighlighter) and
SessionListView, 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 is
no cap. Replaying that exact loop in the running app over a short chat — 2,170
characters of transcript:

term matches build time
e 217 432 ms
a 115 223 ms
er 21 40 ms
the 16 31 ms

~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 — SearchView calls onSearch first and debounces the
highlight by 150 ms, so the highlighter only ever sees the rows the filter already kept
(with R on Skills the bar reports 1/15 highlights, not the catalog's worth).

The choice: a per-surface minSearchLength prop, threaded
SkillsView → SearchView → SearchBar, defaulting to DEFAULT_MIN_SEARCH_LENGTH = 2.
Only SkillsView opts into 1 — the surface the defect was found on, and the one whose
matcher 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 SearchBar and
reaches 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:

  • The reveal animation is a max-height transition under overflow: hidden, so the
    ceiling is also a clip: at the one-row 72px the new sentence was cut through its
    middle (measured on Settings → Extensions). search-bar-has-note raises it only
    while that row is there, so the ordinary open/close keeps the travel it was tuned for.
  • toggleCaseSensitive was guarded on if (searchTerm), so a below-floor term that
    typing had refused was searched anyway the moment Aa was clicked — on a chat that
    was the 432 ms case, one click away. It honours the same floor now.

The other surfaces, and the composer

Every other SearchView consumer keeps the two-character floor and now states it
below that — verified on Settings → Extensions: g shows the sentence and the full
list, gi filters (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 its
provenance headings.

On a chat: e → the sentence, 0 highlights; er → 23 highlights, 1/21;
cleared → nothing highlighted.

Tests

All new assertions fail on origin/main and pass here (Tests 7 failed | 25 passed
before, 5356 passed after across the whole renderer suite):

  • SearchBar.test.tsx — a one-character term searches at minSearchLength={1}; below
    the floor the bar reports '' and renders the sentence; the case toggle no
    longer searches a term typing refused; the note asks the animation for room.
  • SearchView.test.tsx — the prop actually reaches the bar.
  • SkillsView.test.tsxR and z as measured above.
  • styles/searchBarNote.test.ts — the two-row ceiling clears the one-row ceiling by at
    least the note's height (jsdom has no layout engine; the source is what is assertable).

⚠ The SkillsView search tests were green while measuring nothing: their mocked
SearchView handed every keystroke straight through, so R passed in a test while
being unreachable in the app. The mock carries the floor now, and its value is pinned
to DEFAULT_MIN_SEARCH_LENGTH so it cannot drift back into agreeing with itself.

Checked against mutation: deleting minSearchLength={1} from SkillsView fails exactly
the two one-letter tests and nothing else; lowering the CSS ceiling fails the style test.

Gates: npm run lint:check clean, npx prettier --check matched all eight touched files
(verified it flags a deliberate misformat rather than passing vacuously).

🤖 Generated with Claude Code

…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.
@Broccolito
Broccolito merged commit 779f103 into main Sep 12, 2026
16 checks passed
@Broccolito
Broccolito deleted the fix/skill-search-one-char branch September 12, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant