Skip to content

perf(mentions): stop scanning every member on each mention keystroke - #2246

Merged
kalvinnchau merged 2 commits into
mainfrom
peon/fi-mention-continuation
Nov 3, 2026
Merged

kalvinnchau merged 2 commits into
mainfrom
peon/fi-mention-continuation

Conversation

@kalvinnchau

@kalvinnchau kalvinnchau commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem

In a channel with thousands of members, each mention keystroke did work proportional to the whole member list. MentionCompletion did two such scans when publishing its rows:

  • The Space check called continuedMention(model.candidates, query) to ask whether any known name continues the query. It looped over every candidate and folded and compared each name. At 5,080 members this was most of the keystroke's script time.
  • Each shown row (up to 50) called members.includes(pubkey) for its detail and model.candidates.some(...) for its avatar shape. That is up to 50 full scans of both lists per update.

Change

  • Space check: the check is now given only matchingMentions(model.candidates, query), the existing prefix index that ranking already builds for the same array, so the index is reused, not rebuilt.
    • The result is unchanged. A name can continue the query only if its folded, trimmed form starts with the folded, trimmed query, and every such choice is in the index lookup.
    • Choices with names too long to index are always returned.
  • Shown rows: each publish builds one member key Set and one agent key Set, and rows look up both instead of scanning.
  • Check order: the Space check runs only when Space is still undecided: something is pending and the query can still be pending. When the chooser has settled, it no longer folds every matching name and discards the result.
  • Multi-word admission: the rule that decides whether a query with a space is prose now reads only the indexed matches. Both of its rules need a name that starts with the query.
  • matchingMentions docs: the comment now states that it must keep every choice whose name starts with the query, because the Space check relies on that.

The new test in team-mentions.test.tsx holds a directory search open, so Space stays undecided and the check runs. For a query that no member name starts with, no member name is checked. For Person 0, only names starting with it are checked. On main the check runs on all 600 member names and aliases, so the test fails there.

Evidence

Measured with the browser perf harness at 4× CPU throttle, using a probe scenario that is not part of this PR. The probe opens a channel with a named roster of the given size and warms the chooser once. It then types @jami one key at a time, 150 ms apart. Runs used 2 Linux and 2 macOS remote workstations, with main and this branch interleaved. Values are medians of 12 per OS. These were measured at 2d2dfcc, before the check-order and multi-word admission follow-up. That follow-up only skips work, so it was not measured again.

Members Metric Linux before Linux after Linux Δ macOS before macOS after macOS Δ
5,080 Script, ms 789 465 −41% 549 335 −39%
5,080 Long-frame blocking, ms 638 314 −51% 357 166 −54%
5,080 Task, ms 1,046 712 −32% 694 478 −31%
1,688 Script, ms 460 315 −32% 330 235 −29%
1,688 Long-frame blocking, ms 315 170 −46% 137 74 −46%
1,688 Task, ms 709 562 −21% 486 395 −19%
0 Script, ms 98 101 +3% 79 78 −2%
0 Task, ms 198 206 +4% 152 150 −1%

A separate fuzz comparison of 60,000 random choice sets and queries found no case where the filtered and unfiltered continuation checks disagree. The sets mixed accents, ligatures, whitespace, qualifier and (agent) suffixes, and queries over 32 code points.

The mentions, messages, conversation, direct-messages and solo unit tests pass: 2,972 tests. tsc --noEmit is clean.

Tradeoff

Each publish now builds two Sets over the members and candidates. That is one linear pass each, instead of up to 50 scans of each list. With no members, the difference is within run-to-run noise.

Regressions

None found. Space selection, continuation, row details and avatar shapes are unchanged.

🤖 Generated with Claude Code

@kalvinnchau
kalvinnchau marked this pull request as ready for review November 3, 2026 15:58
@kalvinnchau
kalvinnchau requested a review from a team as a code owner November 3, 2026 15:58

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

