From 6f2d3a5388e01b16081f47cbe066993173dfce43 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Nov 2026 12:37:25 +1100 Subject: [PATCH 01/12] fix(mentions): stop other-community agents from blocking a Space tag 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 Signed-off-by: Matt Toohey --- src/bundled/mentions/README.md | 27 +++++-- src/bundled/mentions/mention-candidates.ts | 21 +++++ src/bundled/mentions/mention-ranking.test.ts | 9 ++- .../mentions/mention-rules.fixtures.json | 40 ++++++++++ src/bundled/mentions/resolve-space-mention.ts | 9 ++- src/bundled/mentions/use-mention-choices.ts | 18 ++++- .../messages/MessageComposer.test.tsx | 79 ++++++++++++++++--- 7 files changed, 175 insertions(+), 28 deletions(-) diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index 8fdce63fce..6efa02789c 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -75,7 +75,9 @@ archived and not read-only, the choice set is: 1. the channel's members; 2. the agents that the client offers for mention in this channel; 3. the viewer's own agents from their other communities, which the client can - bring into this one (section 7); and + bring into this one (section 7), except one whose name equals, after + normalization (section 5), the name of one of the viewer's unarchived agents + in this community: that agent already has the name here; and 4. people and agents found by a community directory search for the current query. Solo chats MUST NOT add directory people or agents from other communities. This @@ -221,19 +223,28 @@ one with no aliases, and of no other choice, and no name continues it: a key has no namesakes. Nor does a saved team with the same or a longer name keep it from selecting. The steps below then apply as for a name. -The **local** choices are the ones known without the directory search: the -channel's members and the agents the client offers for mention (section 1, items -1 to 3, or a Solo chat's own agent list). The client selects: +The **local** choices are the channel's members and the agents the client +offers for mention (section 1, items 1 and 2, or a Solo chat's own agent list), +all known without the directory search. The client selects: 1. the exact name among the local choices, once every member's name is known or known to be absent; otherwise -2. the exact name among the full choice set, once the full choice set is known: - no directory search for this query is waiting, running, or failed, and the - search reported no more pages. +2. once the full choice set is known (no directory search for this query is + waiting, running, or failed, and the search reported no more pages), the + exact name among the full choice set without item 3, or else the exact name + among the full choice set. In both cases the choice must be shown and can still be chosen. Check against the whole set, not only the shown rows. +The viewer's agents from their other communities (section 1, item 3) are also +known without the search, but they are not local, and they never make a name in +this community ambiguous: a member named `Jitter` selects although the +viewer's agent `Jitter` from another community is also a choice. Such an agent +is the exact name only when no choice in this community has that name, so, like +a directory result, it waits for the search to finish. Its longer names still +continue the query. + Local choices show before directory results (section 6), so a unique local name selects at once: directory results cannot make it ambiguous, whether or not the search has found them yet. A longer directory name still blocks Space once the @@ -398,7 +409,7 @@ each choice supplies its own label. | --- | --- | | `matching` | Section 3. `expected` is the tier of `label` for `query` (0 to 3), or `null` when it does not match. | | `ranking` | Sections 3 and 4. `expected` is the full ordered key list. | -| `space` | Section 5 exact name among one complete choice set. The local-first step is tested only in the desktop UI tests. `expected` is a key or `null`. | +| `space` | Section 5 step 2 over one complete choice set; `elsewhere` marks section 1, item 3. The local-first step is tested only in the desktop UI tests. `expected` is a key or `null`. | | `query` | Section 2 syntax. `expected` is `{start, end, query}` or `null`. | | `admission` | Section 2 multi-word rule. `expected` is a boolean. | | `tags.write` | Section 8 writer. `expected` is the ordered `p` and `mention` tags, or an error class: `not_member`, `invalid`, or `too_many`. The sender is a member in addition to `members`. | diff --git a/src/bundled/mentions/mention-candidates.ts b/src/bundled/mentions/mention-candidates.ts index daf1b2abb3..9e21f82a61 100644 --- a/src/bundled/mentions/mention-candidates.ts +++ b/src/bundled/mentions/mention-candidates.ts @@ -8,6 +8,7 @@ import { import { knownAgentPubkeys } from "../../features/agents/known"; import type { Agent } from "../../features/agents/service"; import { communityHost } from "../../features/messages/community-host"; +import { normalizeName } from "../../features/search/person-match"; import { allowsOutsideMentions, archivedMention, @@ -71,6 +72,26 @@ type DirectoryPerson = MentionRecipient & { }; type ElsewhereAgent = Pick; const none: readonly never[] = []; + +/** + * The owner's agents from their other communities that the chooser offers by + * name (README section 1): none named like one of the owner's unarchived + * agents here, which already has that name. Teams still reach them by key. + */ +export function offeredElsewhere( + session: ComposerSession, + elsewhere: readonly ElsewhereAgent[] | undefined, +) { + if (!elsewhere?.length) return elsewhere; + const here = new Set( + session.agentChoices + .snapshot() + .identities.filter((agent) => !archivedMention(session, agent.pubkey)) + .map((agent) => normalizeName(agent.name)), + ); + return elsewhere.filter((agent) => !here.has(normalizeName(agent.name))); +} + // Rows change only with their sources, not with each keystroke. Snapshots are // immutable, so identity is their revision; `joinable()` copies on each call. // Each directory array keeps its own rows: the chooser and its team rows pass diff --git a/src/bundled/mentions/mention-ranking.test.ts b/src/bundled/mentions/mention-ranking.test.ts index 17822818ab..5bf51b7db4 100644 --- a/src/bundled/mentions/mention-ranking.test.ts +++ b/src/bundled/mentions/mention-ranking.test.ts @@ -9,6 +9,7 @@ import { type MentionChoice, } from "./mention-ranking"; import { fixtureChoice, mentionConformance } from "./mention-rules.conformance"; +import { spaceMention } from "./use-mention-choices"; import { matchPerson, nameStarts } from "../../features/search/person-match"; const choice = ( key: string, @@ -246,8 +247,14 @@ it.each(mentionConformance.ranking)( it.each(mentionConformance.space)( "conforms to the portable Space contract: $name", (fixture) => { + // Step 2 of section 5: nothing is local and the full choice set is known. expect( - exactMention(fixture.choices.map(fixtureChoice), fixture.query) ?? null, + spaceMention( + fixture.choices.map(fixtureChoice), + new Set(), + fixture.query, + { pending: false, missingNames: false, complete: true }, + ) ?? null, ).toBe(fixture.expected); }, ); diff --git a/src/bundled/mentions/mention-rules.fixtures.json b/src/bundled/mentions/mention-rules.fixtures.json index 8de74ff56f..6cc382d207 100644 --- a/src/bundled/mentions/mention-rules.fixtures.json +++ b/src/bundled/mentions/mention-rules.fixtures.json @@ -1382,6 +1382,46 @@ } ], "expected": null + }, + { + "name": "agent_from_another_community_does_not_make_a_name_here_ambiguous", + "query": "Jitter", + "choices": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Jitter", + "member": true + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Jitter", + "member": false, + "agent": true, + "owned": true, + "elsewhere": "other.example.com" + } + ], + "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "name": "agent_from_another_community_selects_when_nothing_here_has_the_name", + "query": "jitter", + "choices": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Member", + "member": true + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Jitter", + "member": false, + "agent": true, + "owned": true, + "elsewhere": "other.example.com" + } + ], + "expected": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ], "query": [ diff --git a/src/bundled/mentions/resolve-space-mention.ts b/src/bundled/mentions/resolve-space-mention.ts index 7446efb8fb..51eb58c7d8 100644 --- a/src/bundled/mentions/resolve-space-mention.ts +++ b/src/bundled/mentions/resolve-space-mention.ts @@ -6,7 +6,7 @@ import { allowsOutsideMentions } from "../../features/messages/mention-admission import type { MentionRecipient } from "../../features/messages/mention-draft"; import type { MentionRoster } from "../../features/messages/draft-mention-roster"; import type { Agent } from "../../features/agents/service"; -import { mentionCandidates } from "./mention-candidates"; +import { mentionCandidates, offeredElsewhere } from "./mention-candidates"; import { continuingNames } from "./mention-ranking"; import { namedCandidates, spaceMention } from "./use-mention-choices"; import { teamBlocksSpace } from "./use-team-mentions"; @@ -30,7 +30,7 @@ export type SpaceMentionScope = { selected: readonly MentionRecipient[]; /** Current saved names, also read after asynchronous directory resolution. */ teamNames?: (() => readonly string[]) | undefined; - /** The owner's agents in their other communities, as the chooser offers them. */ + /** The owner's agents in their other communities (`Agents.joinable()`). */ elsewhere?: readonly Pick[] | undefined; }; @@ -144,6 +144,7 @@ async function resolve( const kit = fullSession(session)?.channelKit; if (teamBlocksSpace(kit?.snapshot() ?? { entries: [] }, name, teamNames?.())) return null; + const offered = offeredElsewhere(session, elsewhere); const local = new Set( mentionCandidates( session, @@ -151,7 +152,7 @@ async function resolve( inviteAgents, roster, undefined, - elsewhere, + offered, ).map((c) => c.recipient.pubkey), ); const profiles = session.profiles.snapshot(); @@ -175,7 +176,7 @@ async function resolve( roster, selected, people, - elsewhere, + offered, ); const pubkey = spaceMention(choices, local, query, { pending: false, diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 742bd0e106..8052c86137 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -24,6 +24,7 @@ import { directoryChanges, managedByDetail, mentionCandidates, + offeredElsewhere, } from "./mention-candidates"; import { useListedChannel } from "../../features/relay/listed-channel"; import { useNamesRevision } from "../../features/identity-names/react"; @@ -220,6 +221,8 @@ export type SpaceEvidence = { * Space (README section 5): once every member is named, an exact local name * selects at once. Any other exact name needs the full choice set: while the * directory search runs, failed, or has more pages, a namesake may be missing. + * The owner's agents from other communities never make a name here ambiguous: + * one is the exact name only when nothing here has that name. */ export function spaceMention( all: readonly MentionChoice[], @@ -229,11 +232,18 @@ export function spaceMention( ) { if (pending) return undefined; const choices = matchingMentions(all, query); + const here = (c: MentionChoice) => !c.elsewhere; return ( (missingNames ? undefined - : exactMention(choices, query, (c) => local.has(c.recipient.pubkey))) ?? - (complete ? exactMention(choices, query) : undefined) + : exactMention( + choices, + query, + (c) => here(c) && local.has(c.recipient.pubkey), + )) ?? + (complete + ? (exactMention(choices, query, here) ?? exactMention(choices, query)) + : undefined) ); } /** @@ -337,7 +347,7 @@ export function useMentionChoices( roster, selected, people, - community?.joinable(), + offeredElsewhere(session, community?.joinable()), ), [session, channelId, invite, roster, selected, people, community], ); @@ -349,7 +359,7 @@ export function useMentionChoices( invite, roster, undefined, - community?.joinable(), + offeredElsewhere(session, community?.joinable()), ); const cached = keySets.get(local); if (cached) return cached; diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 39dd312962..a778a92ef0 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -14870,13 +14870,51 @@ function elsewhereAgent(agent: MentionRecipient) { } as unknown as Agents; } -it("returns a pending mention to prose when a member shares its name with the owner's agent from another community", async () => { - // The chooser counts the other community's agent as a local namesake, so - // Space holds a chip; the resolver must decide over the same set. +it.each([ + ["at once when every member is named", true], + ["once the pending name resolves", false], +])( + "tags a member who shares its name with the owner's agent from another community, %s", + async (_, named) => { + // The other community's agent never makes a name here ambiguous, in the + // chooser or in the resolver. + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Mira" }), + channel: { channelType: "stream" }, + }); + h.setProfiles( + new Map([ + [first.pubkey, { name: "Mira" }], + ...(named + ? [[second.pubkey, { name: "Another Member" }] as const] + : []), + ]), + ); + await h.user.type(h.input(), "@mira"); + await screen.findByRole("option", { name: /In oss\.example\.com/ }); + await h.user.keyboard(" "); + if (!named) { + // A member's name still unknown keeps the chooser from deciding Space. + expect(h.chip("mira")).toHaveAttribute("aria-busy", "true"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [], hasMore: false }); + } + await waitFor(() => expect(h.input()).toHaveValue("@Mira ")); + expect(h.chip("mira")).toBeNull(); + expect(h.recipients()).toHaveLength(1); + h.submit(); + await act(async () => {}); + expect(h.messages.send).toHaveBeenCalledOnce(); + expect(h.messages.send.mock.calls[0]?.[2]).toEqual([first.pubkey]); + }, +); + +it("does not offer the owner's agent from another community when one of their agents here has its name", async () => { const h = mountDirectoryMention({ agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Mira" }), channel: { channelType: "stream" }, }); + h.setLibrary([{ pubkey: first.pubkey, name: "Mira" }]); h.setProfiles( new Map([ [first.pubkey, { name: "Mira" }], @@ -14884,19 +14922,38 @@ it("returns a pending mention to prose when a member shares its name with the ow ]), ); await h.user.type(h.input(), "@mira"); + // Rows from other communities install with the members' rows. + await screen.findByRole("option", { name: /Mira/ }); + expect( + screen.queryByRole("option", { name: /In oss\.example\.com/ }), + ).toBeNull(); + await h.user.keyboard(" "); + await waitFor(() => expect(h.input()).toHaveValue("@Mira ")); + expect(h.recipients()).toHaveLength(1); +}); + +it("selects the owner's agent from another community on Space once the search finds no other match", async () => { + const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; + const h = mountDirectoryMention({ + agents: elsewhereAgent(rocket), + channel: { channelType: "stream" }, + }); + await h.user.type(h.input(), "@rocket"); await screen.findByRole("option", { name: /In oss\.example\.com/ }); + await screen.findByText("Searching community…"); await h.user.keyboard(" "); + // A directory result in this community could still have the name. + expect(h.chip("rocket")).toHaveAttribute("aria-busy", "true"); await waitFor(() => expect(h.people).toHaveBeenCalled()); await h.release({ people: [], hasMore: false }); - await waitFor(() => expect(h.chip("mira")).toBeNull()); - expect(h.input()).toHaveValue("@mira "); - expect( - screen.queryByRole("region", { name: "Explicit mentions" }), - ).toBeNull(); + await waitFor(() => expect(h.input()).toHaveValue("@Rocket ")); + expect(h.chip("rocket")).toBeNull(); + expect(h.recipients()).toHaveLength(1); h.submit(); - await act(async () => {}); - expect(h.messages.send).toHaveBeenCalledOnce(); - expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([]); + expect(await screen.findByRole("dialog")).toHaveTextContent( + /Bring Rocket into/, + ); + expect(h.messages.send).not.toHaveBeenCalled(); }); it("settles a pending mention into the owner's agent from another community that is archived here, as Tab would", async () => { From 09ae4d347626157010b983d8de9cfe74d027a49e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Nov 2026 13:08:46 +1100 Subject: [PATCH 02/12] fix(mentions): rank other-community agents between here and directory Follow-up to 73ea38cc8, 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 73ea38cc8, 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 73ea38cc8: 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 73ea38cc8'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 Signed-off-by: Matt Toohey --- src/bundled/mentions/README.md | 38 ++++--- src/bundled/mentions/mention-candidates.ts | 37 +++++- src/bundled/mentions/mention-ranking.test.ts | 20 ++-- src/bundled/mentions/mention-ranking.ts | 9 ++ .../mentions/mention-rules.fixtures.json | 45 ++++++++ src/bundled/mentions/resolve-space-mention.ts | 15 ++- src/bundled/mentions/use-mention-choices.ts | 45 +++++--- .../messages/MessageComposer.test.tsx | 107 +++++++++++++++++- 8 files changed, 265 insertions(+), 51 deletions(-) diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index 6efa02789c..e546bf3b15 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -76,7 +76,7 @@ archived and not read-only, the choice set is: 2. the agents that the client offers for mention in this channel; 3. the viewer's own agents from their other communities, which the client can bring into this one (section 7), except one whose name equals, after - normalization (section 5), the name of one of the viewer's unarchived agents + normalization (section 3), the name of one of the viewer's unarchived agents in this community: that agent already has the name here; and 4. people and agents found by a community directory search for the current query. @@ -223,27 +223,29 @@ one with no aliases, and of no other choice, and no name continues it: a key has no namesakes. Nor does a saved team with the same or a longer name keep it from selecting. The steps below then apply as for a name. -The **local** choices are the channel's members and the agents the client -offers for mention (section 1, items 1 and 2, or a Solo chat's own agent list), -all known without the directory search. The client selects: +The **local** choices are the ones known without the directory search: the +channel's members and the agents the client offers for mention (section 1, +items 1 to 3, or a Solo chat's own agent list). The client selects: 1. the exact name among the local choices, once every member's name is known or known to be absent; otherwise -2. once the full choice set is known (no directory search for this query is - waiting, running, or failed, and the search reported no more pages), the - exact name among the full choice set without item 3, or else the exact name - among the full choice set. +2. the exact name among the full choice set, once the full choice set is known: + no directory search for this query is waiting, running, or failed, and the + search reported no more pages. In both cases the choice must be shown and can still be chosen. Check against the whole set, not only the shown rows. -The viewer's agents from their other communities (section 1, item 3) are also -known without the search, but they are not local, and they never make a name in -this community ambiguous: a member named `Jitter` selects although the -viewer's agent `Jitter` from another community is also a choice. Such an agent -is the exact name only when no choice in this community has that name, so, like -a directory result, it waits for the search to finish. Its longer names still -continue the query. +Both steps apply this precedence. When a local choice in this community +(section 1, item 1 or 2) has an alias or label equal to the query, the viewer's +agents from their other communities (item 3) cannot be the exact name. +Otherwise, when one of those agents has one, directory results cannot. So a +member named `Jitter` selects although the viewer's agent `Jitter` from +another community is also a choice. When nothing local in this community is +named `Jitter`, that agent selects at once: a directory result named `Jitter` +cannot make it ambiguous. In step 2, a local choice in this community and a +directory result with the same name are still namesakes. Every choice's longer +names still continue the query. Local choices show before directory results (section 6), so a unique local name selects at once: directory results cannot make it ambiguous, whether or not the @@ -314,6 +316,10 @@ mention is never a recipient or a reference (section 8). These rules keep a row from moving under the user's pointer or keyboard. - Members and offered agents SHOULD show without waiting for the directory. +- The viewer's agents from their other communities (section 1, item 3) SHOULD + show only once the viewer's agents in this community are known, so that the + name exception in section 1 never disables a shown row. Until then the agent + list is still loading (section 5). - A shown row MUST NOT move while the query and the open chooser stay the same. New rows are added only at the bottom. Directory people come after the rows already shown. @@ -409,7 +415,7 @@ each choice supplies its own label. | --- | --- | | `matching` | Section 3. `expected` is the tier of `label` for `query` (0 to 3), or `null` when it does not match. | | `ranking` | Sections 3 and 4. `expected` is the full ordered key list. | -| `space` | Section 5 step 2 over one complete choice set; `elsewhere` marks section 1, item 3. The local-first step is tested only in the desktop UI tests. `expected` is a key or `null`. | +| `space` | Section 5 step 2 over one complete choice set, with its precedence. Members and choices with `elsewhere` (section 1, item 3) are local; other non-members are directory results. The local-first step is tested only in the desktop UI tests. `expected` is a key or `null`. | | `query` | Section 2 syntax. `expected` is `{start, end, query}` or `null`. | | `admission` | Section 2 multi-word rule. `expected` is a boolean. | | `tags.write` | Section 8 writer. `expected` is the ordered `p` and `mention` tags, or an error class: `not_member`, `invalid`, or `too_many`. The sender is a member in addition to `members`. | diff --git a/src/bundled/mentions/mention-candidates.ts b/src/bundled/mentions/mention-candidates.ts index 9e21f82a61..60ac784e01 100644 --- a/src/bundled/mentions/mention-candidates.ts +++ b/src/bundled/mentions/mention-candidates.ts @@ -77,21 +77,52 @@ const none: readonly never[] = []; * The owner's agents from their other communities that the chooser offers by * name (README section 1): none named like one of the owner's unarchived * agents here, which already has that name. Teams still reach them by key. + * None until the owner's agents here are known (`elsewhereHeld`). */ export function offeredElsewhere( session: ComposerSession, elsewhere: readonly ElsewhereAgent[] | undefined, ) { if (!elsewhere?.length) return elsewhere; + const agents = session.agentChoices.snapshot(); + if (agents.pending) return none; const here = new Set( - session.agentChoices - .snapshot() - .identities.filter((agent) => !archivedMention(session, agent.pubkey)) + agents.identities + .filter((agent) => !archivedMention(session, agent.pubkey)) .map((agent) => normalizeName(agent.name)), ); return elsewhere.filter((agent) => !here.has(normalizeName(agent.name))); } +/** + * Whether the chooser holds back the owner's agents from other communities it + * would offer here while the owner's agents here load, so that a row it shows + * never leaves under the name rule (README section 6). Until then the agent + * list is loading: Space waits as for invited agents. + */ +export function elsewhereHeld( + session: ComposerSession, + channelId: string, + inviteAgents: boolean | undefined, + roster: readonly MentionRecipient[] | undefined, + elsewhere: readonly ElsewhereAgent[] | undefined, +) { + if ( + roster || + inviteAgents || + !elsewhere?.length || + !session.agentChoices.snapshot().pending + ) + return false; + const channel = session.channels + .list() + .channels.find((c) => c.id === channelId); + return ( + availableMentionAgents(channel, elsewhere, session.outbox?.supports(9000)) + .length > 0 + ); +} + // Rows change only with their sources, not with each keystroke. Snapshots are // immutable, so identity is their revision; `joinable()` copies on each call. // Each directory array keeps its own rows: the chooser and its team rows pass diff --git a/src/bundled/mentions/mention-ranking.test.ts b/src/bundled/mentions/mention-ranking.test.ts index 5bf51b7db4..36e97606a5 100644 --- a/src/bundled/mentions/mention-ranking.test.ts +++ b/src/bundled/mentions/mention-ranking.test.ts @@ -247,14 +247,20 @@ it.each(mentionConformance.ranking)( it.each(mentionConformance.space)( "conforms to the portable Space contract: $name", (fixture) => { - // Step 2 of section 5: nothing is local and the full choice set is known. + // Step 2 of section 5 over the complete choice set: members and agents + // from other communities are local; other choices are directory results. + const choices = fixture.choices.map(fixtureChoice); + const local = new Set( + choices + .filter((c) => c.member || c.elsewhere) + .map((c) => c.recipient.pubkey), + ); expect( - spaceMention( - fixture.choices.map(fixtureChoice), - new Set(), - fixture.query, - { pending: false, missingNames: false, complete: true }, - ) ?? null, + spaceMention(choices, local, fixture.query, { + pending: false, + missingNames: true, + complete: true, + }) ?? null, ).toBe(fixture.expected); }, ); diff --git a/src/bundled/mentions/mention-ranking.ts b/src/bundled/mentions/mention-ranking.ts index 39fc26e59c..75a96548fb 100644 --- a/src/bundled/mentions/mention-ranking.ts +++ b/src/bundled/mentions/mention-ranking.ts @@ -252,6 +252,15 @@ export const continuedMention = ( choices: readonly MentionChoice[], query: string, ) => continuingNames(choices, query).length > 0; +/** Whether one of the choice's known names equals the query (README section 5). */ +export function namesQuery(choice: MentionChoice, query: string) { + const needle = normalized(query); + return ( + !!needle && + choice.aliases.length > 0 && + choiceNames(choice).some((name) => normalized(name) === needle) + ); +} /** * Space is intent only for one exact key across the full, uncapped choice set. * Every choice can continue the name (`continuedMention`); only choices diff --git a/src/bundled/mentions/mention-rules.fixtures.json b/src/bundled/mentions/mention-rules.fixtures.json index 6cc382d207..c17ee7f80e 100644 --- a/src/bundled/mentions/mention-rules.fixtures.json +++ b/src/bundled/mentions/mention-rules.fixtures.json @@ -1422,6 +1422,51 @@ } ], "expected": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + { + "name": "agent_from_another_community_outranks_a_directory_namesake", + "query": "Jitter", + "choices": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Jitter", + "member": false + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Jitter", + "member": false, + "agent": true, + "owned": true, + "elsewhere": "other.example.com" + } + ], + "expected": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + { + "name": "member_and_directory_namesake_stay_ambiguous_beside_an_agent_from_another_community", + "query": "Jitter", + "choices": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Jitter", + "member": true + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Jitter", + "member": false, + "agent": true, + "owned": true, + "elsewhere": "other.example.com" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Jitter", + "member": false + } + ], + "expected": null } ], "query": [ diff --git a/src/bundled/mentions/resolve-space-mention.ts b/src/bundled/mentions/resolve-space-mention.ts index 51eb58c7d8..12642ffdbb 100644 --- a/src/bundled/mentions/resolve-space-mention.ts +++ b/src/bundled/mentions/resolve-space-mention.ts @@ -6,7 +6,11 @@ import { allowsOutsideMentions } from "../../features/messages/mention-admission import type { MentionRecipient } from "../../features/messages/mention-draft"; import type { MentionRoster } from "../../features/messages/draft-mention-roster"; import type { Agent } from "../../features/agents/service"; -import { mentionCandidates, offeredElsewhere } from "./mention-candidates"; +import { + elsewhereHeld, + mentionCandidates, + offeredElsewhere, +} from "./mention-candidates"; import { continuingNames } from "./mention-ranking"; import { namedCandidates, spaceMention } from "./use-mention-choices"; import { teamBlocksSpace } from "./use-team-mentions"; @@ -127,14 +131,19 @@ async function resolve( "background", ) .catch(() => {}); - if (invite) { + // The chooser holds the owner's agents from other communities until all of + // the owner's agents here are known; invited agents may be partial. + if ( + invite || + elsewhereHeld(session, channelId, inviteAgents, roster, elsewhere) + ) { const release = session.agentChoices.retain(); try { session.agentChoices.ensure(true); await settled( session.agentChoices.subscribe, session.agentChoices.snapshot, - (agents) => !agents.pending || agents.status === "ready", + (agents) => !agents.pending || (invite && agents.status === "ready"), signal, ); } finally { diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 8052c86137..894726eb6b 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -22,6 +22,7 @@ import { archivedMention } from "../../features/messages/mention-admission"; import { mentionHistory } from "../../features/messages/mention-history"; import { directoryChanges, + elsewhereHeld, managedByDetail, mentionCandidates, offeredElsewhere, @@ -36,6 +37,7 @@ import { import { exactMention, matchingMentions, + namesQuery, rankMentions, type MentionChoice, } from "./mention-ranking"; @@ -221,8 +223,8 @@ export type SpaceEvidence = { * Space (README section 5): once every member is named, an exact local name * selects at once. Any other exact name needs the full choice set: while the * directory search runs, failed, or has more pages, a namesake may be missing. - * The owner's agents from other communities never make a name here ambiguous: - * one is the exact name only when nothing here has that name. + * For one name, a local choice here outranks the owner's agents from other + * communities, and they outrank directory results. */ export function spaceMention( all: readonly MentionChoice[], @@ -232,18 +234,20 @@ export function spaceMention( ) { if (pending) return undefined; const choices = matchingMentions(all, query); - const here = (c: MentionChoice) => !c.elsewhere; + const isLocal = (c: MentionChoice) => local.has(c.recipient.pubkey); + const named = choices.filter((c) => namesQuery(c, query)); + const among: (c: MentionChoice) => boolean = named.some( + (c) => isLocal(c) && !c.elsewhere, + ) + ? (c) => !c.elsewhere + : named.some((c) => c.elsewhere) + ? (c) => !!c.elsewhere + : () => true; return ( (missingNames ? undefined - : exactMention( - choices, - query, - (c) => here(c) && local.has(c.recipient.pubkey), - )) ?? - (complete - ? (exactMention(choices, query, here) ?? exactMention(choices, query)) - : undefined) + : exactMention(choices, query, (c) => isLocal(c) && among(c))) ?? + (complete ? exactMention(choices, query, among) : undefined) ); } /** @@ -262,10 +266,14 @@ export function useMentionChoices( const draft = useContext(DraftMentionRoster); const roster = draft?.recipients; const selected = useContext(SelectedMentionContext); - const agents = useAgentChoices(session, !!invite && open); // The owner's agents in their other communities; the snapshot is the revision. const community = useContext(AgentsContext); const revision = useAgentsSnapshot(community); + // Holding them loads the owner's agents here, as inviting agents does. + const held = + open && + elsewhereHeld(session, channelId, invite, roster, community?.joinable()); + const agents = useAgentChoices(session, (!!invite || held) && open); const profiles = useSyncExternalStore( session.profiles.subscribe, session.profiles.snapshot, @@ -403,7 +411,8 @@ export function useMentionChoices( const pending = membersPending || !!draft?.pending || - (!roster && !!invite && agents.pending && agents.status !== "ready"); + (!roster && !!invite && agents.pending && agents.status !== "ready") || + held; let rows = same ? installed.rows : undefined; if (!same && installed) install(undefined); // Only choices the query can match; ranking reads no others. @@ -459,11 +468,15 @@ export function useMentionChoices( (key) => session.presence?.status(key) ?? "unknown", MENTION_ROWS, ); - // Ranked only when read: a full set of installed rows never needs outside choices. + // Rows that may arrive after the list installs: directory people, and the + // owner's agents from other communities once no longer held. Ranked only + // when read: a full set of installed rows never needs outside choices. let outsideRows: readonly MentionChoice[] | undefined; const outside = () => (outsideRows ??= open - ? rank(matches.filter((c) => !local.has(c.recipient.pubkey))) + ? rank( + matches.filter((c) => !local.has(c.recipient.pubkey) || c.elsewhere), + ) : []); // A member a completed read found no profile for has no name to be a namesake. const missingNames = @@ -486,7 +499,7 @@ export function useMentionChoices( } } if (rows && rows.length < MENTION_ROWS) { - // Append-only: late invited agents and directory pages never move shown rows. + // Append-only: late invited agents and `outside` rows never move shown rows. // Member-list changes keep their installed set until the query changes. const shown = new Set(rows.map((row) => row.recipient.pubkey)); const added = (invite ? rank(matches.filter((c) => c.agent)) : outside()) diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index a778a92ef0..8d96b2b016 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -14932,7 +14932,7 @@ it("does not offer the owner's agent from another community when one of their ag expect(h.recipients()).toHaveLength(1); }); -it("selects the owner's agent from another community on Space once the search finds no other match", async () => { +it("selects the owner's agent from another community on Space at once, while the search runs", async () => { const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; const h = mountDirectoryMention({ agents: elsewhereAgent(rocket), @@ -14942,11 +14942,8 @@ it("selects the owner's agent from another community on Space once the search fi await screen.findByRole("option", { name: /In oss\.example\.com/ }); await screen.findByText("Searching community…"); await h.user.keyboard(" "); - // A directory result in this community could still have the name. - expect(h.chip("rocket")).toHaveAttribute("aria-busy", "true"); - await waitFor(() => expect(h.people).toHaveBeenCalled()); - await h.release({ people: [], hasMore: false }); - await waitFor(() => expect(h.input()).toHaveValue("@Rocket ")); + // A directory result found later could not outrank it. + expect(h.input()).toHaveValue("@Rocket "); expect(h.chip("rocket")).toBeNull(); expect(h.recipients()).toHaveLength(1); h.submit(); @@ -14956,6 +14953,102 @@ it("selects the owner's agent from another community on Space once the search fi expect(h.messages.send).not.toHaveBeenCalled(); }); +it.each([ + ["from the chooser", true], + ["once the pending name resolves", false], +])( + "selects the owner's agent from another community over a same-named directory result, %s", + async (_, named) => { + const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; + const stranger = { pubkey: "d".repeat(64), name: "Rocket" }; + const h = mountDirectoryMention({ + agents: elsewhereAgent(rocket), + channel: { channelType: "stream" }, + }); + // A member's name still unknown keeps the chooser from deciding Space. + if (!named) + h.setProfiles(new Map([[first.pubkey, { name: "Other Member" }]])); + await h.user.type(h.input(), "@rocket"); + await screen.findByRole("option", { name: /In oss\.example\.com/ }); + if (named) { + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [stranger], hasMore: false }); + await screen.findByRole("option", { name: /Not in channel/ }); + } + await h.user.keyboard(" "); + if (!named) { + expect(h.chip("rocket")).toHaveAttribute("aria-busy", "true"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [stranger], hasMore: false }); + } + await waitFor(() => expect(h.input()).toHaveValue("@Rocket ")); + expect(h.chip("rocket")).toBeNull(); + expect(h.recipients()).toHaveLength(1); + h.submit(); + expect(await screen.findByRole("dialog")).toHaveTextContent( + /Bring Rocket into/, + ); + expect(h.messages.send).not.toHaveBeenCalled(); + }, +); + +it.each([ + ["never shows it when one of theirs here has its name", true], + ["adds it below the shown rows once theirs here are known", false], +])( + "holds the owner's agent from another community while their agents here load: %s", + async (_, namesake) => { + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Mira" }), + channel: { channelType: "stream" }, + }); + h.setLibrary([], "loading"); + h.setProfiles( + new Map([ + [first.pubkey, { name: "Mira" }], + [second.pubkey, { name: "Another Member" }], + ]), + ); + await h.user.type(h.input(), "@mira"); + // The member's row installs without waiting. + await screen.findByRole("option", { name: /Mira/ }); + await screen.findByText("Loading recipients…"); + expect(screen.getAllByRole("option")).toHaveLength(1); + h.setLibrary(namesake ? [{ pubkey: first.pubkey, name: "Mira" }] : []); + await screen.findByText("Searching community…"); + if (namesake) { + expect(screen.getAllByRole("option")).toHaveLength(1); + expect( + screen.queryByRole("option", { name: /No longer available/ }), + ).toBeNull(); + } else + expect(screen.getAllByRole("option").at(-1)).toHaveAccessibleName( + /In oss\.example\.com/, + ); + }, +); + +it("settles a pending mention into the owner's agent from another community once their agents here load", async () => { + const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; + const h = mountDirectoryMention({ + agents: elsewhereAgent(rocket), + channel: { channelType: "stream" }, + }); + h.setLibrary([], "loading"); + await h.user.type(h.input(), "@rocket"); + await screen.findByText("Loading recipients…"); + await h.user.keyboard(" "); + expect(h.chip("rocket")).toHaveAttribute("aria-busy", "true"); + // The directory read stays held: the name settles without it. + h.setLibrary([]); + await waitFor(() => expect(h.input()).toHaveValue("@Rocket ")); + expect(h.chip("rocket")).toBeNull(); + h.submit(); + expect(await screen.findByRole("dialog")).toHaveTextContent( + /Bring Rocket into/, + ); +}); + it("settles a pending mention into the owner's agent from another community that is archived here, as Tab would", async () => { const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; const archives = { status: "ready" as const, archived: [rocket.pubkey] }; @@ -14972,6 +15065,8 @@ it("settles a pending mention into the owner's agent from another community that }, }, }); + // Archived here, the agent does not keep its own row from being offered. + h.setLibrary([rocket]); // A member's name still unknown keeps the chooser from deciding Space. h.setProfiles(new Map([[first.pubkey, { name: "Other Member" }]])); await h.user.type(h.input(), "@rocket"); From 6897e4aa156b893ec38d344cea7525e5a4d23b52 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Nov 2026 13:33:01 +1100 Subject: [PATCH 03/12] fix(mentions): keep held other-community agents above directory rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to c39512ac6, 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 c39512ac6'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 Signed-off-by: Matt Toohey --- src/bundled/mentions/MentionCompletion.tsx | 2 +- src/bundled/mentions/MentionPicker.tsx | 2 +- src/bundled/mentions/README.md | 9 +- src/bundled/mentions/mention-candidates.ts | 33 +++---- src/bundled/mentions/resolve-space-mention.ts | 7 +- src/bundled/mentions/use-mention-choices.ts | 49 +++++++--- .../messages/MessageComposer.test.tsx | 96 ++++++++++++++++++- 7 files changed, 160 insertions(+), 38 deletions(-) diff --git a/src/bundled/mentions/MentionCompletion.tsx b/src/bundled/mentions/MentionCompletion.tsx index 1d0b5b314e..ffa1ee9285 100644 --- a/src/bundled/mentions/MentionCompletion.tsx +++ b/src/bundled/mentions/MentionCompletion.tsx @@ -395,7 +395,7 @@ export function MentionCompletion({ teams.retry(); model.directory.retry(); void session.agentChoices.refresh( - !!inviteAgents || teams.includeLegacy, + model.includeLegacy || teams.includeLegacy, ); void session.archives?.refresh(); retry((value) => value + 1); diff --git a/src/bundled/mentions/MentionPicker.tsx b/src/bundled/mentions/MentionPicker.tsx index c09c9bd6dd..ce4f7915e6 100644 --- a/src/bundled/mentions/MentionPicker.tsx +++ b/src/bundled/mentions/MentionPicker.tsx @@ -341,7 +341,7 @@ export function MentionPicker({ type="button" onClick={() => void session.agentChoices.refresh( - !!inviteAgents || teams.includeLegacy, + model.includeLegacy || teams.includeLegacy, ) } > diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index e546bf3b15..9f32879ddc 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -317,9 +317,12 @@ These rules keep a row from moving under the user's pointer or keyboard. - Members and offered agents SHOULD show without waiting for the directory. - The viewer's agents from their other communities (section 1, item 3) SHOULD - show only once the viewer's agents in this community are known, so that the - name exception in section 1 never disables a shown row. Until then the agent - list is still loading (section 5). + show only while the viewer's agents in this community are known, so that, + once those are known, the name exception in section 1 does not disable a + shown row. While those load, a query that matches (section 3) the name of one + of the viewer's agents from other communities waits: the agent list is still + loading (section 5), and directory people wait too, so those agents still + show before directory people. - A shown row MUST NOT move while the query and the open chooser stay the same. New rows are added only at the bottom. Directory people come after the rows already shown. diff --git a/src/bundled/mentions/mention-candidates.ts b/src/bundled/mentions/mention-candidates.ts index 60ac784e01..daca0be5db 100644 --- a/src/bundled/mentions/mention-candidates.ts +++ b/src/bundled/mentions/mention-candidates.ts @@ -8,7 +8,7 @@ import { import { knownAgentPubkeys } from "../../features/agents/known"; import type { Agent } from "../../features/agents/service"; import { communityHost } from "../../features/messages/community-host"; -import { normalizeName } from "../../features/search/person-match"; +import { matchPerson, normalizeName } from "../../features/search/person-match"; import { allowsOutsideMentions, archivedMention, @@ -77,7 +77,7 @@ const none: readonly never[] = []; * The owner's agents from their other communities that the chooser offers by * name (README section 1): none named like one of the owner's unarchived * agents here, which already has that name. Teams still reach them by key. - * None until the owner's agents here are known (`elsewhereHeld`). + * None until the owner's agents here are known (`elsewhereMatches`). */ export function offeredElsewhere( session: ComposerSession, @@ -95,32 +95,29 @@ export function offeredElsewhere( } /** - * Whether the chooser holds back the owner's agents from other communities it - * would offer here while the owner's agents here load, so that a row it shows - * never leaves under the name rule (README section 6). Until then the agent - * list is loading: Space waits as for invited agents. + * Whether the chooser would offer here one of the owner's agents from other + * communities that `query` matches (any, for no query). Their name rule needs + * the owner's agents here, so the chooser loads those and holds such agents + * until they are known (README section 6). Meanwhile, for a query they match, + * the agent list is loading: Space waits as for invited agents. */ -export function elsewhereHeld( +export function elsewhereMatches( session: ComposerSession, channelId: string, inviteAgents: boolean | undefined, roster: readonly MentionRecipient[] | undefined, elsewhere: readonly ElsewhereAgent[] | undefined, + query = "", ) { - if ( - roster || - inviteAgents || - !elsewhere?.length || - !session.agentChoices.snapshot().pending - ) - return false; + if (roster || inviteAgents || !elsewhere?.length) return false; const channel = session.channels .list() .channels.find((c) => c.id === channelId); - return ( - availableMentionAgents(channel, elsewhere, session.outbox?.supports(9000)) - .length > 0 - ); + return availableMentionAgents( + channel, + elsewhere, + session.outbox?.supports(9000), + ).some((agent) => !!matchPerson(agent.name, query)); } // Rows change only with their sources, not with each keystroke. Snapshots are diff --git a/src/bundled/mentions/resolve-space-mention.ts b/src/bundled/mentions/resolve-space-mention.ts index 12642ffdbb..c1d63fe5d6 100644 --- a/src/bundled/mentions/resolve-space-mention.ts +++ b/src/bundled/mentions/resolve-space-mention.ts @@ -7,7 +7,7 @@ import type { MentionRecipient } from "../../features/messages/mention-draft"; import type { MentionRoster } from "../../features/messages/draft-mention-roster"; import type { Agent } from "../../features/agents/service"; import { - elsewhereHeld, + elsewhereMatches, mentionCandidates, offeredElsewhere, } from "./mention-candidates"; @@ -132,10 +132,11 @@ async function resolve( ) .catch(() => {}); // The chooser holds the owner's agents from other communities until all of - // the owner's agents here are known; invited agents may be partial. + // the owner's agents here are known, for a name one of them matches; + // invited agents may be partial. if ( invite || - elsewhereHeld(session, channelId, inviteAgents, roster, elsewhere) + elsewhereMatches(session, channelId, inviteAgents, roster, elsewhere, name) ) { const release = session.agentChoices.retain(); try { diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 894726eb6b..6255b039be 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -22,7 +22,7 @@ import { archivedMention } from "../../features/messages/mention-admission"; import { mentionHistory } from "../../features/messages/mention-history"; import { directoryChanges, - elsewhereHeld, + elsewhereMatches, managedByDetail, mentionCandidates, offeredElsewhere, @@ -269,11 +269,32 @@ export function useMentionChoices( // The owner's agents in their other communities; the snapshot is the revision. const community = useContext(AgentsContext); const revision = useAgentsSnapshot(community); - // Holding them loads the owner's agents here, as inviting agents does. + // Offering them needs the owner's agents here for the name rule (README + // section 1): load those, as inviting agents does. + const includeLegacy = + open && + (!!invite || + elsewhereMatches( + session, + channelId, + invite, + roster, + community?.joinable(), + )); + const agents = useAgentChoices(session, includeLegacy); + // Until those are known they are held; only a query one of them matches + // waits (README section 6). const held = open && - elsewhereHeld(session, channelId, invite, roster, community?.joinable()); - const agents = useAgentChoices(session, (!!invite || held) && open); + agents.pending && + elsewhereMatches( + session, + channelId, + invite, + roster, + community?.joinable(), + query, + ); const profiles = useSyncExternalStore( session.profiles.subscribe, session.profiles.snapshot, @@ -468,16 +489,19 @@ export function useMentionChoices( (key) => session.presence?.status(key) ?? "unknown", MENTION_ROWS, ); - // Rows that may arrive after the list installs: directory people, and the - // owner's agents from other communities once no longer held. Ranked only + // Rows that may arrive after the list installs: the owner's agents from + // other communities once no longer held, then directory people, which wait + // with them so that local rows stay above (README section 6). Ranked only // when read: a full set of installed rows never needs outside choices. let outsideRows: readonly MentionChoice[] | undefined; const outside = () => - (outsideRows ??= open - ? rank( - matches.filter((c) => !local.has(c.recipient.pubkey) || c.elsewhere), - ) - : []); + (outsideRows ??= + open && !held + ? [ + ...rank(matches.filter((c) => c.elsewhere)), + ...rank(matches.filter((c) => !local.has(c.recipient.pubkey))), + ] + : []); // A member a completed read found no profile for has no name to be a namesake. const missingNames = open && @@ -553,6 +577,8 @@ export function useMentionChoices( managedBy, list, agents, + /** Whether this chooser reads the legacy agent library; Retry rereads it. */ + includeLegacy, channel, roster, candidates, @@ -587,6 +613,7 @@ export function useMentionChoices( managedBy, list, agents, + includeLegacy, channel, roster, pending, diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 8d96b2b016..6c7b61fad2 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -552,9 +552,11 @@ function mount( setLibrary( identities: AgentLibrarySnapshot["identities"], status: AgentLibrarySnapshot["status"] = "ready", + error?: string, ) { act(() => { - library = { ...library, identities, status }; + const { error: _, ...rest } = library; + library = { ...rest, identities, status, ...(error ? { error } : {}) }; for (const listener of libraryListeners) listener(); }); }, @@ -15028,6 +15030,98 @@ it.each([ }, ); +it.each([ + ["Space", " "], + ["Enter", "{Enter}"], +])( + "shows the owner's agent from another community above a namesake the search found while their agents here load, and %s picks it", + async (_, key) => { + const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; + const stranger = { pubkey: "d".repeat(64), name: "Rocket" }; + const h = mountDirectoryMention({ + agents: elsewhereAgent(rocket), + channel: { channelType: "stream" }, + }); + h.setLibrary([], "loading"); + await h.user.type(h.input(), "@rocket"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + // The search finds the stranger before the owner's agents here load. + await h.release({ people: [stranger], hasMore: false }); + h.setLibrary([]); + await waitFor(() => expect(screen.getAllByRole("option")).toHaveLength(2)); + const [top, below] = screen.getAllByRole("option"); + expect(top).toHaveAccessibleName(/In oss\.example\.com/); + expect(below).toHaveAccessibleName(/Not in channel/); + await h.user.keyboard(key); + await waitFor(() => expect(h.input()).toHaveValue("@Rocket ")); + expect(h.recipients()).toHaveLength(1); + h.submit(); + expect(await screen.findByRole("dialog")).toHaveTextContent( + /Bring Rocket into/, + ); + expect(h.messages.send).not.toHaveBeenCalled(); + }, +); + +it.each([ + ["from the chooser", true], + ["once the pending name resolves", false], +])( + "tags a member while the owner's agents here load when no agent from another community matches, %s", + async (_, named) => { + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Rocket" }), + channel: { channelType: "stream" }, + }); + h.setLibrary([], "loading"); + h.setProfiles( + new Map([ + [first.pubkey, { name: "Alice" }], + ...(named + ? [[second.pubkey, { name: "Another Member" }] as const] + : []), + ]), + ); + await h.user.type(h.input(), "@alice"); + await screen.findByRole("option", { name: /Alice/ }); + // Only a query such an agent matches waits for the owner's agents here. + await screen.findByText("Searching community…"); + await h.user.keyboard(" "); + if (named) expect(h.input()).toHaveValue("@Alice "); + else { + // A member's name still unknown keeps the chooser from deciding Space. + expect(h.chip("alice")).toHaveAttribute("aria-busy", "true"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [], hasMore: false }); + await waitFor(() => expect(h.input()).toHaveValue("@Alice ")); + } + expect(h.chip("alice")).toBeNull(); + expect(h.recipients()).toHaveLength(1); + }, +); + +it("rereads the agent library on Retry when it failed and the owner has agents in other communities", async () => { + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Rocket" }), + channel: { channelType: "stream" }, + }); + h.setLibrary([], "error", "Could not read agent inventory."); + await h.user.type(h.input(), "@rocket"); + await screen.findByText("Could not load agents. Retry to refresh."); + expect(h.session.agentLibrary.refresh).not.toHaveBeenCalled(); + await h.user.click(screen.getByRole("option", { name: "Retry suggestions" })); + expect(h.session.agentLibrary.refresh).toHaveBeenCalledOnce(); + h.setLibrary([]); + await waitFor(() => + expect( + screen.queryByText("Could not load agents. Retry to refresh."), + ).toBeNull(), + ); + expect( + screen.getByRole("option", { name: /In oss\.example\.com/ }), + ).toHaveAttribute("aria-disabled", "false"); +}); + it("settles a pending mention into the owner's agent from another community once their agents here load", async () => { const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; const h = mountDirectoryMention({ From 582895b9dc6c92c2433579f6fcbd5b3b9ab29e09 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Nov 2026 13:48:47 +1100 Subject: [PATCH 04/12] fix(mentions): load the owner's agents here when the composer mounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 2692f6e54, 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 Signed-off-by: Matt Toohey --- src/bundled/mentions/MentionCompletion.tsx | 6 +- src/bundled/mentions/README.md | 4 +- src/bundled/mentions/use-mention-choices.ts | 14 ++-- .../messages/MessageComposer.test.tsx | 71 +++++++++++++++++++ 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/bundled/mentions/MentionCompletion.tsx b/src/bundled/mentions/MentionCompletion.tsx index ffa1ee9285..2a0578a189 100644 --- a/src/bundled/mentions/MentionCompletion.tsx +++ b/src/bundled/mentions/MentionCompletion.tsx @@ -394,8 +394,12 @@ export function MentionCompletion({ retry: () => { teams.retry(); model.directory.retry(); + // Reread the agent library only when it failed: while a reload + // runs, shown rows that depend on it are disabled. void session.agentChoices.refresh( - model.includeLegacy || teams.includeLegacy, + (model.includeLegacy && + (agents.status === "error" || !!agents.error)) || + teams.includeLegacy, ); void session.archives?.refresh(); retry((value) => value + 1); diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index 9f32879ddc..5ac22a67a4 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -322,7 +322,9 @@ These rules keep a row from moving under the user's pointer or keyboard. shown row. While those load, a query that matches (section 3) the name of one of the viewer's agents from other communities waits: the agent list is still loading (section 5), and directory people wait too, so those agents still - show before directory people. + show before directory people. A client SHOULD start loading the viewer's + agents here when a composer that offers such agents opens, so that this wait + is rare. - A shown row MUST NOT move while the query and the open chooser stay the same. New rows are added only at the bottom. Directory people come after the rows already shown. diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 6255b039be..f922f470fa 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -270,17 +270,11 @@ export function useMentionChoices( const community = useContext(AgentsContext); const revision = useAgentsSnapshot(community); // Offering them needs the owner's agents here for the name rule (README - // section 1): load those, as inviting agents does. + // section 1): load those, as inviting agents does. A closed chooser loads + // them too: the composer's picker mounts with it, before the first `@`. const includeLegacy = - open && - (!!invite || - elsewhereMatches( - session, - channelId, - invite, - roster, - community?.joinable(), - )); + (open && !!invite) || + elsewhereMatches(session, channelId, invite, roster, community?.joinable()); const agents = useAgentChoices(session, includeLegacy); // Until those are known they are held; only a query one of them matches // waits (README section 6). diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 6c7b61fad2..5ac3352f4e 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -57,6 +57,7 @@ import { MENTION_RESOLVE_TIMEOUT_MS } from "../conversation/completion"; import { bundledContribution } from "../../plugins/provenance"; import { mentionQuery } from "../../bundled/mentions/mention-query"; import { MentionCompletion } from "../../bundled/mentions/MentionCompletion"; +import { MentionPicker } from "../../bundled/mentions/MentionPicker"; import type { MentionRecipient } from "./mention-draft"; import type { AgentLibrarySnapshot } from "../agents/library"; import { createAgentChoices } from "../agents/choices"; @@ -15122,6 +15123,76 @@ it("rereads the agent library on Retry when it failed and the owner has agents i ).toHaveAttribute("aria-disabled", "false"); }); +it("keeps the agent library on a Retry that does not need it", async () => { + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Rocket" }), + channel: { channelType: "stream" }, + session: { + directMessages: { + people: vi.fn(() => Promise.reject(new Error("offline"))), + }, + }, + }); + await h.user.type(h.input(), "@rocket"); + await screen.findByText( + "Could not search community people. Retry to refresh.", + ); + await h.user.click(screen.getByRole("option", { name: "Retry suggestions" })); + expect(h.session.agentLibrary.refresh).not.toHaveBeenCalled(); + expect( + screen.getByRole("option", { name: /In oss\.example\.com/ }), + ).toHaveAttribute("aria-disabled", "false"); +}); + +it.each([ + ["starts", "stream"], + ["does not start", "dm"], +])( + "%s reading the owner's agents here when a %s composer mounts and the owner has agents in other communities", + (_, channelType) => { + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Rocket" }), + channel: { channelType }, + }); + h.setLibrary([], "idle"); + const empty: [] = []; + const none = { snapshot: () => empty, subscribe: () => () => {} }; + const picker: readonly Contribution[] = [ + { + id: "picker", + key: "buzz.mentions/picker", + pluginId: "buzz.mentions", + revision: "1", + title: "Mentions", + component: (props) => ( + + ), + }, + ]; + h.retarget({ + extensions: { + tools: { snapshot: () => picker, subscribe: () => () => {} }, + inline: none, + completions: none, + }, + }); + // The composer's picker is mounted; nobody has typed `@`. + expect( + screen.getByRole("button", { name: "Mention a member" }), + ).toBeInTheDocument(); + if (channelType === "stream") + expect(h.session.agentLibrary.refresh).toHaveBeenCalled(); + else expect(h.session.agentLibrary.refresh).not.toHaveBeenCalled(); + }, +); + it("settles a pending mention into the owner's agent from another community once their agents here load", async () => { const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; const h = mountDirectoryMention({ From 138e0ba1433cd7483c7d2349af742565de9e85a3 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Nov 2026 14:50:29 +1100 Subject: [PATCH 05/12] test(mentions): pin the owner's agent here outside the channel over its namesake elsewhere Checks a case on top of 2fadf38e4: 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 " 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 Signed-off-by: Matt Toohey --- src/bundled/mentions/README.md | 4 +- src/bundled/mentions/mention-ranking.test.ts | 11 ++--- .../mentions/mention-rules.conformance.ts | 1 + .../mentions/mention-rules.fixtures.json | 25 ++++++++++- .../messages/MessageComposer.test.tsx | 43 +++++++++++++++++++ 5 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index 5ac22a67a4..1cd3439db6 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -420,14 +420,14 @@ each choice supplies its own label. | --- | --- | | `matching` | Section 3. `expected` is the tier of `label` for `query` (0 to 3), or `null` when it does not match. | | `ranking` | Sections 3 and 4. `expected` is the full ordered key list. | -| `space` | Section 5 step 2 over one complete choice set, with its precedence. Members and choices with `elsewhere` (section 1, item 3) are local; other non-members are directory results. The local-first step is tested only in the desktop UI tests. `expected` is a key or `null`. | +| `space` | Section 5 step 2 over one complete choice set, with its precedence. Members, choices with `offered` (section 1, item 2) and choices with `elsewhere` (item 3) are local; other non-members are directory results. The local-first step is tested only in the desktop UI tests. `expected` is a key or `null`. | | `query` | Section 2 syntax. `expected` is `{start, end, query}` or `null`. | | `admission` | Section 2 multi-word rule. `expected` is a boolean. | | `tags.write` | Section 8 writer. `expected` is the ordered `p` and `mention` tags, or an error class: `not_member`, `invalid`, or `too_many`. The sender is a member in addition to `members`. | | `tags.read` | Section 8 reader. `expected` is `{mentions, references}`. | Choice defaults: `label` is `name`, `aliases` is `[name]`, and `agent`, `owned`, -and `managed` are false. `elsewhere` (the host of another community) is absent. `history` maps a key to recency (higher is newer). +`managed` and `offered` are false. `elsewhere` (the host of another community) is absent. `history` maps a key to recency (higher is newer). `presence` maps a key to `online` or `away`; a missing key is unknown. Section 6 and the prompt in section 7 are interaction rules. Test them with diff --git a/src/bundled/mentions/mention-ranking.test.ts b/src/bundled/mentions/mention-ranking.test.ts index 36e97606a5..f6766586ef 100644 --- a/src/bundled/mentions/mention-ranking.test.ts +++ b/src/bundled/mentions/mention-ranking.test.ts @@ -247,13 +247,14 @@ it.each(mentionConformance.ranking)( it.each(mentionConformance.space)( "conforms to the portable Space contract: $name", (fixture) => { - // Step 2 of section 5 over the complete choice set: members and agents - // from other communities are local; other choices are directory results. + // Step 2 of section 5 over the complete choice set: members, offered + // agents and agents from other communities are local; other choices are + // directory results. const choices = fixture.choices.map(fixtureChoice); const local = new Set( - choices - .filter((c) => c.member || c.elsewhere) - .map((c) => c.recipient.pubkey), + fixture.choices + .filter((c) => c.member || c.offered || c.elsewhere) + .map((c) => c.pubkey), ); expect( spaceMention(choices, local, fixture.query, { diff --git a/src/bundled/mentions/mention-rules.conformance.ts b/src/bundled/mentions/mention-rules.conformance.ts index 7c82b3b62d..6f55150b30 100644 --- a/src/bundled/mentions/mention-rules.conformance.ts +++ b/src/bundled/mentions/mention-rules.conformance.ts @@ -9,6 +9,7 @@ export type FixtureChoice = { aliases?: string[]; member: boolean; agent?: boolean; + offered?: boolean; elsewhere?: string; owned?: boolean; managed?: boolean; diff --git a/src/bundled/mentions/mention-rules.fixtures.json b/src/bundled/mentions/mention-rules.fixtures.json index c17ee7f80e..4c32332fa6 100644 --- a/src/bundled/mentions/mention-rules.fixtures.json +++ b/src/bundled/mentions/mention-rules.fixtures.json @@ -1,5 +1,5 @@ { - "$comment": "Mention rules v3: semantic inputs, no signed events. Spec: README.md. Keys are lowercase 64-hex. Choice defaults: label = name, aliases = [name], agent/owned/managed = false. history maps key to recency (higher is more recent); presence maps key to online/away (missing is unknown). matching.expected is the tier (0 to 3) or null; ranking.expected is the full ordered key list; space.expected is a key or null. Expected outputs are literal, not generated by the code under test. A query may also be an exact public key (README Terms).", + "$comment": "Mention rules v3: semantic inputs, no signed events. Spec: README.md. Keys are lowercase 64-hex. Choice defaults: label = name, aliases = [name], agent/owned/managed/offered = false. history maps key to recency (higher is more recent); presence maps key to online/away (missing is unknown). matching.expected is the tier (0 to 3) or null; ranking.expected is the full ordered key list; space.expected is a key or null. Expected outputs are literal, not generated by the code under test. A query may also be an exact public key (README Terms).", "version": 3, "matching": [ { @@ -1403,6 +1403,29 @@ ], "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + { + "name": "agent_offered_here_outside_the_channel_outranks_its_namesake_from_another_community", + "query": "Goose", + "choices": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Goose", + "member": false, + "agent": true, + "owned": true, + "offered": true + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Goose", + "member": false, + "agent": true, + "owned": true, + "elsewhere": "other.example.com" + } + ], + "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, { "name": "agent_from_another_community_selects_when_nothing_here_has_the_name", "query": "jitter", diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 5ac3352f4e..bfe23d77ea 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -14935,6 +14935,49 @@ it("does not offer the owner's agent from another community when one of their ag expect(h.recipients()).toHaveLength(1); }); +it.each([ + ["from the chooser", true], + ["once the pending name resolves", false], +])( + "tags the owner's agent here that is not in the channel, not its namesake from another community, %s", + async (_, named) => { + // The owner's agents here have their names whether or not they are + // members, so the other community's namesake is never offered. + const goose = { pubkey: "f".repeat(64), name: "Goose" }; + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Goose" }), + channel: { channelType: "stream" }, + }); + h.setLibrary([goose]); + // A member's name still unknown keeps the chooser from deciding Space. + if (!named) + h.setProfiles(new Map([[first.pubkey, { name: "Other Member" }]])); + await h.user.type(h.input(), "@goose"); + expect( + await screen.findByRole("option", { name: /Goose/ }), + ).toHaveAccessibleName(/Not in channel/); + expect(screen.getAllByRole("option")).toHaveLength(1); + await h.user.keyboard(" "); + if (!named) { + expect(h.chip("goose")).toHaveAttribute("aria-busy", "true"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [], hasMore: false }); + } + await waitFor(() => expect(h.input()).toHaveValue("@Goose ")); + expect(h.chip("goose")).toBeNull(); + expect(h.recipients()).toHaveLength(1); + h.submit(); + // Not "Bring Goose into": the agent here is the recipient. + expect(await screen.findByRole("dialog")).toHaveTextContent( + "Goose is not in this channel.", + ); + fireEvent.click(screen.getByRole("button", { name: "Send anyway" })); + await act(async () => {}); + expect(h.messages.send).toHaveBeenCalledOnce(); + expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([goose.pubkey]); + }, +); + it("selects the owner's agent from another community on Space at once, while the search runs", async () => { const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; const h = mountDirectoryMention({ From 2828ff7340b4b7381e11d8e50b64b0c4344ad9c0 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Nov 2026 02:21:08 +1100 Subject: [PATCH 06/12] fix(mentions): offer other-community agents only after a complete read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 5c9dde762, 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 " 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 5c9dde762'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 Signed-off-by: Matt Toohey --- src/bundled/mentions/README.md | 20 +-- src/bundled/mentions/mention-candidates.ts | 8 +- src/bundled/mentions/resolve-space-mention.ts | 22 +++- src/bundled/mentions/use-mention-choices.ts | 14 +- .../messages/MessageComposer.test.tsx | 124 ++++++++++++++++++ 5 files changed, 165 insertions(+), 23 deletions(-) diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index 1cd3439db6..a7721aa606 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -317,14 +317,18 @@ These rules keep a row from moving under the user's pointer or keyboard. - Members and offered agents SHOULD show without waiting for the directory. - The viewer's agents from their other communities (section 1, item 3) SHOULD - show only while the viewer's agents in this community are known, so that, - once those are known, the name exception in section 1 does not disable a - shown row. While those load, a query that matches (section 3) the name of one - of the viewer's agents from other communities waits: the agent list is still - loading (section 5), and directory people wait too, so those agents still - show before directory people. A client SHOULD start loading the viewer's - agents here when a composer that offers such agents opens, so that this wait - is rare. + show only while the viewer's agents in this community are known from a + complete read, so that, once those are known, the name exception in section 1 + does not disable a shown row. A failed or partial read is not evidence: it + may omit the agent here that has the name. Until such a read, a query that + matches (section 3) the name of one of the viewer's agents from other + communities holds them, and directory people with them, so those agents + still show before directory people. While the read runs, the agent list is + still loading (section 5). Once it fails, the full choice set stays unknown + for that query: only the local step of section 5 applies, over the local + choices the client has, and a retry reads again. A client SHOULD start + loading the viewer's agents here when a composer that offers such agents + opens, so that this wait is rare. - A shown row MUST NOT move while the query and the open chooser stay the same. New rows are added only at the bottom. Directory people come after the rows already shown. diff --git a/src/bundled/mentions/mention-candidates.ts b/src/bundled/mentions/mention-candidates.ts index daca0be5db..c7eed5972c 100644 --- a/src/bundled/mentions/mention-candidates.ts +++ b/src/bundled/mentions/mention-candidates.ts @@ -77,7 +77,8 @@ const none: readonly never[] = []; * The owner's agents from their other communities that the chooser offers by * name (README section 1): none named like one of the owner's unarchived * agents here, which already has that name. Teams still reach them by key. - * None until the owner's agents here are known (`elsewhereMatches`). + * None until a complete read lists the owner's agents here (`elsewhereMatches`): + * a failed or partial one may omit the agent that has the name. */ export function offeredElsewhere( session: ComposerSession, @@ -85,7 +86,7 @@ export function offeredElsewhere( ) { if (!elsewhere?.length) return elsewhere; const agents = session.agentChoices.snapshot(); - if (agents.pending) return none; + if (!agents.complete) return none; const here = new Set( agents.identities .filter((agent) => !archivedMention(session, agent.pubkey)) @@ -99,7 +100,8 @@ export function offeredElsewhere( * communities that `query` matches (any, for no query). Their name rule needs * the owner's agents here, so the chooser loads those and holds such agents * until they are known (README section 6). Meanwhile, for a query they match, - * the agent list is loading: Space waits as for invited agents. + * the agent list is loading: Space waits as for invited agents. After a failed + * read only local choices here can take Space. */ export function elsewhereMatches( session: ComposerSession, diff --git a/src/bundled/mentions/resolve-space-mention.ts b/src/bundled/mentions/resolve-space-mention.ts index c1d63fe5d6..5260705587 100644 --- a/src/bundled/mentions/resolve-space-mention.ts +++ b/src/bundled/mentions/resolve-space-mention.ts @@ -131,13 +131,18 @@ async function resolve( "background", ) .catch(() => {}); - // The chooser holds the owner's agents from other communities until all of - // the owner's agents here are known, for a name one of them matches; - // invited agents may be partial. - if ( - invite || - elsewhereMatches(session, channelId, inviteAgents, roster, elsewhere, name) - ) { + // The chooser holds the owner's agents from other communities until a + // complete read lists the owner's agents here, for a name one of them + // matches; invited agents may be partial. + const elsewhereNamed = elsewhereMatches( + session, + channelId, + inviteAgents, + roster, + elsewhere, + name, + ); + if (invite || elsewhereNamed) { const release = session.agentChoices.retain(); try { session.agentChoices.ensure(true); @@ -222,6 +227,9 @@ async function resolve( // directory, as it does in the chooser. const direct = decide([], false, name); if (direct) return direct; + // A failed read keeps them held, and directory people with them: the full + // choice set stays unknown, as in the chooser. + if (elsewhereNamed && !session.agentChoices.snapshot().complete) return null; const outside = !roster && !invite && diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index f922f470fa..674a7d2995 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -216,7 +216,8 @@ export type SpaceEvidence = { pending: boolean; /** A member's name is neither known nor known to be absent. */ missingNames: boolean | undefined; - /** The directory search for this query settled with no more pages. */ + /** The full choice set is known: the directory search for this query + * settled with no more pages, and no agents from other communities are held. */ complete: boolean; }; /** @@ -276,11 +277,13 @@ export function useMentionChoices( (open && !!invite) || elsewhereMatches(session, channelId, invite, roster, community?.joinable()); const agents = useAgentChoices(session, includeLegacy); - // Until those are known they are held; only a query one of them matches - // waits (README section 6). + // Until a complete read lists those, they are held, and directory people + // with them, for a query one of them matches (README section 6). Only a read + // in progress is loading: after a failure, local choices stay usable and + // Retry reads again. const held = open && - agents.pending && + !agents.complete && elsewhereMatches( session, channelId, @@ -427,7 +430,7 @@ export function useMentionChoices( membersPending || !!draft?.pending || (!roster && !!invite && agents.pending && agents.status !== "ready") || - held; + (held && agents.pending); let rows = same ? installed.rows : undefined; if (!same && installed) install(undefined); // Only choices the query can match; ranking reads no others. @@ -542,6 +545,7 @@ export function useMentionChoices( } } const complete = + !held && !directory.loading && !directory.error && !directory.more && diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index bfe23d77ea..0354c3ecef 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -15166,6 +15166,130 @@ it("rereads the agent library on Retry when it failed and the owner has agents i ).toHaveAttribute("aria-disabled", "false"); }); +it.each([ + ["from the chooser", true], + ["once the pending name resolves", false], +])( + "never offers the owner's agent from another community while reading their agents here fails, and tags their namesake here once Retry finds it, %s", + async (_, named) => { + // The owner's agent here is only in the library, which the failed read omits. + const jitter = { pubkey: "f".repeat(64), name: "Jitter" }; + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Jitter" }), + channel: { channelType: "stream" }, + }); + h.setLibrary([], "error", "Could not read agent inventory."); + // A member's name still unknown keeps the chooser from deciding Space. + if (!named) + h.setProfiles(new Map([[first.pubkey, { name: "Other Member" }]])); + await h.user.type(h.input(), "@jitter"); + await screen.findByText("Could not load agents. Retry to refresh."); + expect( + screen.queryByRole("option", { name: /In oss\.example\.com/ }), + ).toBeNull(); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + // A settled search leaves the chooser nothing to wait for. + if (named) await h.release({ people: [], hasMore: false }); + // The resolver first reads the members' names; hold it there. + const names = deferred(); + onTestFinished(() => names.resolve()); + if (!named) + vi.mocked(h.session.profiles.ensure).mockImplementationOnce( + () => names.promise, + ); + await h.user.keyboard(" "); + if (!named) { + expect(h.chip("jitter")).toHaveAttribute("aria-busy", "true"); + await act(async () => names.resolve()); + await h.release({ people: [], hasMore: false }); + } + // Neither tags the other community's agent: the text stays prose. + await waitFor(() => expect(h.chip("jitter")).toBeNull()); + expect(h.input()).toHaveValue("@jitter "); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); + await h.user.keyboard("@jitter"); + await screen.findByText("Could not load agents. Retry to refresh."); + await h.user.click( + screen.getByRole("option", { name: "Retry suggestions" }), + ); + expect(h.session.agentLibrary.refresh).toHaveBeenCalledOnce(); + h.setLibrary([jitter]); + // Only the owner's agent here. Its namesake was never shown, so no row + // is disabled. + expect( + await screen.findByRole("option", { name: /Jitter/ }), + ).toHaveAccessibleName(/Not in channel/); + expect(screen.getAllByRole("option")).toHaveLength(1); + await h.user.keyboard(" "); + if (!named) { + expect(h.chip("jitter")).toHaveAttribute("aria-busy", "true"); + await h.release({ people: [], hasMore: false }); + } + await waitFor(() => expect(h.input()).toHaveValue("@jitter @Jitter ")); + expect(h.chip("jitter")).toBeNull(); + expect(h.recipients()).toHaveLength(1); + h.submit(); + // Not "Bring Jitter into": the agent here is the recipient. + expect(await screen.findByRole("dialog")).toHaveTextContent( + "Jitter is not in this channel.", + ); + fireEvent.click(screen.getByRole("button", { name: "Send anyway" })); + await act(async () => {}); + expect(h.messages.send).toHaveBeenCalledOnce(); + expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([jitter.pubkey]); + }, +); + +it.each([ + ["from the chooser", true], + ["once the pending name resolves", false], +])( + "keeps a namesake the search found from taking Space while reading the owner's agents here fails, %s", + async (_, named) => { + // Had the read listed them, the owner's own Jitter, here or in another + // community, would outrank this one: the full choice set is unknown. + const stranger = { pubkey: "d".repeat(64), name: "Jitter" }; + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Jitter" }), + channel: { channelType: "stream" }, + }); + h.setLibrary([], "error", "Could not read agent inventory."); + // A member's name still unknown keeps the chooser from deciding Space. + if (!named) + h.setProfiles(new Map([[first.pubkey, { name: "Other Member" }]])); + await h.user.type(h.input(), "@jitter"); + await screen.findByText("Could not load agents. Retry to refresh."); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + if (named) { + await h.release({ people: [stranger], hasMore: false }); + // It waits with the other community's agent. + expect( + screen.queryByRole("option", { name: /Not in channel/ }), + ).toBeNull(); + } + // The resolver first reads the members' names; hold it there. + const names = deferred(); + onTestFinished(() => names.resolve()); + if (!named) + vi.mocked(h.session.profiles.ensure).mockImplementationOnce( + () => names.promise, + ); + await h.user.keyboard(" "); + if (!named) { + expect(h.chip("jitter")).toHaveAttribute("aria-busy", "true"); + await act(async () => names.resolve()); + await h.release({ people: [stranger], hasMore: false }); + } + await waitFor(() => expect(h.chip("jitter")).toBeNull()); + expect(h.input()).toHaveValue("@jitter "); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); + }, +); + it("keeps the agent library on a Retry that does not need it", async () => { const h = mountDirectoryMention({ agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Rocket" }), From 5048cd29453a467d483fd736cdf71b17fced322b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Nov 2026 20:41:29 +1100 Subject: [PATCH 07/12] fix(mentions): add late offered agents here and hold aliases cached here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 3ee75393a, 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 Signed-off-by: Matt Toohey --- src/bundled/mentions/README.md | 10 +- .../mentions/mention-candidates.test.ts | 59 ++++++- src/bundled/mentions/mention-candidates.ts | 17 ++- src/bundled/mentions/use-mention-choices.ts | 17 ++- .../messages/MessageComposer.test.tsx | 144 ++++++++++++++++++ 5 files changed, 239 insertions(+), 8 deletions(-) diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index a7721aa606..32768bd184 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -321,9 +321,11 @@ These rules keep a row from moving under the user's pointer or keyboard. complete read, so that, once those are known, the name exception in section 1 does not disable a shown row. A failed or partial read is not evidence: it may omit the agent here that has the name. Until such a read, a query that - matches (section 3) the name of one of the viewer's agents from other + matches (section 3) a name of one of the viewer's agents from other communities holds them, and directory people with them, so those agents - still show before directory people. While the read runs, the agent list is + still show before directory people. Its names are the ones its row has + without the directory search, including aliases cached in this community, + such as the profile name of an agent archived here. While the read runs, the agent list is still loading (section 5). Once it fails, the full choice set stays unknown for that query: only the local step of section 5 applies, over the local choices the client has, and a retry reads again. A client SHOULD start @@ -331,7 +333,9 @@ These rules keep a row from moving under the user's pointer or keyboard. opens, so that this wait is rare. - A shown row MUST NOT move while the query and the open chooser stay the same. New rows are added only at the bottom. Directory people come after the rows - already shown. + already shown. Offered agents (section 1, item 2) that load after the list + is shown are added too, before the viewer's agents from other communities + and the directory people added with them. - A row that becomes unavailable stays in place and is disabled. Enter and Tab MUST NOT select it or fall through to Send. - A client MAY show at most 50 rows and ask the user to type more. diff --git a/src/bundled/mentions/mention-candidates.test.ts b/src/bundled/mentions/mention-candidates.test.ts index c530148856..bb69e4f009 100644 --- a/src/bundled/mentions/mention-candidates.test.ts +++ b/src/bundled/mentions/mention-candidates.test.ts @@ -1,5 +1,9 @@ import { expect, it } from "vitest"; -import { directoryChanges, mentionCandidates } from "./mention-candidates"; +import { + directoryChanges, + elsewhereMatches, + mentionCandidates, +} from "./mention-candidates"; import type { ComposerSession } from "../../features/messages/composer-session"; import { type NamePreference, @@ -178,3 +182,56 @@ it("shows the verified name each row is not, and matches every verified name", ( setNamePreference("username"); } }); + +it("holds an agent from another community for every name its row has without the directory", () => { + const far = key(7); + const session = fresh(); + // Archived here under another name, it still takes its row from the other + // community, with the names cached here. + Object.assign(session, { + profiles: { + snapshot: () => + new Map([ + ...profiles, + [ + far, + { + name: "Old", + verified: { + displayName: "farh", + username: "farh", + fullName: "Faraday", + }, + }, + ], + ]), + }, + agentChoices: { + snapshot: () => ({ + identities: [...agents, { pubkey: far, name: "Renamed" }], + }), + }, + archives: { + snapshot: () => ({ status: "ready", archived: [key(5), far] }), + }, + }); + const row = mentionCandidates( + session, + "c", + false, + undefined, + undefined, + elsewhere, + ).find((c) => c.recipient.pubkey === far); + expect(row?.elsewhere).toBe("other.example"); + expect([...(row?.aliases ?? [])].sort()).toEqual( + ["Far", "Faraday", "Old", "Renamed", "farh"].sort(), + ); + for (const name of row?.aliases ?? []) + expect( + elsewhereMatches(session, "c", false, undefined, elsewhere, name), + ).toBe(true); + expect( + elsewhereMatches(session, "c", false, undefined, elsewhere, "Ada"), + ).toBe(false); +}); diff --git a/src/bundled/mentions/mention-candidates.ts b/src/bundled/mentions/mention-candidates.ts index c7eed5972c..2a87c26cfe 100644 --- a/src/bundled/mentions/mention-candidates.ts +++ b/src/bundled/mentions/mention-candidates.ts @@ -101,7 +101,9 @@ export function offeredElsewhere( * the owner's agents here, so the chooser loads those and holds such agents * until they are known (README section 6). Meanwhile, for a query they match, * the agent list is loading: Space waits as for invited agents. After a failed - * read only local choices here can take Space. + * read only local choices here can take Space. An agent matches by the names + * its row has without the directory (`buildCandidates` aliases), such as the + * name cached here for one archived here. */ export function elsewhereMatches( session: ComposerSession, @@ -115,11 +117,22 @@ export function elsewhereMatches( const channel = session.channels .list() .channels.find((c) => c.id === channelId); + const profiles = session.profiles.snapshot(); + const here = session.agentChoices.snapshot().identities; return availableMentionAgents( channel, elsewhere, session.outbox?.supports(9000), - ).some((agent) => !!matchPerson(agent.name, query)); + ).some((agent) => { + const profile = profiles.get(agent.pubkey); + return [ + agent.name, + profile?.name, + profile?.verified?.fullName, + profile?.verified?.username, + ...here.flatMap((a) => (a.pubkey === agent.pubkey ? [a.name] : [])), + ].some((name) => name !== undefined && !!matchPerson(name, query)); + }); } // Rows change only with their sources, not with each keystroke. Snapshots are diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 674a7d2995..0761f056a8 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -520,10 +520,23 @@ export function useMentionChoices( } } if (rows && rows.length < MENTION_ROWS) { - // Append-only: late invited agents and `outside` rows never move shown rows. + // Append-only: late invited or offered agents and `outside` rows never + // move shown rows. Offered agents here come first: they are local. // Member-list changes keep their installed set until the query changes. const shown = new Set(rows.map((row) => row.recipient.pubkey)); - const added = (invite ? rank(matches.filter((c) => c.agent)) : outside()) + const added = ( + invite + ? rank(matches.filter((c) => c.agent)) + : [ + ...rank( + matches.filter( + (c) => + !c.member && !c.elsewhere && local.has(c.recipient.pubkey), + ), + ), + ...outside(), + ] + ) .filter((c) => !shown.has(c.recipient.pubkey)) .slice(0, MENTION_ROWS - rows.length); if (added.length) { diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 0354c3ecef..c91ec4df82 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -15074,6 +15074,67 @@ it.each([ }, ); +it.each([ + ["from the chooser", true], + ["once the pending name resolves", false], +])( + "adds the owner's agent here that loads after a longer member name shows, above directory people, and tags it, %s", + async (_, loaded) => { + // The owner's agent here is only in the library, so it loads after the + // member's row installs. Its namesake from another community is never + // offered. + const jitter = { pubkey: "f".repeat(64), name: "Jitter" }; + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Jitter" }), + channel: { channelType: "stream" }, + }); + h.setLibrary([], "loading"); + h.setProfiles( + new Map([ + [first.pubkey, { name: "Jitterbug" }], + [second.pubkey, { name: "Another Member" }], + ]), + ); + await h.user.type(h.input(), "@jitter"); + await screen.findByRole("option", { name: /Jitterbug/ }); + await screen.findByText("Loading recipients…"); + if (loaded) { + // The search settles first; its people wait with the held rows. + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ + people: [{ pubkey: "d".repeat(64), name: "Jitterbird" }], + hasMore: false, + }); + expect(screen.getAllByRole("option")).toHaveLength(1); + } else { + await h.user.keyboard(" "); + expect(h.chip("jitter")).toHaveAttribute("aria-busy", "true"); + } + h.setLibrary([jitter]); + if (loaded) { + await waitFor(() => + expect(screen.getAllByRole("option")).toHaveLength(3), + ); + const [member, here, found] = screen.getAllByRole("option"); + expect(member).toHaveAccessibleName(/Jitterbug/); + expect(here).toHaveAccessibleName(/^JitterAgentNot in channel/); + expect(found).toHaveAccessibleName(/Jitterbird/); + await h.user.keyboard(" "); + } + await waitFor(() => expect(h.input()).toHaveValue("@Jitter ")); + expect(h.chip("jitter")).toBeNull(); + expect(h.recipients()).toHaveLength(1); + h.submit(); + expect(await screen.findByRole("dialog")).toHaveTextContent( + "Jitter is not in this channel.", + ); + fireEvent.click(screen.getByRole("button", { name: "Send anyway" })); + await act(async () => {}); + expect(h.messages.send).toHaveBeenCalledOnce(); + expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([jitter.pubkey]); + }, +); + it.each([ ["Space", " "], ["Enter", "{Enter}"], @@ -15107,6 +15168,89 @@ it.each([ }, ); +/** + * The owner's agent Rocket from another community was archived here as Comet, + * the name cached here. The search finds a stranger named Comet. The owner's + * agents here are loading; `load()` lists the archived one. + */ +function mountCachedAlias() { + const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; + const archives = { status: "ready" as const, archived: [rocket.pubkey] }; + const h = mountDirectoryMention({ + agents: elsewhereAgent(rocket), + channel: { channelType: "stream" }, + session: { + archives: { + snapshot: () => archives, + subscribe: () => () => {}, + state: (key: string) => + key === rocket.pubkey ? "archived" : "not-archived", + ensure: async () => {}, + }, + }, + }); + h.setLibrary([], "loading"); + h.setProfiles( + new Map([ + [first.pubkey, { name: "Other Member" }], + [second.pubkey, { name: "Another Member" }], + [rocket.pubkey, { name: "Comet" }], + ]), + ); + return { + ...h, + stranger: { pubkey: "d".repeat(64), name: "Comet" }, + load: () => h.setLibrary([{ pubkey: rocket.pubkey, name: "Comet" }]), + }; +} + +it.each([ + ["Space", " "], + ["Enter", "{Enter}"], +])( + "holds a namesake the search found for the name cached here for the owner's agent from another community, and %s picks that agent", + async (_, key) => { + const h = mountCachedAlias(); + await h.user.type(h.input(), "@comet"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [h.stranger], hasMore: false }); + // The stranger waits with the owner's agent. + await screen.findByText("Loading recipients…"); + expect(screen.queryByRole("option", { name: /Comet|Rocket/ })).toBeNull(); + h.load(); + await waitFor(() => expect(screen.getAllByRole("option")).toHaveLength(2)); + const [top, below] = screen.getAllByRole("option"); + expect(top).toHaveAccessibleName(/In oss\.example\.com/); + expect(below).toHaveAccessibleName(/Not in channel/); + await h.user.keyboard(key); + await waitFor(() => expect(h.input()).toHaveValue("@Rocket ")); + expect(h.recipients()).toHaveLength(1); + h.submit(); + expect(await screen.findByRole("dialog")).toHaveTextContent( + /Bring Rocket into/, + ); + expect(h.messages.send).not.toHaveBeenCalled(); + }, +); + +it("settles a pending mention of the name cached here for the owner's agent from another community into that agent, not a namesake the search found", async () => { + const h = mountCachedAlias(); + await h.user.type(h.input(), "@comet"); + // The chooser is open and searching. + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.user.keyboard(" "); + expect(h.chip("comet")).toHaveAttribute("aria-busy", "true"); + h.load(); + // Answers the chooser's search, and the resolver's had it read the directory. + await h.release({ people: [h.stranger], hasMore: false }); + await waitFor(() => expect(h.input()).toHaveValue("@Rocket ")); + expect(h.chip("comet")).toBeNull(); + h.submit(); + expect(await screen.findByRole("dialog")).toHaveTextContent( + /Bring Rocket into/, + ); +}); + it.each([ ["from the chooser", true], ["once the pending name resolves", false], From 776165d09e550f8e0b03597dff0f9eafc0122b88 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Sat, 7 Nov 2026 00:56:38 +1100 Subject: [PATCH 08/12] fix(mentions): hold an other-community agent for its exact key too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to bdb4d9bf4, 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, `@` 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 bdb4d9bf4'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 Signed-off-by: Matt Toohey --- src/bundled/mentions/README.md | 5 +- .../mentions/mention-candidates.test.ts | 13 +++- src/bundled/mentions/mention-candidates.ts | 6 +- src/bundled/mentions/resolve-space-mention.ts | 24 ++++---- .../messages/MessageComposer.test.tsx | 59 +++++++++++++++++++ 5 files changed, 90 insertions(+), 17 deletions(-) diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index 32768bd184..6a7b0a24a8 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -325,8 +325,9 @@ These rules keep a row from moving under the user's pointer or keyboard. communities holds them, and directory people with them, so those agents still show before directory people. Its names are the ones its row has without the directory search, including aliases cached in this community, - such as the profile name of an agent archived here. While the read runs, the agent list is - still loading (section 5). Once it fails, the full choice set stays unknown + such as the profile name of an agent archived here; its exact public key + (Terms) holds it too. While the read runs, the agent list is still loading + (section 5). Once it fails, the full choice set stays unknown for that query: only the local step of section 5 applies, over the local choices the client has, and a retry reads again. A client SHOULD start loading the viewer's agents here when a composer that offers such agents diff --git a/src/bundled/mentions/mention-candidates.test.ts b/src/bundled/mentions/mention-candidates.test.ts index bb69e4f009..87c3dffdbb 100644 --- a/src/bundled/mentions/mention-candidates.test.ts +++ b/src/bundled/mentions/mention-candidates.test.ts @@ -1,3 +1,4 @@ +import { npubEncode } from "nostr-tools/nip19"; import { expect, it } from "vitest"; import { directoryChanges, @@ -231,7 +232,13 @@ it("holds an agent from another community for every name its row has without the expect( elsewhereMatches(session, "c", false, undefined, elsewhere, name), ).toBe(true); - expect( - elsewhereMatches(session, "c", false, undefined, elsewhere, "Ada"), - ).toBe(false); + // Its exact key names only it, as it does for Space. + for (const typed of [far, npubEncode(far), `nostr:${npubEncode(far)}`]) + expect( + elsewhereMatches(session, "c", false, undefined, elsewhere, typed), + ).toBe(true); + for (const typed of ["Ada", key(3), npubEncode(key(3))]) + expect( + elsewhereMatches(session, "c", false, undefined, elsewhere, typed), + ).toBe(false); }); diff --git a/src/bundled/mentions/mention-candidates.ts b/src/bundled/mentions/mention-candidates.ts index 2a87c26cfe..5b4a8b3925 100644 --- a/src/bundled/mentions/mention-candidates.ts +++ b/src/bundled/mentions/mention-candidates.ts @@ -9,6 +9,7 @@ import { knownAgentPubkeys } from "../../features/agents/known"; import type { Agent } from "../../features/agents/service"; import { communityHost } from "../../features/messages/community-host"; import { matchPerson, normalizeName } from "../../features/search/person-match"; +import { typedPublicKey } from "../../shared/identity/public-key"; import { allowsOutsideMentions, archivedMention, @@ -103,7 +104,8 @@ export function offeredElsewhere( * the agent list is loading: Space waits as for invited agents. After a failed * read only local choices here can take Space. An agent matches by the names * its row has without the directory (`buildCandidates` aliases), such as the - * name cached here for one archived here. + * name cached here for one archived here, or by its exact public key + * (`matchingMentions`), which no name matches. */ export function elsewhereMatches( session: ComposerSession, @@ -119,11 +121,13 @@ export function elsewhereMatches( .channels.find((c) => c.id === channelId); const profiles = session.profiles.snapshot(); const here = session.agentChoices.snapshot().identities; + const key = typedPublicKey(query); return availableMentionAgents( channel, elsewhere, session.outbox?.supports(9000), ).some((agent) => { + if (key !== undefined) return agent.pubkey === key; const profile = profiles.get(agent.pubkey); return [ agent.name, diff --git a/src/bundled/mentions/resolve-space-mention.ts b/src/bundled/mentions/resolve-space-mention.ts index 5260705587..2f2b3ac59a 100644 --- a/src/bundled/mentions/resolve-space-mention.ts +++ b/src/bundled/mentions/resolve-space-mention.ts @@ -159,17 +159,6 @@ async function resolve( const kit = fullSession(session)?.channelKit; if (teamBlocksSpace(kit?.snapshot() ?? { entries: [] }, name, teamNames?.())) return null; - const offered = offeredElsewhere(session, elsewhere); - const local = new Set( - mentionCandidates( - session, - channelId, - inviteAgents, - roster, - undefined, - offered, - ).map((c) => c.recipient.pubkey), - ); const profiles = session.profiles.snapshot(); const unnamed = session.profiles.unnamed?.(); const missingNames = channel?.members?.some( @@ -184,6 +173,19 @@ async function resolve( teamBlocksSpace(kit?.snapshot() ?? { entries: [] }, query, teamNames?.()) ) return null; + // Read when deciding, as the chooser does on each render: a library read + // that lands during the directory lookup offers its agents here too. + const offered = offeredElsewhere(session, elsewhere); + const local = new Set( + mentionCandidates( + session, + channelId, + inviteAgents, + roster, + undefined, + offered, + ).map((c) => c.recipient.pubkey), + ); const choices = namedCandidates( session, channelId, diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index c91ec4df82..3d5a448d01 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -81,6 +81,7 @@ import { setEditWithUpArrowPreference } from "./edit-key-preference"; import { createRelaySession, type RelaySession } from "../relay/session"; import { keypair, metadata, roster, signed } from "../relay/testing"; import type { EventTemplate } from "nostr-tools"; +import { npubEncode } from "nostr-tools/nip19"; import type { RelayEvent } from "../relay/events"; import type { ChannelMessage, @@ -15525,6 +15526,64 @@ it("settles a pending mention into the owner's agent from another community once ); }); +const rocketKey = "e".repeat(64); +it.each([ + ["hex key", rocketKey], + ["npub", npubEncode(rocketKey)], + ["nostr:npub", `nostr:${npubEncode(rocketKey)}`], +])( + "settles a pending mention of the %s of the owner's agent from another community into it once their agents here load, after the directory found no one", + async (_, typed) => { + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: rocketKey, name: "Rocket" }), + channel: { channelType: "stream" }, + }); + h.setLibrary([], "loading"); + await h.user.type(h.input(), `@${typed}`); + // Its key holds the agent as its name does; the author lookup finds + // no profile here first. + await screen.findByText("Loading recipients…"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [], hasMore: false }); + expect(screen.queryByRole("option")).toBeNull(); + await h.user.keyboard(" "); + expect(h.chip(typed)).toHaveAttribute("aria-busy", "true"); + h.setLibrary([]); + await waitFor(() => expect(h.input()).toHaveValue("@Rocket ")); + expect(h.chip(typed)).toBeNull(); + expect(h.recipients()).toHaveLength(1); + h.submit(); + expect(await screen.findByRole("dialog")).toHaveTextContent( + /Bring Rocket into/, + ); + expect(h.messages.send).not.toHaveBeenCalled(); + }, +); + +it("shows the owner's agent from another community for its key once their agents here load, after the directory found no one, and Space tags it", async () => { + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: rocketKey, name: "Rocket" }), + channel: { channelType: "stream" }, + }); + h.setLibrary([], "loading"); + await h.user.type(h.input(), `@${rocketKey}`); + await screen.findByText("Loading recipients…"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [], hasMore: false }); + expect(screen.queryByRole("option")).toBeNull(); + h.setLibrary([]); + await screen.findByRole("option", { name: /In oss\.example\.com/ }); + expect(screen.getAllByRole("option")).toHaveLength(1); + await h.user.keyboard(" "); + await waitFor(() => expect(h.input()).toHaveValue("@Rocket ")); + expect(h.recipients()).toHaveLength(1); + h.submit(); + expect(await screen.findByRole("dialog")).toHaveTextContent( + /Bring Rocket into/, + ); + expect(h.messages.send).not.toHaveBeenCalled(); +}); + it("settles a pending mention into the owner's agent from another community that is archived here, as Tab would", async () => { const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; const archives = { status: "ready" as const, archived: [rocket.pubkey] }; From 2e44ccfd166f7ca86aec9f8dd7873eed197e2b80 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Sat, 7 Nov 2026 02:29:48 +1100 Subject: [PATCH 09/12] fix(mentions): offer a hidden other-community agent for its exact key Follow-up to bce71233d, 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 bce71233d 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 " 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 bce71233d'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 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/bundled/mentions/README.md | 10 +++- .../mentions/mention-candidates.test.ts | 27 +++++++++ src/bundled/mentions/mention-candidates.ts | 16 ++++-- .../mentions/mention-rules.fixtures.json | 20 +++++++ src/bundled/mentions/resolve-space-mention.ts | 2 +- src/bundled/mentions/use-mention-choices.ts | 12 ++-- .../messages/MessageComposer.test.tsx | 56 +++++++++++++++++++ 7 files changed, 130 insertions(+), 13 deletions(-) diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index 6a7b0a24a8..b26f89d081 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -77,7 +77,9 @@ archived and not read-only, the choice set is: 3. the viewer's own agents from their other communities, which the client can bring into this one (section 7), except one whose name equals, after normalization (section 3), the name of one of the viewer's unarchived agents - in this community: that agent already has the name here; and + in this community: that agent already has the name here. A query that is + the exact public key (Terms) of such an agent still includes it, because a + key has no namesakes (section 5); and 4. people and agents found by a community directory search for the current query. Solo chats MUST NOT add directory people or agents from other communities. This @@ -326,8 +328,10 @@ These rules keep a row from moving under the user's pointer or keyboard. still show before directory people. Its names are the ones its row has without the directory search, including aliases cached in this community, such as the profile name of an agent archived here; its exact public key - (Terms) holds it too. While the read runs, the agent list is still loading - (section 5). Once it fails, the full choice set stays unknown + (Terms) holds it too, even when the name exception leaves it out of name + queries: only that read shows whether the key is one of the viewer's agents + here, which then has its row here. While the read runs, the agent list is + still loading (section 5). Once it fails, the full choice set stays unknown for that query: only the local step of section 5 applies, over the local choices the client has, and a retry reads again. A client SHOULD start loading the viewer's agents here when a composer that offers such agents diff --git a/src/bundled/mentions/mention-candidates.test.ts b/src/bundled/mentions/mention-candidates.test.ts index 87c3dffdbb..f868669900 100644 --- a/src/bundled/mentions/mention-candidates.test.ts +++ b/src/bundled/mentions/mention-candidates.test.ts @@ -4,6 +4,7 @@ import { directoryChanges, elsewhereMatches, mentionCandidates, + offeredElsewhere, } from "./mention-candidates"; import type { ComposerSession } from "../../features/messages/composer-session"; import { @@ -242,3 +243,29 @@ it("holds an agent from another community for every name its row has without the elsewhereMatches(session, "c", false, undefined, elsewhere, typed), ).toBe(false); }); + +it("offers an agent from another community named like one of the owner's agents here only for its exact key, after a complete read", () => { + const namesake = { + pubkey: key(8), + name: " helper ", + relay: "wss://o.example", + }; + const listed = [...elsewhere, namesake]; + let complete = true; + const session = fresh(); + Object.assign(session, { + agentChoices: { snapshot: () => ({ identities: agents, complete }) }, + }); + const offered = (query?: string) => + offeredElsewhere(session, listed, query)?.map((agent) => agent.pubkey); + // "Helper" here already has the name. + for (const query of [undefined, "", "helper", "Helper", key(7)]) + expect(offered(query)).toEqual([key(7)]); + // A key has no namesakes: it names only its owner. + const npub = npubEncode(key(8)); + for (const typed of [key(8), key(8).toUpperCase(), npub, `nostr:${npub}`]) + expect(offered(typed)).toEqual([key(7), key(8)]); + // A failed or partial read may omit the agent here that has its key. + complete = false; + expect(offered(key(8))).toEqual([]); +}); diff --git a/src/bundled/mentions/mention-candidates.ts b/src/bundled/mentions/mention-candidates.ts index 5b4a8b3925..cefbea5608 100644 --- a/src/bundled/mentions/mention-candidates.ts +++ b/src/bundled/mentions/mention-candidates.ts @@ -75,15 +75,18 @@ type ElsewhereAgent = Pick; const none: readonly never[] = []; /** - * The owner's agents from their other communities that the chooser offers by - * name (README section 1): none named like one of the owner's unarchived - * agents here, which already has that name. Teams still reach them by key. + * The owner's agents from their other communities that the chooser offers for + * `query` (README section 1): none named like one of the owner's unarchived + * agents here, which already has that name, except the one whose exact public + * key `query` is: a key names only its owner. Teams still reach them by key. * None until a complete read lists the owner's agents here (`elsewhereMatches`): - * a failed or partial one may omit the agent that has the name. + * a failed or partial one may omit the agent here that has the name, or the + * key, which then takes its row here. */ export function offeredElsewhere( session: ComposerSession, elsewhere: readonly ElsewhereAgent[] | undefined, + query = "", ) { if (!elsewhere?.length) return elsewhere; const agents = session.agentChoices.snapshot(); @@ -93,7 +96,10 @@ export function offeredElsewhere( .filter((agent) => !archivedMention(session, agent.pubkey)) .map((agent) => normalizeName(agent.name)), ); - return elsewhere.filter((agent) => !here.has(normalizeName(agent.name))); + const key = typedPublicKey(query); + return elsewhere.filter( + (agent) => agent.pubkey === key || !here.has(normalizeName(agent.name)), + ); } /** diff --git a/src/bundled/mentions/mention-rules.fixtures.json b/src/bundled/mentions/mention-rules.fixtures.json index 4c32332fa6..70b175fd77 100644 --- a/src/bundled/mentions/mention-rules.fixtures.json +++ b/src/bundled/mentions/mention-rules.fixtures.json @@ -1490,6 +1490,26 @@ } ], "expected": null + }, + { + "name": "exact_public_key_selects_an_agent_from_another_community_beside_its_namesake_here", + "query": "npub1hwamhwamhwamhwamhwamhwamhwamhwamhwamhwamhwamhwamhwasxw04hu", + "choices": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Jitter", + "member": true + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Jitter", + "member": false, + "agent": true, + "owned": true, + "elsewhere": "other.example.com" + } + ], + "expected": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ], "query": [ diff --git a/src/bundled/mentions/resolve-space-mention.ts b/src/bundled/mentions/resolve-space-mention.ts index 2f2b3ac59a..5d0ec27a2b 100644 --- a/src/bundled/mentions/resolve-space-mention.ts +++ b/src/bundled/mentions/resolve-space-mention.ts @@ -175,7 +175,7 @@ async function resolve( return null; // Read when deciding, as the chooser does on each render: a library read // that lands during the directory lookup offers its agents here too. - const offered = offeredElsewhere(session, elsewhere); + const offered = offeredElsewhere(session, elsewhere, query); const local = new Set( mentionCandidates( session, diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 0761f056a8..d2501092b1 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -28,6 +28,7 @@ import { offeredElsewhere, } from "./mention-candidates"; import { useListedChannel } from "../../features/relay/listed-channel"; +import { typedPublicKey } from "../../shared/identity/public-key"; import { useNamesRevision } from "../../features/identity-names/react"; import { useVerifiedNames, @@ -364,6 +365,9 @@ export function useMentionChoices( ...carried.filter((person) => !known.has(person.pubkey)), ]; }, [searched, carried]); + // Of the owner's agents from other communities, only an exact public key + // changes which are offered, so other keystrokes keep the choice set. + const typedKey = typedPublicKey(query); const current = useCallback( () => namedCandidates( @@ -373,9 +377,9 @@ export function useMentionChoices( roster, selected, people, - offeredElsewhere(session, community?.joinable()), + offeredElsewhere(session, community?.joinable(), typedKey), ), - [session, channelId, invite, roster, selected, people, community], + [session, channelId, invite, roster, selected, people, community, typedKey], ); // Keys known without the directory search: members and agent choices. const localKeys = useCallback(() => { @@ -385,14 +389,14 @@ export function useMentionChoices( invite, roster, undefined, - offeredElsewhere(session, community?.joinable()), + offeredElsewhere(session, community?.joinable(), typedKey), ); const cached = keySets.get(local); if (cached) return cached; const keys = new Set(local.map((c) => c.recipient.pubkey)); keySets.set(local, keys); return keys; - }, [session, channelId, invite, roster, community]); + }, [session, channelId, invite, roster, community, typedKey]); const { candidates, local } = useMemo(() => { // Source revisions invalidate the projection; selection reads them again synchronously. void profiles; diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 3d5a448d01..dcdff11a3e 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -15584,6 +15584,62 @@ it("shows the owner's agent from another community for its key once their agents expect(h.messages.send).not.toHaveBeenCalled(); }); +// One of the owner's agents here is named like their agent from another +// community, which name queries therefore never offer. +const jitterHere = { pubkey: "f".repeat(64), name: "Jitter" }; + +it("shows the owner's agent from another community for its key although one of theirs here has its name, and Space tags it", async () => { + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: rocketKey, name: "Jitter" }), + channel: { channelType: "stream" }, + }); + h.setLibrary([jitterHere]); + await h.user.type(h.input(), `@${rocketKey}`); + // A key has no namesakes, so the name rule does not hide it. + await screen.findByRole("option", { name: /In oss\.example\.com/ }); + expect(screen.getAllByRole("option")).toHaveLength(1); + await h.user.keyboard(" "); + expect(h.input()).toHaveValue("@Jitter "); + expect(h.chip(rocketKey)).toBeNull(); + expect(h.recipients()).toHaveLength(1); + h.submit(); + // Not "Jitter is not in this channel.": the agent here is not the recipient. + expect(await screen.findByRole("dialog")).toHaveTextContent( + /Bring Jitter into/, + ); + expect(h.messages.send).not.toHaveBeenCalled(); +}); + +it.each([ + ["hex key", rocketKey], + ["npub", npubEncode(rocketKey)], + ["nostr:npub", `nostr:${npubEncode(rocketKey)}`], +])( + "settles a pending mention of the %s of the owner's agent from another community into it once their agents here load, although one of those has its name", + async (_, typed) => { + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: rocketKey, name: "Jitter" }), + channel: { channelType: "stream" }, + }); + h.setLibrary([], "loading"); + await h.user.type(h.input(), `@${typed}`); + await screen.findByText("Loading recipients…"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [], hasMore: false }); + await h.user.keyboard(" "); + expect(h.chip(typed)).toHaveAttribute("aria-busy", "true"); + h.setLibrary([jitterHere]); + await waitFor(() => expect(h.input()).toHaveValue("@Jitter ")); + expect(h.chip(typed)).toBeNull(); + expect(h.recipients()).toHaveLength(1); + h.submit(); + expect(await screen.findByRole("dialog")).toHaveTextContent( + /Bring Jitter into/, + ); + expect(h.messages.send).not.toHaveBeenCalled(); + }, +); + it("settles a pending mention into the owner's agent from another community that is archived here, as Tab would", async () => { const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; const archives = { status: "ready" as const, archived: [rocket.pubkey] }; From 77aab0d6209a6dbb07e2baeaee2260afd91c5b70 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 9 Nov 2026 16:09:59 +1100 Subject: [PATCH 10/12] fix(mentions): show the picker loading while held agents wait, not empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 52794e102, 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 52794e102'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 Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- src/bundled/mentions/MentionPicker.tsx | 14 ++- src/bundled/mentions/README.md | 11 +- .../messages/MessageComposer.test.tsx | 118 +++++++++++++----- 3 files changed, 104 insertions(+), 39 deletions(-) diff --git a/src/bundled/mentions/MentionPicker.tsx b/src/bundled/mentions/MentionPicker.tsx index ce4f7915e6..d56edadb3e 100644 --- a/src/bundled/mentions/MentionPicker.tsx +++ b/src/bundled/mentions/MentionPicker.tsx @@ -181,6 +181,11 @@ export function MentionPicker({ // Underline with the same rule that ranks mentions. const matched = (label: string) => search.trim() ? matchPerson(label, search)?.positions : undefined; + // Rows may still arrive: the chooser is pending (members, a roster, or the + // owner's agents here that hold rows, README section 6) or the agent list + // is loading. The combined agent status is ready once native inventory is, + // so it alone misses the hold. No results is only known after this. + const loading = model.pending || agents.status === "loading"; return ( )} - {agents.status === "loading" && !candidates.length && ( + {loading && !candidates.length && (

Loading agents…

)} {(agents.status === "error" || !!agents.error) && ( @@ -519,9 +524,10 @@ export function MentionPicker({ onClick={() => chooseTeam(team)} /> ))} - {members && !candidates.length && !teams.choices.length && ( -

No matching channel members.

- )} + {members && + !loading && + !candidates.length && + !teams.choices.length &&

No matching channel members.

} {audience?.named && (

Your message tags{" "} diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index b26f89d081..60fb445bfe 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -331,11 +331,12 @@ These rules keep a row from moving under the user's pointer or keyboard. (Terms) holds it too, even when the name exception leaves it out of name queries: only that read shows whether the key is one of the viewer's agents here, which then has its row here. While the read runs, the agent list is - still loading (section 5). Once it fails, the full choice set stays unknown - for that query: only the local step of section 5 applies, over the local - choices the client has, and a retry reads again. A client SHOULD start - loading the viewer's agents here when a composer that offers such agents - opens, so that this wait is rare. + still loading (section 5), and a chooser SHOULD show that it is loading, not + that nothing matches, even when its agent list is otherwise ready. Once it + fails, the full choice set stays unknown for that query: only the local step + of section 5 applies, over the local choices the client has, and a retry + reads again. A client SHOULD start loading the viewer's agents here when a + composer that offers such agents opens, so that this wait is rare. - A shown row MUST NOT move while the query and the open chooser stay the same. New rows are added only at the bottom. Directory people come after the rows already shown. Offered agents (section 1, item 2) that load after the list diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index dcdff11a3e..8365051446 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -14765,7 +14765,13 @@ it("leaves Space literal beside a loaded longer member name and selects that nam * directory and every member named, so the first Space is undecided. Each * `people` read is held until the test releases it. */ function mountDirectoryMention( - options: { agents?: Agents; channel?: object; session?: object } = {}, + options: { + agents?: Agents; + channel?: object; + session?: object; + /** Native agent inventory; absent, the agent list has only the library. */ + control?: LocalAgents; + } = {}, ) { const completions: readonly Contribution[] = [ { @@ -14791,7 +14797,7 @@ function mountDirectoryMention( }, }, }, - undefined, + options.control, undefined, options.agents, ); @@ -15456,6 +15462,36 @@ it("keeps the agent library on a Retry that does not need it", async () => { ).toHaveAttribute("aria-disabled", "false"); }); +/** The composer's toolbar mention picker alone, as the bundled plugin mounts it. */ +const pickerOnly: MessageComposerProps["extensions"] = (() => { + const empty: [] = []; + const none = { snapshot: () => empty, subscribe: () => () => {} }; + const picker: readonly Contribution[] = [ + { + id: "picker", + key: "buzz.mentions/picker", + pluginId: "buzz.mentions", + revision: "1", + title: "Mentions", + component: (props) => ( + + ), + }, + ]; + return { + tools: { snapshot: () => picker, subscribe: () => () => {} }, + inline: none, + completions: none, + }; +})(); + it.each([ ["starts", "stream"], ["does not start", "dm"], @@ -15467,34 +15503,7 @@ it.each([ channel: { channelType }, }); h.setLibrary([], "idle"); - const empty: [] = []; - const none = { snapshot: () => empty, subscribe: () => () => {} }; - const picker: readonly Contribution[] = [ - { - id: "picker", - key: "buzz.mentions/picker", - pluginId: "buzz.mentions", - revision: "1", - title: "Mentions", - component: (props) => ( - - ), - }, - ]; - h.retarget({ - extensions: { - tools: { snapshot: () => picker, subscribe: () => () => {} }, - inline: none, - completions: none, - }, - }); + h.retarget({ extensions: pickerOnly }); // The composer's picker is mounted; nobody has typed `@`. expect( screen.getByRole("button", { name: "Mention a member" }), @@ -15505,6 +15514,55 @@ it.each([ }, ); +/** Native inventory is ready, so the agent list reads `ready` while the + * library that lists the owner's agents here still loads. */ +function mountNativeReadyHold() { + const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; + const h = mountDirectoryMention({ + agents: elsewhereAgent(rocket), + channel: { channelType: "stream" }, + control: localAgentsFixture(), + }); + h.setLibrary([], "loading"); + expect(h.session.agentChoices.snapshot().status).toBe("ready"); + return h; +} + +it("shows the toolbar picker loading, not empty, while the owner's agents here load with native inventory ready", async () => { + const h = mountNativeReadyHold(); + h.retarget({ extensions: pickerOnly }); + await h.user.click(screen.getByRole("button", { name: "Mention a member" })); + const search = screen.getByRole("searchbox", { + name: "Search community people and agents", + }); + await waitFor(() => expect(search).toHaveFocus()); + await h.user.type(search, "rocket"); + expect(screen.getByText("Loading agents…")).toBeInTheDocument(); + expect(screen.queryByText("No matching channel members.")).toBeNull(); + // The directory finds no one; the held agent is still to come. + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [], hasMore: false }); + expect(screen.getByText("Loading agents…")).toBeInTheDocument(); + expect(screen.queryByText("No matching channel members.")).toBeNull(); + h.setLibrary([]); + // Picker rows are buttons; the inline chooser's are options. + await screen.findByRole("button", { name: /Rocket.*In oss\.example\.com/ }); + expect(screen.queryByText("Loading agents…")).toBeNull(); + expect(screen.queryByText("No matching channel members.")).toBeNull(); +}); + +it("shows the inline chooser loading while the owner's agents here load with native inventory ready", async () => { + const h = mountNativeReadyHold(); + await h.user.type(h.input(), "@rocket"); + await screen.findByText("Loading recipients…"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [], hasMore: false }); + expect(screen.getByText("Loading recipients…")).toBeInTheDocument(); + h.setLibrary([]); + await screen.findByRole("option", { name: /In oss\.example\.com/ }); + expect(screen.queryByText("Loading recipients…")).toBeNull(); +}); + it("settles a pending mention into the owner's agent from another community once their agents here load", async () => { const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; const h = mountDirectoryMention({ From 1514d2945f00fc15458e5efb9e7d7726ad2f80cb Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 9 Nov 2026 16:47:21 +1100 Subject: [PATCH 11/12] fix(mentions): keep the picker's empty message from showing while agents wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- src/bundled/mentions/MentionPicker.tsx | 29 +++++--- src/bundled/mentions/README.md | 12 ++-- src/bundled/mentions/use-mention-choices.ts | 8 +++ .../messages/MessageComposer.test.tsx | 69 +++++++++++++++++++ 4 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/bundled/mentions/MentionPicker.tsx b/src/bundled/mentions/MentionPicker.tsx index d56edadb3e..bf41048c98 100644 --- a/src/bundled/mentions/MentionPicker.tsx +++ b/src/bundled/mentions/MentionPicker.tsx @@ -181,11 +181,21 @@ export function MentionPicker({ // Underline with the same rule that ranks mentions. const matched = (label: string) => search.trim() ? matchPerson(label, search)?.positions : undefined; - // Rows may still arrive: the chooser is pending (members, a roster, or the - // owner's agents here that hold rows, README section 6) or the agent list - // is loading. The combined agent status is ready once native inventory is, - // so it alone misses the hold. No results is only known after this. - const loading = model.pending || agents.status === "loading"; + // Agent rows may still arrive: the agent list is loading, or the chooser + // waits on a read of it (inviting, or the hold of README section 6) that + // the combined status misses once native inventory is ready. Members and a + // roster have their own status text. + const agentsLoading = agents.status === "loading" || model.agentsPending; + const agentsFailed = agents.status === "error" || !!agents.error; + // No results is only known once nothing is pending and the agent read has + // not failed: Retry may still bring rows. + const empty = + !!members && + !model.pending && + !agentsLoading && + !agentsFailed && + !candidates.length && + !teams.choices.length; return ( )} - {loading && !candidates.length && ( + {agentsLoading && !candidates.length && (

Loading agents…

)} - {(agents.status === "error" || !!agents.error) && ( + {agentsFailed && (