镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

fix(mentions): stop same-named agents from other communities blocking typed @mentions - #2581

Open
matt2e wants to merge 12 commits into
mainfrom
agent-doesnt-get-tagged-after-fully-typing
Open

matt2e wants to merge 12 commits into
mainfrom
agent-doesnt-get-tagged-after-fully-typing

Conversation

@matt2e

@matt2e matt2e commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Since #1601, the mention chooser in streams and forums also offers the viewer's own agents from their other communities ("Jitter · In feedback…"). The Space rule from #935 and #1623 tags a typed name only when exactly one choice has that exact name, and it counted those other-community agents as local choices.

So a viewer with two agents named Jitter, one in this community and one in another, got two exact matches for @Jitter. Space stayed a plain space, the message went out untagged, and Jitter never woke. The same happened to a member who shared a name with one of the viewer's agents elsewhere (a person Mira vs. the viewer's agent Mira in another community). An existing MessageComposer test pinned this as expected behaviour. Before #1601, @Jitter + Space tagged the Jitter here, which is why the report says "it used to work".

Change

All of this lives in the mentions plugin (src/bundled/mentions). The chooser and the headless pending-chip resolver share spaceMention, so they follow the same rules.

  • Rule 1: hide same-named other-community agents. The chooser no longer offers an agent from another community when one of the viewer's unarchived agents in this community has the same normalized name, whether or not that agent is in the channel (offeredElsewhere in mention-candidates.ts). Team eligibility and the Agents page import list still use the unfiltered list. An agent archived here can still be brought back by mention.

  • Rule 2: Space precedence (here → own elsewhere → directory). Among choices with the typed name:

    1. a local choice in this community (a member, or an agent offered here, including "Not in channel" rows) wins, so the viewer's agents from other communities can't be the exact name;
    2. otherwise, one of the viewer's agents from another community wins, so directory results can't be the exact name;
    3. otherwise, the plain full-set rule applies.

    In step 2, a local choice here and a directory result with the same name are still namesakes, as before.

  • The instant tag stays for elsewhere-only names. When the only local match is the viewer's agent from another community (for example @Rocket), Space tags it at once, as on main. A directory result found later can't make it ambiguous, and a truncated directory search can't turn it back into prose.

  • The cold-start hold covers only matching names. Rule 1 needs to know the viewer's agents here. Until they are known, other-community rows are held, but only for queries that match one of their names (elsewhereMatches). Directory rows for that query wait too, so a late-released agent never lands below a directory stranger, and Space and Enter can't pick different rows. Other names, such as @alice, tag a fully named member at once.

  • Library prefetch when the composer mounts. In channels that offer such agents, the composer's mounted mention picker now loads the viewer's agent library before the first @. This avoids a cold read that shares the resolver's 8 s timeout and could send Space back to prose. It does not start for DMs, Solo chats, rosters, invite mode, or when there are no other-community agents.

  • Retry rereads the library only when it failed. Retry for a directory, membership or name error no longer reloads a ready library, which used to disable shown rows until the reload landed. This also applies to invite mode.

Behaviour in a single community, with no agents elsewhere, is unchanged. elsewhereMatches is false there, so every rank reduces to the old exactMention calls.

Spec (src/bundled/mentions/README.md)

  • §1, item 3: adds the exception for the viewer's agents from other communities that have the same name as one of the viewer's unarchived agents here, normalized as in §3.
  • §3 (unchanged): its normalization (case, accents, outer whitespace) is now what §1's exception and §6's matching-name hold refer to.
  • §5: adds the precedence paragraph (here → own elsewhere → directory) and the instant tag for an elsewhere-only name.
  • §6: other-community rows show only once the viewer's agents here are known. Matching queries wait, and directory people wait with them. A client SHOULD start that read when an offering composer opens.
  • Fixtures: the space fixtures now run §5 step 2 with the precedence. Members and choices marked offered or elsewhere count as local. There are new cases for each precedence tier and one for an offered non-member vs. its namesake elsewhere. offered is a new optional field (default false), and the fixture version stays 2.

docs/buzz-help.md does not describe the Space-tag rule, so it isn't updated.

Validation

Unit and component suites

  • At 2fadf38e4: vitest run src/bundled/mentions MessageComposer.test.tsx SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts passed all 946 tests in 11 files.
  • At the head 5c9dde762 (adds tests only), on a heavily loaded machine (load average about 70–120 on 16 cores): 947 of 949 passed. Both failures were in tests this branch doesn't touch. "asks a mentioned agent of the viewer's for a model and effort on the next run only" hit the 5 s timeout, and a SoloDraft retry test lost its chip. Both passed when rerun alone, and SoloDraft.test.tsx then passed as a whole (89 tests).
  • tsc --noEmit, biome check on the changed files, and git diff --check passed.
  • Fail-before: each commit's new cases fail against the previous production files or a targeted mutation (5c9dde762 has no production change). Each commit message lists which change fails which case.

AI review rounds

  1. Review of 73ea38cc8 → c39512ac6. The review found that a non-member directory result could beat the viewer's own other-community agent on Space, so Space and the highlighted row disagreed. An elsewhere-only name also waited for the directory and fell back to prose on a truncated search, and a row could appear and then go disabled on a cold start. Changed: the three-tier precedence, the instant tag restored, other-community rows held until the viewer's agents here are known, and a test for the archived-agent filter.
  2. Review of c39512ac6 → 2692f6e54. A late-released elsewhere row could land below a directory stranger, so Enter and Space picked different rows. The hold also delayed every query, @alice included, and Retry never reread a failed library. Changed: directory rows wait while held and each batch puts elsewhere rows first, the hold covers only matching names, Retry rereads the library, and the §6 wording was tightened.
  3. Review of 2692f6e54 → 2fadf38e4. The ship gate: the first matching @ of a session waited for a cold library read that shares the 8 s resolver timeout. Retry also reread a ready library. Changed: the library loads when an offering composer mounts, and Retry rereads only on failure.
  4. Review of 2fadf38e4. "I'd ship this: prefetch lifecycle and Retry gate are sound, in-app check pending." It verified the prefetch effect's retain/release lifecycle, that it is gated to offering channels, the StrictMode double call, and the invite-mode Retry change. No blocking findings; the in-app check is still pending.

5c9dde762 is a test-only commit, not a review round. It pins a case the author raised: the viewer owns an agent here that isn't in the channel, plus a namesake elsewhere. That already works, through rule 1 and the "Not in channel" row counting as local. MessageComposer tests and a space fixture now cover it.

Not run, and still needed

  • The browser journeys (mention-rules, completion-work, typeahead) were not run.
  • The change was not tested in the installed app or with a real account. Nobody has measured how long the cold-start library read takes in the app. The prefetch should make the hold rare, but that isn't verified.
  • Not covered by tests: release of the library demand when the composer unmounts (the fixture stubs retain), and the picker's "Retry agent list" (it shares its gate with the tested inline Retry).
  • The demo below ran the real composer and mentions plugin in headless Chromium against an in-memory relay, with other-community agents from a stub: Chromium only, light theme, one width, default text size.
  • At the author's request, this branch was pushed with --no-verify, so the pre-push hooks did not run on the push. It is based on ebbdb5a69 and has not been rebased onto current main.
  • A human test is still needed. In a stream where you can add members, with an agent Jitter here and another Jitter in another community: type @Jitter, and only one Jitter row should show (no "In …" row). Press Space and it should become a chip; send, and Jitter should wake. Control: @ plus a name that only your agent elsewhere has should chip at once, and sending should ask "Bring … into …?".

Demo

