From 47e2132a4f0a7bcbc068197dc1f70a3aca2b8a75 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sat, 25 Jul 2026 12:44:55 -0700 Subject: [PATCH 01/21] Give pinned saved searches their own scrollable row Reviewers deliberately pin many saved searches (every personal roster auto-pins one, plus hand-saved filters), which overflowed the single filter row: the pins wrapped to two or three lines and shoved the dimension triggers and search box down with them. That is the badge-soup shape PRODUCT.md warns against, moved into the header. Move the pins to their own dedicated line, a horizontally-scrollable strip reusing the lens tab strip's overflow-x-auto no-scrollbar pattern. Chips are shrink-0 so a long set scrolls instead of compressing, every pin stays one click away, and a growing set can never wrap the triggers again. The divider follows whichever row leads so the header keeps one separator. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 67 +++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 6b22dab5..12b28bd1 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -929,35 +929,44 @@ export function App() { /> -
- {/* pinned saved searches, FIRST in the bar so a growing - trigger can never displace them: every roster earns one - automatically (kept in sync with its members), and any - search can be pinned from the Saved menu. Click applies - it on the lens you're standing on; click again clears. - State is carried entirely by color — the chip's text - never changes, so nothing ever shifts. */} - {pinnedSearches.map(f => { - const active = matchesView(f.hash, currentHash); - const title = active - ? `showing ${f.name}: click to clear` - : `show ${f.name}: ${describeHash(f.hash)}`; - return ( - - ); - })} + {/* Pinned saved searches get their own line. Reviewers deliberately + pin many (every roster earns one automatically, plus hand-saved + ones), which overflowed the shared trigger row into a wall. A + dedicated horizontally-scrollable strip keeps every pin one click + away and stops a growing set from wrapping the dimension triggers + below. Click applies it on the lens you're standing on; click + again clears. State is a background tint; the text never changes, + so nothing shifts. */} + {pinnedSearches.length > 0 && ( +
+ {pinnedSearches.map(f => { + const active = matchesView(f.hash, currentHash); + const title = active + ? `showing ${f.name}: click to clear` + : `show ${f.name}: ${describeHash(f.hash)}`; + return ( + + ); + })} +
+ )} +
0 ? '' : 'border-t border-secondary' + }`} + > Date: Sat, 25 Jul 2026 12:57:47 -0700 Subject: [PATCH 02/21] Stop pulling starved and region PRs out of the review queue With a repo priority set, starved pulls were extracted into a Starving fold and removed from their repo block, and code-region matches were pulled out of the queue and QA lanes. A high-volume repo (ops) filling the Starving fold's cap could bury a starved PR from another repo (ifixit) behind 'show more' with no way back onto the board, since it no longer appeared in its own block either. Make both non-exclusive: the Review queue keeps everything, and 'Starving' and 'In your code regions' show a copy of a slice rather than removing it. This matches how 'Recently updated' and a claimed PR already relate to their lanes. The four state lanes (Ready, Waiting on you/others, Needs QA) stay mutually exclusive, so position still encodes state where it matters. Also fixes a pull you already hold a live CR stamp on showing in 'your code regions': the QA-pool leg feeding regionMatches never checked crBy, so a needs_qa pull you had already reviewed sailed in. A needs_recr pull whose stamp is only yours-and-stale still shows, since that stamp has dropped out of the live crBy set. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/repoBlocks.test.ts | 6 +-- frontend-v2/src/model/repoBlocks.ts | 12 +++--- frontend-v2/src/model/reviewLanes.test.ts | 41 +++++++++++++++++-- frontend-v2/src/model/reviewLanes.ts | 49 +++++++++++------------ frontend-v2/src/views/Review.tsx | 15 +++++-- 5 files changed, 82 insertions(+), 41 deletions(-) diff --git a/frontend-v2/src/model/repoBlocks.test.ts b/frontend-v2/src/model/repoBlocks.test.ts index 65a712bf..edc5c355 100644 --- a/frontend-v2/src/model/repoBlocks.test.ts +++ b/frontend-v2/src/model/repoBlocks.test.ts @@ -27,13 +27,13 @@ describe('repoBlocks', () => { expect(shape(repoBlocks(queue, ['ops'])).blocks[0].nums).toEqual([9, 1, 5]); }); - it('starved pulls pierce the partition and lead in their own order', () => { + it('surfaces starved pulls as a highlight while keeping them in their own repo block', () => { const queue = [pull('mono', 1), pull('ops', 2, true), pull('mono', 3, true), pull('ops', 4)]; expect(shape(repoBlocks(queue, ['mono', 'ops']))).toEqual({ starved: [2, 3], blocks: [ - { repo: 'mono', nums: [1] }, - { repo: 'ops', nums: [4] }, + { repo: 'mono', nums: [1, 3] }, + { repo: 'ops', nums: [2, 4] }, ], }); }); diff --git a/frontend-v2/src/model/repoBlocks.ts b/frontend-v2/src/model/repoBlocks.ts index ca859523..13b1470d 100644 --- a/frontend-v2/src/model/repoBlocks.ts +++ b/frontend-v2/src/model/repoBlocks.ts @@ -10,9 +10,12 @@ import type { DerivedPull } from '../../../shared/model/status'; * monopolize the screen). * * Two guarantees survive the partition: - * - Starved pulls pierce it. They lead the lane regardless of repo, in their - * existing rank order — the fairness backstop must not sit below a repo - * the viewer ranked last, or "surface the other repos" becomes a lie. + * - Starved pulls are surfaced as a highlight (returned separately, still in + * rank order) AND kept in their own repo block — a starved pull from a + * low-priority repo is never hidden by a higher-priority repo's block, + * unlike an earlier version that extracted starved pulls out of the + * blocks entirely and could bury one behind a high-volume repo's "show + * more" fold. * - Order WITHIN a block is exactly the incoming order (stable partition), * so teammates still lead and the score still ranks — the priority only * decides which repo's run comes first, never which pull is "best". @@ -31,9 +34,8 @@ export function repoBlocks( priority: string[] ): { starved: DerivedPull[]; blocks: RepoBlock[] } { const starved = queue.filter(p => p.starved); - const rest = queue.filter(p => !p.starved); const byRepo = new Map(); - for (const p of rest) { + for (const p of queue) { const list = byRepo.get(p.data.repo); if (list) list.push(p); else byRepo.set(p.data.repo, [p]); diff --git a/frontend-v2/src/model/reviewLanes.test.ts b/frontend-v2/src/model/reviewLanes.test.ts index 265227ff..de21a601 100644 --- a/frontend-v2/src/model/reviewLanes.test.ts +++ b/frontend-v2/src/model/reviewLanes.test.ts @@ -260,11 +260,11 @@ describe('buildReviewLanes — waiting on you vs waiting on others', () => { }); describe('buildReviewLanes — region matches', () => { - it('surfaces a pull matching a configured code region and pulls it out of the queue', () => { + it('surfaces a pull matching a configured code region as a highlight, without removing it from the queue', () => { const p = dp({ author: 'alice', status: 'needs_cr', title: 'Rework the Shopify sync' }); const lanes = buildReviewLanes(input({ pulls: [p], codeRegions: ['Shopify'] })); expect(lanes.regionMatches).toEqual([p]); - expect(lanes.queue).not.toContain(p); + expect(lanes.queue).toContain(p); }); it('leaves regionMatches empty when no regions are configured', () => { @@ -274,7 +274,7 @@ describe('buildReviewLanes — region matches', () => { expect(lanes.queue).toEqual([p]); }); - it('pulls a QA-pool match out of needsQa into regionMatches too', () => { + it('surfaces a QA-pool match in regionMatches too, without removing it from needsQa', () => { const p = dp({ author: 'alice', status: 'needs_qa', @@ -282,7 +282,40 @@ describe('buildReviewLanes — region matches', () => { }); const lanes = buildReviewLanes(input({ pulls: [p], codeRegions: ['Shopify'] })); expect(lanes.regionMatches).toEqual([p]); - expect(lanes.needsQa).not.toContain(p); + expect(lanes.needsQa).toContain(p); + }); + + it('excludes a pull you already CR-stamped from regionMatches, even via the QA pool', () => { + // qaPool itself doesn't care about crBy — a needs_qa pull with a live + // stamp from you would otherwise sail through the QA-pool leg and land + // in "your code regions" as if it were news to you. It still belongs + // in needsQa (that lane's own filters don't look at crBy at all). + const p = dp({ + author: 'alice', + status: 'needs_qa', + crBy: ['me'], + title: 'Rework the Shopify sync', + }); + const lanes = buildReviewLanes(input({ pulls: [p], codeRegions: ['Shopify'] })); + expect(lanes.regionMatches).not.toContain(p); + expect(lanes.needsQa).toContain(p); + }); + + it('keeps a needs_recr pull in regionMatches when only your own stamp went stale', () => { + // recrBy including you means YOUR re-stamp is owed (a "Re-stamp" verb + // in Waiting on you) and crBy no longer includes you, since a stale + // stamp drops out of the active crBy set. The new qaPool exclusion + // only checks crBy (a currently-live stamp), so this pull still + // matches through the QA-pool leg. + const p = dp({ + author: 'alice', + status: 'needs_recr', + recrBy: ['me'], + crBy: [], + title: 'Rework the Shopify sync', + }); + const lanes = buildReviewLanes(input({ pulls: [p], codeRegions: ['Shopify'] })); + expect(lanes.regionMatches).toContain(p); }); }); diff --git a/frontend-v2/src/model/reviewLanes.ts b/frontend-v2/src/model/reviewLanes.ts index e32c2482..811c1b43 100644 --- a/frontend-v2/src/model/reviewLanes.ts +++ b/frontend-v2/src/model/reviewLanes.ts @@ -92,8 +92,9 @@ export interface ReviewLanes { /** the review queue's contiguous per-repo blocks (only meaningful with * repoPriority set) */ queueBlocks: RepoBlock[]; - /** CR- or QA-pool pulls matching a configured code region, deduped and - * pulled out of every lane below */ + /** CR- or QA-pool pulls matching a configured code region, deduped — a + * highlight copy, not an extraction: matches also stay in the queue/QA + * lanes below */ regionMatches: DerivedPull[]; needsQa: DerivedPull[]; needsQaOther: DerivedPull[]; @@ -259,20 +260,23 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { ); // In your code regions: reviewable pulls (CR or QA pool) matching a region - // you set in Settings, deduped across the two pools and genuinely pulled - // out of the queue/QA lanes below (including their "other repos" folds) - // into their own section — the most explicit "this is my area" signal - // earns its own spot instead of a float within the queue. + // you set in Settings, deduped across the two pools, surfaced as a + // highlight above the queue/QA lanes below — a copy of a slice, not an + // extraction (the same non-exclusive relationship "Recently updated" and + // a claimed PR already have with their lanes): a region match still shows + // up in its normal queue/QA spot too. The QA-pool leg excludes a pull you + // already hold a live CR stamp on — you've already reviewed it, so it + // isn't region news to you, even though qaPool itself doesn't otherwise + // care about CR state. const regionSeen = new Set(); const regionMatches = crSort( - [...crPool, ...qaPool].filter(p => { + [...crPool, ...qaPool.filter(p => !p.crBy.includes(me))].filter(p => { const k = pullKey(p.data); if (regionSeen.has(k) || !matchesRegion(p, codeRegions)) return false; regionSeen.add(k); return true; }) ); - const regionKeys = new Set(regionMatches.map(p => pullKey(p.data))); // ONE review queue: your primary repos' reviewables, every starved pull // regardless of repo (the fairness backstop rides in the ranking now, not @@ -280,26 +284,24 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { // them to the top numerically instead of positionally), and the day's bot // bumps sinking to the tail. The top of this lane IS the board's best // next pickup; the retired "Deal me one" button dealt this exact order, - // which is why the button became redundant and was removed. Non-starved work outside your primary repos still - // folds into "other repos" below, reachable but not in the way. Region - // matches are excluded here too (same Set-filter pattern as botKeys) — they - // live in their own lane above, not doubled up in the queue. + // which is why the button became redundant and was removed. Non-starved + // work outside your primary repos still folds into "other repos" below, + // reachable but not in the way. Region matches stay in this queue too: + // "In your code regions" above is a highlight, a copy of a slice, not an + // extraction — the same non-exclusive relationship "Recently updated" and + // a claimed PR already have with their lanes. const queue = teamFirst( dealRank( [ - ...nonStarved.filter( - p => isPrimaryRepo(p.data.repo) && !regionKeys.has(pullKey(p.data)) - ), - ...crPool.filter(p => p.starved && !regionKeys.has(pullKey(p.data))), + ...nonStarved.filter(p => isPrimaryRepo(p.data.repo)), + ...crPool.filter(p => p.starved), ...botReviewable, ], { me, pulls, deprioritize: isDemoted, warnDays: ageWarnDays } ), team ); - const queueOther = crSort( - nonStarved.filter(p => !isPrimaryRepo(p.data.repo) && !regionKeys.has(pullKey(p.data))) - ); + const queueOther = crSort(nonStarved.filter(p => !isPrimaryRepo(p.data.repo))); // Ready to merge is finishable work for anyone: fully signed off, green, // one button-press from done. It earns a real lane in Pick up next rather @@ -310,13 +312,8 @@ export function buildReviewLanes(input: ReviewLanesInput): ReviewLanes { ...bots.filter(p => p.status === 'ready'), ].sort((a, b) => b.ageDays - a.ageDays); - const needsQa = teamFirst( - qaSort(qaPool.filter(p => isPrimaryRepo(p.data.repo) && !regionKeys.has(pullKey(p.data)))), - team - ); - const needsQaOther = qaSort( - qaPool.filter(p => !isPrimaryRepo(p.data.repo) && !regionKeys.has(pullKey(p.data))) - ); + const needsQa = teamFirst(qaSort(qaPool.filter(p => isPrimaryRepo(p.data.repo))), team); + const needsQaOther = qaSort(qaPool.filter(p => !isPrimaryRepo(p.data.repo))); // your live CR stamp is in, the PR just isn't fully signed off yet (another // reviewer owes a stamp, or a re-CR). Covers needs_recr too, so a PR you diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 90a495da..72e43f71 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -240,9 +240,10 @@ export function Review({ {repoPriority.length > 0 ? ( // the owner's priority-and-cap model: contiguous per-repo blocks in // the Settings repo order, each block score-ranked inside and - // flood-bounded by the per-repo cap. Starving PRs pierce the - // partition — the fairness backstop can't sit below a repo the - // viewer ranked last, or "surface the other repos" becomes a lie. + // flood-bounded by the per-repo cap. Starving PRs lead the "Starving" + // fold as a highlight, but also stay in their own repo block below — + // the fairness backstop can't be hidden behind a repo the viewer + // ranked last, or "surface the other repos" becomes a lie.

PRs you claim stay in the queue and also appear in Waiting on you.

+

+ Starving PRs and PRs in your code regions still show up in their repo block + below; the call-outs above are copies, not removals. +

} pulls={[]} @@ -321,6 +326,10 @@ export function Review({ sink to the bottom.

PRs you claim stay in the queue and also appear in Waiting on you.

+

+ PRs in your code regions still show up in the queue below; the call-out + above is a copy, not a removal. +

} pulls={lanes.queue} From f93099ce2f142c35b518351af41b216d5dfec2e0 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sat, 25 Jul 2026 13:03:56 -0700 Subject: [PATCH 03/21] Narrow Recently updated to recent, undone review work Recently updated was an unbounded activity feed: it listed anything that moved since you last hit Clear, including PRs you had already CR'd and bumps from over a week ago when you had not looked in a while. That cuts against the board's actionability-first ethos and was the noisiest lane. Bound it two ways and narrow it: within the last 3 days (not only since Clear), drop PRs you hold a live CR stamp on (finished for you) and non-reviewable ones (others' drafts, dev-blocked), and keep your own PRs since activity on them is worth seeing. It now reads as what changed on your review work since last look. The lane still lives in the component; moving it into buildReviewLanes for unit coverage is a fair follow-up. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/views/Review.tsx | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 72e43f71..810e3b1e 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -50,18 +50,24 @@ export function Review({ const pulls = allPulls.filter(p => !isSnoozed(p.data, snoozed)); const bots = allBots.filter(p => !isSnoozed(p.data, snoozed)); - // Recently updated: everything that moved since the user last hit Clear, - // newest first. A pull can also live in a lane below; this is the "what - // happened" glance, not an exclusive bucket — and only the Clear button - // empties it (nothing leaves the list silently). + // Recently updated: your review work that moved recently, newest first. + // Bounded two ways so a quiet board stops resurfacing stale bumps — since + // you last hit Clear (isFresh) AND within the last RECENT_MAX_AGE_DAYS. + // Already-done and non-reviewable pulls drop out: a PR you hold a live CR + // stamp on is finished for you, and others' drafts or dev-blocked PRs + // aren't yours to act on. Your own PRs always stay; activity on them is + // worth seeing. A pull can still live in a lane below; this is a highlight, + // not an exclusive bucket, and only Clear empties it. + const RECENT_MAX_AGE_DAYS = 3; + const recentCutoff = Date.now() - RECENT_MAX_AGE_DAYS * 24 * 60 * 60 * 1000; const changed = pulls .filter(p => isFresh(p.data, opts.lastSeen)) - // others' drafts stay out of the "what changed" glance: a draft you - // don't own isn't yours to act on, and draftsMode already keeps them off - // the board — they only reach this pool through boardHidden's - // review-requested exception (app.tsx). Your own drafts stay; they're - // your work. - .filter(p => !p.data.draft || p.data.user.login === me) + .filter(p => Date.parse(p.data.updated_at) > recentCutoff) + .filter( + p => + p.data.user.login === me || + (!p.data.draft && p.status !== 'dev_block' && !p.crBy.includes(me)) + ) .sort((a, b) => Date.parse(b.data.updated_at) - Date.parse(a.data.updated_at)); const lanes = buildReviewLanes({ From ce2b87ba4d3ee5dfed2f5118c32c123f792cda0e Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sat, 25 Jul 2026 13:16:53 -0700 Subject: [PATCH 04/21] List shipped PRs in the toast and stop truncating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When more than one PR landed while you were away, the shipped catch-up toast threw the titles away and collapsed to a bare count ('2 yours · 1 you reviewed'). It now lists the top 3 as links with a '+N more' line, so the catch-up names real work. The board's Recently-closed fold already did this; only the toast lagged. Also stops the toast and bell-panel text from truncating (DESIGN.md: nothing truncates except the wait-word door; density comes from geometry, not from hiding text), so cards grow to fit the full title and body. And fixes the bell panel dropping the leading number on the two count-bearing nudges (milestone 'reviews this sitting', top-of-board 'stamps in view'): it rendered the title without the count the live toast shows via its Odometer, leaving a number-less fragment. Co-Authored-By: Claude Opus 4.8 --- .../src/components/NotificationPanel.tsx | 48 ++++++++++++----- frontend-v2/src/model/shipped.test.ts | 18 +++++++ frontend-v2/src/model/shipped.ts | 7 ++- frontend-v2/src/model/toast.ts | 12 +++++ frontend-v2/src/toasts.tsx | 53 +++++++++++++------ 5 files changed, 109 insertions(+), 29 deletions(-) diff --git a/frontend-v2/src/components/NotificationPanel.tsx b/frontend-v2/src/components/NotificationPanel.tsx index 45a4da87..26267c66 100644 --- a/frontend-v2/src/components/NotificationPanel.tsx +++ b/frontend-v2/src/components/NotificationPanel.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { ArrowLeft, Bell, Settings as SettingsIcon, X } from 'lucide-react'; -import { ago, githubUrl } from '../../../shared/format'; +import { ago, githubUrl, pullKey, shortRepo } from '../../../shared/format'; import { type CheerGroup, CONFIGURABLE_TOASTS } from '../model/cheers'; import { type Settings as SettingsShape, @@ -15,6 +15,7 @@ import { testNotification, unlockSound, } from '../notifications'; +import { TOAST_PULLS_SHOWN } from '../model/toast'; import type { ToastRecord } from '../toasts'; import { HeaderIconButton, QuietButton, Segmented, Switch } from './bits'; import { Icon } from './Icon'; @@ -361,23 +362,46 @@ export function NotificationPanel({ - + + {r.toast.count != null && `${r.toast.count} `} {r.toast.title} {ago(r.at / 1000)} - {r.toast.pull && ( - - #{r.toast.pull.number} - {r.toast.pull.title ? ` ${r.toast.pull.title}` : ''} - + {r.toast.pulls && r.toast.pulls.length > 0 ? ( + + {r.toast.pulls.slice(0, TOAST_PULLS_SHOWN).map(p => ( + + {shortRepo(p.repo)}#{p.number} + {p.title ? ` ${p.title}` : ''} + + ))} + {r.toast.pulls.length > TOAST_PULLS_SHOWN && ( + + +{r.toast.pulls.length - TOAST_PULLS_SHOWN} more + + )} + + ) : ( + r.toast.pull && ( + + #{r.toast.pull.number} + {r.toast.pull.title ? ` ${r.toast.pull.title}` : ''} + + ) )} {r.toast.body && ( {r.toast.body} diff --git a/frontend-v2/src/model/shipped.test.ts b/frontend-v2/src/model/shipped.test.ts index 3836a4b6..0a1645db 100644 --- a/frontend-v2/src/model/shipped.test.ts +++ b/frontend-v2/src/model/shipped.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { PullData } from '../../../shared/types'; import { rankShipped, shipRelevance, shippedToast } from './shipped'; +import { TOAST_PULLS_SHOWN } from './toast'; /** A closed PullData carrying only the fields shipped ranking reads. */ function dp(o: { @@ -116,6 +117,23 @@ describe('shippedToast', () => { expect(toast?.body).toContain('2 you reviewed'); }); + it('lists the shipped pulls ranked, for the renderer to show a top few plus an overflow count', () => { + const shipped = [1, 2, 3, 4, 5].map(number => + dp({ number, author: 'me', closedAt: `2026-0${number}-01T00:00:00Z` }) + ); + const toast = shippedToast(shipped, 'me'); + // no single `pull` for a batch; the list carries every relevant pull, + // ranked newest-closed-first (all tie on tier here, since every one is + // 'yours') so the renderer's top-N slice and "+N more" fold both read + // off the same ranked array instead of a separately-tracked count + expect(toast?.pull).toBeUndefined(); + expect(toast?.pulls?.map(p => p.number)).toEqual([5, 4, 3, 2, 1]); + const shown = toast?.pulls?.slice(0, TOAST_PULLS_SHOWN) ?? []; + expect(shown.map(p => p.number)).toEqual([5, 4, 3]); + const overflow = (toast?.pulls?.length ?? 0) - TOAST_PULLS_SHOWN; + expect(overflow).toBe(2); + }); + it('re-fires on a newer merge but is stable for the same backlog', () => { const a = dp({ number: 1, author: 'me', closedAt: '2026-01-01T00:00:00Z' }); const b = dp({ number: 2, author: 'me', closedAt: '2026-01-02T00:00:00Z' }); diff --git a/frontend-v2/src/model/shipped.ts b/frontend-v2/src/model/shipped.ts index 70b4bdf1..576c60b5 100644 --- a/frontend-v2/src/model/shipped.ts +++ b/frontend-v2/src/model/shipped.ts @@ -75,8 +75,13 @@ export function shippedToast(shipped: PullData[], me: string): Toast | null { ? 'Your PR landed.' : 'One you reviewed landed.' : breakdown, - // a single relevant merge gets a link to it; a batch stays a summary + // a single relevant merge gets a link to it; a batch lists the ranked + // PRs instead (the renderer shows the first few and folds the rest + // into a "+N more" line, per TOAST_PULLS_SHOWN) so a multi-ship + // catch-up names real work instead of collapsing to a bare count pull: n === 1 ? { repo: top.repo, number: top.number, title: top.title } : undefined, + pulls: + n > 1 ? ranked.map(p => ({ repo: p.repo, number: p.number, title: p.title })) : undefined, dedupeKey: `shipped:${latest}:${n}`, }; } diff --git a/frontend-v2/src/model/toast.ts b/frontend-v2/src/model/toast.ts index f0fd8c9b..0a0d9b40 100644 --- a/frontend-v2/src/model/toast.ts +++ b/frontend-v2/src/model/toast.ts @@ -1,5 +1,10 @@ export type ToastTone = 'reward' | 'nag' | 'info'; +/** How many of a batched toast's `pulls` render as links before the rest + * fold into a "+N more" line. Shared by the toast stack and the notification + * panel so a batched nudge lists the same number of PRs in both places. */ +export const TOAST_PULLS_SHOWN = 3; + /** * The reusable toast contract. Both the gamified cheers evaluator * (model/cheers) and the shipped catch-up (model/shipped) produce these; @@ -30,6 +35,13 @@ export interface Toast { * on GitHub if off-screen), and the card renders its number + title as a * link, so a toast says WHICH pull, not just a bare #number */ pull?: { repo: string; number: number; title?: string }; + /** for a batched toast covering more than one PR (e.g. the shipped + * catch-up when several landed at once): the pulls it covers, ranked + * most-relevant-first. The renderer shows the first `TOAST_PULLS_SHOWN` + * as links and folds the rest into a "+N more" line, the same overflow + * pattern the board's other lists use. Falls back to the singular `pull` + * above when absent or empty. */ + pulls?: { repo: string; number: number; title?: string }[]; /** stable id so the same logical toast isn't re-fired every board tick */ dedupeKey?: string; /** per-toast lifetime override (ms); falls back to the tone default */ diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index 3b604706..0eff5b03 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { X } from 'lucide-react'; -import { githubUrl, rowDomId } from '../../shared/format'; +import { githubUrl, pullKey, rowDomId, shortRepo } from '../../shared/format'; import { Icon } from './components/Icon'; import { type CheerBaseline, @@ -12,7 +12,7 @@ import { type ToastKind, } from './model/cheers'; import type { DerivedPull } from '../../shared/model/status'; -import type { Toast } from './model/toast'; +import { type Toast, TOAST_PULLS_SHOWN } from './model/toast'; import { getSettings } from './settings'; import { readSessionStorage, writeSessionStorage } from './storage'; import type { PullData } from '../../shared/types'; @@ -554,21 +554,42 @@ function ToastCard({ toast, onDismiss }: { toast: LiveToast; onDismiss: (id: num )} {toast.title} - {toast.pull && ( - e.stopPropagation()} - className="mt-0.5 block truncate text-xs font-medium text-brand hover:underline" - > - #{toast.pull.number} - {toast.pull.title ? ` ${toast.pull.title}` : ''} - - )} - {toast.body && ( - {toast.body} + {toast.pulls && toast.pulls.length > 0 ? ( + + {toast.pulls.slice(0, TOAST_PULLS_SHOWN).map(p => ( + e.stopPropagation()} + className="block text-xs font-medium text-brand hover:underline" + > + {shortRepo(p.repo)}#{p.number} + {p.title ? ` ${p.title}` : ''} + + ))} + {toast.pulls.length > TOAST_PULLS_SHOWN && ( + + +{toast.pulls.length - TOAST_PULLS_SHOWN} more + + )} + + ) : ( + toast.pull && ( + e.stopPropagation()} + className="mt-0.5 block text-xs font-medium text-brand hover:underline" + > + #{toast.pull.number} + {toast.pull.title ? ` ${toast.pull.title}` : ''} + + ) )} + {toast.body && {toast.body}} {toast.actionLabel && toast.onAction && (
{/* min-w-0 + overflow-x-auto (no-scrollbar in styles.css) @@ -1008,7 +1023,7 @@ export function App() { ({ id, label: LENS_LABELS[id], @@ -1112,8 +1127,8 @@ export function App() { sessionActive={sessionActive} inputProps={{ 'aria-label': - 'Filter PRs: text, #number, label:x, status:x, older:5, repo:x, author:x, weight:xs, has:action, is:draft, is:mine, is:restamp, is:blocked, is:bot', - placeholder: 'Filter (press /)', + 'Search all PRs, open and closed: text, #number, label:x, status:x, older:5, repo:x, author:x, weight:xs, has:action, is:draft, is:mine, is:restamp, is:blocked, is:bot', + placeholder: 'Search all PRs (press /)', title: 'text, #number, label:x, status:x, older:5, repo:x, author:x, weight:xs, has:action, is:draft, is:mine, is:restamp, is:blocked, is:bot', className: `w-[210px] max-w-full grow pr-2.5 pl-8 sm:grow-0 ${textInputClass}`, }} @@ -1153,7 +1168,20 @@ export function App() { Loading the board…
)} - {initialized && lens === 'review' && ( + {/* free text is a find, not a filter: while there's a query the + board becomes the global Search lens (open + closed, every lens, + nothing folded), regardless of which tab was active */} + {initialized && searching && ( + + )} + {initialized && !searching && lens === 'review' && ( )} - {initialized && lens === 'mine' && ( + {initialized && !searching && lens === 'mine' && ( )} - {initialized && lens === 'team' && ( + {initialized && !searching && lens === 'team' && ( !isBot(p))} @@ -1175,9 +1203,11 @@ export function App() { extraBots={extraBots} /> )} - {initialized && lens === 'classic' && } - {initialized && lens === 'ci' && } - {initialized && lens === 'stats' && ( + {initialized && !searching && lens === 'classic' && ( + + )} + {initialized && !searching && lens === 'ci' && } + {initialized && !searching && lens === 'stats' && ( )} From c7d5cc6c07fdfd76b8d6a3e7d68720f9b02b51f7 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 27 Jul 2026 14:12:47 -0700 Subject: [PATCH 16/21] Show a search glyph, not the success check, on empty results EmptyState always drew the green all-clear check. That's right when there's genuinely nothing left to do (your work is all merged, the board is clear), but it also showed up when a search or a filter matched nothing, where a green "all done" check reads as success when the real message is "nothing matched, widen it." The new Search lens made it obvious, but the filtered-empty states on Classic, CI, Stats, and Review had the same tell. Add a 'search' variant: a muted magnifying glass in the copy's own ink, drawn in with the same stroke animation (pathLength normalizes the lens and handle to the check's length, so the one draw-check keyframe animates either glyph). Point the null-result states at it, and leave the genuine all-clear states (My work all merged, Team clear, CI all green) on the check. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/bits.tsx | 54 ++++++++++++++++++++++------- frontend-v2/src/views/Ci.tsx | 4 ++- frontend-v2/src/views/Classic.tsx | 4 ++- frontend-v2/src/views/Review.tsx | 3 +- frontend-v2/src/views/Search.tsx | 2 ++ frontend-v2/src/views/Stats.tsx | 1 + 6 files changed, 53 insertions(+), 15 deletions(-) diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index d64c7d30..5d2185ea 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -401,20 +401,50 @@ export function Switch({ ); } -export function EmptyState({ title, sub }: { title: string; sub: string }) { +export function EmptyState({ + title, + sub, + variant = 'ok', +}: { + title: string; + sub: string; + /** 'ok' is the green check: a genuine all-clear, nothing left to do (a good + * state). 'search' is the muted magnifying glass, for a null result from + * narrowing the board (a search or filter matched nothing) — where the check + * would wrongly read as success. Both draw the same stroke-in. */ + variant?: 'ok' | 'search'; +}) { return (
- - - - + {variant === 'search' ? ( + // pathLength normalizes the lens+handle to the check's 26, so the + // one draw-check keyframe animates either glyph. currentColor keeps + // it in the muted ink of the surrounding copy: informational, not an + // alarm and not a success. + + + + ) : ( + + + + + )} {title} {sub}
diff --git a/frontend-v2/src/views/Ci.tsx b/frontend-v2/src/views/Ci.tsx index f7fcaab6..1e4853bd 100644 --- a/frontend-v2/src/views/Ci.tsx +++ b/frontend-v2/src/views/Ci.tsx @@ -101,7 +101,9 @@ export function Ci({ pulls, opts }: { pulls: DerivedPull[]; opts: RowOptions }) const noChecks = useMemo(() => pulls.filter(p => p.ci === 'none'), [pulls]); if (!pulls.length) { - return ; + return ( + + ); } if (!withChecks.length) { return ; diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx index 36695dbc..797b6816 100644 --- a/frontend-v2/src/views/Classic.tsx +++ b/frontend-v2/src/views/Classic.tsx @@ -84,7 +84,9 @@ function Column({ export function Classic({ pulls, opts }: { pulls: DerivedPull[]; opts: RowOptions }) { const me = opts.me; if (!pulls.length) { - return ; + return ( + + ); } const base = [...pulls].sort(defaultCompare(me)); diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 1490558f..d1aa8e91 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -108,7 +108,8 @@ export function Review({ if (lanes.empty) { return ( ); diff --git a/frontend-v2/src/views/Search.tsx b/frontend-v2/src/views/Search.tsx index 7837b0fc..c5e330ef 100644 --- a/frontend-v2/src/views/Search.tsx +++ b/frontend-v2/src/views/Search.tsx @@ -60,6 +60,7 @@ export function Search({ if (!q) { return ( @@ -77,6 +78,7 @@ export function Search({ if (total === 0) { return ( diff --git a/frontend-v2/src/views/Stats.tsx b/frontend-v2/src/views/Stats.tsx index 41a06e3a..dbd8090f 100644 --- a/frontend-v2/src/views/Stats.tsx +++ b/frontend-v2/src/views/Stats.tsx @@ -127,6 +127,7 @@ export function Stats({ if (!pulls.length && !closed.length) { return ( From 34d323a9f31a051f28b497c22e35b224283b02c6 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 27 Jul 2026 14:18:22 -0700 Subject: [PATCH 17/21] Swap the empty-search glyph for lucide search-alert The muted magnifying glass was the right idea but the plainer icon. Use lucide's search-alert, the glass with an exclamation in the lens, so an empty result reads as "nothing matched, heads up" at a glance. The glass still draws in like the check; the exclamation pops in last (a one-shot scale and fade from its own center, fill-box) so the eye lands on the alert. Reduced motion drops the pop. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/bits.tsx | 30 +++++++++++++++++++---------- frontend-v2/src/styles.css | 23 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 5d2185ea..d0e2f7ff 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -417,20 +417,30 @@ export function EmptyState({ return (
{variant === 'search' ? ( - // pathLength normalizes the lens+handle to the check's 26, so the - // one draw-check keyframe animates either glyph. currentColor keeps - // it in the muted ink of the surrounding copy: informational, not an - // alarm and not a success. - + // search-alert (lucide): the magnifying glass draws in like the + // check (pathLength normalizes the lens+handle to 26 so the one + // draw-check keyframe animates it), then the exclamation pops in + // last to pull the eye to "nothing matched." currentColor keeps it + // in the muted ink of the copy: a heads-up, not a red alarm. + + + + + ) : ( diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 1a1671c9..01bcafc4 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -726,6 +726,29 @@ label:has(> input:not(:disabled)) { animation: spin-once 400ms var(--ease-quart); } +/* the empty-search exclamation punching in after the glass draws — one shot, + scale + fade from its own center (fill-box), delayed so it lands last */ +@keyframes alert-pop { + from { + opacity: 0; + scale: 0.4; + } + to { + opacity: 1; + scale: 1; + } +} +.alert-pop { + transform-box: fill-box; + transform-origin: center; + animation: alert-pop 380ms var(--ease-quint) 300ms backwards; +} +@media (prefers-reduced-motion: reduce) { + .alert-pop { + animation: none; + } +} + @keyframes nod { 50% { scale: 1.25; From 931a8762f27d95031409314ba8ec018b6b105d60 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 27 Jul 2026 14:37:23 -0700 Subject: [PATCH 18/21] Stop re-filtering the whole board on every search keystroke Typing now shows the global Search lens, not a filtered version of the current lens, so the board pipeline no longer needs to run matchesQuery over all ~600 pulls on each keystroke. But preWeightScoped and botsBypassingHideBots still did, feeding humans/bots/ciPulls behind a lens view that never renders while searching. That was wasted work, and it also made the "My work" tab badge and the header "X of Y open" count reflect the query-filtered board instead of the real one. Drop the query filter (and its now-dead deps) from both pools, and stop counting a query as a scope in the header. The pools recompute only when a repo/people/weight/state filter actually changes now, and the chrome counts show the true board while you search. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 46 +++++++++++++++++------------------------ 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 35684d91..ae3d20ad 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -16,7 +16,6 @@ import { useBoardHotkeys, useHeaderHeightVar } from './hooks'; import { ageRotDays, getSettings, type Settings as SettingsShape, useSettings } from './settings'; import { useNotifications } from './notifications'; import { ToastStack, useToasts } from './toasts'; -import { matchesQuery } from './model/query'; import { requestNames, useNames } from './model/names'; import { CRYO_KEY, isBotLogin, personHidden, repoHidden } from '../../shared/model/visibility'; import { reviewRequestedFrom } from './model/reviewers'; @@ -547,10 +546,13 @@ export function App() { // settings above), not the current filter useNotifications(nudgeablePulls, me, turns, initialized); - // every existing scope/query pass, but not yet the Weight/State filters — - // WeightFilter's live per-option counts read this pool, so narrowing to - // one weight class doesn't make the other classes' counts vanish (the - // same reasoning PeopleFilter's authorCounts follows for its own pool) + // scope + hidden, but deliberately NOT free text: a query drives the global + // Search lens (App renders instead of the current lens), so + // filtering the whole board by it here would recompute every keystroke only + // to feed a view that never shows while searching. Also not the Weight/State + // filters yet: WeightFilter's live per-option counts read this pool, so + // narrowing to one weight class doesn't make the other classes' counts + // vanish (the same reasoning PeopleFilter's authorCounts follows). const preWeightScoped = useMemo(() => { let out = pulls.filter(p => !boardHidden(p)); if (scope.repos.length) out = out.filter(p => scope.repos.includes(p.data.repo)); @@ -562,9 +564,8 @@ export function App() { // allow-list above (a dependency bump is nobody's teammate) if (scope.notAuthors.length) out = out.filter(p => isBot(p) || !scope.notAuthors.includes(p.data.user.login)); - if (query) out = out.filter(p => matchesQuery(p, query, me, extraBots, names)); return out; - }, [pulls, boardHidden, scope, isBot, query, names, me, extraBots]); + }, [pulls, boardHidden, scope, isBot]); // preWeightScoped narrowed by the Weight filter, but not yet State — // StateFilter's own live counts read this pool for the same @@ -592,31 +593,20 @@ export function App() { // Bots that pass every hidden rule except "Ignore bot PRs": hidden // repos/people, cryo, and the drafts rule still apply, and the active - // repo scope / weight / state / query filters narrow it the same way - // they narrow `scoped` (scope.authors/notAuthors don't need repeating - // here — bots already bypass both in preWeightScoped above). Only the - // hideBots-specific exclusion is skipped. Feeds the CI lens and - // Ready-to-merge (below), the two surfaces that need bot signal - // regardless of hideBots. + // repo scope / weight / state filters narrow it the same way they narrow + // `scoped` (scope.authors/notAuthors don't need repeating here — bots + // already bypass both in preWeightScoped above). Free text is left out for + // the same reason as preWeightScoped: a query shows the Search lens, not + // this pool's CI/Ready surfaces. Only the hideBots-specific exclusion is + // skipped. Feeds the CI lens and Ready-to-merge (below), the two surfaces + // that need bot signal regardless of hideBots. const botsBypassingHideBots = useMemo(() => { let out = pulls.filter(p => isBot(p) && !boardHiddenExceptBots(p)); if (scope.repos.length) out = out.filter(p => scope.repos.includes(p.data.repo)); - if (query) out = out.filter(p => matchesQuery(p, query, me, extraBots, names)); if (weightSel.length) out = out.filter(p => matchesWeightFilter(p, weightSel)); if (stateSel.length) out = out.filter(p => stateSel.includes(actionState(p, me))); return out; - }, [ - pulls, - isBot, - boardHiddenExceptBots, - scope.repos, - query, - me, - names, - extraBots, - weightSel, - stateSel, - ]); + }, [pulls, isBot, boardHiddenExceptBots, scope.repos, me, weightSel, stateSel]); // The CI lens's pool: `scoped` (which already respects hideBots, escape // hatch included) plus whatever bot the bypass pool above adds back — // deduped by key so a bot already visible in `scoped` (hideBots off, or @@ -690,7 +680,9 @@ export function App() { return c; }, [pulls, me, settings.repoPrefs, settings.hiddenPeople, isBot, boardHidden]); - const isScoped = scope.repos.length || scope.authors.length || query; + // a query no longer scopes the board (it shows the Search lens instead), so + // the header's "X of Y" only reflects the repo/people filters + const isScoped = scope.repos.length || scope.authors.length; // what a "Save current filter…" click right now would capture — reads the // same hashState the write-effect above builds, so a saved filter's hash From cf4a53097726f3ebc86f55d6511274c5f0a30a52 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 27 Jul 2026 14:37:24 -0700 Subject: [PATCH 19/21] Defer the search render so typing stays instant The search box is a controlled useState, so it updates immediately, but the filter + group + render of results ran on the same keystroke and could block on a broad query over a ~600-pull board. Wrap the results in useDeferredValue: the box stays urgent while React renders results at low priority, shows the previous query's hits (dimmed) during the catch-up, and can abandon a stale intermediate render on fast typing. Better than a fixed debounce here, no latency on a fast machine, adaptive on a slow one, and interruptible. On mount it equals the query and App only mounts this view for a non-empty query, so it never filters on "". Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/views/Search.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend-v2/src/views/Search.tsx b/frontend-v2/src/views/Search.tsx index c5e330ef..4c10f5af 100644 --- a/frontend-v2/src/views/Search.tsx +++ b/frontend-v2/src/views/Search.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { type ReactNode, useDeferredValue } from 'react'; import { closedEpoch, pullKey } from '../../../shared/format'; import type { DerivedPull } from '../../../shared/model/status'; import type { PullData } from '../../../shared/types'; @@ -55,7 +55,16 @@ export function Search({ names: Readonly>; }) { const me = opts.me; - const q = query.trim(); + // keep the box instant: setQuery is urgent (the input updates immediately), + // but the heavy filter + group + render of results runs off a deferred copy, + // so React shows the previous query's hits (dimmed) and can abandon a stale + // intermediate render on fast typing instead of blocking the keystroke. + // useDeferredValue beats a fixed debounce here: no latency on a fast machine, + // adaptive on a slow one, and interruptible. On mount it equals `query`, and + // App only mounts this view for a non-empty query, so it never filters on ''. + const deferred = useDeferredValue(query); + const stale = deferred !== query; + const q = deferred.trim(); if (!q) { return ( @@ -68,10 +77,10 @@ export function Search({ } const openHits = pulls - .filter(p => matchesQuery(p, query, me, extraBots, names)) + .filter(p => matchesQuery(p, deferred, me, extraBots, names)) .sort((a, b) => b.data.created_at.localeCompare(a.data.created_at)); const closedHits = closed - .filter(p => matchesClosedQuery(p, query, me, extraBots, names)) + .filter(p => matchesClosedQuery(p, deferred, me, extraBots, names)) .sort((a, b) => closedEpoch(b) - closedEpoch(a)); const total = openHits.length + closedHits.length; @@ -103,7 +112,9 @@ export function Search({ ); return ( - <> + // dim while the deferred query is still catching up, so a stale result set + // reads as "updating" instead of final +

Search

@@ -131,6 +142,6 @@ export function Search({ closedHits.length, closedHits.map(p => ) )} - +

); } From 0cc7448490feb897a727f26e87729210ac32e293 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 27 Jul 2026 14:51:15 -0700 Subject: [PATCH 20/21] Move the search deferral to App so the first keystroke doesn't block Deferring inside Search (the previous commit) missed the case that actually hurt: useDeferredValue gives no deferral on a component's initial mount, and the first character typed MOUNTS the Search view. So its heavy render (a broad query renders every matched row, each a full board Row) ran synchronously. On the dummy board, typing "a" (74 matches) blocked the main thread for one 347ms task, freezing the keystroke itself, and it's worse at prod scale. Defer the query in App instead, where the value already exists across the lens/Search swap, so the mount is a low-priority, time-sliced transition: the box updates urgently, the board keeps the current lens until the deferred render is ready, and React interrupts a stale broad render when you keep typing. Measured on the same worst case: the 347ms block drops to zero long tasks, same 74 rows. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 29 +++++++++++++++++++++++++---- frontend-v2/src/views/Search.tsx | 24 +++++++++++------------- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index ae3d20ad..3f3f463c 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; import { ago, closedEpoch, n, pullKey, shortRepo } from '../../shared/format'; import type { ActionStateKey } from './model/actions'; import { actionState } from './model/actions'; @@ -266,8 +274,20 @@ export function App() { const [query, setQuery] = useState(() => urlState.q); // free text is find, not filter: any query switches the board to the Search // lens (global, over open + closed), so the header box stops narrowing the - // current lens and starts finding across the whole board - const searching = query.trim().length > 0; + // current lens and starts finding across the whole board. + // + // The heavy part of a search is rendering the matched rows, and the first + // keystroke MOUNTS the Search view. useDeferredValue gives no deferral on a + // mount, so deferring inside Search left that first render synchronous, and a + // broad query (say "a") blocked the main thread ~350ms on the dummy board, + // more at prod scale, freezing the keystroke itself. Deferring the query HERE + // makes the view swap and its render a low-priority, time-sliced pass: the + // box updates urgently off `query`, the board keeps showing the current lens + // until the deferred render is ready, and React can interrupt a stale broad + // render when you keep typing. `stale` dims the results while it catches up. + const deferredQuery = useDeferredValue(query); + const searching = deferredQuery.trim().length > 0; + const searchStale = query !== deferredQuery; // narrows the board to one or more review-effort classes ('xs'..'xl', // 'unknown'); empty = no filter const [weightSel, setWeightSel] = useState(() => urlState.weight); @@ -1167,7 +1187,8 @@ export function App() {