Skip to content

feat(projects): share project filtering globally with a sidebar filter row - #918

Merged
matt2e merged 7 commits into
mainfrom
global-filtering
Aug 13, 2026
Merged

feat(projects): share project filtering globally with a sidebar filter row#918
matt2e merged 7 commits into
mainfrom
global-filtering

Conversation

@matt2e

@matt2e matt2e commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Project filtering was view-local to the landing page: pick a filter, open a project, come back, and the selection was gone. This branch lifts that state into a shared store and gives the sidebar its own filter surface, so a filtered view of the project list follows you around the app session.

What changed

Shared filter state. A module-scoped projectFiltersStore (projectFilters.svelte.ts) owns the selection that ProjectsList and ProjectsSidebar both render. It survives navigating into a project and back — session-only, not persisted to disk. The pure computations (computeRepoFilters, filterProjects, toggleFilterKey, filterKey/parseRepoFilterKey) are exported as plain functions with vitest coverage of the repo-fallback rules (headRepo ?? githubRepo, hydrated repos list vs. legacy project.githubRepo). Reactive reads are $derived fields, so filteredProjects is computed once for the grid, the sidebar list and the match count.

Shared chip bar. The landing page's chips move into ProjectFilterChips (with a compact mode) rendered by both surfaces so they can't drift, plus a Clear chip — the chips that produced a selection can all disappear, so the bar needs its own way out.

Sidebar filter row. SidebarFilterRow is a collapsed summary — funnel icon, active-filter summary with repo badges, matched/total count, ✕ to clear — that opens the compact chip bar in a bits-ui Popover (elevated, closes on outside click or Escape) and kicks ensureProjectsHydrated() so repo counts aren't undercounted when the app opens straight into a project. The row is position: sticky at the top of the sidebar scroll area, with a hairline that appears only while pinned (an IntersectionObserver detects the stuck state, which CSS can't select).

Sidebar list follows the filters, keeping the selected project's row visible when it doesn't match (mirroring the All Repos row rule) and showing a "No projects match filters" state with a clear affordance. The grid picks up the same empty state.

Fixes along the way

  • ⌘1–9 indexed the unfiltered project list while the numbered overlays are painted on the filtered grid — with filters active, ⌘2 could open a project other than the one labeled ⌘2. Now indexes the same derivation the overlays iterate.
  • The scroll-restore "return target" card was captured on every mount and never cleared, so landing → settings → back pinned a stale project into the grid. Capture is now gated on restorePending, and the first-filter-change drop moved from an $effect into the derivation so the grid never paints the stale card.
  • A pending restore against an empty filtered list now drops instead of staying armed until something refilled the list and jumped scroll to a position captured for a different list.
  • SidebarFilterRow falls back to a RepoLabel when a badge lookup misses, so a fork-backed repo no longer renders a funnel and a bare count naming nothing.

matt2e and others added 6 commits August 13, 2026 16:26
…r row

Extract the landing page's view-local filter state into a global
projectFiltersStore shared by ProjectsList and ProjectsSidebar, so the
selection survives navigating into a project and back (session-only, not
persisted). The pure computations (computeRepoFilters, filterProjects,
toggleFilterKey, filterKey/parseRepoFilterKey) are exported as plain
functions with vitest coverage of the repo-fallback rules.

The chip bar moves into a shared ProjectFilterChips component (with a
compact mode) rendered by both surfaces so they can't drift. The sidebar
gains a collapsed SidebarFilterRow — funnel icon, active-filter summary
with repo badges and a matched/total count, a ✕ to clear without
expanding — that expands into the compact chip bar and kicks
ensureProjectsHydrated() so repo chips aren't undercounted when the app
opens straight into a project.

Sidebar project rows now render the filtered list, keeping the selected
project's row visible (appended) when it doesn't match, mirroring the
All Repos row rule, and show a "No projects match filters" state with a
clear-filters affordance when everything is filtered out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ering

Now that the filter selection outlives the view that set it, six edge cases
from the review of ba3c158:

- ProjectFilterChips gains a Clear chip whenever filters are active. The
  chips that produced a selection can all disappear (delete the last project
  using a filtered repo; both status chips go disabled at count 0), and the
  sidebar's ✕ is desktop-only, so the bar needed its own way out.
- ProjectsList keeps the returnTargetProjectId card in the grid when the
  filters no longer match it — visiting a project marks it read, so with
  Unread active the card the scroll restore aims at used to vanish. Captured
  at mount and dropped on the first filter change, and the grid rebuilds from
  the full list so the card holds its position. Mirrors the sidebar's
  selected-project exception.
- The restore effect now drops a pending restore when the filtered list is
  empty instead of staying armed until something refilled the list, at which
  point it jumped scroll to a position captured for a different list.
- SidebarFilterRow falls back to a RepoLabel when a badge lookup misses.
  Badges are only ensured for githubRepo while filter keys use headRepo ??
  githubRepo, so filtering by a fork-backed repo rendered a funnel and a bare
  count naming nothing.
- The store's reactive reads become $derived fields rather than getters, so
  filteredProjects is computed once for the grid, the sidebar list and the
  match count instead of three times (each running getProjectStatus over
  every project when `running` is active).