Left: merge-base ebbdb5a69. Right: branch head 5c9dde762. Same typing in a #launch stream; captions name who owns each row.

elsewhere-before-after.mp4
# Time Rows and owners Typed Before After
1 0:04–0:09 "Jitter": yours here (a member). "Jitter · In feedback…": yours elsewhere @Jitter Both rows Only yours here
2 0:09–0:17 As in 1 Space, please look, Enter Space stays a space. Sent with no one tagged Chip. Sent, tagging Jitter here
3 0:17–0:27.5 "Mira": a person here. "Mira · In feedback…": your agent elsewhere @Mira, Space, can you review, Enter Space stays plain text. Sent with no one tagged Space tags the person. Sent, tagging Mira
4 0:27.5–0:38 Only row: "Rocket · In feedback…", yours elsewhere @Rocket, Space, ship it, Enter Chip at once, then "Bring Rocket into team.example.com?" Same
5a 0:38–0:51 "Goose · Not in channel": yours here, not in #launch. "Goose · In feedback…": yours elsewhere @Goose, Space, hi, Enter, then "Do nothing" Both rows. Space stays plain text. Sent with no one tagged One row. Space makes a chip. Enter asks "Goose is not in this channel." Goose is mentioned (not notified)
5b 0:51–1:02 "Goose · In feedback…": yours elsewhere. "Goose · Not in channel": someone else's, found by directory search @Goose, Space, hi, Enter Your Goose from feedback… is chosen, then "Bring Goose into team.example.com?" Same
6 1:02–1:04.9 "Alice": a person here. Fresh session, library read held 6 s @alice, Space Chip at once Chip at once, no "Loading recipients…"
6b 1:04.9–1:14 Your Jitter here, and (before only) your Jitter elsewhere and @Jitter, Space, look, Enter Both rows. Space stays plain text. Sent tagging Alice only Pending chip that settles to your Jitter here once the library lands. Sent tagging Alice and Jitter

Segments 5b and 6 can't show a difference. The bugs that c39512ac6 and 2692f6e54 fix were introduced within this branch, and in this fixture both builds read your agents here when the composer mounts. After an agent is tagged, the right pane refills @Jitter . That is the existing follow-up-draft behaviour.

Not in this PR

  • A chip for one of the viewer's agents from another community reads aria-label="Person Rocket" (or "Person Goose"), while agents here read "Agent …". This happens on main too (segments 4 and 5b) and is left unchanged.

🤖 Generated with Claude Code

@matt2e
matt2e marked this pull request as ready for review November 5, 2026 14:30
@matt2e
matt2e requested a review from a team as a code owner November 5, 2026 14:30

@kalvinnchau kalvinnchau 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.

🤖 One failure/recovery case remains in the new namesake exclusion.

@matt2e
matt2e force-pushed the agent-doesnt-get-tagged-after-fully-typing branch 2 times, most recently from 21dfe47 to 3ee7539 Compare November 6, 2026 01:08

@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.

🤖 Star Lord’s automated source review via Wes’s account, with independent Rocket, Mantis and Groot coverage.

Reviewed head 3ee75393a69490361dc55ce3d760f49859322021 against base/merge-base 744278296c255c398b38e8e8fc13df445aa9f8ba. Two findings are attached inline: a P2 where the new cold-library flow hides both same-named agents and leaves an exact typed mention untagged, and a narrower P3 where a cached alias bypasses the loading hold. The P3 is a lower-priority follow-up, not an additional readiness blocker.

The review covered the shared chooser/resolver precedence, full-set ambiguity and longer names, agent-library lifecycle/retry, archive and channel admission, notification/import intent, and regression-test source. This was source-only: no tests, builds, app runs, live interaction checks or CI verification. The PR’s historical validation claims were not rerun. The public PR description was inspected; its attached demo video was not viewed, so the video’s visual/privacy surface remains unverified.

@matt2e
matt2e force-pushed the agent-doesnt-get-tagged-after-fully-typing branch from 3ee7539 to f0557dc Compare November 6, 2026 09:42

@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.

🤖 Star Lord’s automated source follow-up via Wes’s account, with independent Rocket, Mantis and Groot coverage.

Reviewed head f0557dc615e139eeb4ab34a77fbf5005f44d7299 against base fb9cd7cf45c6d80368cc79a7c34535f8f3b2fd20, retaining the earlier review’s unchanged coverage. Both previous findings are addressed in source; no new actionable findings in this focused follow-up.

  • The P2 late-local-row case now appends the offered agent before newly released elsewhere/directory results, preserving shown rows and the result limit. The added composer cases check the local Space target, pending resolution and sent key.
  • The lower-priority P3 alias case now holds queries matching cached profile, verified and local-agent aliases in both chooser and resolver. Added cases cover directory-first arrival, Space/Enter selection and pending resolution.

The follow-up also checked failure/retry behavior and the rebase’s naming/cache integration. Source-only: no tests, builds, app runs, CI verification, visual checks or performance measurements. Author-reported validation was not rerun. The public PR description was inspected; its historical demo video remains unviewed, including its visual/privacy surface. This COMMENT is not runtime validation or merge approval.

@matt2e
matt2e force-pushed the agent-doesnt-get-tagged-after-fully-typing branch from f0557dc to bdb4d9b Compare November 6, 2026 12:21

@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.

🤖 Star Lord’s automated source integration follow-up via Wes’s account, with independent Rocket, Mantis and Groot coverage.

Reviewed head bdb4d9bf483247f9eb686a8f6c0600d23adf9173, live base 3bcf1c4bb1111d924a84d9b710c1da2db80ccf0b, merge-base fd21dba4af2edf63d7b038e3f0f6bdf3703b5df7. Prior unchanged coverage from review 5798537429 is retained; both earlier late-local-row and cached-alias repairs remain intact.

One new P2 is attached inline: exact-public-key mentions of an owned agent elsewhere can return to prose before a successful cold library read completes. This follow-up checked the retained feature patches against the inherited exact-key ranking, team-name rules, directory cache/lookup and shared chooser/resolver lifecycle.

Source-only: no tests, builds, app runs, CI verification, visual checks or performance measurements. Regression-test source and the public PR description were inspected; historical validation claims were not rerun. The historical demo video remains unviewed, including its visual/privacy surface.

@matt2e
matt2e force-pushed the agent-doesnt-get-tagged-after-fully-typing branch from bdb4d9b to bce7123 Compare November 6, 2026 13: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.

🤖 Star Lord’s automated source repair follow-up via Wes’s account, with independent Rocket, Mantis and Groot coverage.

Reviewed head bce71233dc79d86ec35990482819a6f59ed5176a, live base 067db608fbc624e56161ccf3e4ff1c0f11c30baf, merge-base 3bcf1c4bb1111d924a84d9b710c1da2db80ccf0b. The prior P2 (4492884106) is addressed in source; no new actionable findings in this focused follow-up. All seven previously reviewed feature patches are retained, including the earlier late-local-row and cached-alias repairs.

elsewhereMatches now recognizes the eligible agent’s exact public key through the shared parser, so hex, npub and nostr:npub queries reach the chooser hold and resolver’s bounded library wait. The resolver also reads offered candidates and local keys when each decision runs. Added regression source covers an empty directory response arriving before library completion, pending resolution for all three key forms, and the hex chooser/Space path through the Bring prompt. Failure, Retry, timeout and candidate eligibility continue to use the existing owners and policies.

Source-only: no tests, builds, app runs, CI verification, visual checks or performance measurements. Author-reported validation was not rerun. The public PR description was inspected and is unchanged; its historical demo video remains unviewed, including its visual/privacy surface.

