feat(mentions): typing @ offers the Cat — and posts stop mislinking real handles - #788
Merged
Merged
Conversation
The Cat has answered to @cat on the wall and in DMs since yesterday, and nobody knows. There is no affordance anywhere in the product that says so — you either read a commit message or you never find out. A feature you have to be told about does not exist for the people who were not told, which made the previous two PRs a rumour rather than a feature. So typing `@` now opens a menu, and the Cat is the first row in it, before any person, on the first keystroke. That placement is the whole point rather than a decoration, and it is a rule with a test rather than an accident of sort order: the Cat appears while the query is still a prefix of its handle (bare `@`, `c`, `ca`, `cat`) and drops out the moment it stops matching, because a suggestion that ignores what you typed is worse than no suggestion. `@catalogue` is not the Cat. The row shows the handle, not only the name. The menu is a teaching surface — you use it once and then type `@cat` directly forever. WHERE THE CODE WENT, AND WHY IT IS NOT IN THE COMPOSERS What counts as a mention is decided in exactly one place. `activeMention` sits in domain/mentions/parse.ts beside `parseMentionCandidates` and reads the same character class and the same word-boundary rule, so what the menu can offer and what the resolver will actually link are the same set by construction. A menu with its own idea of a handle could offer a completion the resolver then refuses — the product would be lying about what it does. `bob@example.com` opens no menu for the same reason it mentions nobody, and both facts come from one function. There is no new people query. `/api/profiles?search=` already searches username and name, escapes LIKE metacharacters and hides CI fixture accounts; a second query here would have re-inherited none of that, and the fixture filter is exactly the kind of rule that gets fixed in one copy and stays broken in the other. The only genuinely new thing is the ORDER, so the only new domain file is a pure ranking function. Three composers needed this and they are not the same kind of editor: messages are a <textarea> with selectionStart, wall posts are a contentEditable with the Selection API. That difference is confined to caret.ts, which reduces both to "what is before the caret" and "replace this range". Everything above it — parsing, ranking, fetching, the menu — is written once and does not know which editor it is attached to. Both contentEditable composers then share useContentEditableMentions, so the event chaining and the combobox ARIA exist once. That extraction was not planned: check:sizes failed at 310/300 lines on PostComposerMobile and pointed straight at wiring I had written out twice. The gate was right, the exception list stayed at 36, and both files got shorter. Two surfaces take a REF, not an element, and read `.current` when called. Capturing the element would capture the first render's null — refs are assigned at commit and nothing re-renders between mounting a composer and its first keystroke, so a captured null survives exactly long enough to swallow the first `@` anyone ever types into a fresh composer. Writes go through execCommand('insertText'), deprecated and deliberate: it is the only way to change either editor that fires a real input event (so React's onChange runs) and keeps the browser's undo stack, so ctrl-Z undoes the pick rather than the whole message. handlePaste already depends on it for the same reasons. A native-setter fallback covers jsdom and any browser that finally removes it. VERIFICATION Three mutations, each proven red and then restored green: removing the Cat hoist (3 tests fail), dropping the word-boundary check so an email address opens a menu (1 fails), and making the textarea's replaceRange inert as if the feature were dead (2 fail). The wiring tests drive a real textarea through userEvent — type, arrow, Enter, Escape — because the pure tests prove the rules and cannot prove that a keystroke ever reaches them. Known limit, stated rather than discovered later: suggestions rank on handle prefix first and display-name prefix second, over whatever /api/profiles returns. There is no fuzzy or middle-of-name matching. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The renderer had its own idea of what a handle is. `markdown.tsx` matched
`@[a-zA-Z0-9_]{1,30}` inside its inline regex — a THIRD definition,
after the parser and the resolver — and it disagreed with both in ways
that were visible on screen rather than merely untidy:
- No `.`, `-` or `+`. `@dacota-plaettli` rendered as a link to
/profiles/dacota with `-plaettli` trailing as loose text, and
`@m.schaupensteiner` linked to /profiles/m. Both are real production
accounts. The resolver notifies the right person; the post they read
linked to somebody else, or to nobody.
- No word boundary. `bob@example.com` in a post rendered `@example` as
a profile link, which is the exact bug the resolver was written to
avoid and which now had a second implementation to be re-fixed in.
Autocomplete is what made this worth fixing now rather than later: it
turns inserting a dotted or hyphenated handle into one keystroke, so the
handles that render wrong are precisely the ones about to be typed most.
The fix deletes the third definition rather than correcting it. Mentions
come out of the inline regex and are found by `parseMentionCandidates` in
the plain-text runs between the other matches — which also means a handle
inside a link target is left alone, because the URL is consumed first and
never re-scanned.
The renderer cannot ask the database who exists, so it links the LONGEST
candidate. That is the same preference the resolver applies before
checking existence, and it gives `ask @alice.` the right answer: `@alice`
linked, full stop left as punctuation.
This file had NO tests, which is how three definitions of a mention
managed to coexist. It has fourteen now: the two bugs above, the handle
shapes production actually contains, and the ordinary bold / italic /
markdown-link / bare-URL formatting that had to keep working while the
mention alternative was pulled out of the regex. Proven by mutation —
linking the shortest candidate instead of the longest turns 3 red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits. The first makes
@catdiscoverable; the second fixes a rendering bug the first one makes more likely to be hit.1. Typing
@offers the CatThe Cat has answered to
@caton the wall and in DMs since yesterday, and nobody knows. No affordance anywhere says so — you either read a commit message or you never find out. That made the previous two PRs a rumour rather than a feature.Typing
@now opens a menu, and the Cat is the first row, before any person, on the first keystroke.The Cat appears while the query is still a prefix of its handle (bare
@,c,ca,cat) and drops out the moment it stops matching —@catalogueis not the Cat. That's a tested rule, not sort order. Rows show the handle, not only the name, because the menu is a teaching surface: use it once, type@catdirectly forever.Where the code went
One definition of a mention.
activeMentionsits indomain/mentions/parse.tsbesideparseMentionCandidatesand reads the same character class and word-boundary rule, so what the menu offers and what the resolver links are the same set by construction. A menu with its own idea of a handle could offer a completion the resolver then refuses.No new people query.
/api/profiles?search=already searches username and name, escapes LIKE metacharacters, and hides CI fixture accounts. A second query would have re-inherited none of that. The only new thing is the order, so the only new domain file is a pure ranking function.Three composers, two kinds of editor. Messages are a
<textarea>; wall posts arecontentEditable. That difference is confined tocaret.ts, which reduces both to "what is before the caret" and "replace this range". Parsing, ranking, fetching and the menu are written once and don't know which editor they're attached to.Both contentEditable composers share
useContentEditableMentions. That extraction wasn't planned:check:sizesfailed at 310/300 onPostComposerMobileand pointed straight at wiring I'd written twice. The gate was right, the exception list stayed at 36, and both files got shorter.Two traps worth naming
.currentwhen called. Capturing the element captures the first render'snull— refs are assigned at commit and nothing re-renders between mounting a composer and its first keystroke, so a captured null survives exactly long enough to swallow the first@anyone ever types.execCommand('insertText')— deprecated and deliberate. It's the only way to change either editor that fires a realinputevent (so React'sonChangeruns) and keeps the browser's undo stack, so ctrl-Z undoes the pick rather than the whole message.handlePastealready depends on it.2. A post linked
@dacota-plaettlito somebody calleddacotaFound while wiring the above.
markdown.tsxmatched@[a-zA-Z0-9_]{1,30}in its inline regex — a third definition of a handle, after the parser and the resolver — and disagreed with both visibly:.,-or+.@dacota-plaettlirendered as a link to/profiles/dacotawith-plaettlitrailing as loose text;@m.schaupensteinerlinked to/profiles/m. Both are real production accounts. The resolver notifies the right person while the post links to someone else.bob@example.comrendered@exampleas a profile link — the exact bug the resolver was written to avoid, with a second implementation to re-fix it in.Autocomplete is what makes this urgent rather than untidy: it turns inserting a dotted or hyphenated handle into one keystroke, so the handles that render wrong are precisely the ones about to be typed most.
The fix deletes the third definition rather than correcting it. The renderer can't ask the database who exists, so it links the longest candidate — the same preference the resolver applies before checking existence, which also gives
ask @alice.the right answer.That file had no tests at all, which is how three definitions coexisted. It has fourteen now.
Verification
Four mutations, each proven red then restored green:
replaceRangeinertWiring tests drive a real textarea through
userEvent— type, arrow, Enter, Escape — because the pure tests prove the rules and cannot prove a keystroke ever reaches them.npm run verifygreen: 247 suites, 2453 tests.Known limit
Ranking is handle-prefix first, display-name-prefix second, over whatever
/api/profilesreturns. No fuzzy or middle-of-name matching. Stated here rather than discovered later.🤖 Generated with Claude Code