No blockers. Not approving; that is Wes's call.

  • Correctness: filtering continuedMention through matchingMentions holds. extendsName needs the folded, trimmed name to start with the folded, trimmed query, and nameStarts always indexes that whole-name start (same fold, same trim, cut to 32 code points), so every continuing choice is in the lookup. Over-long names come back via unindexed. A stripped-qualifier label is a prefix of the indexed label, so it is covered too. spaceMention already relies on this same superset for exactMention, and model.candidates is the array ranking indexes, so the WeakMap entry is reused.
  • Row Sets: same results as includes/some.
  • Test: I checked it locally at 2d2dfcc. It passes on the PR head and fails when MentionCompletion.tsx is reverted to main, so it does tell the two apart.
  • Non-blocking (PR text): the evidence section names internal "Blox workstations" in a public repo. "Remote Linux and macOS workstations" says the same thing.

@loganj loganj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Larry and Auto: an independent adversarial review of 2d2dfcc. Source review only, plus local test runs; we did not run the app.

Verdict: approve. No must-fix items. The Space-check change gives the same result, and the new test proves the claim.

What we checked

  • The Space check gives the same result. A name blocks Space only when its folded, trimmed form starts with the normalized query. The first start key of every name is that same form, cut to 32 code points, and startKey cuts the needle the same way. So the index lookup always returns a superset. This also holds for a label without its · qualifier (a prefix of the indexed label), for generated (agent) labels (only their indexed aliases count), and for choices with more than 16 starts (never indexed, always returned). The exact-name skip in continuingNames is per choice, so filtering never changes another choice's answer. spaceMention (use-mention-choices.ts:177) already uses this filter-then-check pattern on main. Auto's fuzz of 20k random sets (accents, ligatures, ς/σ, whitespace, qualifiers, (agent), long and many-word names) found 0 disagreements in 1,036 cases with a continuing name.
  • Row detail and avatar shape are the same. Set.has gives the same answer as the old includes and some(... && agent).
  • The test fails without the fix. With main's MentionCompletion.tsx, extendsName is called 600 times ("Person 00", "zz", ...). Existing tests also guard the behavior: replacing the filtered list with [] fails 2 of them (solo-agents "Space is declined, not pending, beside a loaded name…" and MessageComposer "leaves Space literal beside a loaded longer member name…").
  • Mentions tests: 250 pass at head. Full vitest run: 15,415 pass. tsc --noEmit is clean.

Findings outside the diff

  1. advice (completeness; we would like this here or in a quick follow-up). MentionCompletion.tsx:92-101: the admitted check still goes through every candidate on every keystroke. It always builds [...model.candidates, ...choices].flatMap(...), even for a query with no space, where matchesMentionQuery returns true at once. For a query with a space, it runs normalizeName on every name until one matches. When the text is prose, nothing matches, so every name is folded on each keystroke after the space. This is the same full fold the PR removes lower down. The benchmark typed @jami, which has no space, so it did not measure this path. Fix: pass matchingMentions(model.candidates, query.query) instead of model.candidates (both rules need the normalized name to start with the trimmed query), or check query.query.includes(" ") before building the list.
  2. convention. The matchingMentions doc comment (mention-ranking.ts:41-44) promises a superset of the choices that rankMentions / exactMention keep. This caller and spaceMention also need it to include every choice with a name that continues the query. One sentence saying so would stop a later index change from silently breaking Space (for example, dropping the position-0 start, or indexing labels without their qualifier).
  3. advice (minor). Line 146 still runs model.directory.people.find once per row. That list is directory pages, not members, so it is bounded. Not blocking.

!teams.blocksSpace &&
!continuedMention(model.candidates, query.query) &&
// A continuing name starts with the query: only indexed matches can have one.
!continuedMention(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 advice (perf, optional). This lookup runs before (model.pending || model.directory.loading) && pendingMentionQuery(...). When the list has settled, which is the usual case, its result is thrown away, but it still folds every matching name. A one-letter query can match hundreds of names. Putting the cheap conditions first skips the lookup.

Caveat: the new test's fixture is not pending, so after this reorder the test would also pass on main. It would need a pending fixture to keep proving the claim.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Done in 64c57a8. The pending and pendingMentionQuery conditions now run before the continuation check. The test now holds a directory search open, so Space stays undecided and the test still reaches the check. It fails on main.

};
}
// Each shown row asks both; one pass each instead of one scan per row.
const memberKeys = new Set(members);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 question / preference (simplicity). Each row already has member and agent from the same live candidate: choices rows are {...(live ?? row)}, and member is memberKeys.has(pubkey) over the same roster ?? channel.members (mention-candidates.ts:148,227; use-mention-choices.ts:403-414). Destructuring member, agent from the row would remove both new Sets.

