Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,23 @@ chooser is unavailable because activation failed.
The picker shows keys alongside names (namesakes remain separate), reads optional
profiles only on demand, and keeps selected identity spans in scoped drafts.
Typing a name alone does not notify anyone. Plain Space after a unique exact name
selects its recipient; ambiguous names require Tab, Enter or click. Editing a
selects its recipient; ambiguous names require Tab, Enter or click. While the
chooser is still waiting on members, names, agents or a directory search, Space
holds the name as a pending chip (a spinner in place of its `@`) and applies the
same rule once the evidence arrives, within 8 s, or returns it to prose; sending
waits for a pending chip, and when one it waited for returns to prose it keeps
the draft and says which name was not tagged and why (not confirmed as one
person or agent, not taggable here, the recipient limit or the message
length), so the next send sends the text knowingly. A name already known to
continue the query (`Mira Example` for `mira`) is a declined Space, not a
pending one, so the next word
can still complete it; a longer name learned only after the chip was held
extends the chip over the words typed after it when they spell one of those
names up to a name boundary, the longest they complete (`@mira Example hi`
tags `Mira Example`), and otherwise returns it to prose, where the reopened
chooser answers the rest of the name from the page already read for the chip
(a complete directory page settles every query extending its own, with no
further read), so the Space that completes the name selects it. Editing a
selected span removes its notification intent. Native beforeinput ranges preserve untouched spans; missing
range evidence, IME/history edits and collapsed deletions clear selections rather
than guess. Even a same-text replacement drops the edited identity. Selected mentions appear as inline identity chips in the composer. Namesakes
Expand Down Expand Up @@ -479,10 +495,17 @@ recipients among the destination's members plus the draft recipients.

Plain Space selects only a unique exact name/alias/label across the full uncapped
candidate set, and only if that identity is displayed and still eligible. A known
longer name beginning with that name plus a space prevents selection. Partial
names, ambiguous names, modified Space, IME composition, code and protected literal
ranges keep ordinary editing behavior. Selection rechecks available evidence and
stores only `{pubkey, name}`; qualifiers are presentation, not wire data.
longer name beginning with that name plus a space prevents selection, unless it is
another name of that same identity. Partial names, ambiguous names, modified Space,
IME composition, code and protected literal ranges keep ordinary editing behavior. Selection rechecks available evidence and
stores only `{pubkey, name}`; qualifiers are presentation, not wire data. A Space
the chooser cannot decide yet, because its evidence is still loading, holds the
name as a pending chip that the plugin's headless resolver settles by the same
rule (`resolve-space-mention.ts`); a known longer name declines Space outright
rather than holding it, while one found after the chip was held extends the
chip over the typed words that complete it or returns it to prose; the host
admits the result like any selection, and a pending chip is never stored in a
draft or sent as a tag.

