Skip to content

fix(workspace-cleanup): stop a sync broadcast reverting a filter you just set - #15369

Merged
brennanb2025 merged 4 commits into
mainfrom
brennanb2025/cleanup-browse-hydration-guard
Aug 24, 2026
Merged

brennanb2025 merged 4 commits into
mainfrom
brennanb2025/cleanup-browse-hydration-guard

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​77 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​77
Prod 1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​5 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​4

ELI5

You change a filter in the workspace-cleanup dialog. A quarter-second later, something unrelated elsewhere in Orca saves its own UI state. That save broadcasts to every window — carrying the old filters, because yours hasn't been written yet. Your window takes that as truth and puts the old filter back. Then your pending save fires and writes the reverted value to disk. Your filter is gone, and it looks random.

What Changed

One line in hydratePersistedUI: workspaceCleanupBrowse now only hydrates when source === 'startup', matching activeView two lines below it.

Why

store.onUIChanged broadcasts to every window on every UI change (src/main/ipc/ui.ts:54-61). The browse writer debounces 250 ms (WORKSPACE_CLEANUP_BROWSE_PERSIST_DEBOUNCE_MS). Your own write echoing back is harmless — the value matches. The damage comes from someone else's ui.set landing inside your window: main broadcasts state still holding the previous filters, the renderer hydrates it over your edit, and then the pending debounce writes get().workspaceCleanupBrowse — which is now the reverted value. So it is persisted, not merely displayed.

Plenty of things route through ui.set (sidebar state, modal dismissals, tour state, mobile). It needs a coincidence inside 250 ms, so it is intermittent rather than constant — which is the worst kind, because it reads as "Orca randomly forgot my filter" rather than a reproducible bug.

activeView already carries the guard and the reason, in the same function:

// Why: restore only on startup; on 'sync' broadcasts it would clobber the window's current per-window view.

The trade, stated plainly. The guard means browse state no longer syncs between windows or from mobile. I checked whether that costs anything real, and it does not today: mobile/src has no workspace-cleanup consumer at all, and the dashboard popout runs a separate store and JS context. Nothing else in the product edits browse state. Filters become per-window, which is the same call already made for activeView.

The alternative I rejected: last-write-wins via a local dirty generation. It preserves cross-window sync — a sync nobody currently uses — in exchange for more state to get wrong, on a code path that has just demonstrated it can silently discard user input. Worth revisiting only if a second surface ever edits these filters.

Linked Issue

No tracking issue. Found while verifying, for @BrennanKB5, whether cleanup filters survive restarts — the answer is yes, except through this path. Same family as the originally reported "a filter I never set was applied".

Visual Proof

N/A — nothing renders differently. The change is that an edit stops being silently reverted, which does not photograph; a before/after would be two identical screenshots separated by a race. Covered by tests that fail against the unguarded source instead.

Testing

New ui-hydration-workspace-cleanup-browse.test.ts, 5 cases. I ran them against the unguarded source and confirmed exactly the right split:

Fail before the fix (3) — these are the bug:

  • a sync broadcast does not revert a filter the user just set
  • a sync broadcast does not revert a typed query
  • an unlabelled broadcast (the parameter defaults to 'sync') cannot revert either

Pass before and after (2) — these assert what must not change:

  • startup still restores persisted filters
  • dismissals still hydrate on sync, because they are main-owned and not edited here

That second group is the point of the guard being narrow: it would be easy to "fix" this by skipping the whole workspaceCleanup block and quietly break dismissal sync.

Gates on the rebased branch: pnpm typecheck, npx oxlint, pnpm lint:react-doctor:changed, plus 330 tests across the UI-hydration and workspace-cleanup suites — all clean. No strings added.

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

Review

The judgement worth checking is the per-window trade. If you think cleanup filters should follow you between windows, this is the wrong fix and the dirty-generation approach is the right one — say so and I will swap it. My read is that per-window matches activeView and matches what actually happens today, since no second surface writes these filters.

