Repository navigation
fix(workspace-cleanup): stop a wheel tick setting a filter, and land every filter patch - #15298
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review. 📝 WalkthroughWalkthroughWorkspace cleanup browse actions now use functional store updates for filter patches, sort toggles, and filter clearing. The store accepts complete browse state values or updater functions. Tests cover successive updates. Numeric threshold facets now use text inputs with 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…every filter patch A profile carried `activity.idleMinDays = 20` that the user never set, hiding 253 of 799 workspaces on open. Chromium mutates a *focused* number input on every wheel tick, and before #14629 the facet panel could not scroll, so the natural response -- cursor into the panel, spin the wheel -- walked the threshold up and persisted it. Two fixes: - `FacetNumberField` renders `type="text" inputMode="numeric"`. A wheel cannot mutate a text input, and the parser already takes strings. `preventDefault` on a focused number input would also work, but it blocks the wheel's default action -- which includes scrolling the nearest scrollable ancestor -- and would re-break the panel scrolling #14629 just fixed, in exactly the reported gesture. All five numeric facets share this one field. - `patchFilters` closed over the render's `browse` snapshot, so two patches in one tick dropped one. It now writes through a functional update against the latest store state. `toggleSortField` and `clearFilters` had the same defect. Both tests were confirmed to fail against the unfixed source before being kept.
5abd09b to
e7278b7
Compare
|
Superseding review complete: PASS / ready for merge after CI.
Owner decision: none beyond normal merge timing; no broader product scope was added. |
Electron QA — running-app validationValidated in the dev app launched from this PR's worktree ( Core claim: wheel over a focused numeric thresholdValue protection: PASS. The field renders "…and the Filters panel scrolls instead": FAIL — but pre-existing, not this PR. Wheel events over the Filters popover arrive
The Check results
Also verified: Observations (not blocking)
EvidenceChecks 5 + 7 — pristine open, Check 2 — two constraints active, Check 2 — branch constraint removed, idle survives, Check 4 — panel scrolled (viewport at 916px via drag/focus): Check 6 — after two wheel ticks over the focused field: value still 20, panel unmoved (byte-identical to the before shot): |
#15298 landed the functional-update `patchFilters`, so `replaceFilters` uses the same idiom rather than reading the store directly. Same guarantee, one pattern.
…e it removable (#15300) * feat(workspace-cleanup): name every applied filter in the bar and make it removable Replaces the one-time filter migration this PR used to carry, and the per-group apply checkboxes that were planned to follow it. Both existed to answer one question -- why is a filter I never turned on hiding my workspaces -- and neither was the cheapest honest answer. The bar already read "Showing 546 of 799", so the *effect* was always visible. What was missing was the *cause*: which filter, and that it came from a previous session. Active constraints now render as removable chips in the bar, and Clear filters is promoted out of the popover it was buried in. Why this replaces the migration: a blanket clear cannot tell a wheel mutation from a deliberate choice, and the version here was worse than that -- it neutralized all ten groups, including location.repoIds and location.pathPrefix, which a wheel cannot set. With a chip, a stray threshold is visible on open and one click removes it. No marker, no provenance guessing, nobody's deliberate filters deleted. Why this replaces the apply toggle: every group already has a neutral resting state that does not constrain -- an empty numeric field, tri-states at 'any', the two booleans permissive. "Not applied" and "empty" are the same state today, so the toggle's only unique power was parking a value you are not using. That is a modest convenience against ten checkboxes, two-way drafts, a persistence model that could not use an 'enabled' flag without an older host dropping it, and a hydration guard. Chips are per-field, not per-group: "Activity" tells a reader nothing, while "Idle 20d+" names the thing hiding their workspaces. Zero handling matches the matchers: a 0 minimum is inert and shows no chip, a 0 maximum hides every measured non-empty workspace and does. * fix(workspace-cleanup): address the second review on the filter chips Four findings from the re-review of 3b88645: - **Same-tick writes could restore a cleared chip.** `replaceFilters` read the store but `patchFilters` still rebuilt from the render snapshot, so a chip clear plus a facet patch in one tick left `idleMinDays` at 20. Every writer now derives from current store state. Tests cover both call orders, and reverting the reader reproduces the reported failure. - **Chip labels went stale across a language change.** They were memoized on `filters` alone, so unchanged filters reused the previous language's strings. Derivation is constant-size (one pass over the filter fields, not per row), so it just runs each render. - **The new catalog entries were English-only.** ko and zh now carry all 22 chip strings. The verifier stayed green because missing target-locale entries are allowed and fall back to English -- which is exactly the trap this series has now hit twice. - **Remove targets were 16x16.** They use the shared button primitive at the canonical `icon-xs` size. Also trimmed the defect-history comments to the repo's concise style. * fix(workspace-cleanup): unify chip clears on the merged updater form #15298 landed the functional-update `patchFilters`, so `replaceFilters` uses the same idiom rather than reading the store directly. Same guarantee, one pattern.
…every filter patch (stablyai#15298) A profile carried `activity.idleMinDays = 20` that the user never set, hiding 253 of 799 workspaces on open. Chromium mutates a *focused* number input on every wheel tick, and before stablyai#14629 the facet panel could not scroll, so the natural response -- cursor into the panel, spin the wheel -- walked the threshold up and persisted it. Two fixes: - `FacetNumberField` renders `type="text" inputMode="numeric"`. A wheel cannot mutate a text input, and the parser already takes strings. `preventDefault` on a focused number input would also work, but it blocks the wheel's default action -- which includes scrolling the nearest scrollable ancestor -- and would re-break the panel scrolling stablyai#14629 just fixed, in exactly the reported gesture. All five numeric facets share this one field. - `patchFilters` closed over the render's `browse` snapshot, so two patches in one tick dropped one. It now writes through a functional update against the latest store state. `toggleSortField` and `clearFilters` had the same defect. Both tests were confirmed to fail against the unfixed source before being kept. (cherry picked from commit 4bc9e6b)
…e it removable (stablyai#15300) * feat(workspace-cleanup): name every applied filter in the bar and make it removable Replaces the one-time filter migration this PR used to carry, and the per-group apply checkboxes that were planned to follow it. Both existed to answer one question -- why is a filter I never turned on hiding my workspaces -- and neither was the cheapest honest answer. The bar already read "Showing 546 of 799", so the *effect* was always visible. What was missing was the *cause*: which filter, and that it came from a previous session. Active constraints now render as removable chips in the bar, and Clear filters is promoted out of the popover it was buried in. Why this replaces the migration: a blanket clear cannot tell a wheel mutation from a deliberate choice, and the version here was worse than that -- it neutralized all ten groups, including location.repoIds and location.pathPrefix, which a wheel cannot set. With a chip, a stray threshold is visible on open and one click removes it. No marker, no provenance guessing, nobody's deliberate filters deleted. Why this replaces the apply toggle: every group already has a neutral resting state that does not constrain -- an empty numeric field, tri-states at 'any', the two booleans permissive. "Not applied" and "empty" are the same state today, so the toggle's only unique power was parking a value you are not using. That is a modest convenience against ten checkboxes, two-way drafts, a persistence model that could not use an 'enabled' flag without an older host dropping it, and a hydration guard. Chips are per-field, not per-group: "Activity" tells a reader nothing, while "Idle 20d+" names the thing hiding their workspaces. Zero handling matches the matchers: a 0 minimum is inert and shows no chip, a 0 maximum hides every measured non-empty workspace and does. * fix(workspace-cleanup): address the second review on the filter chips Four findings from the re-review of 3b88645: - **Same-tick writes could restore a cleared chip.** `replaceFilters` read the store but `patchFilters` still rebuilt from the render snapshot, so a chip clear plus a facet patch in one tick left `idleMinDays` at 20. Every writer now derives from current store state. Tests cover both call orders, and reverting the reader reproduces the reported failure. - **Chip labels went stale across a language change.** They were memoized on `filters` alone, so unchanged filters reused the previous language's strings. Derivation is constant-size (one pass over the filter fields, not per row), so it just runs each render. - **The new catalog entries were English-only.** ko and zh now carry all 22 chip strings. The verifier stayed green because missing target-locale entries are allowed and fall back to English -- which is exactly the trap this series has now hit twice. - **Remove targets were 16x16.** They use the shared button primitive at the canonical `icon-xs` size. Also trimmed the defect-history comments to the repo's concise style. * fix(workspace-cleanup): unify chip clears on the merged updater form stablyai#15298 landed the functional-update `patchFilters`, so `replaceFilters` uses the same idiom rather than reading the store directly. Same guarantee, one pattern. (cherry picked from commit 9d87532)
…every filter patch (stablyai#15298) A profile carried `activity.idleMinDays = 20` that the user never set, hiding 253 of 799 workspaces on open. Chromium mutates a *focused* number input on every wheel tick, and before stablyai#14629 the facet panel could not scroll, so the natural response -- cursor into the panel, spin the wheel -- walked the threshold up and persisted it. Two fixes: - `FacetNumberField` renders `type="text" inputMode="numeric"`. A wheel cannot mutate a text input, and the parser already takes strings. `preventDefault` on a focused number input would also work, but it blocks the wheel's default action -- which includes scrolling the nearest scrollable ancestor -- and would re-break the panel scrolling stablyai#14629 just fixed, in exactly the reported gesture. All five numeric facets share this one field. - `patchFilters` closed over the render's `browse` snapshot, so two patches in one tick dropped one. It now writes through a functional update against the latest store state. `toggleSortField` and `clearFilters` had the same defect. Both tests were confirmed to fail against the unfixed source before being kept.
…e it removable (stablyai#15300) * feat(workspace-cleanup): name every applied filter in the bar and make it removable Replaces the one-time filter migration this PR used to carry, and the per-group apply checkboxes that were planned to follow it. Both existed to answer one question -- why is a filter I never turned on hiding my workspaces -- and neither was the cheapest honest answer. The bar already read "Showing 546 of 799", so the *effect* was always visible. What was missing was the *cause*: which filter, and that it came from a previous session. Active constraints now render as removable chips in the bar, and Clear filters is promoted out of the popover it was buried in. Why this replaces the migration: a blanket clear cannot tell a wheel mutation from a deliberate choice, and the version here was worse than that -- it neutralized all ten groups, including location.repoIds and location.pathPrefix, which a wheel cannot set. With a chip, a stray threshold is visible on open and one click removes it. No marker, no provenance guessing, nobody's deliberate filters deleted. Why this replaces the apply toggle: every group already has a neutral resting state that does not constrain -- an empty numeric field, tri-states at 'any', the two booleans permissive. "Not applied" and "empty" are the same state today, so the toggle's only unique power was parking a value you are not using. That is a modest convenience against ten checkboxes, two-way drafts, a persistence model that could not use an 'enabled' flag without an older host dropping it, and a hydration guard. Chips are per-field, not per-group: "Activity" tells a reader nothing, while "Idle 20d+" names the thing hiding their workspaces. Zero handling matches the matchers: a 0 minimum is inert and shows no chip, a 0 maximum hides every measured non-empty workspace and does. * fix(workspace-cleanup): address the second review on the filter chips Four findings from the re-review of 3b88645: - **Same-tick writes could restore a cleared chip.** `replaceFilters` read the store but `patchFilters` still rebuilt from the render snapshot, so a chip clear plus a facet patch in one tick left `idleMinDays` at 20. Every writer now derives from current store state. Tests cover both call orders, and reverting the reader reproduces the reported failure. - **Chip labels went stale across a language change.** They were memoized on `filters` alone, so unchanged filters reused the previous language's strings. Derivation is constant-size (one pass over the filter fields, not per row), so it just runs each render. - **The new catalog entries were English-only.** ko and zh now carry all 22 chip strings. The verifier stayed green because missing target-locale entries are allowed and fall back to English -- which is exactly the trap this series has now hit twice. - **Remove targets were 16x16.** They use the shared button primitive at the canonical `icon-xs` size. Also trimmed the defect-history comments to the repo's concise style. * fix(workspace-cleanup): unify chip clears on the merged updater form stablyai#15298 landed the functional-update `patchFilters`, so `replaceFilters` uses the same idiom rather than reading the store directly. Same guarantee, one pattern.
…every filter patch (stablyai#15298) A profile carried `activity.idleMinDays = 20` that the user never set, hiding 253 of 799 workspaces on open. Chromium mutates a *focused* number input on every wheel tick, and before stablyai#14629 the facet panel could not scroll, so the natural response -- cursor into the panel, spin the wheel -- walked the threshold up and persisted it. Two fixes: - `FacetNumberField` renders `type="text" inputMode="numeric"`. A wheel cannot mutate a text input, and the parser already takes strings. `preventDefault` on a focused number input would also work, but it blocks the wheel's default action -- which includes scrolling the nearest scrollable ancestor -- and would re-break the panel scrolling stablyai#14629 just fixed, in exactly the reported gesture. All five numeric facets share this one field. - `patchFilters` closed over the render's `browse` snapshot, so two patches in one tick dropped one. It now writes through a functional update against the latest store state. `toggleSortField` and `clearFilters` had the same defect. Both tests were confirmed to fail against the unfixed source before being kept.
…e it removable (stablyai#15300) * feat(workspace-cleanup): name every applied filter in the bar and make it removable Replaces the one-time filter migration this PR used to carry, and the per-group apply checkboxes that were planned to follow it. Both existed to answer one question -- why is a filter I never turned on hiding my workspaces -- and neither was the cheapest honest answer. The bar already read "Showing 546 of 799", so the *effect* was always visible. What was missing was the *cause*: which filter, and that it came from a previous session. Active constraints now render as removable chips in the bar, and Clear filters is promoted out of the popover it was buried in. Why this replaces the migration: a blanket clear cannot tell a wheel mutation from a deliberate choice, and the version here was worse than that -- it neutralized all ten groups, including location.repoIds and location.pathPrefix, which a wheel cannot set. With a chip, a stray threshold is visible on open and one click removes it. No marker, no provenance guessing, nobody's deliberate filters deleted. Why this replaces the apply toggle: every group already has a neutral resting state that does not constrain -- an empty numeric field, tri-states at 'any', the two booleans permissive. "Not applied" and "empty" are the same state today, so the toggle's only unique power was parking a value you are not using. That is a modest convenience against ten checkboxes, two-way drafts, a persistence model that could not use an 'enabled' flag without an older host dropping it, and a hydration guard. Chips are per-field, not per-group: "Activity" tells a reader nothing, while "Idle 20d+" names the thing hiding their workspaces. Zero handling matches the matchers: a 0 minimum is inert and shows no chip, a 0 maximum hides every measured non-empty workspace and does. * fix(workspace-cleanup): address the second review on the filter chips Four findings from the re-review of 3b88645: - **Same-tick writes could restore a cleared chip.** `replaceFilters` read the store but `patchFilters` still rebuilt from the render snapshot, so a chip clear plus a facet patch in one tick left `idleMinDays` at 20. Every writer now derives from current store state. Tests cover both call orders, and reverting the reader reproduces the reported failure. - **Chip labels went stale across a language change.** They were memoized on `filters` alone, so unchanged filters reused the previous language's strings. Derivation is constant-size (one pass over the filter fields, not per row), so it just runs each render. - **The new catalog entries were English-only.** ko and zh now carry all 22 chip strings. The verifier stayed green because missing target-locale entries are allowed and fall back to English -- which is exactly the trap this series has now hit twice. - **Remove targets were 16x16.** They use the shared button primitive at the canonical `icon-xs` size. Also trimmed the defect-history comments to the repo's concise style. * fix(workspace-cleanup): unify chip clears on the merged updater form stablyai#15298 landed the functional-update `patchFilters`, so `replaceFilters` uses the same idiom rather than reading the store directly. Same guarantee, one pattern.






ELI5
You spun the scroll wheel over the workspace-cleanup filter panel. Your cursor happened to be over a number box you'd clicked into. The browser quietly counted that as "change this number", so a filter you never turned on started hiding 253 of your 799 workspaces — and it got saved. This stops the wheel from being able to do that.
What Changed
FacetNumberFieldrenderstype="text" inputMode="numeric"instead oftype="number". All five numeric facets (idle days, min/max size, ahead/behind) share this one field, so one change covers them.patchFiltersnow writes through a functional update against the latest store state instead of closing over the render's snapshot.toggleSortFieldandclearFiltershad the same defect and are fixed with it.Why
The wheel. Chromium mutates a focused
<input type="number">on every wheel tick. Before #14629 the facet panel could not scroll, so the natural response — put the cursor in the panel and spin — walkedactivity.idleMinDaysup and persisted it. The filter bar did render "Showing 546 of 799" the whole time, so the effect was visible; what was missing was any attribution telling you which filter, or that it came from a previous session.preventDefault()on a focused number input also stops the value changing, and I rejected it:preventDefaultblocks the wheel's default action, which includes scrolling the nearest scrollable ancestor. It would re-break the panel scrolling #14629 just fixed, in exactly the gesture that caused this. A text input can't be wheel-mutated at all and keeps focus and scrolling intact.parseWorkspaceCleanupFacetNumberalready parses strings and clamps negatives, so nothing downstream changes.The patch drop.
patchFiltersreadbrowsefrom the render, so two patches in the same tick both started from the same snapshot and the second overwrote the first. Nothing in today's UI writes two groups at once, so this is latent rather than user-visible — but the per-group apply checkboxes that follow write several fields per toggle, and this is the seam they'd land on.Linked Issue
Reported directly by @BrennanKB5 in-session with a screenshot of the 20-day filter applied on open. No tracking issue was filed; the design work is in
docs/workspace-cleanup-filter-apply-plan.md.Visual Proof
N/A— no rendered change. The input looks and behaves identically for keyboard and mouse entry; only the wheel gesture's effect is removed, and the native spinner arrows go with it (deliberate: they were also a mis-click surface in a dense panel). The measurable behaviour is covered by tests below rather than a screenshot, since "a wheel no longer does something" does not photograph.Testing
Both new tests were confirmed to fail against the unfixed source before being kept — I reverted each source file, re-ran, saw red, and restored:
use-workspace-cleanup-browse-state.test.tsx(new, 3 cases): two different groups patched in one tick both land; the same group patched twice in one tick keeps both fields; sort direction flips against latest state. All 3 red before the fix.workspace-cleanup-facet-controls.test.tsx: asserts every numeric facet is offtype="number"and oninputMode="numeric". Red before the fix.On the wheel test specifically — I assert the input's type rather than dispatching a wheel event on purpose. The spinner is a Chromium behaviour happy-dom does not implement, so a dispatched wheel changes nothing in the test environment either way and such a test would pass without the fix. The type is the property that makes the class impossible; the comment in the test says so, so nobody later "improves" it into a vacuous one.
Gates run locally on the rebased branch:
pnpm typecheck,npx oxlint,pnpm lint:react-doctor:changed,pnpm verify:localization-catalog, and the full workspace-cleanup suite (36 files, 248 tests) — all clean. No strings added or removed.Review
Worth a look at the
type="number"→type="text"trade specifically. It drops nativemin={0}enforcement; typing-5now yieldsnull(filter off) rather than being blocked at the input, which I judged acceptable since the parser already clamped. If you'd rather keep the spinners, the alternative is a blur-on-wheel guard in the sharedInputprimitive — that would also cover the 8 othertype="number"inputs across settings and SSH forms, which have the identical exposure and are deliberately out of scope here.Agent skill upstream boundary
Notes
ui.setpath; no wire change, no host involvement.patchFilterskeeps its signature; only its read is now functional.browsefrom the callback's dependency array, sopatchFiltersstops re-creating on every browse change.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm testpass locallyAuthor