- hasRepoFilterKeys classifies via parseRepoFilterKey instead of by exclusion,
  so it can't disagree with filterProjects and turn a future third status
  filter into a repo filter that matches nothing.

The grid also picks up the sidebar's "No projects match filters" state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Three points from the review of d34ec8d:

- ProjectsList captured returnTargetProjectId on every mount, but nothing
  ever clears that field: finishProjectsListRestore() only resets
  restorePending, and requestProjectsListRestore(null) early-returns
  before touching it. So landing → settings → back — which pops to the
  root route with no selected project — inherited the target from a
  previous visit and pinned that project into the filtered grid. The
  capture is now gated on restorePending, which is still true at
  component init since the restore effect runs post-mount.
- The first-filter-change drop moves from an $effect into the
  filteredProjects derivation, comparing the active Set's identity
  against the one captured at mount. Effects run after render, so the
  grid used to paint once with the sticky card still present under the
  new filters before a second pass removed it; deriving also retires the
  mutable lastSeenFilters companion, whose identity check silently
  depended on the store always replacing the Set.
- Drop the store's hasRepoFilters derived field: nothing reads it —
  components use hasActiveFilters/repoFilters/activeRepoFilters, and
  filterProjects calls hasRepoFilterKeys on its argument directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ered Nth

The ⌘1–9 handler indexed into the unfiltered projects list while the
numbered overlays are painted onto the filtered grid, so with filters
active ⌘2 could open a different project than the card labeled ⌘2. The
mismatch predates this branch but was only reachable mid-toggle when the
filter was view-local; now that the selection persists for the session,
landing on a filtered grid is the normal case. The handler indexes
filteredProjects — the same component derivation the overlays iterate,
sticky return card included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
The expanded filter chips rendered inline in the sidebar flow, pushing
the project rows down with no elevation and no way to dismiss them short
of re-clicking the row. The row is now a bits-ui Popover trigger — the
same primitive the settings panel uses for its theme dropdown — so the
compact ProjectFilterChips bar floats under the row in an elevated panel
(--bg-elevated, --shadow-elevated, anchor-width) that closes on outside
click or Escape while the click still lands where it was aimed.

The trigger's styles move to :global under a component-specific name
since Popover.Trigger renders in a child component, outside this file's
scope hash; the hydration kick that keeps repo chip counts honest now
keys on the popover's open state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
The filter row sat in the sidebar flow, so scrolling a long project list
pushed the active-filter summary — and the ✕ that clears it — out of
view, exactly when a filtered-down list most needs to explain itself.
The row is now position: sticky at the top of the sidebar scroll area,
opaque against the rows passing beneath (bg-app-bar, matching the
sidebar so it stays invisible at rest).

A hairline appears under the row only while it is actually pinned. CSS
has no stuck-state selector for sticky elements, so an
IntersectionObserver watches the row against its nearest scrollable
ancestor with the root top edge inset 1px: being clipped at the top is
unique to the pinned state (bottom clipping just means the row scrolled
below the fold), and the observer also catches pin changes from content
above the row resizing, which fire no scroll event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: abe4a747f1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +88 to +90
$effect(() => {
if (!open || !projectsDataStore.loaded) return;
void projectsDataStore.ensureProjectsHydrated();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep run-action state alive for sidebar filtering

When this row is opened from the All Repos route, App renders ProjectsSidebar next to ReposListView, so neither ProjectsList nor ProjectHome is mounted; those are the only places that start/hydrate projectRunActionsStore, and they stop it on unmount. This effect only hydrates branches/repos, so the new Running chip/count and sidebarProjects filter will miss projects whose only active work is a run action in the Repos view. Start/hydrate the run-action store from the sidebar/filter path too.

Useful? React with 👍 / 👎.

Review of the popover change flagged a real gap: projectRunActionsStore
was started by ProjectsList/ProjectHome on mount and torn down on
unmount, but on the All Repos route App renders ProjectsSidebar next to
ReposListView — neither owner is mounted, so navigating there wiped the
store. With filtering now global, the sidebar's Running chip count and
filtered project list read that store from every route, so a project
whose only activity is a run action (say a dev server started before
switching to the repos view) dropped out of a Running-filtered sidebar,
and its status dot went dark.

The store's consumer is effectively app-global now — projectFiltersStore
is module-scoped and rendered by the sidebar or the landing grid on
every route — so its Tauri listeners move to App.svelte alongside
projectsDataStore's, started once and stopped on app teardown. This also
retires the wipe-and-refetch churn every landing↔project transition
paid: hydratedBranchIds survives navigation and the listeners keep state
current from events.

ProjectsSidebar picks up the same hydrateFromProjectBranches effect
ProjectsList runs, since on the repos route it is the only mounted
surface that can feed newly loaded branch data (the filter row's
ensureProjectsHydrated kick, the store's idle drip) into run-action
state. The store dedupes already-queried branches, so the overlap with
ProjectHome on the project route costs nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e merged commit 21c0fa9 into main Aug 13, 2026
2 checks passed
@matt2e
matt2e deleted the global-filtering branch August 13, 2026 23:51
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