fix(web): the board follows the mine/theirs filter - #389
Merged
Conversation
The Board read the same thread atom as the sidebar but never applied the ownership filter, so a sidebar filtered to Mine sat beside a board showing everyone's threads. It also has no ownership control of its own, so there was no way to correct it from the board. Ownership is a user-level "whose work am I looking at", not a per-surface view, so both surfaces now share one selection. The sidebar held it in component state seeded from localStorage, which the board could not observe: switching the filter would not have reached a board rendered beside it. It now lives in a shared hook over the same two keys, subscribed through the same change channel useLocalStorage uses, so both update together. The values are persisted as bare strings, predating useLocalStorage's JSON codec. The hook keeps that raw format deliberately: re-encoding would decode as invalid and silently reset every existing selection back to the default. Both surfaces now filter through one buildOwnershipPredicate rather than each re-deriving the threadMatchesMine call, which is what let them drift, and a fork-surface guard asserts both keep doing so. Not changed, but worth knowing: the board's project dropdown persists under its own key and still does not follow the sidebar's project scope, and snoozed threads remain visible as board cards. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
patroza
enabled auto-merge (squash)
August 10, 2026 07:06
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 Board read the same thread atom as the sidebar but never applied the ownership filter, so a
sidebar filtered to Mine sat beside a board showing everyone's threads. The board has no
ownership control of its own either, so there was no way to correct it from the board.
Why it needed more than a one-line filter
The obvious fix — read the stored value in
BoardViewand filter — would have been half a fix. Thesidebar held the selection in component state seeded from
localStorage, so the board couldnever observe a change: switching the filter in a sidebar rendered beside the board would not have
reached it until a remount. Two surfaces on screen at once disagreeing is the bug, not just the
missing filter.
So ownership is now one shared selection, because it is a user-level "whose work am I looking at"
rather than a per-surface view:
useOwnershipFilter()reads both keys throughuseSyncExternalStore, subscribing to thestorageevent and the sameLOCAL_STORAGE_CHANGE_EVENTthatuseLocalStoragealreadydispatches for same-tab updates. Sidebar and board now move together.
buildOwnershipPredicate()is the single call site forthreadMatchesMine. Both surfaces filterthrough it rather than each re-deriving the call — which is exactly what let them drift.
The raw storage format is preserved deliberately. These two keys hold bare strings (
mine), notJSON, predating
useLocalStorage's codec. Reusing that hook would have decodedmineas invalidJSON and silently reset every existing selection back to the default — a quiet regression for anyone
who had chosen Anyone or Theirs.
What changed
components/ownershipFilter.tscomponents/Sidebar.tsxuseState+localStorageblocks and two inline writes replaced by the hook; filtering routed through the shared predicatecomponents/board/BoardView.tsxthreadsmemocomponents/LegacySidebar.tsxhooks/useLocalStorage.tsAdversarial review
Both reviewers landed on the same three gaps. Neither found a HIGH, and both confirmed the core
sidebar↔board synchronisation is correct. All findings are fixed in
09615163c.localStoragewrite made the filter appear dead rather than session-only. Each surface previously held React state, so a selection still applied for the session; reading straight back out of storage meant the click did nothing at all in private mode or at quotasetItemstill applies for the sessionclear()reports a null key and was ignoredConfirmed sound by both, having traced it rather than taking my description: same-tab sync works
(the writer stores synchronously, dispatches, both subscriptions reread);
getSnapshotreturnsprimitives so there is no
useSyncExternalStoreloop; SSR is safe since module init never toucheswindow; the bare-string format is preserved and no other reader of these keys exists anywhere inweb, mobile or VS Code; both predicate closures are memoised with complete dependency arrays; and
the filter is applied before project filtering and column building, with column counts, worktree
grouping, drag targets and the empty state all deriving from the filtered list.
Verification
vp buildof the web app passes.CodexTextGenerationlaunch-args one that reproduces on unmerged
fork/dev.first time and corrected them against
threadMatchesMine: attributed work in an environment withno identity claim reads as Theirs, not Mine, while a fully unattributed thread stays
under Mine so local work does not vanish for anyone not using identity claims.
forkSurfaceExistencegains a guard asserting both surfaces still go through the shared predicate,so the board cannot quietly lose it again.
Two related divergences left alone
Found while tracing this, deliberately not folded in — say the word and I will:
t3code:board:project-filter:v1) andstill does not follow the sidebar's project scope.
Co-authored by @patroza
opened by Patrick Roza in chat thread Discord · Discord · T3