Skip to content

feat(mentions): typing @ offers the Cat — and posts stop mislinking real handles - #788

Merged
github-actions[bot] merged 2 commits into
mainfrom
feat/mention-autocomplete
Aug 27, 2026
Merged

feat(mentions): typing @ offers the Cat — and posts stop mislinking real handles#788
github-actions[bot] merged 2 commits into
mainfrom
feat/mention-autocomplete

Conversation

@catomean

@catomean catomean commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Two commits. The first makes @cat discoverable; the second fixes a rendering bug the first one makes more likely to be hit.


1. Typing @ offers the Cat

The Cat has answered to @cat on 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 — @catalogue is 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 @cat directly forever.

Where the code went

One definition of a mention. activeMention sits in domain/mentions/parse.ts beside parseMentionCandidates and 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 are contentEditable. That difference is confined to caret.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:sizes failed at 310/300 on PostComposerMobile and 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

  • Surfaces take a ref, not an element, and read .current when called. Capturing the element captures 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.
  • Writes go through execCommand('insertText') — deprecated and deliberate. It's 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.

2. A post linked @dacota-plaettli to somebody called dacota

Found while wiring the above. markdown.tsx matched @[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:

  • No ., - or +. @dacota-plaettli rendered as a link to /profiles/dacota with -plaettli trailing as loose text; @m.schaupensteiner linked to /profiles/m. Both are real production accounts. The resolver notifies the right person while the post links to someone else.
  • No word boundary. bob@example.com rendered @example as 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:

Mutation Result
Remove the Cat hoist 3 tests fail
Drop the word-boundary check (email opens a menu) 1 test fails
Make the textarea's replaceRange inert 2 tests fail
Renderer links shortest candidate instead of longest 3 tests fail

Wiring 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 verify green: 247 suites, 2453 tests.

Known limit

Ranking is handle-prefix first, display-name-prefix second, over whatever /api/profiles returns. No fuzzy or middle-of-name matching. Stated here rather than discovered later.

🤖 Generated with Claude Code

catomean and others added 2 commits August 27, 2026 22:13
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>
@catomean catomean changed the title feat(mentions): typing @ offers the Cat, so somebody can find it feat(mentions): typing @ offers the Cat — and posts stop mislinking real handles Aug 27, 2026
@github-actions
github-actions Bot merged commit d40322b into main Aug 27, 2026
6 checks passed
@github-actions
github-actions Bot deleted the feat/mention-autocomplete branch August 27, 2026 21:27
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.

1 participant