The only difference: a stale "No longer available" or "Archived" agent row would keep its squircle instead of falling back to a circle. Its detail is the disabled reason either way. Fine as is if you want the old fallback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 I kept the two Sets. Destructuring member and agent from the row would also work. But Prettier then re-indents the whole row block, and a stale agent row would keep its squircle. Building the Sets is one pass each over the candidates, per publish.

h.library.dispose();
});

it("a mention keystroke checks longer names only on choices the query matches", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 advice (test, optional). This test only uses a query with no matches. A second case such as person 0, asserting that extendsName sees only Person 0x names, would show that the filtered path checks only the matching names.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Added in 64c57a8. The test now also types Person 0 and asserts that every name checked for continuation starts with it.

kalvinnchau and others added 2 commits November 3, 2026 08:25
every mention keystroke asked continuedMention whether any known name
continues the query, over every candidate. in a channel with thousands
of members that folded and compared every member name per keystroke.

a name can continue the query only if it starts with it, so the
existing matchingMentions prefix index returns every choice that can.
pass only those; the result is unchanged. the shown rows also looked up
membership and agent shape by scanning the member and candidate lists
once per row; build each set once per update instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
Signed-off-by: Kalvin Chau <[email protected]>
the longer-name check ran before the cheap pending conditions, so a
settled chooser folded every matching name and discarded the result.
check pending and the query shape first. the admission rule for
multi-word queries also folded every candidate name; both of its rules
need a name that starts with the query, so give it only the indexed
matches. document that matchingMentions must keep every choice whose
name starts with the query.

the test now runs with a directory search in flight, so it still
reaches the check, and also covers a query that matches.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
Signed-off-by: Kalvin Chau <[email protected]>
@kalvinnchau
kalvinnchau force-pushed the peon/fi-mention-continuation branch from 2d2dfcc to 64c57a8 Compare November 3, 2026 16:26
@kalvinnchau

Copy link
Copy Markdown
Contributor Author

🤖 Thanks, both. Changes in 64c57a8, rebased on main:

  • Out-of-diff 1 (admission): the multi-word admission check now reads only matchingMentions(model.candidates, query) plus the shown rows. Both of its rules need a name that starts with the query, so the result is unchanged.
  • Out-of-diff 2 (docs): the matchingMentions doc comment now says it must keep every choice whose name starts with the query, because the Space check relies on that.
  • Out-of-diff 3 (directory find): left as is. That list is bounded directory pages, not members.
  • Carl's PR-text note: the description now says "remote workstations". I made the same change in perf(plugins): share host request clients across calls #2243 and perf(relay): read each signed roster once in large channels #2244.

The mentions, messages, conversation, direct-messages, solo and search suites pass (3,004 tests), and tsc is clean. The benchmark table is from 2d2dfcc. The follow-up only skips work, so I did not measure it again.

@kalvinnchau
kalvinnchau merged commit 1090010 into main Nov 3, 2026
41 checks passed
@kalvinnchau
kalvinnchau deleted the peon/fi-mention-continuation branch November 3, 2026 16:41
dr0uleau added a commit that referenced this pull request Nov 3, 2026
…xecution-support-for-native-buzz-33ea2bea

* origin/main: (74 commits)
  feat(host): harden byte lifecycle and add protected image upload (#2199)
  Updated onboarding (#2183)
  perf(relay): read each signed roster once in large channels (#2244)
  AIDA-2346: Keep spoilers hidden in reminder previews (#2261)
  fix: AIDA-2371 Keep sidebar selection pill aligned during resize (#2182)
  fix(relay): skip background rereads of profiles a read found absent (#2147)
  fix: prevent startup logo selection (#1823)
  fix(agents): send sign-in evidence with agent Git credentials (#2240)
  perf(mentions): stop scanning every member on each mention keystroke (#2246)
  fix(attachments): send image dimensions so previews don't shift (#2262)
  feat(shortcuts): add Slack-style hotkeys (#2170)
  perf(plugins): share host request clients across calls (#2243)
  fix(identity): keep federated sessions and agent grants in one keychain item (#2225)
  fix(profiles): evict the least recently requested profile (#2255)
  fix(unread): repair unread from the recovery tick only every 15 minutes (#2252)
  AIDA-2374: Use readable labels in chat titles (#2185)
  perf(profiles): keep verified names checked through a planned renewal (#2234)
  feat(plugins): let a plugin ask Buzz to review an install (#2059)
  fix(identity): sign FI builds in at first run through the native session (FOUNDATION: App.tsx, services.ts) (#2249)
  Place profile verification beside the verified name (#2224)
  ...

Signed-off-by: Daniel Rouleau <[email protected]>
dr0uleau added a commit that referenced this pull request Nov 3, 2026
* commit 'c8e882165': (50 commits)
  Scope execution targets by harness session
  Read durable execution state before admission
  feat(host): harden byte lifecycle and add protected image upload (#2199)
  Updated onboarding (#2183)
  perf(relay): read each signed roster once in large channels (#2244)
  AIDA-2346: Keep spoilers hidden in reminder previews (#2261)
  fix: AIDA-2371 Keep sidebar selection pill aligned during resize (#2182)
  fix(relay): skip background rereads of profiles a read found absent (#2147)
  fix: prevent startup logo selection (#1823)
  fix(agents): send sign-in evidence with agent Git credentials (#2240)
  perf(mentions): stop scanning every member on each mention keystroke (#2246)
  fix(attachments): send image dimensions so previews don't shift (#2262)
  feat(shortcuts): add Slack-style hotkeys (#2170)
  perf(plugins): share host request clients across calls (#2243)
  fix(identity): keep federated sessions and agent grants in one keychain item (#2225)
  fix(profiles): evict the least recently requested profile (#2255)
  fix(unread): repair unread from the recovery tick only every 15 minutes (#2252)
  AIDA-2374: Use readable labels in chat titles (#2185)
  perf(profiles): keep verified names checked through a planned renewal (#2234)
  feat(plugins): let a plugin ask Buzz to review an install (#2059)
  ...

Signed-off-by: Daniel Rouleau <[email protected]>
Co-authored-by: Codex <[email protected]>
ljknight added a commit to ljknight/buzz-app that referenced this pull request Nov 3, 2026
…ection

* origin/main: (39 commits)
  Authorize local apps to use Buzz MCP tools (block#2211)
  feat(bestie): choose the private channels Bestie can read (block#2036)
  Polish message actions, spacing, and membership notices (block#2258)
  test(unread): prevent startup catch-up in receipt reserve journey (block#2274)
  test(search): wait for rejected join clearance to settle (block#2278)
  test(reactions): provide stable emoji media for icon rendering (block#2279)
  feat(profiles): reserve profile room for an open channel's roster (block#2269)
  feat(host): harden byte lifecycle and add protected image upload (block#2199)
  Updated onboarding (block#2183)
  perf(relay): read each signed roster once in large channels (block#2244)
  AIDA-2346: Keep spoilers hidden in reminder previews (block#2261)
  fix: AIDA-2371 Keep sidebar selection pill aligned during resize (block#2182)
  fix(relay): skip background rereads of profiles a read found absent (block#2147)
  fix: prevent startup logo selection (block#1823)
  fix(agents): send sign-in evidence with agent Git credentials (block#2240)
  perf(mentions): stop scanning every member on each mention keystroke (block#2246)
  fix(attachments): send image dimensions so previews don't shift (block#2262)
  feat(shortcuts): add Slack-style hotkeys (block#2170)
  perf(plugins): share host request clients across calls (block#2243)
  fix(identity): keep federated sessions and agent grants in one keychain item (block#2225)
  ...

# Conflicts:
#	src/features/messages/ThreadPanel.tsx
arividar pushed a commit to minniai/minni-buzz-app that referenced this pull request Nov 4, 2026
…lock#2246)

## Problem

In a channel with thousands of members, each mention keystroke did work
proportional to the whole member list. `MentionCompletion` did two such
scans when publishing its rows:

- **The Space check** called `continuedMention(model.candidates, query)`
to ask whether any known name continues the query. It looped over every
candidate and folded and compared each name. At 5,080 members this was
most of the keystroke's script time.
- **Each shown row** (up to 50) called `members.includes(pubkey)` for
its detail and `model.candidates.some(...)` for its avatar shape. That
is up to 50 full scans of both lists per update.

## Change

- **Space check:** the check is now given only
`matchingMentions(model.candidates, query)`, the existing prefix index
that ranking already builds for the same array, so the index is reused,
not rebuilt.
- The result is unchanged. A name can continue the query only if its
folded, trimmed form starts with the folded, trimmed query, and every
such choice is in the index lookup.
  - Choices with names too long to index are always returned.
- **Shown rows:** each publish builds one member key `Set` and one agent
key `Set`, and rows look up both instead of scanning.
- **Check order:** the Space check runs only when Space is still
undecided: something is pending and the query can still be pending. When
the chooser has settled, it no longer folds every matching name and
discards the result.
- **Multi-word admission:** the rule that decides whether a query with a
space is prose now reads only the indexed matches. Both of its rules
need a name that starts with the query.
- **`matchingMentions` docs:** the comment now states that it must keep
every choice whose name starts with the query, because the Space check
relies on that.

The new test in `team-mentions.test.tsx` holds a directory search open,
so Space stays undecided and the check runs. For a query that no member
name starts with, no member name is checked. For `Person 0`, only names
starting with it are checked. On main the check runs on all 600 member
names and aliases, so the test fails there.

## Evidence

Measured with the browser perf harness at 4× CPU throttle, using a probe
scenario that is not part of this PR. The probe opens a channel with a
named roster of the given size and warms the chooser once. It then types
`@jami` one key at a time, 150 ms apart. Runs used 2 Linux and 2 macOS
remote workstations, with main and this branch interleaved. Values are
medians of 12 per OS. These were measured at 2d2dfcc, before the
check-order and multi-word admission follow-up. That follow-up only
skips work, so it was not measured again.

| Members | Metric | Linux before | Linux after | Linux Δ | macOS before
| macOS after | macOS Δ |
|---|---|---|---|---|---|---|---|
| 5,080 | Script, ms | 789 | 465 | −41% | 549 | 335 | −39% |
| 5,080 | Long-frame blocking, ms | 638 | 314 | −51% | 357 | 166 | −54%
|
| 5,080 | Task, ms | 1,046 | 712 | −32% | 694 | 478 | −31% |
| 1,688 | Script, ms | 460 | 315 | −32% | 330 | 235 | −29% |
| 1,688 | Long-frame blocking, ms | 315 | 170 | −46% | 137 | 74 | −46% |
| 1,688 | Task, ms | 709 | 562 | −21% | 486 | 395 | −19% |
| 0 | Script, ms | 98 | 101 | +3% | 79 | 78 | −2% |
| 0 | Task, ms | 198 | 206 | +4% | 152 | 150 | −1% |

A separate fuzz comparison of 60,000 random choice sets and queries
found no case where the filtered and unfiltered continuation checks
disagree. The sets mixed accents, ligatures, whitespace, qualifier and
`(agent)` suffixes, and queries over 32 code points.

The mentions, messages, conversation, direct-messages and solo unit
tests pass: 2,972 tests. `tsc --noEmit` is clean.

## Tradeoff

Each publish now builds two `Set`s over the members and candidates. That
is one linear pass each, instead of up to 50 scans of each list. With no
members, the difference is within run-to-run noise.

## Regressions

None found. Space selection, continuation, row details and avatar shapes
are unchanged.

🤖 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]>
Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
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.

3 participants