Agent skill upstream boundary

  • Not applicable.

Notes

Checklist

  • This PR is small and focused
  • I explained what changed and why (including ELI5)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test pass locally

Author

  • X / Twitter: @BrennanKB5

…just set

`workspaceCleanupBrowse` re-hydrated on every hydration source. Two lines below
it, `activeView` is startup-guarded for the same reason and says so.

How it fired: the browse writer debounces 250ms. Any other `ui.set` inside that
window makes main broadcast to all windows (`main/ipc/ui.ts:54-61`) carrying
state that still holds the previous filters. The window hydrated that over the
edit, and the pending debounce then wrote `get().workspaceCleanupBrowse` -- by
then the reverted value -- so the revert was persisted, not just displayed.
Your own write echoing back is harmless; it is somebody else's write landing
first that does the damage. Intermittent, which is why it reads as "Orca forgot
my filter".

Guarded to `source === 'startup'`, matching activeView. The cost is that browse
state no longer syncs between windows or from mobile. That costs nothing today:
`mobile/src` has no workspace-cleanup consumer, and the popout runs a separate
store and JS context, so nothing else in the product edits browse state. Filters
become per-window, which is the call already made for activeView.

Rejected the alternative (last-write-wins via a local dirty generation): it
preserves a cross-window sync nobody uses, in exchange for more state to get
wrong on a path that just proved it can silently discard user input.

Three of the five tests fail against the unguarded source; the two that stay
green assert what must not change -- startup still restores, and dismissals
still hydrate on sync because they are main-owned.
@coderabbitai

coderabbitai Bot commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c6c2b2dc-79ec-4df8-8a3d-83da01d0f8c6

📥 Commits

Reviewing files that changed from the base of the PR and between a70fe63 and 388ad91.

📒 Files selected for processing (2)
  • src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts
  • src/renderer/src/store/slices/ui.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/renderer/src/store/slices/ui.ts
  • src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.


📝 Walkthrough

Walkthrough

Startup hydration restores workspaceCleanupBrowse from persisted state. Sync hydration preserves the current window’s browse state. Tests cover persisted filter restoration, protection of locally edited activity and query filters, and continued hydration of main-owned dismissals.

Merge Risk: ⚪ Minimal · up to 388ad

This localized change prevents workspace-cleanup filters from being silently reverted by unrelated UI broadcasts while preserving startup restoration and dismissal synchronization; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed and covers the change, rationale, testing, visual proof, and checklist, but it omits the required linked issue. Add a linked issue using the required “Fixes #” format, or document an approved issue reference according to repository policy.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main fix: preventing workspace cleanup filters from being reverted by sync broadcasts.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status

Ready to land. I kept the focused startup-only hydration guard and tightened the code/test rationale to the project comment style.

  • Review loop: two fresh independent passes checked the user-visible race, regressions, and performance; both returned clean with no findings.
  • Readiness: initial and final checklists both passed with zero P0/P1/P2 candidates. No wire/schema, persistence-format, data-loss, SSH/remote, platform, security, crash/retry, or resource change was found.
  • Regression proof: with the guard temporarily removed, the new behavioral suite failed exactly the 3 stale-sync cases while the 2 startup/dismissal preservation cases stayed green. Restoring the guard returned the focused suites to 9/9 passing.
  • Required gates: pnpm run typecheck, changed-directory oxlint, the separate type-aware code-quality audit, changed-file React Doctor, localization catalog verification, and explicit-config Vitest all passed.
  • Semantics: cleanup filters are now per-window and no longer adopt mobile-originated browse state. Current mobile code has no workspace-cleanup consumer, and the dashboard popout owns an independent store, so there is no competing writer today. Versioned last-write-wins would preserve hypothetical future synchronization but adds coordination state without a current consumer; the startup-only guard is the narrower and safer fix.

Owner decision: none required for the current product behavior. Revisit versioned synchronization only if another window or mobile surface later edits cleanup filters.

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

