feat(projects): share project filtering globally with a sidebar filter row - #918
Conversation
…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>
There was a problem hiding this comment.
💡 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".
| $effect(() => { | ||
| if (!open || !projectsDataStore.loaded) return; | ||
| void projectsDataStore.ensureProjectsHydrated(); |
There was a problem hiding this comment.
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>
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 thatProjectsListandProjectsSidebarboth 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. legacyproject.githubRepo). Reactive reads are$derivedfields, sofilteredProjectsis 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.
SidebarFilterRowis 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 kicksensureProjectsHydrated()so repo counts aren't undercounted when the app opens straight into a project. The row isposition: stickyat 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
restorePending, and the first-filter-change drop moved from an$effectinto the derivation so the grid never paints the stale card.SidebarFilterRowfalls back to aRepoLabelwhen a badge lookup misses, so a fork-backed repo no longer renders a funnel and a bare count naming nothing.