The composer rejects already-known archived recipients (never the viewer) at
send entry. The next draft keeps only agents that the sent message notified. This is not an archive transaction: archive
Expand Down
12 changes: 12 additions & 0 deletions src/bundled/composer/lab/fixture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ const rawSession = {
subscribe: empty,
ensure: async () => undefined,
},
directMessages: {
// The community directory knows the same four profiles, so a picker
// search settles (and is cached) instead of failing.
people: async (query: string) => ({
people: [...profiles]
.filter(([, { name }]) =>
name.toLowerCase().startsWith(query.trim().toLowerCase()),
)
.map(([pubkey, profile]) => ({ pubkey, ...profile })),
hasMore: false,
}),
},
channels: {
list: () => channelList,
subscribeList: empty,
Expand Down
61 changes: 51 additions & 10 deletions src/bundled/mentions/MentionCompletion.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { TeamMentionAvatars } from "./TeamMentionAvatars";
import { useEffect, useRef, useState } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import { useTeamMentions } from "./use-team-mentions";
import { useMentionChoices } from "./use-mention-choices";
import type { ComposerCompletionProps } from "../../features/conversation/contracts";
import type { ComposerSession } from "../../features/messages/composer-session";
import { Avatar } from "../../shared/design-system/ui/Avatar";
import { outsideMentionDetail } from "./mention-candidates";
import { matchesMentionQuery } from "./mention-query";
import { matchesMentionQuery, pendingMentionQuery } from "./mention-query";
import { continuedMention } from "./mention-ranking";
import { resolveSpaceMention } from "./resolve-space-mention";
import { SelectedMentionContext } from "../../features/messages/selected-mention-context";
import { DraftMentionRoster } from "../../features/messages/draft-mention-roster";
import { AgentsContext } from "../../features/agents/react";
import { avatarMedia } from "../../shared/avatar-source";

// Demand bookkeeping only, not another profile cache. Missing names do not issue
Expand All @@ -30,21 +35,18 @@ export function MentionCompletion({
query.query,
JSON.stringify(["inline", scope, channelId, threadRootId, query.start]),
);
const {
profiles,
agents,
channel,
list,
choices,
roster: draftRoster,
} = model;
const { profiles, agents, channel, list, choices } = model;
const teams = useTeamMentions(
session,
channelId,
inviteAgents,
query.query,
model,
);
const selected = useContext(SelectedMentionContext);
const roster = useContext(DraftMentionRoster);
const community = useContext(AgentsContext);
const draftRoster = roster?.recipients;
const members = draftRoster?.map((p) => p.pubkey) ?? channel?.members ?? [];
const memberKey = members.join(":");
const parentAdmission = !!channel && channel.channelType !== "solo";
Expand Down Expand Up @@ -182,8 +184,44 @@ export function MentionCompletion({
const next = [...ids, ...additions.map((item) => item.id)].slice(0, 50);
shown.current = { session, key, ids: next };
const byId = new Map(available.map((item) => [item.id, item]));
// Space is undecided, not declined, only while evidence is still arriving:
// members, names or agents loading, or the directory search waiting or
// running. Then the host may hold the name and let the resolver apply
// section 5 once the choice set is known. A name already known to continue
// the query declines Space whatever else arrives: the next word may
// complete that longer name, so Space stays prose and the query open. The
// known set is every candidate the chooser has, not only the shown rows:
// members or roster, offered agents and the directory rows received so
// far, the same set `spaceId` is decided over. Anything else is prose.
const undecided =
admitted &&
model.spaceId === undefined &&
!teams.blocksSpace &&
!continuedMention(model.candidates, query.query) &&
(model.pending || model.directory.loading) &&
Comment thread
matt2e marked this conversation as resolved.
Resolved
Comment thread
matt2e marked this conversation as resolved.
Resolved
pendingMentionQuery(query.query);
const withdraw = publish({
spaceId: teams.blocksSpace ? undefined : model.spaceId,
...(undecided
? {
spacePending: {
name: query.query,
resolve: (signal: AbortSignal) =>
resolveSpaceMention(
session,
{
channelId,
inviteAgents,
roster,
selected,
elsewhere: community?.joinable(),
},
query.query,
signal,
),
},
}
: {}),
items: next.flatMap((id) => byId.get(id) ?? []),
...(model.pending
? { status: "Loading recipients…" }
Expand Down Expand Up @@ -260,8 +298,11 @@ export function MentionCompletion({
choices,
channel,
draftRoster,
roster,
inviteAgents,
parentAdmission,
selected,
community,
]);
return null;
}
78 changes: 68 additions & 10 deletions src/bundled/mentions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,11 @@ did not fail. While the search runs, the chooser shows no rows for that query.
If the search fails, the chooser keeps its error and a way to retry. The
desktop UI test "a failed multi-word directory search keeps its error and
retry" in [solo-agents.test.tsx](solo-agents.test.tsx) covers this case:
`@Mary J` fails, then Retry finds Mary Jane. The one case where the client MAY
skip the search is the refuted prefix in section 6. An `@` inside a mention
that the user already selected does not open a query.
`@Mary J` fails, then Retry finds Mary Jane. The client MAY skip the search
only for the refuted prefix in section 6, or when a complete finished search
for a prefix of the query already answers it (section 6). An `@` inside a mention
that the user already selected, or that is pending (section 5), including one
that has extended over the words typed after it, does not open a query.

## 3. Matching (normative)

Expand Down Expand Up @@ -203,14 +205,17 @@ set of choices when all of these are true:
(`Jose Luis` and `Jose-Luis` continue `jose`; `Josefa` does not). A label's
key qualifier or generated `(agent)` suffix only tells namesakes apart, so
`Honey · 4prr` and a generated `Honey (agent)` do not continue `honey`.
A literal name `Honey (agent)` still continues `honey`.
A literal name `Honey (agent)` still continues `honey`. A choice's own longer
name does not continue the query when that same choice also has an alias or
label equal to it: an agent known as both `Example` and `Example Agent` is one
identity, not a namesake of itself, so `example` selects it.

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;
otherwise
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.
Expand All @@ -222,10 +227,52 @@ 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
search reports it, but not before. A member whose name is not known yet may be a
namesake, so until it is, only step 2 applies. A name that only a directory
result has could still gain a namesake from a pending search, so Space MUST NOT
select it before the search finishes. Otherwise Space is a normal space. Tab,
Enter, and a click select the highlighted row.
namesake, so until it is, only step 2 applies. A member whose profile read
completed without finding a profile has no name, so it cannot be a namesake and
does not hold step 1 back; a failed read is not evidence. A name that only a
directory result has could still gain a namesake from a pending search, so Space
MUST NOT select it before the search finishes. Otherwise Space is a normal
space. Tab, Enter, and a click select the highlighted row.

While the client cannot yet decide — the member list, member names or agent
list is still loading, or a directory search for the query is waiting or
running — it MAY instead mark the query as a **pending mention**: a chip with
no recipient, shown as still resolving. When the full choice set is known it
applies this section to the pending name as if Space had been typed then: a
selection becomes the recipient (checked for admission as any selection is);
anything else, including a failed or truncated search or a bounded wait
expiring (desktop: 8 s), returns the text to prose. A name the client has
already declined is not pending: a trailing space or punctuation, a multi-word
query that continues no name, a saved team with the same or a longer name, or
a known choice whose name continues the query (a loaded member `Mira Example`
for `mira`) keeps Space a normal space. Such a longer name blocks whatever
else arrives, and the next word may complete it (`@mira Example ` selects the
member), which a chip that ended the query could not. A pending name that,
once the full choice set is known, only longer known names keep from selecting
(`Mira Example`, found by the directory search for `mira` after the chip was
held) MAY instead extend over the words typed after the chip when they spell
one of those names up to a name boundary (`@mira Example hi` becomes a pending
`@mira Example` with ` hi` outside it), taking the longest name the words
complete, as Space would have needed all of it typed; this section then applies
to the longer name as if Space had been typed after it, under the same bound.
Words that stop mid-name or complete none of those names return the text to
prose. A pending mention, extended or not, MUST end at a name boundary: a name
character typed right after it (`@honey`, Space, Backspace, `suckle`) returns
it to the typed query, as an edit does to a recipient. The chooser then reopens over the typed name, and the complete search
for the pending name already settles the directory for every query extending
it (section 6), so no search is waiting and the Space that completes the
longer name selects it: `@mira Exam` returned to prose, then `ple ` selects
`Mira Example`. In a
DM a pending name that resolves to someone outside the DM returns to prose,
since the chooser's "Will not be notified" row was never shown. Sending while
a mention is pending waits for it, through such an extension; if a mention the
send waited for returns to prose, the client MUST NOT send in that same action,
since the user saw the name held and would notify no one: it keeps the draft,
says which name was not tagged and why (it could not be confirmed as one
person or agent, because nothing matched, several did or the search did not
finish; or the match could not be tagged here, would exceed the recipient limit
or would make the message too long), and the next send sends the text. A pending
mention is never a recipient or a reference (section 8).

## 6. List stability (desktop behavior; not in the fixtures)

Expand All @@ -243,6 +290,14 @@ These rules keep a row from moving under the user's pointer or keyboard.
last finished search. It MAY cache finished searches for the session. It MUST
NOT cache failures or searches that find no one. A new opening of the chooser
searches again for such a query.
- A finished search that reports no more pages holds every match for a longer
query that starts with its query, because the relay matches names by prefix.
A client MAY answer such a longer query from that page, filtered by section 3,
with no search and no typing pause; the directory is then settled for it
(section 5). A search that reported more pages, or failed, answers nothing
beyond its own query; the listing shown for an empty query is not a name
search and answers no query but its own; and an exact 64-hex key is never
answered this way. A retry searches afresh.
- A search refutes its query when it finds no one, reports no more pages, and
the query contains a letter or digit. After that, a client MAY skip the
directory search for a longer query that starts with the refuted query, and
Expand Down Expand Up @@ -348,6 +403,9 @@ bin/pnpm exec vitest run src/bundled/mentions/mention-ranking.test.ts \
and [use-mention-choices.ts](use-mention-choices.ts). Directory pages come from
[useMentionDirectory.ts](useMentionDirectory.ts).
- Matching, order, and Space: [mention-ranking.ts](mention-ranking.ts).
A pending mention is decided by [resolve-space-mention.ts](resolve-space-mention.ts);
the host holds and settles the chip in
[MessageComposer.tsx](../../features/messages/MessageComposer.tsx).
- Query syntax: [mention-query.ts](mention-query.ts).
- Recipient admission (host): [mention-admission.ts](../../features/messages/mention-admission.ts).
Explicit-choice recency (host): [mention-history.ts](../../features/messages/mention-history.ts).
Expand Down
6 changes: 6 additions & 0 deletions src/bundled/mentions/mention-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export function mentionQuery(text: string, caret: number) {
return { start, end: caret, query };
}

/** A query Space may hold as a pending mention: a name still being typed.
* Trailing space or punctuation says the user has moved on to prose. */
export function pendingMentionQuery(query: string) {
return !!normalizeName(query) && !/[\s.,!?;:)\]}]$/u.test(query);
}

export function matchesMentionQuery(query: string, names: readonly string[]) {
if (!query.includes(" ")) return true;
const lower = foldName(query);
Expand Down
34 changes: 34 additions & 0 deletions src/bundled/mentions/mention-ranking.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { expect, it } from "vitest";
import { npubEncode } from "nostr-tools/nip19";
import {
continuedMention,
continuingNames,
exactMention,
matchingMentions,
rankMentions,
Expand Down Expand Up @@ -126,6 +128,38 @@ it("orders mixed people and agents the same way from every input order", () => {
]);
});

it("a known longer name continues the query, except an identity's own beside its exact name", () => {
const longer = choice("1", "Mira Example");
expect(continuedMention([longer], "mira")).toBe(true);
expect(continuedMention([longer], "Mira Example")).toBe(false);
// The continuing names, each once, for a pending mention to extend over.
expect(
continuingNames(
[longer, choice("5", "Mira Example"), choice("6", "Mira Example Two")],
"mira",
),
).toEqual(["Mira Example", "Mira Example Two"]);
expect(continuingNames([longer], "Mira Example")).toEqual([]);
// Section 5's exception: `Example Agent` is another name of the `Example`
// agent, not a namesake, so it neither continues nor blocks `example`.
const self = choice("2", "Example", {
agent: true,
aliases: ["Example Agent", "Example"],
label: "Example Agent",
});
expect(continuedMention([self], "example")).toBe(false);
expect(exactMention([self], "example")).toBe(self.recipient.pubkey);
// A key qualifier or generated `(agent)` suffix is not a longer name.
expect(
continuedMention(
[
choice("3", "Mira", { label: "Mira · 4prr", qualifier: "4prr" }),
choice("4", "Mira", { agent: true, label: "Mira (agent)" }),
],
"mira",
),
).toBe(false);
});
it("validates the portable mention fixture version and case lists", () => {
expect(mentionConformance.version).toBe(2);
expect(mentionConformance.matching.length).toBeGreaterThan(0);
Expand Down
Loading
Loading