1 similar comment
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

QA validation — Electron CDP run ($electron, isolated dev instance)

Validated on an isolated dev instance launched from this PR's worktree (own user-data dir, own CDP port, scratch repo + 3 scratch worktrees; identity verified via app.getIdentity() before any capture). All four checks pass.

# Check Result
1 Filter survives unrelated UI updates inside the 250 ms debounce ✅ PASS
2 Filter survives full quit + relaunch ✅ PASS
3 Filters restore correctly on normal startup ✅ PASS
4 Dismissals (Ignored rows) still behave ✅ PASS

Check 1 — filter not reverted by a sync broadcast (PASS)

Set Ignored: Excluded via the real radio control, then fired an unrelated ui.set (sidebarWidth) before the browse writer's 250 ms debounce elapsed, with a listener recording every ui:stateChanged broadcast:

  • radio click at t=98 ms → store dismissed: "exclude", debounce armed
  • broadcast at t=112 ms carrying stale dismissed: "any" (the exact bug trigger) → after hydration the store still read "exclude" — the guard refused the revert
  • broadcast at t=452 ms carried "exclude" (the debounced write's echo); final ui.get() from main: "exclude" — the correct value was persisted, not the reverted one

Also survived: a typed query (qa-old), repeated sidebarWidth writes, and a theme settings change ("change a setting"). Note the dialog is modal, so sidebar toggles aren't clickable while it is open — the broadcast-race injection above exercises the same path those would.

Filter applied (chip + count):
check1a

Query + chip intact after the churn (Showing 2 of 4):
check1b

Checks 2 + 3 — quit, relaunch, startup restore (PASS)

Fully quit the instance (SIGTERM to the recorded process group), relaunched on the same profile. On startup the store hydrated query: "qa-old", dismissed: "exclude", and the qa-old-alpha dismissal; reopening the dialog through the status bar showed the restored query, the Ignored: Excluded chip, and Showing 1 of 4:

check23

Check 4 — dismissals unbroken (PASS)

Clicked "Ignore qa-old-alpha": the dismissal landed in the store and persisted immediately (it rides along with the browse write), and with Ignored: Excluded active the row vanished (Showing 1 of 4):

check4a

Flipping the facet to Ignored: Only shows exactly the ignored row with its "Ignored" badge; the dismissal also survived the relaunch:

check4b

Extras — themes and 600 px minimum width

Light theme: contrast fine (chip, badges, disabled Delete button all legible). ✅
light

600 px width: no horizontal overflow; controls wrap cleanly. One pre-existing cosmetic nit (not from this PR — it touches no rendering): the dialog title "Delete Inactive Workspaces" breaks mid-word ("Workspac / es") at 600 px — a break-word-style wrap on the heading.
600px

Not covered

  • True multi-window / mobile-originated ui.set (single-window instance; the injected ui.set broadcast is the identical main-process path).
  • The known pre-existing dead mouse-wheel scroll inside the Filters popover (reproduces on main; unrelated to this change).

@brennanb2025
brennanb2025 merged commit d14923e into main Aug 24, 2026
47 checks passed
paidaxingyo666 pushed a commit to paidaxingyo666/Manta that referenced this pull request Aug 25, 2026
…just set (stablyai#15369)

* fix(workspace-cleanup): stop a sync broadcast reverting a filter you just set

`workspaceCleanupBrowse` re-hydrated on every hydration source. Two lines below
it, `activeView` is startup-guarded for the same reason and says so.

How it fired: the browse writer debounces 250ms. Any other `ui.set` inside that
window makes main broadcast to all windows (`main/ipc/ui.ts:54-61`) carrying
state that still holds the previous filters. The window hydrated that over the
edit, and the pending debounce then wrote `get().workspaceCleanupBrowse` -- by
then the reverted value -- so the revert was persisted, not just displayed.
Your own write echoing back is harmless; it is somebody else's write landing
first that does the damage. Intermittent, which is why it reads as "Orca forgot
my filter".

Guarded to `source === 'startup'`, matching activeView. The cost is that browse
state no longer syncs between windows or from mobile. That costs nothing today:
`mobile/src` has no workspace-cleanup consumer, and the popout runs a separate
store and JS context, so nothing else in the product edits browse state. Filters
become per-window, which is the call already made for activeView.

Rejected the alternative (last-write-wins via a local dirty generation): it
preserves a cross-window sync nobody uses, in exchange for more state to get
wrong on a path that just proved it can silently discard user input.

Three of the five tests fail against the unguarded source; the two that stay
green assert what must not change -- startup still restores, and dismissals
still hydrate on sync because they are main-owned.

* chore(workspace-cleanup): tighten hydration guard rationale

(cherry picked from commit d14923e)
dallascrilley pushed a commit to dallascrilley/orca that referenced this pull request Aug 27, 2026
…just set (stablyai#15369)

* fix(workspace-cleanup): stop a sync broadcast reverting a filter you just set

`workspaceCleanupBrowse` re-hydrated on every hydration source. Two lines below
it, `activeView` is startup-guarded for the same reason and says so.

How it fired: the browse writer debounces 250ms. Any other `ui.set` inside that
window makes main broadcast to all windows (`main/ipc/ui.ts:54-61`) carrying
state that still holds the previous filters. The window hydrated that over the
edit, and the pending debounce then wrote `get().workspaceCleanupBrowse` -- by
then the reverted value -- so the revert was persisted, not just displayed.
Your own write echoing back is harmless; it is somebody else's write landing
first that does the damage. Intermittent, which is why it reads as "Orca forgot
my filter".

Guarded to `source === 'startup'`, matching activeView. The cost is that browse
state no longer syncs between windows or from mobile. That costs nothing today:
`mobile/src` has no workspace-cleanup consumer, and the popout runs a separate
store and JS context, so nothing else in the product edits browse state. Filters
become per-window, which is the call already made for activeView.

Rejected the alternative (last-write-wins via a local dirty generation): it
preserves a cross-window sync nobody uses, in exchange for more state to get
wrong on a path that just proved it can silently discard user input.

Three of the five tests fail against the unguarded source; the two that stay
green assert what must not change -- startup still restores, and dismissals
still hydrate on sync because they are main-owned.

* chore(workspace-cleanup): tighten hydration guard rationale
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…just set (stablyai#15369)

* fix(workspace-cleanup): stop a sync broadcast reverting a filter you just set

`workspaceCleanupBrowse` re-hydrated on every hydration source. Two lines below
it, `activeView` is startup-guarded for the same reason and says so.

How it fired: the browse writer debounces 250ms. Any other `ui.set` inside that
window makes main broadcast to all windows (`main/ipc/ui.ts:54-61`) carrying
state that still holds the previous filters. The window hydrated that over the
edit, and the pending debounce then wrote `get().workspaceCleanupBrowse` -- by
then the reverted value -- so the revert was persisted, not just displayed.
Your own write echoing back is harmless; it is somebody else's write landing
first that does the damage. Intermittent, which is why it reads as "Orca forgot
my filter".

Guarded to `source === 'startup'`, matching activeView. The cost is that browse
state no longer syncs between windows or from mobile. That costs nothing today:
`mobile/src` has no workspace-cleanup consumer, and the popout runs a separate
store and JS context, so nothing else in the product edits browse state. Filters
become per-window, which is the call already made for activeView.

Rejected the alternative (last-write-wins via a local dirty generation): it
preserves a cross-window sync nobody uses, in exchange for more state to get
wrong on a path that just proved it can silently discard user input.

Three of the five tests fail against the unguarded source; the two that stay
green assert what must not change -- startup still restores, and dismissals
still hydrate on sync because they are main-owned.

* chore(workspace-cleanup): tighten hydration guard rationale
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