@kalvinnchau kalvinnchau 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.

🤖 One narrow exact-key selection regression remains.

@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.

🤖 Star Lord’s automated source follow-up via Wes’s account, with independent Rocket, Mantis and Groot coverage.

Reviewed head 52794e10267d3fdc59013d017d2eb5cc9f3075c2, target base 067db608fbc624e56161ccf3e4ff1c0f11c30baf, merge-base 3bcf1c4bb1111d924a84d9b710c1da2db80ccf0b. The exact-key namesake gap reported in discussion r4493800251 is addressed in source; no new actionable findings in this focused follow-up. The eight earlier patches and previous repairs are unchanged.

The helper now preserves the specifically identified elsewhere agent despite a local namesake. Both chooser candidate sets depend on the decoded key, and each resolver decision supplies its query. Name filtering, channel admission, local-row precedence for the same key, and the complete-library hold remain intact. The README now explicitly states the key exception. Added regression source covers the completed-library chooser and pending resolution for hex, npub and nostr:npub, ending at the Bring prompt without sending.

Source-only: no tests, builds, app runs, CI verification, visual checks or performance measurements. The author’s reported 1,027 passing tests and fail-before checks were not rerun. The public PR description remains historical and unchanged; its demo video remains unviewed, including its visual/privacy surface.

@tellaho

tellaho commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Must-fix — show loading while the toolbar mention picker waits for agents.

With native inventory ready and the local agent library still loading, search the toolbar picker for an agent you own in another community. The new hold hides that agent and matching directory results, but the picker can show “No matching channel members” with no loading feedback after the directory settles. The combined agent status is already ready even though this read is still pending.

Use model.pending for the picker’s existing loading feedback and suppress its empty message during that wait. Cover ready native inventory plus a delayed library read; the matching row should appear when the read completes.

Evidence: new hold, combined status, picker loading, empty message. This conflicts with the design guide’s loading/empty distinction.

Reviewed source at 52794e10267d3fdc59013d017d2eb5cc9f3075c2 and sampled the historical demo (5c9dde762, light theme). Not checked: current app behavior, real notification delivery, cold-start latency, dark theme, narrow/wide layouts, enlarged text, or live keyboard/focus behavior. Tests were not rerun.

@matt2e
matt2e force-pushed the agent-doesnt-get-tagged-after-fully-typing branch from 52794e1 to e21a524 Compare November 9, 2026 08:53
@matt2e

matt2e commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

