Skip to content

fix(mentions): stop per-keystroke member scans in large channels - #1828

Merged
loganj merged 6 commits into
mainfrom
loganj/mention-candidates-perf
Oct 28, 2026
Merged

loganj merged 6 commits into
mainfrom
loganj/mention-candidates-perf

Conversation

@loganj

@loganj loganj commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

🤖

Summary

  • Typing in a composer in a large channel (#general has about 2,500 members) lagged on every keystroke, in both the channel composer and thread replies. On each key, the app rebuilt the full list of people you could @mention, even when you were not mentioning anyone.
  • Now ordinary typing does no mention work. Typing an @ query looks up matching names in a sorted index instead of scanning every member.
  • Mention behavior is the same: which people appear, their order, and what Space selects do not change.

Measurements

Local headless Chromium, dev build, against real #general data. Typing 43 characters into the #general composer:

Before After
Time per character 29–34 ms 21 ms
CPU in mention code ~330–385 ms ~6 ms

A small channel takes 22 ms per character, so channel size no longer adds cost. Typing @logan in #general dropped from 118 ms to 62 ms per character; mention CPU went from 260 ms to 69 ms, most of it the one-time index build on the first @.

Details

  • Closed chooser does nothing. The saved-teams part of the mention chooser built every member's candidate row on each render to check team eligibility, then threw it away while the chooser was closed. It now builds eligibility only for team rows an open chooser shows. The chooser button also no longer joins every member key into a string while closed.
  • Candidates are cached by their sources. mentionCandidates caches its rows per session and channel. The cache key is the immutable snapshots it reads: the channel row, profiles, agent choices, archives, roster and directory page. Each directory array has its own entry, so the chooser and its team rows do not evict each other, and only the latest few channels and directory pages keep rows. The rows rebuild only when one of those changes, not on each keystroke. Labels are cached by the same rows plus the draft's selected recipients, compared by key and name, because the draft recreates that list on every edit. Every empty directory page now shares one array. The build itself is now a single keyed pass. Before, it did a find, filter or some scan over agents and directory people for each member.
  • Query matching uses an index. The new matchingMentions keeps a sorted list of each choice's name starts: the folded name and every later word start, as the matching rules in src/bundled/mentions/README.md §3 define. A binary search returns only the choices that can match, at a cost of O(log n + matches). Ranking and Space selection then apply the unchanged rules to that subset. A test runs every portable ranking and Space fixture through the index and checks that the results equal the full-list results.
  • Composer chips. The rich composer computed chip names over every channel member on each keystroke. It now recomputes them only when the draft's recipients, the members, or naming change.
  • Directory pages change only what they add. Most directory hits in a large channel are members whose profile already names them. They change no row, alias, key or label, so directoryChanges leaves them out, and the rest are compared by content, not by array. A page of members rebuilds nothing. Repeated pages and the chooser's merged page lists with the same outsiders reuse one build. A test checks that the rows are equal with and without the people it leaves out.
  • New regression tests: a closed chooser reads no member profile while its composer re-renders (the old code read 600). An inline @ query that remounts on each keystroke, with a saved team shown, a new recipient array and settled empty directory searches, rebuilds no name starts (the old code rebuilt 600). Pages of members rebuild nothing, and a page with an outsider rebuilds once while it stays the same (the old code rebuilt 600).

Not changed:

  • A multi-word inline @ query still checks all names once per keystroke to decide whether it is a search or prose.
  • A directory page that brings in people outside the channel (or renames an agent that is not a member) still rebuilds candidates, labels and the index over all members, once per distinct set of such people. Labels must be resolved over the whole choice set: the naming policy is replaceable and its contract compares every name in the set. Directory names can change member aliases and the namesake qualifiers of member labels, so splitting local rows from directory rows needs its own change.

🤖 Generated with Claude Code

loganj and others added 5 commits October 28, 2026 11:23
Typing in any composer in a large channel (#general, ~2,500 members)
rebuilt every mention candidate on each keystroke, even with the @
chooser closed: useTeamMentions called mentionCandidates
unconditionally, and that build was O(members x agents) with
per-member find/filter/some scans.

- Build team eligibility only for shown team rows of an open chooser.
- Cache mentionCandidates per session/channel on its immutable source
  snapshots, and build it in one keyed pass.
- Reuse labeled choices and local keys per cached candidate list.
- Match queries through a sorted index of name starts
  (matchingMentions), so an @ keystroke costs O(log n + matches)
  instead of ranking every candidate.
- Compute composer chip names only when recipients change.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
The draft recreates its recipient array on every edit, team rows and the
chooser shared one candidate cache slot, and every empty directory page
was a fresh array. Each one rebuilt every label and the name index on an
@ keystroke. Name on recipient facts, keep a slot per directory array,
share one empty page, and keep rows for only the latest few channels.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Most directory hits in a large channel are members a profile already
names; they change no row, alias, key or label. Pass only the other
people, interned by content, so a page of members rebuilds nothing and
each settled page or carried merge with the same outsiders reuses one
build instead of rebuilding over every member up to three times.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
@loganj
loganj marked this pull request as ready for review October 28, 2026 16:46
@loganj
loganj requested a review from a team as a code owner October 28, 2026 16:46
kalvinnchau

This comment was marked as resolved.

Profile names are member input of any length. Each index entry copied
the rest of the name at every word start, so a many-word name grew the
index with the square of its length. Cut entries to 32 code points and
keep at most 16 per name; a name with more is matched directly by
ranking. Also fail a test when candidate building reads a session store
that its cache does not compare.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
@loganj

This comment has been minimized.

kalvinnchau

This comment was marked as resolved.

@loganj

This comment has been minimized.

@loganj
loganj merged commit 0bc0752 into main Oct 28, 2026
37 checks passed
@loganj
loganj deleted the loganj/mention-candidates-perf branch October 28, 2026 17:48
matt2e added a commit that referenced this pull request Oct 29, 2026
`team-mentions.test.tsx > directory pages rebuild the name index only for
people they add` (0bc0752, #1828) failed on every run of this branch
since the rebase onto main: `people` was called once, not three times.
It assumes every keystroke reads the directory. Its mock returned
`hasMore: false`, so on this branch the complete `p` page answers `pe`
and `per` through `prefixPage` (95050d1) with no read, and the later
`pers` step, which swaps an outsider into the page, would be served from
the cache too and never see it.

The mock now returns truncated pages, as 95050d1 did for the other
tests where a read is the behavior under test, so each keystroke still
reads and the test checks what #1828 meant it to. No product change.

Verified: team-mentions.test.tsx 13/13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
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.

2 participants