Skip to content

fix(web): the board follows the mine/theirs filter - #389

Merged
patroza merged 1 commit into
fork/devfrom
fix/board-ownership-filter
Aug 10, 2026
Merged

fix(web): the board follows the mine/theirs filter#389
patroza merged 1 commit into
fork/devfrom
fix/board-ownership-filter

Conversation

@omegent-app

@omegent-app omegent-app Bot commented Aug 10, 2026

Copy link
Copy Markdown

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 BoardView and filter — would have been half a fix. The
sidebar held the selection in component state seeded from localStorage, so the board could
never 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 through useSyncExternalStore, subscribing to the
    storage event and the same LOCAL_STORAGE_CHANGE_EVENT that useLocalStorage already
    dispatches for same-tab updates. Sidebar and board now move together.
  • buildOwnershipPredicate() is the single call site for threadMatchesMine. Both surfaces filter
    through 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), not
JSON, predating useLocalStorage's codec. Reusing that hook would have decoded mine as invalid
JSON and silently reset every existing selection back to the default — a quiet regression for anyone
who had chosen Anyone or Theirs.

What changed

File
components/ownershipFilter.ts new — the shared hook and predicate
components/Sidebar.tsx two useState+localStorage blocks and two inline writes replaced by the hook; filtering routed through the shared predicate
components/board/BoardView.tsx applies the predicate in its threads memo
components/LegacySidebar.tsx migrated onto the same shared selection
hooks/useLocalStorage.ts exports the same-tab change primitives so a raw-format key can join the same channel instead of inventing a second one

Adversarial 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.

Finding Disposition
both, MEDIUM — the legacy sidebar was left behind. It is still selectable, kept its own state, and wrote the two keys directly without notifying anyone — so changing Mine/Theirs there never reached a Board mounted beside it Fixed. This was the same bug one surface over, and my guard's "every thread surface" wording was writing a cheque the test didn't cover. It now uses the shared hook and predicate, and the guard covers it
both, MEDIUM — a blocked localStorage write 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 quota Fixed. The store keeps the value this tab wrote and drops it when another tab writes, so an external value still wins. My original comment claimed "does not survive a reload" — it was worse than that, and the comment was wrong
both, LOW — the tests only covered the pure predicate. A regression that dropped the board's filtering entirely would have passed them, and the source-text guard can be satisfied by dead code Fixed. The tests now exercise the synchronisation contract: two subscribers both see a write, unsubscribe stops delivery, the format stays bare strings so pre-existing selections load, and a failing setItem still applies for the session
grok-4.5, LOW — a cross-tab clear() reports a null key and was ignored Fixed
grok-4.5, LOW — imports orphaned by the migration Fixed

Confirmed sound by both, having traced it rather than taking my description: same-tab sync works
(the writer stores synchronously, dispatches, both subscriptions reread); getSnapshot returns
primitives so there is no useSyncExternalStore loop; SSR is safe since module init never touches
window; the bare-string format is preserved and no other reader of these keys exists anywhere in
web, 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

  • Typecheck clean across 18 packages; vp build of the web app passes.
  • 2,368 tests pass (11 new), the only failure being the pre-existing CodexTextGeneration
    launch-args one that reproduces on unmerged fork/dev.
  • The new predicate tests pin the semantics rather than my assumptions — I had two of them wrong
    first time and corrected them against threadMatchesMine: attributed work in an environment with
    no 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.
  • forkSurfaceExistence gains 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:

  • The board's project dropdown persists under its own key (t3code:board:project-filter:v1) and
    still does not follow the sidebar's project scope.
  • Snoozed threads are shelved out of the sidebar's list but still render as board cards.

Co-authored by @patroza

opened by Patrick Roza in chat thread Discord · Discord · T3

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
patroza enabled auto-merge (squash) August 10, 2026 07:06
@patroza
patroza merged commit b61ab57 into fork/dev Aug 10, 2026
4 checks passed
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