🤖 @tellaho Fixed in fb91f81 (fix(mentions): show the picker loading while held agents wait, not empty) and tightened in e21a524 (fix(mentions): keep the picker's empty message from showing while agents wait), now rebased onto main.

The picker no longer keys its feedback off the combined agent status alone. useMentionChoices exposes agentsPending (the library read that rows are waiting on: inviting, or the other-community hold), the picker's "Loading agents…" shows while that read or the agent list is loading, and "No matching channel members." waits until nothing is pending and the agent read has not failed. model.pending itself is unchanged, so Space keeps tagging a unique local name in invite mode. README section 6 records the loading-over-empty rule. Tests in MessageComposer.test.tsx cover ready native inventory plus a held library read for the toolbar picker and the inline chooser, plus invite mode, a failed read, and membership still loading, each with fail-before evidence in the commit messages.

Side by side, before (left, 52794e102, the revision you reviewed) and after (right, dcae5f69f, the first fix; those are the pre-rebase SHAs of e144f52 and fb91f81). Same scripted steps and timing in both arms: native inventory ready, the legacy agent library read held for ~7 s, the viewer owns Rocket on feedback.communities.buzz.xyz with no same-named agent here, open the toolbar picker and search rocket, let the directory settle with no one, then release the read.

picker-loading-before-after.mp4
Mark Before (52794e102) After (dcae5f69f)
Directory still searching, library held "Searching community…" and "No matching channel members." "Loading agents…" only
Directory settled with no one, library still held "No matching channel members." alone "Loading agents…" only
Library read lands "Rocket · In feedback.communities.buzz.xyz" row same row
Enter @Rocket in the composer same
Inline @rocket while held (parity) "Loading recipients…" "Loading recipients…"

The harness is the real ConversationService composer with the mentions plugin on an in-memory relay session, captured in headless Chromium (light theme, 640×720). It is not the native app, and the recording predates e21a524, which changes the invite, failed-read and membership-loading states but not this scenario (agentsPending is true while the hold waits on the read). Not checked here: dark theme, narrow/wide layouts, enlarged text, or the browser journeys.

@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.

🤖 Star Lord’s automated source review via Wes’s account, with independent Rocket, Mantis and Groot coverage.

No new actionable findings in this bounded follow-up at e21a5246649a3acee5a8da9c953c01c3cfa3f0b5, against base/merge-base 40e6ccb597cf9d8b47f9692de8285458cc304017.

The picker now distinguishes a pending agent read from an empty result when native inventory is already ready, including invite mode, and suppresses empty copy after an agent-read failure. agentsPending remains separate from selection evidence, preserving usable local choices. We checked the two new patches and their interaction with ranking, punctuation selection, candidate completeness, library reconnect/Retry and the existing exact-key/alias/late-row repairs. The nine previously reviewed feature patches remain unchanged.

Source-only: no tests, builds or app workflows were executed, and CI was not verified. Regression bodies were inspected; the new failure-state test sets the library directly, so clicking toolbar Retry and focus recovery remain unexercised. The PR description, author discussion and sampled frames from both attached demos (eleven historical-demo frames and nine loading-repair frames) were inspected. Both demos show older revisions and do not validate this head. Current native/browser behavior, visual states and performance remain unverified.

@matt2e
matt2e force-pushed the agent-doesnt-get-tagged-after-fully-typing branch from e21a524 to 3077f0d Compare November 9, 2026 09:56

@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.

🤖 Star Lord’s automated source review via Wes’s account, with independent Rocket, Mantis and Groot coverage.

No new actionable findings in this bounded test follow-up at 3077f0de0a3fdbcfe449bfeb25c08320f1cc22d4, against base/merge-base 58116b826171d190a7376fc57b0509c32c507dde.

The helper now pastes a complete nostr: key after @, avoiding the existing typed-colon terminator, while hex and npub remain typed. Both regression matrices retain their loading, directory-first completion, pending-chip and Bring assertions. We traced the real composer paste path and fixture ordering. All eleven previously reviewed patches and the production mention/input paths remain unchanged; prior source coverage and limitations carry forward.

Source-only: we did not run tests, builds or app workflows, inspect CI logs, or independently verify the commit’s test-pass and React-warning claims. These cases cover pasted nostr: queries, not character-by-character entry. The unchanged public demos show older revisions; browser/native clipboard behavior and prior Retry/focus gaps remain unverified.

matt2e and others added 8 commits November 11, 2026 11:42
Typing `@Jitter` and Space in a stream or forum left the name as prose
when the viewer also had an agent named Jitter in another community. The
chooser offered that agent as a local choice, so the name was no longer
unique and Space never tagged the member here.

Two rules fix this:

- The chooser no longer offers an agent from the viewer's other
  communities when one of the viewer's unarchived agents in this
  community has the same name. Names are normalized the same way the
  exact-name check folds case, accents and outer whitespace.
  `offeredElsewhere` filters `Agents.joinable()` where the chooser and
  the headless resolver read it. Team eligibility keeps the unfiltered
  list, so a saved team with such an agent stays usable. The Agents
  page import list is unchanged.
- Agents from other communities never make a name here ambiguous. In
  `spaceMention`, step 1 considers only this community's local choices.
  Step 2 takes the unique exact name among choices here first, and an
  `elsewhere` row only when nothing here has the name. The resolver
  calls `spaceMention`, so it follows the same rule.

Behaviour change: an `elsewhere` row that is the only match no longer
selects at once. Like any outside name, it waits for the directory
search (as a pending chip) and returns to prose when the search fails,
is truncated, or the name is one character long. Its longer names still
block Space, as before.

README sections 1 and 5 state both rules. The `space` fixtures now run
through `spaceMention` step 2 and gain two `elsewhere` cases.

Tests:
- The MessageComposer test that expected prose now expects the member's
  tag, both from the chooser and through the resolver. It checks the
  mention keys (send argument 2).
- New: an `elsewhere` row is hidden when a same-named agent of the
  viewer's is here.
- New: an `elsewhere` row that is the only match still selects on Space
  and opens the "Bring Rocket into" prompt.
- With the original production files, the 4 new MessageComposer cases
  and the new precedence fixture fail. The `elsewhere`-only case fails
  only on its pending-chip assertion.

Validation:
- `vitest run src/bundled/mentions MessageComposer.test.tsx
  SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts`:
  11 files, 931 tests passed.
- `tsc --noEmit` passed, and `biome check` on the changed files passed.
- Not run: the browser journeys (mention-rules, completion-work,
  typeahead) and a check in the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to 73ea38c, addressing its review. It let a non-member
directory result beat the viewer's own agent from another community on
Space, so Space and the highlighted row could disagree on one name. It
also made an other-community agent that was the only match wait for the
directory, and that row could appear and then go disabled on a cold
start.

Space now ranks the choices that have the typed name:

1. A local choice in this community (a member or an offered agent)
   wins. The viewer's agents from other communities can't be the exact
   name.
2. Otherwise one of the viewer's agents from another community wins.
   Directory results can't be the exact name.
3. Otherwise the plain full-set rule applies.

`spaceMention` picks the rank and then runs both steps over it. The
resolver calls `spaceMention`, so the chooser and the pending-chip path
share one rule. `namesQuery` (mention-ranking.ts) is the "has this
exact name" check the rank needs. In step 2, a local choice here and a
directory result with the same name stay ambiguous, as before.

An other-community agent counts as local again (README section 5
items 1 to 3), so when it is the only local match Space tags it at once.
Directory results found later rank below it. This reverts the
wait-for-search behaviour of 73ea38c, and with it the scale problem: a
truncated search (`more`) no longer leaves that name as plain text.

Other-community rows are now held until `agentChoices` is no longer
pending, so the name rule can't remove a row the chooser has shown:

- Ordinary mentions never load the agent library, so `pending` could
  stay true forever. While it holds rows, the chooser loads the library
  as invite mode does (`useAgentChoices(session, invite || held)`).
- `elsewhereHeld` is true only where such rows would be offered (not in
  rosters or invite mode, and only where `availableMentionAgents` allows
  them). DMs and Solo chats are unaffected.
- While rows are held, the agent list counts as loading. Space waits as
  a pending chip, and the resolver waits for the same evidence, so no
  directory result can win during the wait.
- Rows released after the list has installed are added at the bottom
  (README section 6).

README:
- Section 1 now points to section 3 for normalization.
- Section 5 states the rank and the instant tag.
- Section 6 states the hold.
- The fixture table says members and `elsewhere` choices are local.

Fixtures (step 2, now with members and `elsewhere` choices local):
- new: a directory result plus an `elsewhere` agent -> `elsewhere`
- new: a member, an `elsewhere` agent and a directory result with one
  name -> null
- kept from 73ea38c: member plus `elsewhere` -> member, and
  `elsewhere` only -> `elsewhere`

MessageComposer tests:
- Replaced the test that pinned the wait with one where Space tags the
  agent at once while the search is still running.
- New: the viewer's agent outranks a directory result of the same name,
  from the chooser and through the resolver.
- New: the hold. The row never appears when one of the viewer's agents
  here has its name, and it is added last otherwise.
- New: a pending chip settles once the library loads, without the
  directory read.
- The archived-elsewhere test now calls `h.setLibrary([rocket])`, which
  covers the `!archivedMention` filter.
- Against 73ea38c's production files, all 6 new cases and the new
  directory-result fixture fail. With only the hold removed, the hold
  cases fail. With the archived filter removed, the archived test fails.

Single-community behaviour is unchanged. With no other-community agents,
`elsewhereHeld` is false, `offeredElsewhere` returns early, every rank
reduces to the old `exactMention` calls, and the resolver waits only in
invite mode, as before.

Validation:
- `vitest run src/bundled/mentions MessageComposer.test.tsx
  SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts`:
  11 files, 938 tests passed.
- `tsc --noEmit` passed. `biome check` on the changed files and
  `git diff --check` passed.
- Not run: the browser journeys (mention-rules, completion-work,
  typeahead) and a check in the app, including how long the hold lasts
  on a cold start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to c39512a, addressing its review.

1. Agents released late never land below directory rows.
   On a cold start, `@goose` in a stream could install a stranger
   `Goose` from the directory as row 0 while the library loaded. The
   viewer's `Goose · In b.example` then appeared at the bottom. Space
   picked the viewer's agent, but Enter and Tab picked the highlighted
   stranger. While rows are held for the query, directory rows now stay
   out of `outside`. After release, each batch adds other-community
   agents first, then directory people (`[...rank(elsewhere),
   ...rank(directory)]`), because `rankMentions` puts other-community
   agents after directory people in one batch.

2. The hold now delays only the names it can affect.
   `elsewhereHeld` is now `elsewhereMatches`, with no pending check and
   an optional query. It is true when the chooser would offer an agent
   from another community whose name `matchPerson` matches the query.
   - The chooser's `held` is `agents.pending &&
     elsewhereMatches(..., query)`. That drives `pending` and keeps
     directory rows waiting.
   - The resolver waits for the library only when
     `elsewhereMatches(..., name)` holds. Longer names start with
     `name`, so they are covered too.
   - During the cold window, `@alice ` tags a fully named member at
     once again. The chooser shows "Searching community…", not
     "Loading recipients…".

3. Retry rereads the agent library.
   The chooser now returns `includeLegacy`: open and either invite mode
   or `elsewhereMatches(...)` with no query. It replaces `!!invite ||
   held` as the `useAgentChoices` demand. Retry in MentionCompletion
   and "Retry agent list" in MentionPicker pass
   `model.includeLegacy || teams.includeLegacy` to
   `agentChoices.refresh`, so a failed library read can recover.
   Behaviour change: in a channel that offers such agents, the open
   chooser now keeps the library retained, not only while it loads,
   as invite mode already does.

4. README section 6 now says these agents show only while the viewer's
   agents here are known, so a shown row is not disabled once they are
   known. A reload (Retry, `refresh({ fresh })`, a disconnect) still
   disables shown rows in place until it lands, as it does for offered
   agents here. A query that matches one of their names waits, and
   directory people wait with it, so section 5's "Local choices show
   before directory results" holds.

Differences from the review's proposal:
- "Keep directory rows out of `outside` while held" is gated on the
  narrowed, per-query hold, not on every query while the library
  loads. A query that matches no held agent has no late local row to
  order, so its directory rows don't wait.
- The gate matches the name `joinable()` gives, as proposed. A row
  that matches only through another name known here, such as a
  profile name cached for an agent archived here, is not held for
  that query. That is rare, and I did not cover it.

Tests (MessageComposer.test.tsx; `setLibrary` takes an optional error):
- New, for Space and for Enter: the search finds a stranger `Rocket`
  before `setLibrary`. Rows are then viewer's agent first, stranger
  second, and both keys lead to "Bring Rocket into".
- New, from the chooser and through the resolver: while the library
  loads, a member named Alice is tagged, and the chooser shows
  "Searching community…".
- New: after a failed library read, "Retry suggestions" calls
  `agentLibrary.refresh`, and the error clears when the library loads.

Fail-before:
- With c39512a's production files, all 5 new cases fail.
- With only fix 1 reverted, both ordering cases fail. Without their
  order assertions, Enter still fails because it picks the stranger
  while Space picks the viewer's agent.
- With only the chooser's narrowing reverted, both Alice cases fail.
  With only the resolver's narrowing reverted, the resolver case fails.
- With only the Retry change reverted, the Retry case fails (0 calls).

Single-community behaviour is unchanged. With no agents in other
communities, `elsewhereMatches` is false. Then `includeLegacy` is
`open && !!invite`, `held` is false, `outside` is the old directory
rank, and Retry passes the same value as before.

Validation:
- `vitest run src/bundled/mentions MessageComposer.test.tsx
  SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts`:
  11 files, 943 tests passed. The new cases passed in 3 more runs.
- `tsc --noEmit` passed. `biome check` on src/bundled/mentions and
  the test file, and `git diff --check`, passed.
- Not run: the browser journeys (mention-rules, completion-work,
  typeahead), the picker's "Retry agent list" (it shares
  `model.includeLegacy` with the tested inline Retry), and a check in
  the app, including how long the cold-start hold lasts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to 2692f6e, addressing its review.

1. No cold-start wait on the first `@`.
   Before, an offering channel loaded the legacy agent library only once
   the chooser opened. The first matching `@` of a session then waited
   for that read, and so did the resolver. The resolver shares the 8 s
   `MENTION_RESOLVE_TIMEOUT_MS`, so a slow read could send Space back to
   prose. Now `useMentionChoices` demands the library whenever
   `elsewhereMatches(...)` holds, open or closed. The composer's mention
   picker stays mounted while closed, so its chooser retains and ensures
   the library through `useAgentChoices` when the composer mounts. The
   library is then normally `ready` before the first `@`.
   - Released by the same effect on unmount, or when the channel no
     longer offers such agents (a channel switch remounts the composer).
   - Not started for DMs, Solo chats, rosters, invite mode, or an empty
     `joinable()`: `elsewhereMatches` is false there.
   - Invite mode still demands the library only while the chooser is
     open, as before.
   README section 6 says a client SHOULD start this read when such a
   composer opens.

2. Retry rereads the agent library only when it failed.
   Inline Retry passes `(model.includeLegacy && (agents.status ===
   "error" || !!agents.error)) || teams.includeLegacy`. A Retry for a
   directory, archive, membership or name error no longer reloads a
   ready library. That reload disabled shown other-community rows
   ("No longer available…") until it landed.
   Behaviour change: this also applies to invite mode, which before
   reread a ready library on every Retry.
   The picker's "Retry agent list" is unchanged. It renders only when
   `agents.status === "error" || agents.error`, so it already follows
   this rule.

Tests (MessageComposer.test.tsx):
- New: with the library idle, mounting the real MentionPicker in a
  stream composer starts the library read before any `@`. In a DM it
  does not. The stream case asserts `toHaveBeenCalled`: the fixture
  mounts under StrictMode and its `refresh` stub never goes to
  `loading`, while the real library joins the read in flight.
- New: a Retry for a failed directory search in an offering channel
  does not call `agentLibrary.refresh`, and the other-community row
  stays enabled.
- Kept: Retry after a failed library read still rereads it.

Fail-before:
- With only the chooser demand reverted, the stream mount case fails.
- With only the Retry gate reverted, the no-reread case fails.

Validation:
- `vitest run src/bundled/mentions MessageComposer.test.tsx
  SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts`:
  11 files, 946 tests passed.
- `tsc --noEmit` passed. `biome check` on src/bundled/mentions and the
  test file, and `git diff --check`, passed.
- Not run: the browser journeys (mention-rules, completion-work,
  typeahead), a test that the demand is released on unmount (the
  fixture stubs `retain`), and a check in the app with a real account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ts namesake elsewhere

Checks a case on top of 2fadf38: the viewer owns an agent "Goose" in
this community that is not a member of the current stream, and another
"Goose" in another community. This already works, so the commit adds
no production change. It pins the behaviour with tests.

Why it already holds:
- `offeredElsewhere` (mention-candidates.ts) compares names with
  `session.agentChoices.snapshot().identities`, the viewer's unarchived
  agents in this community, not channel members. So the "In <host>"
  row for the other Goose is never offered, as README section 1 item 3
  says.
- In a stream or forum where the viewer can add members (outbox
  supports 9000), `availableMentionAgents` offers the viewer's agents
  that are not members. Goose here gets a "Not in channel" row, and
  `mentionAdmission` admits it because streams allow outside mentions.
- That row comes from `mentionCandidates` without the directory, so it
  is in `local` in the chooser (`localKeys`) and in the resolver.
  `spaceMention` therefore treats it as a local choice in this
  community (section 5, item 2): step 1 selects it at once, and step 2
  ranks it above an agent from another community. It is not handled as
  a directory stranger.

Tests:
- MessageComposer: "tags the owner's agent here that is not in the
  channel, not its namesake from another community", from the chooser
  and through the resolver. Only the "Not in channel" Goose row shows.
  Space tags it. Submit asks "Goose is not in this channel." (not
  "Bring Goose into"), and "Send anyway" sends its key as the outside
  recipient.
- New `space` fixture
  `agent_offered_here_outside_the_channel_outranks_its_namesake_from_another_community`:
  an offered non-member agent and an `elsewhere` agent share a name, and
  the offered one wins. The fixtures had no way to mark an offered
  (section 1, item 2) non-member as local, so choices gain an optional
  `offered` flag (default false). The runner counts members, `offered`
  choices and `elsewhere` choices as local. The README fixture table and
  the fixture `$comment` say so. The fixture version stays 2: the field
  is optional, and this repository's test is its only reader.

Fail-before (production files mutated, then restored):
- `offeredElsewhere` counting only the viewer's agents that are channel
  members: both MessageComposer cases fail (two Goose rows).
- `spaceMention` treating offered non-members as non-local: the new
  fixture fails (it picks the agent from another community), and so
  does the chooser case (Space leaves a pending chip). The resolver
  case still passes there, because rule 1 already hides the other row.
- The runner without `offered`: the new fixture fails.

Single-community behaviour is unchanged: no production file changed.
When the relay cannot add members, neither Goose is offered, as before.

Validation:
- `vitest run src/bundled/mentions MessageComposer.test.tsx
  SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts`:
  11 files, 947 of 949 passed, at load average about 70 to 120 on 16
  cores. The 2 failures were in tests this commit does not touch: one
  MessageComposer test ("asks a mentioned agent of the viewer's for a
  model and effort on the next run only") hit the 5 s timeout, and one
  Solo chat test in SoloDraft ("tags an agent whose name was typed
  with Space while a retry after a failed inventory read loads") lost
  its chip. Both passed when rerun alone, and SoloDraft.test.tsx then
  passed as a whole (89 tests).
- `tsc --noEmit` passed. `biome check` on src/bundled/mentions and the
  test file, and `git diff --check`, passed.
- Not run: the browser journeys (mention-rules, completion-work,
  typeahead) and a check in the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to 5c9dde7, addressing the GitHub review on PR 2581
(discussion r4481855494).

The name rule (README section 1, item 3) hid an agent from another
community only when one of the viewer's agents here had its name, and
it trusted `agentChoices` as soon as it was no longer pending. A failed
library read is not pending, but it leaves out the library's agents. So
when the viewer's Jitter here was only in the library and native
inventory listed a Jitter elsewhere, a cold read failure showed the
"In <host>" row. Space then tagged that key, submit asked to bring it
in, and a Retry that found the local Jitter disabled the shown row.

`agentChoices.complete` is the evidence docs/agents.md requires for
inference: a ready library with no errors, and a local source that is
ready or unavailable. All three places now require it:

- `offeredElsewhere` returns none until `complete`, not until
  `!pending`.
- The chooser's `held` is `!agents.complete && elsewhereMatches(...,
  query)`. Held rows and directory people stay out of `outside` until a
  complete read. Only a read in progress counts as loading
  (`pending` includes `held && agents.pending`). After a failure, the
  chooser shows the local rows it has, the agent error, and Retry.
  `SpaceEvidence.complete` now also requires `!held`, so Space takes
  only step 1 (a unique local name) and otherwise stays prose.
- The resolver, after its existing wait, returns null when the name
  matches such an agent and the read is still incomplete. It does this
  after the local step and before the directory read, as the chooser
  does.

Retry is unchanged. It rereads the library when it failed, because
`agents.status === "error" || agents.error` still holds. Once the read
lands, the rule works as it does after a successful first read. A
partial read (a ready library with `error`) and a failed native source
are also not complete, so they hold such rows the same way until Retry.

README section 6 now says these agents show only once a complete read
has listed the viewer's agents here. A failed or partial read is not
evidence. While the read runs, the agent list is loading. After it
fails, only the local step applies to a matching query, and a retry
reads again.

Single-community behaviour is unchanged. With no agents in other
communities, `elsewhereMatches` is false, so `held` is false and the
resolver gate never applies. DMs, Solo chats, rosters and invite mode
are unaffected for the same reason.

Tests (MessageComposer.test.tsx), each from the chooser and through the
resolver. The resolver cases hold its member-name read with a gate that
is released in `onTestFinished`, so the chip can be observed:
- New: the library read fails. The viewer's Jitter here is only in the
  library, and their Jitter elsewhere is in native inventory. The
  "In oss.example.com" row never shows, and Space leaves "@jitter "
  as prose with no recipient. In a second mention, Retry calls
  `agentLibrary.refresh`, and the library returns the local Jitter. The
  only row is then "Jitter · Not in channel", and Space tags it. Submit
  asks "Jitter is not in this channel.", and "Send anyway" sends the
  local key.
- New: during the same failure, a directory result named Jitter is not
  shown, and Space does not tag it.

Fail-before (production files mutated, then restored):
- With 5c9dde7's production files, all 4 new cases fail.
- With only `offeredElsewhere` reverted, 3 fail. The resolver
  directory case still passes because of the resolver gate.
- With only the chooser hold reverted, the chooser directory case
  fails.
- With only the resolver gate reverted, the resolver directory case
  fails.

Validation:
- `vitest run src/bundled/mentions MessageComposer.test.tsx
  SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts`:
  11 files, 953 tests passed, at load average about 9 to 16. The new
  cases passed in 3 more runs.
- `tsc --noEmit` passed. `biome check` on src/bundled/mentions and the
  test file, and `git diff --check`, passed.
- Not run: the browser journeys (mention-rules, completion-work,
  typeahead), the reviewer's modeled HTTP 502 in Chromium and WebKit,
  and a check in the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to 3ee7539, addressing the GitHub review on PR 2581
(discussions r4489800862 and r4489800870).

1. Offered agents here that load late are added (P2).
   In a stream where the viewer can add members, take a member
   `Jitterbug`, the viewer's agent `Jitter` in another community, and
   the viewer's non-member agent `Jitter` here that only the loading
   library lists. `@jitter` installed the member's row at once. When
   the library loaded, the name rule hid the other community's Jitter
   and the Jitter here became a local choice. But a non-invite chooser
   appended only `outside()` (other-community agents, then directory
   people), so the Jitter here never showed. `spaceMention` still chose
   its key. Because it was not a shown item, completion.ts dropped it,
   and no pending resolver was published, so Space left the name as
   prose.
   The append step now adds offered non-member agents here (local, not
   members, not from another community) before `outside()`. Shown rows
   stay where they are, and member-list changes still keep their
   installed set. This also covers an offered agent that arrives late
   in a single community, which before was never added for an
   unchanged query. Invite mode is unchanged.

2. The hold matches the names cached here (P3).
   `elsewhereMatches` matched only the name from `joinable()`. The
   agent's eventual row also matches the names it has without the
   directory (`buildCandidates` aliases): its cached profile name here,
   the verified full name and username in that profile, and its names
   among the viewer's agents here. So for an agent archived here as
   `Comet` that is `Rocket` in another community, `@comet` did not
   hold. A directory stranger named Comet showed and took Space while
   the library loaded. Once the library landed, the agent was added
   below the stranger: Space chose the agent, and Enter chose the
   stranger. The resolver did not wait either, and tagged the stranger.
   `elsewhereMatches` now checks all of those names. Undefined names
   are skipped; the `joinable()` name is checked as before.

README section 6 states both rules: an agent from another community is
held by the names its row has without the directory search, including
aliases cached here; and late offered agents are added before
other-community agents and the directory people added with them.

Tests:
- MessageComposer, from the chooser and through the resolver: member
  `Jitterbug`, library loading, a namesake `Jitter` elsewhere. The
  search finds `Jitterbird` during the hold. Once the library lists
  the Jitter here, rows are Jitterbug, then Jitter (Not in channel),
  then Jitterbird. Space tags Jitter. Submit asks "Jitter is not in
  this channel.", and "Send anyway" sends its key. In the resolver
  variant, Space during the load leaves a pending chip that settles to
  the same key.
- MessageComposer (`mountCachedAlias`), for Space and for Enter: the
  agent archived here as Comet, library loading, and the search finds
  a stranger Comet. While the library loads, the chooser shows
  "Loading recipients…" and no Comet or Rocket row. Once it loads,
  rows are the agent (In oss.example.com) above the stranger (Not in
  channel). Both keys lead to "Bring Rocket into".
- MessageComposer: a pending `@comet` chip typed during the load
  settles to the agent, not the stranger.
- mention-candidates.test.ts: `elsewhereMatches` is true for every
  alias of the agent's elsewhere row (joinable name, cached profile
  name, verified full name and username, name here) and false for an
  unrelated name.

Fail-before (production files mutated, then restored):
- With only the append change reverted, the P2 chooser case fails
  (2 rows, not 3). The P2 resolver variant still passes: the resolver
  never read shown rows, so it pins parity only.
- With offered agents appended after `outside()`, the P2 chooser case
  fails on row order.
- With only the `elsewhereMatches` change reverted, all 3 P3
  MessageComposer cases and the unit test fail. The chooser cases fail
  at "Loading recipients…"; the resolver case does not settle to
  `@Rocket `.
- Without just the names here, the verified names, or the cached
  profile name, the unit test fails. Without the cached profile name,
  the 3 P3 MessageComposer cases fail as well.

Validation (load average about 5 to 6 on 16 cores):
- `vitest run src/bundled/mentions MessageComposer.test.tsx
  SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts`:
  11 files, 1000 tests passed. After a last change to the alias guard
  (skip only undefined names), mention-candidates.test.ts and the 29
  MessageComposer cases for agents from other communities passed again.
- `tsc --noEmit` passed. `biome check` on the changed files and
  `git diff --check` passed.
- Not run: the browser journeys (mention-rules, completion-work,
  typeahead) and a check in the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to bdb4d9b, addressing the GitHub review on PR 2581
(discussion r4492884106).

`elsewhereMatches` matched one of the owner's agents from other
communities only by its names. Its exact public key (hex, npub or
`nostr:npub`) is the exact name of the choice with that key
(`matchingMentions`, `exactMention`), but no name matches it. So while
the owner's agents here loaded, `@<key>` of such an agent did not hold:
`offeredElsewhere` left the agent out until a complete read, the chooser
searched the directory, and when the author lookup found no profile
here, the chooser finished with no recipient. A pending chip skipped
the library wait in the resolver as well, captured an empty `offered`
set, and returned the key to prose after the empty lookup.

- `elsewhereMatches` now treats a query that is a public key
  (`typedPublicKey`) as matching only the agent with that key, before
  the name aliases. The chooser's `held` and `includeLegacy`, and the
  resolver's wait and incomplete-read gate, all read it, so a key takes
  the same bounded library wait a name does.
- The resolver reads `offeredElsewhere` and the local key set when it
  decides, not once before the directory lookup, as the chooser does on
  each render. A library read that lands during the lookup offers its
  agents to that decision. With the key hold in place the resolver
  always waits first, so no test pins this move on its own.

README section 6 says an agent's exact public key holds it too.

Tests:
- mention-candidates.test.ts: `elsewhereMatches` is true for the
  agent's hex key, npub and `nostr:npub`, and false for a member
  agent's key and npub.
- MessageComposer, for hex, npub and `nostr:npub`: the library is
  loading, the typed key shows "Loading recipients…" with no row, the
  directory finds no one, Space leaves a pending chip, and once the
  library lists the owner's agents here the chip settles to `@Rocket `.
  Submit asks "Bring Rocket into".
- MessageComposer, hex key, from the chooser: after the empty lookup
  and the library read, the only row is the agent's "In
  oss.example.com" row, and Space tags it.

Fail-before (production files replaced with bdb4d9b's, then
restored): all 5 cases fail. The composer cases never see "Loading
recipients…". With only the resolver change reverted, the 4 composer
cases still pass.

Single-community behaviour is unchanged: with no agents in other
communities `elsewhereMatches` returns false before the key check, and
the resolver's decision reads the same empty `offered` set as before.

Validation (load average about 22 to 34 on 16 cores):
- `vitest run src/bundled/mentions MessageComposer.test.tsx
  SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts`:
  10 files, 932 tests passed.
- `tsc --noEmit` passed. `biome check` on the changed files and
  `git diff --check` passed.
- Not run: the browser journeys (mention-rules, completion-work,
  typeahead) and a check in the app.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e and others added 4 commits November 11, 2026 11:42
Follow-up to bce7123, addressing the GitHub review on PR 2581
(discussion r4493800251).

Say the owner has Jitter here (key A) and Jitter in another community
(key B). The name rule (README section 1, item 3) dropped B in
`offeredElsewhere` before the query was considered. So typing B's hex
key, npub or `nostr:npub` could never select B, although a key names
only its owner. The exact-key hold from bce7123 also waited for the
library and then offered nothing: the chooser finished with no row, and
a pending chip returned to prose after the directory lookup.

`offeredElsewhere` now takes the query. It applies the same-name
exclusion only to name queries and keeps the agent whose key
`typedPublicKey(query)` returns.
- The chooser passes the typed key to both `current` and `localKeys`,
  and depends on that key rather than the query. Other keystrokes keep
  the cached choice set.
- The resolver passes the query it decides, so continued names stay
  name queries.

The key still waits for a complete read of the owner's agents here,
like a name. `joinable()` leaves out only agents that native inventory
lists here, and the library can still list that key here. Until a
complete read, it is unknown whether the key has a row here or an
"In <host>" row. So the hold, Retry and the incomplete-read gate are
unchanged.

In `spaceMention`, a key query matches only B (`matchingMentions`), and
no name equals a key, so A's name cannot make it ambiguous. A new
`space` fixture pins this: a member Jitter and a Jitter from another
community, with B's npub as the query, select B.

README section 1 item 3 states the key exception. Section 6 says that
the key still holds the agent until the complete read, and why.

Tests:
- mention-candidates.test.ts: with a complete read, a namesake
  elsewhere is not offered for no query, "", or its name in either
  case. It is offered for its hex key, its upper-case hex key, its npub
  and its `nostr:npub`, beside the other agent. The other agent's key
  does not offer it. After an incomplete read, its key offers nothing.
- MessageComposer, from the chooser: the library lists Jitter here. The
  only row for B's hex key is "In oss.example.com". Space tags
  "@jitter " at once, and submit asks "Bring Jitter into".
- MessageComposer, through the resolver, for hex, npub and
  `nostr:npub`: the library is loading and the directory finds no one.
  Space leaves a pending chip. The library then lists Jitter here, and
  the chip settles to "@jitter ". Submit asks "Bring Jitter into".

Fail-before (production files replaced with bce7123's, then restored):
- All 3 production files reverted: the unit test and all 4 composer
  cases fail.
- Only use-mention-choices.ts reverted: only the chooser case fails.
- Only resolve-space-mention.ts reverted: only the 3 resolver cases
  fail.
- The new fixture passes before and after. It pins `spaceMention`,
  which did not change.

Single-community behaviour is unchanged. With no agents in other
communities, `offeredElsewhere` returns before reading the query. For a
name query, `typedPublicKey` returns undefined and the filter is the
same as before.

Validation (load average about 35 to 44 on 16 cores):
- `vitest run src/bundled/mentions MessageComposer.test.tsx
  SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts`:
  11 files, 1027 tests passed.
- `tsc --noEmit` passed. `biome check` on the changed source, test and
  fixture files, and `git diff --check`, passed.
- Not run: the browser journeys (mention-rules, completion-work,
  typeahead) and a check in the app.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to 52794e1, addressing the GitHub review on PR 2581 (issue
comment 6702822296).

With native inventory ready and the agent library still loading, a
search in the toolbar mention picker for an agent the owner has in
another community held that agent and the directory people with it
(README section 6). But the picker showed "Loading agents…" only while
the combined agent status was `loading`, and `createAgentChoices` reports
`ready` as soon as native inventory is, with the library read still
`pending`. So the hold showed nothing, and once the directory search
settled with no one, "No matching channel members." showed although the
row was still to come. The design guide's "States, feedback, and motion"
section keeps loading distinct from no results.

The picker now has one `loading` flag: the chooser is pending
(`model.pending`, which includes members, a draft roster, invite mode,
and the hold while the library read runs) or the agent list is
`loading`. It drives the existing "Loading agents…" text and suppresses
"No matching channel members." while it holds. The second term keeps the
existing feedback for a plain channel whose agent list loads while the
chooser is not pending, which `model.pending` leaves out by design so
Space still works there.

The inline chooser has no such gap: MentionCompletion already puts
"Loading recipients…" first in its status chain when `model.pending`,
and it has no empty message. A new test pins that.

README section 6 now says a chooser SHOULD show that it is loading, not
that nothing matches, while this read runs, even when its agent list is
otherwise ready.

Tests (MessageComposer.test.tsx):
- `mountDirectoryMention` takes an optional `control`, the native agent
  inventory, so a test can hold the library with the combined status
  `ready`. `mountNativeReadyHold` asserts that status.
- New, toolbar picker: searching `rocket` shows "Loading agents…" and no
  "No matching channel members.", before and after the directory settles
  with no one. Once the library loads, the agent's "In oss.example.com"
  row shows and both messages are gone.
- New, inline chooser: the same hold shows "Loading recipients…" before
  and after the directory settles, then the row.
- The picker tool contribution used by the mount test moved into a
  shared `pickerOnly` fixture, which the new toolbar test reuses.

Fail-before (production file replaced with 52794e1's, then restored):
the toolbar case fails at its first assertion, with no "Loading
agents…". With only the `!loading` gate removed from the empty message,
it fails at "No matching channel members." being shown. The inline case
passes before and after: it pins parity.

Single-community behaviour is unchanged: with no agents in other
communities and no invite, `model.pending` is true only while members or
a draft roster load, when the picker had no rows to show anyway.

Validation (load average about 380 to 570 on 16 cores):
- `vitest run src/bundled/mentions MessageComposer.test.tsx
  SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts`:
  11 files, 1027 of 1029 passed. The 2 failures hit the 5 s timeout in
  tests this commit does not touch ("sends and restores model and effort
  per Solo chat and agent" and "a refused start reports why and never
  overwrites settings begun in New chat since"); both passed when rerun
  alone.
- `tsc --noEmit` passed. `biome check` on the changed files and
  `git diff --check` passed.
- Not run: the browser journeys (mention-rules, completion-work,
  typeahead) and a check in the app, including how the picker looks
  while held in both themes.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…nts wait

Follow-up to dcae5f69f, addressing its review.

That commit gave the toolbar mention picker one `loading` flag,
`model.pending || agents.status === "loading"`, and gated "Loading
agents…" and "No matching channel members." on it. The review found
three states it still got wrong:

1. Invite mode had the same loading-vs-empty gap. With native inventory
   ready and the library pending, the chooser's invite term in `pending`
   is false by design (Space still tags a unique local name), so
   `loading` was false. But the install gate (`!invite ||
   !agents.pending`) withholds rows until the library lands, so a query
   that matched no local row showed "No matching channel members." and
   then the row appeared.
2. After a failed library read, `agents.pending` is false and the
   install gate withholds rows on `agents.error`, so the empty message
   rendered beside "Retry agent list".
3. `loading` also covered `membersPending` and a draft roster, so
   "Loading agents…" rendered beside "Channel membership unavailable."
   while membership loaded, with no agent read involved.

Rule: "Loading agents…" names the agent read only; the empty message
waits for every pending state and for a failed agent read.

- `useMentionChoices` returns `agentsPending`: `agents.pending &&
  (!!invite || held)`, the read of the agent list that rows wait on.
  It mirrors the install gate's agent terms and is not folded into
  `pending`, so Space in invite mode is unchanged. Its `pending` is
  unchanged.
- The picker's `agentsLoading` is `agents.status === "loading" ||
  model.agentsPending`. It drives "Loading agents…". The string is
  unchanged.
- `agentsFailed` (`agents.status === "error" || !!agents.error`) names
  the existing Retry condition.
- `empty` requires members, `!model.pending`, `!agentsLoading`,
  `!agentsFailed`, no candidates and no teams. It drives "No matching
  channel members.".

README section 6 now says the same loading-over-empty rule holds while
inviting agents waits for the agent list, and that after the read fails
a chooser SHOULD NOT say that nothing matches.

Single-community behaviour otherwise unchanged: with no invite and no
agents in other communities, `agentsPending` is false, so "Loading
agents…" shows exactly when the agent status is `loading`, as before
dcae5f69f, and the empty message is additionally suppressed only while
membership or a roster loads (no rows then anyway) or the agent read has
failed.

Tests (MessageComposer.test.tsx; `mountDirectoryMention` takes an
optional `inviteAgents`, and `openPicker` opens the toolbar picker):
- New, invite mode with native inventory ready and the library loading:
  searching `rocket` shows "Loading agents…" and no empty message; once
  the library lists Rocket, its row shows and both messages are gone.
- New, failed library read: searching `rocket` shows "Retry agent list"
  with neither message; once the read lands with no Rocket, the empty
  message shows and Retry is gone.
- New, membership loading: the picker shows "Channel membership
  unavailable." and neither "Loading agents…" nor the empty message.

Fail-before (production file replaced, then restored):
- With dcae5f69f's MentionPicker.tsx, all 3 new cases fail.
- With `model.agentsPending` dropped from `agentsLoading`, the invite
  case fails, and so does the existing other-community hold case.
- With `!agentsFailed` dropped from `empty`, the failed-read case fails.
- With "Loading agents…" gated on `model.pending || agentsLoading`, the
  membership case fails.

Validation (load average about 190 to 250 on 16 cores):
- `vitest run src/bundled/mentions MessageComposer.test.tsx
  SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts`:
  11 files, 1032 tests passed.
- `tsc --noEmit` passed. `biome check` on the changed files and
  `git diff --check` passed.
- Not run: the browser journeys (mention-rules, completion-work,
  typeahead) and a check in the app, including the invite-mode picker
  and the failed-read picker in both themes.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… colon ends a name

Follow-up to e21a524, fixing the CI failure on PR 2581 (run
42589272929, JavaScript 1/6 and both Flake check jobs).

The two `nostr:npub` cases of "settles a pending mention of the %s of the
owner's agent from another community into it once their agents here
load" failed at `findByText("Loading recipients…")` in every run. They
typed `@nostr:npub1…` key by key. Since #2713 (cae3922, in this
branch's base after the last rebase), `:` is a mention terminator:
typed straight after a query it selects the exact name, or holds the
query as a pending mention while the chooser is undecided (mentions
README section 5, `mentionTerminator`). With the directory still
searching `nostr`, the colon committed `@nostr` as a pending chip, and
`:npub1…` was typed as prose after it (the CI DOM shows a chip with
`data-source="@nostr"` and the key as trailing text). No key query ever
reached the chooser, so nothing was held. The hex and npub cases have
no colon and passed.

This is the documented product rule, not a defect in the exact-key hold
(bce7123, 52794e1): a `nostr:` key reaches the chooser pasted, as a
63-character key does in practice, and the hold then applies to the
whole query. The tests now enter the key as a user would: `enterKey`
types `@` and the hex key or npub, and types `@` then pastes a `nostr:`
key. The assertions are unchanged.

The "Maximum update depth exceeded" warning that CI printed for these
two cases is not a render loop. Recording commits with a React DevTools
hook showed one commit per keystroke; the warning comes from React's
nested-passive-update counter, which `setPasteTooLong(false)` in the
composer's draft effect (92d4c36, on main) increments on every
keystroke whose passive flush runs while the next keystroke's update is
already pending, so that 60-odd keys typed after a pending chip cross
its limit of 50. The paste removes the long typed run, and the warning
no longer appears. The effect itself is main's code and is left alone.

Fail-before: with the colon typed, both cases fail as in CI; with the
paste, all 6 variants pass.

Validation (load average about 5 to 9 on 16 cores):
- `vitest run src/bundled/mentions MessageComposer.test.tsx
  SoloDraft.test.tsx person-match.test.ts relay/mentions.test.ts`:
  11 files, 1063 tests passed, with no "Maximum update depth" line
  on stderr.
- The two `nostr:npub` cases passed in 3 more runs each, with no
  warning on stderr.
- `tsc --noEmit` passed. `biome check` on the test file and
  `git diff --check` passed.
- Not run: the browser journeys (mention-rules, completion-work,
  typeahead) and a check in the app.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the agent-doesnt-get-tagged-after-fully-typing branch from 1a59982 to 1d9cfce Compare November 11, 2026 00:43

This branch has not been deployed

No deployments
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.

4 participants