Repository navigation
perf(search): fold people names once across palette keystrokes - #2570
Conversation
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.
Reviewed head 1f86b64098ff497cad63ebc3c2ce80c9598e18fe against base f27e3089a261db1e63860fd28503c93e26eb98be. No required changes found. The shared search preserves matching tiers and underline origins, while raw-name cache keys avoid stale folding when profiles change. Minimalness, elegance and correctness meet the 9/10 source-review bar. One optional cache-memory suggestion is inline.
Source-only: no tests, builds, app workflows or benchmarks were run. The PR’s jsdom timing and test results remain author-provided evidence; browser/native latency, retained-memory size and rendered focus/accessibility were not validated. The public PR description and commit were inspected; no image attachments were present.
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 ranking is unchanged: matchTier and matchPerson share search over the same folded characters, and only people-rank.ts moves to matchTier. one small thing on the cache key bound inline, otherwise this looks good
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source follow-up via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.
Reviewed 3c5e54eb379bba27dc9229192651f93278bf08d2 against unchanged base f27e3089a261db1e63860fd28503c93e26eb98be, focusing on the change since reviewed 1f86b64098ff497cad63ebc3c2ce80c9598e18fe. The earlier optional raw-key retention concern is resolved: names above 128 UTF-16 code units bypass caching, alongside the existing folded-value limit. Long names still receive the complete fold and unchanged matching; underline generation is unchanged. No new findings or required changes. The small repair meets the 9/10 source-review bar.
Source-only: no tests, app workflows or performance/memory measurements were run. The existing parity test uses short names and does not directly exercise the new length boundary. The public description and follow-up commit were inspected; no attachments were present.
3c5e54e to
ba3ae52
Compare
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.
Reviewed head ba3ae528491a6860e36de31057716ab6963a29d3 against base 1c3047558547e76bd006c2dc76951b3f1d601c5d. No new findings or required changes. Both feature commits and all three feature files are preserved from reviewed 3c5e54eb379bba27dc9229192651f93278bf08d2; the earlier raw-key concern remains resolved. This follow-up checked the rebased callers: recipient identity and exact-name completion remain separate from cached folding, highlights retain their origin mapping, and the per-render channel-rank cache and unread-first suggestions do not reuse stale people ranks. The change continues to meet the 9/10 source-review bar.
Source-only: no tests, builds, app workflows or performance/memory measurements were run. Existing parity coverage uses short names and does not directly exercise the raw-length boundary. The public PR description was inspected; no image attachments were present.
ba3ae52 to
8bc8355
Compare
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.
Reviewed head 8bc8355bc2d4c871b14437251a744e028a7bb4a9 against base 8c0cb0c1efb68068b89c813ad6f890d8b8f07401. No new findings or required changes. Both feature patches and all three feature files are unchanged from reviewed ba3ae528491a6860e36de31057716ab6963a29d3; the earlier raw-key retention concern remains resolved.
This follow-up checked the rebased name-preference, profile-retention and search callers. Current labels feed freshly computed ranks; cached folds retain neither identity nor authority and cannot restore evicted candidates. Channel-member verified-only results and direct-message profile results keep their distinct upstream paths, with equivalent matching tiers. Highlight origins and collision-label ownership remain intact. Minimalness, elegance and correctness continue to meet the 9/10 source-review bar.
Source-only: no tests, builds, app workflows or performance/memory measurements were run. The PR’s benchmark and test claims remain author-provided evidence; rendered focus/accessibility and preference changes in a running app were not validated. The existing matcher parity test uses short names and does not directly exercise the raw-length boundary. The public PR description was inspected; no image attachments were present.
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 matching is unchanged: matchTier and matchPerson run the same search over the same folded characters, and the cache bounds look right. one nonblocking suggestion inline
c5456ad to
4011e26
Compare
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot checks.
Reviewed head 4011e26b050ead7de1b6b5ada17e82112fb305d7 against base 5a4cdc0a5599076d74b7277d02d1a6d5ef7de904, preserving prior coverage at 8bc8355b.
No new findings or required changes. The final commit folds the query once per rankPeople call and passes a local, read-only array through matching. It preserves the distinction between whitespace-only queries and nonblank text that folds empty, shown/hidden name tiers, and the inherited exact-public-key rank override. Removing the personMatch export has no other consumer in the pinned repository. Highlighting remains on matchPerson; the previously reviewed raw-key and folded-value cache bounds remain intact. Minimalness, elegance and correctness continue to meet the 9/10 source-review bar.
Source-only: no tests, builds, app workflows or benchmarks were run. The final commit adds no test; the existing matcher parity loop does not cover a nonblank folded-empty query or the raw-length boundary. Browser/jsdom results and the separate Node estimate remain author-provided evidence; the PR explicitly excludes the final query-fold optimization from its benchmark table. The public description was inspected; no image attachments were present.
personmatch re-folded every name and the query on each call, so cmd+k ran nfkd and regex per code point over every cached profile on every keystroke. add matchtier, which shares the matchperson search over a bounded cache of folded names; personmatch folds the query once and uses matchtier. matchperson keeps its positions. ranking is unchanged. Signed-off-by: Kalvin C <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
a letter followed by thousands of combining marks folds to one character, so the folded-length guard alone kept a multi-kb raw key in a cache that outlives profile eviction. bypass the cache when the raw name exceeds 128 code units too; matching is unchanged. Signed-off-by: Kalvin C <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
rankpeople called personmatch per person, and personmatch folded the same query each time, so 10,240 cached profiles meant 10,240 folds per keystroke. fold it once in rankpeople and pass the folded code points in. personmatch has no other caller, so it is no longer exported. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Kalvin Chau <[email protected]>
4011e26 to
a13d00f
Compare
Co-authored-by: BuzzPRReviewAssistant1 <a581a7e8115222acdc32ffb14bfce55d48c24478d98e155f8eba126b69b83c7b@buzz.block.builderlab.xyz> * origin/main: (71 commits) test(activity): scope preview assertions to the request row (#2843) perf(search): fold people names once across palette keystrokes (#2570) fix(github): keep PR watches running when localStorage is full (#2699) Make drafts and sent part of Messages (#2728) fix(canvas): render every Canvas within the save limit (#2839) test(messages): wait for the row measurement frame before thread catch-up dwell (#2837) Keep agent coordination history readable after work finishes (#2454) test(mentions): serve the mentions fixture as a build so cold loads fit (#2776) test(browser): let a late publication land after its page closed (#2565) test(relay): wait for the verified owner before a guest edits an agent (#2413) test: wait for the companion default width measurement (#2750) test(browser): preserve conversation scroll while revealing actions (#2715) test(browser): wait for link hit-testing after thread close (#2792) perf(messages): skip micromark for plain prose bodies (#2780) perf(inbox): load the emoji name table on first use (#2774) perf(messages): cut main-thread work while a busy channel is open (#2729) Refine agent Attention cards, watch details, and status controls (#2717) fix(bestie): remove a Bestie from its agent card on any device (#2720) feat(agents): preview Builderlab agent card links with Add to Buzz (#2719) Restore macOS Cmd-W window closing when no tab handles it (#2443) ... Signed-off-by: Ravneet Arora <[email protected]>
…2726) This PR combines two changes that cut main-thread work in large channels. The conversation header no longer re-renders its member avatars and members button on traffic in other channels, and matching people by name folds each name once instead of on every keystroke. Both costs grow with the member count. ## Problem **Header.** #2626 moved `ChannelMemberAvatars` into every conversation header through `ChannelHeaderIdentity`. It also gave `ChannelMembersButton` its own subscription to the whole channel list. Both components re-render on every list publish, which means every live message in any channel. On each of those renders, both components run a `list.channels.find` over every channel. The avatars also join, filter and sort every member of the open channel. With a 5,080-member channel open, other channels' traffic keeps re-rendering a header whose content has not changed. **Name matching.** Typing in the Manage members filter of a large channel re-scans every member on every keystroke. #2356 raised the per-member cost: the filter now checks the verified display name, username and full name, which is about 3 `matchPerson` calls per member where v0.7.10 made 1. #2585 added a similar scan of every cached profile to the New message recipient picker. For each name it checks, `matchPerson` folds every code point through `normalize("NFKD")`, an accent regex and `toLowerCase`, and folds the query again on every call. The filter also bech32-encodes each member's key on every keystroke to match npubs. In a 5,080-member channel this adds up to seconds of main-thread work while the user types a short query. ## Change **Header.** - `useHeaderChannel` is a new helper in `listed-channel.ts`. It is `useListedChannel` plus the existing `channels.get` fallback for channels the list does not hold. Each header component selects only what it renders through it: members, the Solo flag, or the access summary. All of these are referentially stable, so unrelated list publishes skip the render. - `memberAccess` returns one of four shared frozen objects instead of a fresh object. - `ChannelMemberAvatars` memoizes the member key, the agent map and the preview. **Name matching.** - `foldPoint` handles ASCII with a plain `toLowerCase`. ASCII has no accents, decompositions or final sigma, so the fold is the same. - `ChannelMembersDialog` folds the query once per render and matches each name with `matchTier`, which reuses the folded-name cache #2570 added. It also keeps one npub map for as long as it is open, so each member's npub is encoded at most once. - Rebased onto #2570: the earlier per-dialog `folds` map in `matchPerson` was dropped, because #2570's cache already keeps each name folded across keystrokes. ## No behavior changes **Header.** The lookup order is the same as before: the list entry first, then `channels.get`. A membership change still produces a new `members` array, so the header updates. Previews and activity reuse the channel summary's `members`, so they no longer re-render the header. New tests cover three cases: - An unrelated list publish commits nothing. - A membership change updates both the button and the avatars. - A channel missing from the list renders through the fallback, and still skips renders on unrelated publishes. **Name matching.** The ASCII path gives the same fold as before. `matchTier` gives the same tier as `matchPerson` (the #2570 test checks this), and an empty query still matches at tier 0. The npub map is dropped when the dialog closes and holds one entry per member. ## Evidence The table compares main (`8c0cb0c1e`) with this PR as a whole. All runs are interleaved, with the same two builds on both sides. - **Browser:** production Chromium at 4× CPU throttling with 128 DMs, 1,001 participants, read state and a 5,080-member channel, while another channel receives a message every 200 ms. **Idle** is 3 s with no input while the 5k channel is open. **Switch** opens Cmd+K, types the channel name and presses Enter. **Members dialog** opens Manage members for the 5k channel and types a 6-character query one key at a time; 1,000 members have profiles. Two Linux and two macOS workstations, 36 samples per cell; values are the mean of the per-workstation medians. - **Typing latency:** the same app with 5,080 cached member profiles that share name prefixes, a deliberate worst case. Each value is the median Event Timing duration from keydown to the next paint, over 6 interleaved pairs on one Linux and one macOS workstation. Event Timing quantizes to 8 ms and only reports events of 16 ms or more. **Mention** types `@Member004` in the composer, **palette miss** types `zzzzzz` in Cmd+K, and **prose** types `ordinarytext`. Each key waits for the next rendering opportunity before the following key. - **Header micro-benchmark:** renders the header with 400 channels and 5,080 members, then delivers 50 live messages to another channel. - **Picker micro-benchmark:** runs the New message recipient picker's profile scan, typing 6 keys. - Micro-benchmark cells are the mean of two workstations per OS, each the median of 6 runs. | metric | Linux: main | Linux: this PR | Linux Δ % | macOS: main | macOS: this PR | macOS Δ % | |---|---|---|---|---|---|---| | idle 5k, script | 226.0 ms | 182.5 ms | −19.2% | 150.8 ms | 126.7 ms | −15.9% | | idle 5k, task | 367.5 ms | 323.1 ms | −12.1% | 266.6 ms | 240.5 ms | −9.8% | | switch 5k, script | 580.5 ms | 563.1 ms | −3.0% | 325.5 ms | 312.9 ms | −3.9% | | switch 5k, blocking | 279.1 ms | 264.3 ms | −5.3% | 116.8 ms | 115.6 ms | −1.0% (noise) | | members dialog, script | 3,272.1 ms | 1,834.2 ms | −43.9% | 2,643.0 ms | 1,173.2 ms | −55.6% | | members dialog, task | 3,455.5 ms | 2,055.1 ms | −40.5% | 2,863.3 ms | 1,321.1 ms | −53.9% | | members dialog, blocking | 2,764.1 ms | 1,327.0 ms | −52.0% | 2,039.2 ms | 650.7 ms | −68.1% | | mention typing, keydown to paint | 448 ms | 200 ms | −55.4% | 272 ms | 112 ms | −58.8% | | palette miss, keydown to paint | 136 ms | 104 ms | −23.5% | 88 ms | 72 ms | −18.2% | | prose typing, keydown to paint | 24 ms | 24 ms | 0% | 16 ms | 16 ms | 0% | | header renders for 50 messages | 50 | 0 | −100% | 50 | 0 | −100% | | header time for 50 messages | 58.2 ms | 1.5 ms | −97.4% | 32.8 ms | 1.1 ms | −96.6% | | picker scan, 1,000 profiles | 42.4 ms | 17.3 ms | −59.2% | 24.3 ms | 9.8 ms | −59.5% | | picker scan, 5,000 profiles | 213.3 ms | 86.8 ms | −59.3% | 119.1 ms | 48.8 ms | −59.0% | | picker scan, 10,000 profiles | 431.0 ms | 178.0 ms | −58.7% | 240.5 ms | 98.9 ms | −58.9% | Lower is better. These numbers were measured with the earlier per-dialog `folds` map, before the rebase onto #2570. Blocking is long-animation-frame blocking time. Typing rows have 162 mention keys, 108 palette keys and 216 prose keys per build; one Linux palette key of this PR had no Event Timing entry, so that cell covers 107 keys. The browser harness runs the built frontend with modeled HTTP, not the native app. **Larger rosters.** A standalone Node bench of the whole member predicate over a typing session takes 0.22–0.40× of the old time. It covers 5k, 9k, 20k and 50k members, with and without names. The first keystroke after the dialog opens pays the one-time fold and encode: on large rosters without names it is up to about 1.2× the old cost, and later keystrokes are 10× faster or more. Checks: - The channels, search, mentions, direct-messages and channel-members unit suites pass. - `tsc` and `biome` pass. - After the rebase: the search and channels suites pass (1,163 tests). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Kalvin Chau <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
* origin/main: fix(sidebar): rename Remove from Team to Close conversation (#2845) perf(channels): cut header and name-matching work in large channels (#2726) fix(browser): silence a browser page once its tab goes away (#2721) Community Mesh: shared compute plugin (including agent type) (#941) perf(relay): cut main-thread work from profile updates (#2730) fix(composer): lead the channel placeholder with its sidebar icon (#2829) test(activity): scope preview assertions to the request row (#2843) perf(search): fold people names once across palette keystrokes (#2570) fix(github): keep PR watches running when localStorage is full (#2699) Make drafts and sent part of Messages (#2728) fix(canvas): render every Canvas within the save limit (#2839) Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell <[email protected]> Signed-off-by: Tyler Longwell <[email protected]>
…leanup * origin/main: feat(agents): let app-hosted agents read their owner's channels and members (#2745) NEXUS-4271: Expose installed plugin status (#2754) fix(menu): stop clipping lifecycle actions behind a hidden scrollbar (#2716) feat(agents): give approved external apps the selected agent's full Buzz tools (#2707) AIDA-2549: Keep Claude settings drafts in sync (#2841) fix(channels): keep a channel's side panels open when returning to it (#2838) fix(builderlab): clarify remote-agent account availability (#2764) fix(sidebar): rename Remove from Team to Close conversation (#2845) perf(channels): cut header and name-matching work in large channels (#2726) fix(browser): silence a browser page once its tab goes away (#2721) Community Mesh: shared compute plugin (including agent type) (#941) perf(relay): cut main-thread work from profile updates (#2730) fix(composer): lead the channel placeholder with its sidebar icon (#2829) test(activity): scope preview assertions to the request row (#2843) perf(search): fold people names once across palette keystrokes (#2570) fix(github): keep PR watches running when localStorage is full (#2699) Make drafts and sent part of Messages (#2728) fix(canvas): render every Canvas within the save limit (#2839) # Conflicts: # .github/workflows/release.yml
…ime-hint * upstream/main: ci: give the native job room for a cold Rust cache (block#2867) fix(pairing): refuse unknown communities natively and name the requesting process (block#2835) Add per-interest Attention pause and management controls (block#2847) feat(drafts): show where unsent drafts wait (block#2853) feat(agents): let app-hosted agents read their owner's channels and members (block#2745) NEXUS-4271: Expose installed plugin status (block#2754) fix(menu): stop clipping lifecycle actions behind a hidden scrollbar (block#2716) feat(agents): give approved external apps the selected agent's full Buzz tools (block#2707) AIDA-2549: Keep Claude settings drafts in sync (block#2841) fix(channels): keep a channel's side panels open when returning to it (block#2838) fix(builderlab): clarify remote-agent account availability (block#2764) fix(sidebar): rename Remove from Team to Close conversation (block#2845) perf(channels): cut header and name-matching work in large channels (block#2726) fix(browser): silence a browser page once its tab goes away (block#2721) Community Mesh: shared compute plugin (including agent type) (block#941) perf(relay): cut main-thread work from profile updates (block#2730) fix(composer): lead the channel placeholder with its sidebar icon (block#2829) test(activity): scope preview assertions to the request row (block#2843) perf(search): fold people names once across palette keystrokes (block#2570) fix(github): keep PR watches running when localStorage is full (block#2699) Signed-off-by: Cheng Peng <[email protected]> Co-authored-by: Cursor <[email protected]>
Problem
Each Cmd+K keystroke ranks every cached profile against the typed text.
personMatchcallsmatchPersonfor each of a person's shown and hidden names.matchPersonfolds the whole name on every call: NFKD, two regex replaces and a lowercase per code point, plus array spreads. It also folds the query again for every name. #2322 added a verified full name and profile names to each person. #2269 raised the profile cache ceiling from 1,024 to 10,240. Together they made this per-keystroke pass several times larger, and nearly all of it re-folds names that did not change since the previous keystroke.Change
matchTierreturns the tiermatchPersonwould give. It reads folded names from a module-level cache that holds at most 65,536 names and is cleared when full. Names that fold to more than 128 code points are not cached.matchPersonandmatchTiershare the matching body (search), so tier rules live in one place.matchPersonstill builds its own fold with origins for underlining.rankPeoplefolds the query once per ranking and passes it topersonMatch, which usesmatchTier. Before,personMatchrefolded the same query for every person: 10,240 folds per keystroke with a full profile cache, about 2.4–4.4 ms for a 2–4 character query in a Node micro-benchmark. This last step is not in the table below.nameStartsand the cache share one fold helper.No behavior changes
matchTierruns the same search over the same folded characters asmatchPerson, so people rank exactly as before. A new test checks thatmatchTiergivesmatchPerson's tier for repeated names and changing queries, including accented, compatibility, final-sigma and padded names. Underlined positions still come frommatchPerson.Evidence
Two benchmarks compare main and this PR:
8c0cb0c1eand this PR were run as six serial, interleaved pairs on one Linux and one macOS workstation. All keys matched in every run.SearchResultsrenders in jsdom with 1,700, 5,000 or 10,240 cached profiles (accented and plain names, half with verified full names). It typesa,al,ale,alex(cold), then repeats the same keystrokes (warm). Values are the median time to render one keystroke. Main and this PR were interleaved on 2 Linux and 2 macOS workstations, with 16 samples per cell.@mentionLower is better.
Checks: the search, mentions, shell and channel-members unit suites pass, along with
tscandbiome.🤖 Generated with Claude Code