Repository navigation
Conversation
8d19b16 to
ec15ed9
Compare
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account, with Rocket, Mantis and Groot: one P2 integration gap in the new Solo mention flow, detailed inline.
Reviewed head e99f360f8b10dcde30a2f2aeec1e422d6c6d2aeb against base c40dfdf4c81aabe759f3a4329c3e77f854746a59. Source-only: no tests or app execution; CI and native/IME/accessibility behavior were not verified. The attached screenshot redirected to sign-in and the demo video was not inspected, so their visual/privacy contents remain unverified.
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source follow-up via Wes’s account, integrating Rocket, Mantis and Groot. The initial-load repair addresses the prior finding, but one P2 remains on Retry agents, detailed inline. Source assessment: 8/10; renew the pending roster promise for each loading cycle.
Reviewed head 308aa691fc092740716415dcaf7a0f6080b18642 against base c40dfdf4c81aabe759f3a4329c3e77f854746a59, focusing on the nine-file delta since e99f360f8. Source-only: no tests or app execution; CI, native behavior and rendered accessibility were not verified. The screenshot was inspected; the demo video remains uninspected.
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source follow-up via Wes’s account, integrating Rocket, Mantis and Groot: no new actionable findings; the prior Retry agents defect is fixed by renewing the pending roster promise for each loading period, with a regression test checking recipient tags. Source assessment: 9/10.
Reviewed head 949f96324b7d4c542101c3db93ad0f83fbcb9335 against base c40dfdf4c81aabe759f3a4329c3e77f854746a59, focusing on the two-file repair since 308aa691. Source-only: no tests or app execution; CI, native behavior and rendered accessibility remain unverified; the screenshot was inspected, but the demo video was not.
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source follow-up via Wes’s account, integrating Rocket, Mantis and Groot: no new actionable findings in the one-file test repair since 949f9632; production code is unchanged. The helper settles selection before typing, and the added regression checks both editor DOM and draft text. Source assessment: 9/10.
Reviewed head ff4bf9ddacf24ff6f3acc602c77d3ef2017417e9 against base c40dfdf4c81aabe759f3a4329c3e77f854746a59. Source-only: no tests or app execution; fail-before/pass-after and timing claims were not independently reproduced, and CI was still running at the snapshot. The screenshot was inspected; the demo video and native/browser behavior remain unverified.
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 One pending-mention regression found; details inline.
ff4bf9d to
a2c84b6
Compare
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source follow-up via Wes’s account, integrating Rocket, Mantis and Groot: two P2 defects and one P3 IME boundary defect are detailed inline. Preserve the separator during chip extension, include cross-community namesakes in pending resolution, and recheck the boundary after composition. Source assessment: 8/10 pending those fixes.
Reviewed head a2c84b61e75949e400628c7196a8f6a68bbcb365 against base 64020ded90882f5e7609b76d8778c4f1f27a3dac, including rebase integration and changes since ff4bf9dd. Source-only: no tests or app execution; CI, performance and native/accessibility behavior remain unverified. The public screenshot was inspected; the demo video was not.
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 One remaining recipient-loss defect in pending mention extension.
…r the typed words `typedContinuation` took the longest remainder after the chip that, joined to the chip's name, normalized to a continued name at a `mentionBoundary`. `normalizeName` trims, so with the draft ending at `@mira Example ` when the directory page landed, both `Example` and `Example ` spelled `Mira Example`, and the trailing-space remainder, found last, won. The editor's extension deleted that remainder, separator included: the settled draft was `@Mira Example`, typing `hi` produced `@Mira Examplehi`, and the joined word dropped the explicit recipient, so the message went out with none. Reported on PR #1623 (kalvinnchau), reproduced in Chromium and WebKit; the existing extension tests type `Example hi` before releasing the page and never met the boundary. A remainder that ends in whitespace is no longer a candidate: it spells the same name as the shorter remainder before it, which the loop already took, so the extension stops on the name's last character and the separator stays in the text. Punctuation needed no rule: `normalizeName` keeps it, so `Example,` never spelled the name. Test: the page is released while the draft ends at `Example `; the chip extends and settles to `@Mira Example ` with the recipient, `hi` typed after it reads `@Mira Example hi` with the recipient kept, and the send carries Mira's tag. It fails at HEAD~ at the settled-value assertion. Verified: MessageComposer.test.tsx 375/375, `tsc --noEmit` clean, biome clean on the changed files (one pre-existing `useTemplate` info in the test file). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source follow-up via Wes’s account, integrating Rocket, Mantis and Groot.
The separator defect is fixed; no new defect found in the two-file repair. These unchanged prior findings still need attention:
- P2 cross-community namesakes: preserve the chooser’s complete eligible choice set in pending resolution.
- P3 IME replay boundary: recheck the current name boundary before applying a held extension.
Source assessment remains 8/10. Head e188fffa9fccea7aab1d32bfbcc5728765c1beae, base 64020ded90882f5e7609b76d8778c4f1f27a3dac, compared with a2c84b61. Source-only: no tests, app execution or current CI assessment; native/accessibility behavior unverified. Screenshot inspected; demo video uninspected.
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Carl, an automated reviewer, commenting via Wes’s GitHub account, with two independent Court lanes (Princess Donut, Mordecai) plus my own pass.
Verdict: not ready. Two prior findings remain; one new product question and two optional hardening notes are inline.
- Still open: P2 cross-community namesakes (now also covering settle admission) and P3 IME replay boundary.
- Fixed at this head: separator preservation, Space beside a known longer name, Solo initial-load and Retry promise renewal.
Optional, non-blocking: the change adds roughly 65 tests and 2.6k test lines, mostly one per edge. A few journeys (channel Space → pending → p tag on send, including extension and IME under Send; Solo failed then retried inventory; undo/redo/Backspace) plus one table of resolver outcomes would cover the same contracts with less to maintain. The extension machinery (typedContinuation, applyExtend, chained links, held extends) drives most review rounds; it covers a real case, but it is the natural cut if a smaller slice is wanted.
Unverified: prefixPage in useMentionDirectory.ts assumes a longer relay query never matches more than its prefix. If relay search does not guarantee that, Space can select over a page missing a namesake.
Merge criteria: the two open findings fixed with coverage; a decision on the inline send-after-wait question.
Head e188fffa9fccea7aab1d32bfbcc5728765c1beae. Source review only; no local tests, IME or app runs.
…r returns to prose Two follow-ups from review of PR #1623. A send that waited on a pending chip went out as prose when the chip reverted: `@mira hi`, Enter on a slow relay, the resolver hits its bound or finds a namesake, and the message leaves with no `p` tag and no notice. The user saw a chip, waited, and notified no one. `send()` now learns how each chip it waited for settled: the resolution chain (`resolveChip` -> `settlePendingMention` -> `settled`) settles with the typed name when the chip returned to prose, under the name the chip had when it settled (an extended chip reports `mira Example`). Any such name keeps the draft and sets the composer's alert: "@mira could not be matched to a person or agent. Send again to send it as text.", or, where a recipient is required and none is left, the existing recipient hint in its place. The next send goes out as text, since nothing is pending by then. A send whose waited chips all became recipients continues as before. The settle path stored a provider's resolution verbatim, skipping the shape check `insertMention` applies, so `{ pubkey, name: undefined }` from a third-party provider became an `@undefined` chip. `wellFormedRecipient` (64-hex pubkey, non-empty name) is now one module-level guard used by both, and settle copies only `{ pubkey, name }`. Tests: the two bound tests now assert the kept draft and alert, then a second submit sends the text; the wait table splits into the recipient case and the prose case with its second send (alert gone after it); a required recipient gets the hint with Send `aria-disabled`; a name-less resolution returns the chip to prose. README section 5 and docs/agents.md state the rule. Verified: MessageComposer.test.tsx 377/377 (one earlier run under load average 90 failed `…pending chip to prose mid-word` once; it passes 3/3 alone and in the full rerun), SoloDraft.test.tsx 40/40, `tsc --noEmit` clean, biome clean on the changed files (one pre-existing `useTemplate` info in the test file). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 One new diagnostic issue in the pending-mention send alert.
cd08389 to
c45830a
Compare
…r the typed words `typedContinuation` took the longest remainder after the chip that, joined to the chip's name, normalized to a continued name at a `mentionBoundary`. `normalizeName` trims, so with the draft ending at `@mira Example ` when the directory page landed, both `Example` and `Example ` spelled `Mira Example`, and the trailing-space remainder, found last, won. The editor's extension deleted that remainder, separator included: the settled draft was `@Mira Example`, typing `hi` produced `@Mira Examplehi`, and the joined word dropped the explicit recipient, so the message went out with none. Reported on PR #1623 (kalvinnchau), reproduced in Chromium and WebKit; the existing extension tests type `Example hi` before releasing the page and never met the boundary. A remainder that ends in whitespace is no longer a candidate: it spells the same name as the shorter remainder before it, which the loop already took, so the extension stops on the name's last character and the separator stays in the text. Punctuation needed no rule: `normalizeName` keeps it, so `Example,` never spelled the name. Test: the page is released while the draft ends at `Example `; the chip extends and settles to `@Mira Example ` with the recipient, `hi` typed after it reads `@Mira Example hi` with the recipient kept, and the send carries Mira's tag. It fails at HEAD~ at the settled-value assertion. Verified: MessageComposer.test.tsx 375/375, `tsc --noEmit` clean, biome clean on the changed files (one pre-existing `useTemplate` info in the test file). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…r returns to prose Two follow-ups from review of PR #1623. A send that waited on a pending chip went out as prose when the chip reverted: `@mira hi`, Enter on a slow relay, the resolver hits its bound or finds a namesake, and the message leaves with no `p` tag and no notice. The user saw a chip, waited, and notified no one. `send()` now learns how each chip it waited for settled: the resolution chain (`resolveChip` -> `settlePendingMention` -> `settled`) settles with the typed name when the chip returned to prose, under the name the chip had when it settled (an extended chip reports `mira Example`). Any such name keeps the draft and sets the composer's alert: "@mira could not be matched to a person or agent. Send again to send it as text.", or, where a recipient is required and none is left, the existing recipient hint in its place. The next send goes out as text, since nothing is pending by then. A send whose waited chips all became recipients continues as before. The settle path stored a provider's resolution verbatim, skipping the shape check `insertMention` applies, so `{ pubkey, name: undefined }` from a third-party provider became an `@undefined` chip. `wellFormedRecipient` (64-hex pubkey, non-empty name) is now one module-level guard used by both, and settle copies only `{ pubkey, name }`. Tests: the two bound tests now assert the kept draft and alert, then a second submit sends the text; the wait table splits into the recipient case and the prose case with its second send (alert gone after it); a required recipient gets the hint with Send `aria-disabled`; a name-less resolution returns the chip to prose. README section 5 and docs/agents.md state the rule. Verified: MessageComposer.test.tsx 377/377 (one earlier run under load average 90 failed `…pending chip to prose mid-word` once; it passes 3/3 alone and in the full rerun), SoloDraft.test.tsx 40/40, `tsc --noEmit` clean, biome clean on the changed files (one pre-existing `useTemplate` info in the test file). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 The new draft-preservation behavior and recipient-shape guard address the prior follow-ups. These existing findings still need attention:
- P2 cross-community namesakes/admission: use the chooser’s complete eligible set and the host’s current admission policy.
- P3 IME replay boundary: recheck the name boundary when applying a held extension.
- P3 refused-recipient wording: distinguish a recipient refusal from a name that could not be resolved.
Star Lord’s automated source follow-up via Wes’s account, integrating Rocket, Mantis and Groot. Head c45830a8090f4e1f4b9aa30495df34ebb0cb2113, base 87eefd993e02d06f8fab5f9478e02b822486ac60; compared with e188fffa. No tests, app execution or CI assessment. Screenshot inspected; demo video, native/IME behavior and rendered accessibility remain unverified.
…gged Review of PR #1623 (r4400503808): with 32 recipients already in the draft, `@honey ` held and Enter pressed, the resolver finds Honey but the recipient cap refuses it, and the alert said "@honey could not be matched to a person or agent". The same wording covered a recipient that is not admitted here and one whose current name would overrun the length limit. The user is told the name matched no one and retries it, when the fix is to remove a recipient or shorten the message. The settle path already makes each check in turn, so it now keeps the reason with the typed name: `settled` resolves `{ name, reason }`, where the reason is `unmatched` (no resolution, a malformed one, a timeout, a namesake or a continuation the words did not complete), `unavailable` (admission refused), `limit` (32 recipients), or `length` (accepted, but the editor kept the typed text because the name would overrun the limit). The send alert gives one sentence per reason, naming its chips: - "@honey could not be matched to a person or agent." - "@honey could not be tagged here." - "@honey could not be tagged: a message can tag at most 32 recipients." - "@honey could not be tagged: the message would be too long." followed, as before, by "Send again to send it as text." or the recipient hint. Draft retention and the second-send behavior are unchanged. README section 5 and docs/agents.md say the alert names why. Tests: a resolved recipient refused at send for each of the cap, admission and length, asserting the reason in the alert, the kept draft and no send. All three fail at HEAD~, which says the name could not be matched. Two chips refused for different reasons get a sentence each and "them". Verified: MessageComposer.test.tsx 411/411 at load average 112, including the four new cases; the combined run with EditableInput, SoloDraft, src/bundled/mentions and src/features/conversation passed 1040/1041, the one failure being the unrelated load-sensitive case noted in the previous commit; `tsc --noEmit` clean; biome clean on the changed files (one existing `useTemplate` info in the test file). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 Host-side refusal diagnostics are repaired. One residual wording issue remains below; existing namesake and IME findings are not duplicated.
…nd alert Review of PR #1623 (r4401471378): every null resolution of a pending chip a send waited for was reported as "@mira could not be matched to a person or agent". The resolver returns null for more than no match: a namesake, a team block, a DM outsider, a truncated page, a failed search and the 8 s bound all end the same way, and for most of them a match may well exist. The alert told the user the name matched no one when the lookup was ambiguous or incomplete. The reason is now `unresolved` and says "@mira could not be confirmed as one person or agent.", true of every null and still a hint that picking the name from the list is the way to tag it. The other reasons (not taggable here, the recipient limit, the message length) and the draft retention are unchanged. README section 5 and docs/agents.md use the same wording. Tests: the two bound tests (first resolution and chained extension) assert the new wording for a timeout, and a new case over the real chooser holds `@mira`, sends, and answers the search with two people named Mira; the alert says the name could not be confirmed and the draft is kept. All three fail with the previous wording. Verified: MessageComposer.test.tsx 412/412, `tsc --noEmit` clean, biome clean on the changed files (one existing `useTemplate` info). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…tinues it `exactMention` counted every choice's longer names as blockers, including the choice that matches the query exactly. An agent known by both its library name `Example` and its profile name `Example Agent` therefore blocked its own Space selection on `@example `, and only `@example agent ` could pick it. One identity is not its own namesake: a choice's longer names no longer block when that same choice has an alias or label equal to the query. Other choices' longer names block as before. Adds the portable `space` fixture `alias_of_same_identity_does_not_block` (fails on the previous rule) and states the exception in README section 5 and docs/agents.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
The profile directory only ever recorded kind-0 events it received, so a channel member with no profile stayed "missing" forever. The Mentions chooser's local-first Space step waits until every member is named, which meant one profile-less member demoted every mention in that channel to the directory-gated path, where a fast-typed `@name ` arrives before the 200 ms debounce and relay round trip settle and is sent as prose. The directory now records the authors a completed read returned no kind 0 for as `unnamed` (bounded, replaced rather than mutated, notified like the snapshot) and exposes them through an optional `ProfileQueries.unnamed()`. A profile that arrives later, live or by a later read, removes the entry; `clear()` forgets it; a failed read records nothing. `useMentionChoices` subscribes to it and no longer counts known-unnamed members as missing names. Such a member has no aliases, so it can neither be selected nor block a name. The chooser's "Some names unavailable" status and Retry are unchanged. Tests: the directory records, outranks, ignores failures, and forgets the evidence; a member mention selects on Space once another member's read settles without a profile while the directory search is still running, and keeps waiting while the read is in flight or has failed until a retry settles it. README section 5 states the rule. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
… pure functions
`useMentionChoices` built its named choice set and applied the Space rule in
closures; `useMentionDirectory` read a directory page inside its debounced
effect. A pending mention (next commits) needs to apply the same rule to the
same page off-screen, after the chooser has unmounted. Extract them without
changing behavior:
- `namedCandidates(session, channelId, invite, roster, selected, people)`:
the labelled choice set under one naming scope.
- `spaceMention(choices, local, query, evidence)`: README section 5 over
explicit `{ pending, missingNames, complete }` evidence.
- `fetchMentionDirectoryPage(session, query, signal)`: the shared page cache
and refuted-prefix bookkeeping around one `people()` read; the hook's
effect keeps its debounce and React state around it.
Existing chooser and conformance tests cover the moved code unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
A mention Space commits before the chooser has decided needs somewhere to
live while the host resolves it. Add a `pending: { id, name }` attr to the
token node, next to `recipient` and `resource`:
- `projectComposerDocument` emits `draft.pending` (`{ id, name, start, end }`
per chip, only when any exist) beside `recipients`; `MentionDraft.pending`
is documented as projection-only and `mentionDraft()` never copies it, so a
stored draft cannot restore a chip whose resolver died with its editor. The
restore branch of `readComposerDocument` demotes such a token to its text.
- `insertPendingMention(name, range)` replaces the typed `@name` with the
atom and a space in one history event (the typed query is the undo target,
like a selected mention) and returns the chip's id; it refuses a caret away
from the range, a range that is not the name, and code blocks.
- `settlePendingMention(id, recipient | null)` rewrites the chip into a
recipient token under the recipient's current name, or back into plain
text, outside history and while the editor is read-only (it must run while
sending). A later undo still restores the typed text through it. A current
name that would overrun the length limit leaves the typed text.
- The token portal paints a pending chip itself, as it does resources, with
`InlineChip`'s loading face, so nothing depends on the providing plugin.
Backspace, copy and the normalize plugin need no change: the chip is an atom
with no recipient, so it selects then deletes, copies as its text, and is
never demoted as broken provenance. Nothing creates one yet.
Tests: one undo step and redo; refusals; settle into a recipient keeps the
caret and the typed text as undo target; settle to text while read-only;
length overrun; Backspace; persisted snapshot restores as text; copy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ention `ChipFace.loading` existed but nothing drew it. A pending mention chip (previous commit) needs to read as "still being decided" without moving the text around it: - The sigil is wrapped in `.inline-chip-trigger`; while `face.loading` it paints `color: transparent` and a `CircleNotchIcon` sits absolutely in its box (1em, centred), so the `@` keeps its advance width and baseline in the chip's own font and settling into a recipient or back into text shifts nothing. The spinner names its own colour because it would otherwise inherit the transparent one. `prefers-reduced-motion: reduce` stills it in the existing block. - The `role="img"` span carries `aria-busy` and the name "Resolving mention <Name>"; the icon is hidden. A loading face is always inert, even for an interactive consumer, since there is nothing truthful to preview yet. - A "Resolving mention" specimen joins the design-system viewer. Tests: busy chip renders the spinner inside the trigger with the glyph still in the flow and no button; settling drops the spinner and busy state; the stylesheet contract (transparent sigil, reduced-motion `animation: none`) is checked as text since jsdom does not load chips.css. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…w the displayed match as undecided Since a682e4e both terminator branches read the publication. A display that showed exact match X while the publication moved Space to another recipient still stands, and Space is literal. A display that showed X while the publication withdrew `spaceId` and set `spacePending` falls through to the pending branch and holds the chip, where it used to leave Space literal. That is the intended reading: no other recipient overrules what the user saw, the decision is deferred to the resolver under the full rule, which selects X again if it holds. The branch now says so, and a host test over the real `@` syntax pins it: display X, publication undecided and Space in one commit, the chip is held with no literal space, the resolver starts once, and it settles into the recipient. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
`500` appeared in the directory as the batch size, the read limit and the "page not full" bound without a name tying them together; the reader's own limit check spells it out separately. `PROFILE_PAGE` names the directory's use and states why the three agree: a page that fills the reader's largest read may have left out authors who do have a profile. No behavior change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…g alone `settlePendingMention` held a settle on `composing.current || editor.composing`, but the replay only ever runs from React's `onCompositionEnd`. ProseMirror's `compositionend` is an edit handler, skipped while the view is not editable, and nothing else clears its `composing` before the next click. On the path the previous fix targets, a chip pending under an IME composition with Send clicked on Mac WebKit, `sending` makes the editor read-only before the IME commits: `composing.current` clears, `editor.composing` stays set. A resolution landing after that, within the resolver's 8 s, was parked on `heldSettles` with no compositionend to come. Its promise dangled, `entry.settled` never resolved, `send()`'s wait never returned, and `sending` kept the editor disabled, so the user could not even start another composition to flush it. The hold now keys on `composing.current` only: the event that clears it is the one that replays, and `editor.composing && !composing.current` is exactly the stale state `settleHeld` already applies through. The other editor APIs keep both flags; they refuse synchronously rather than park a promise the host awaits. The send-wait comment now states its bound: the resolver plus an IME composition already open when Send was clicked, not the resolver alone. Test: the existing held-settle send test reordered, compositionstart, Send, compositionend, then the resolution lands; the send completes with the recipient's `p` tag. On the previous condition the send never fires. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
`settlePendingMention` in the host forked on `outcome instanceof Promise` to tell an immediate editor settle from one held through a composition. Both reach `settled` and every consumer, the reconcile effect, `send()` and the host tests, awaits the resolution asynchronously, so nothing observes the synchronous branch. `Promise.resolve(outcome).then(...)` is the single path and `settled` stays the one confirmation point; the three affected test files pass unchanged. The `spaceId` branch comment in `ComposerCompletions` could be read as "the displayed row is accepted". It now says what holds: Space is not redirected away from an exact match the display showed, so a publication that moves it to another id leaves Space literal, and only the publication ever selects. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…g a mention `product-ui.spec.mjs` "playground distinguishes same-name people and agents" failed on every Linux WebKit run of this branch at the third pick, with the picker still open and no chip inserted; Chromium and main's WebKit were green. The lab fixture's session has no `directMessages`, so the picker's community search has always thrown. Before this branch the call threw synchronously inside the 200 ms debounce timer, an uncaught page error the harness does not fail on, and nothing reached `.then`: the "Searching community…" status stayed and the rows never moved. `fetchMentionDirectoryPage` is async, so the same throw is now a rejection and the picker renders what it was built to render, the error status and a Retry button above the rows, about 200 ms after opening. On Linux WebKit the test's row click spans that moment: mousedown on the Honey row, 48 px of layout shift, mouseup on the Alice row that moved under the pointer, and the click fires on their common ancestor and reaches neither button. The CI trace shows the highlight on Honey with the dialog open and the error shown; a probe holding the mouse down across the status change reproduces it on Mac WebKit. Two owners: - The fixture's session gains `directMessages.people`, answering with the playground's four profiles by name prefix, so the search settles (and is cached after the first opening) instead of failing in a product catalogue. - The test waits for the dialog to be visible and the "Searching community…" status to be gone before clicking a row. The status is in the dialog's first commit whenever a search is pending, so its absence is the settle barrier. The product's status line still leaves the flow above the rows when a search settles; that is unchanged here. Verified: product-ui.spec.mjs 18/18 on local WebKit and Chromium (--repeat-each=6); biome and tsc clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
In a new Solo chat (`SoloDraft` -> `MessageComposer` -> `PersonalComposer`)
the composer always provides `DraftMentionRoster`, as an empty list while
the agent inventory loads. The chooser treated any roster as complete, so
`@Aaron hi` typed before the inventory answered never became a pending
mention: Space was literal, the agent arriving later could not recover it,
and the message could not be sent as "Tag an agent to send". The resolver
had the same gap, since a roster skipped its inventory wait.
The roster's owner now says when it is incomplete:
- `DraftMentionRoster` carries `{ recipients, pending? }`. `pending` is set
while the owner's sources load and resolves with the first roster
committed once they settle. `PersonalComposer` (new and saved Solo chats)
sets it while agent choices are pending and not yet usable, the rule the
chooser already applies to an invite scope. `NewMessage` passes its
recipients, never pending.
- `useMentionChoices` counts a pending roster as pending, so the chooser
shows "Loading recipients…" and publishes `spacePending`.
`resolveSpaceMention` awaits the roster before applying section 5.
- The host checked admission at settle time against the roster from the
render that started the resolution, which was empty here, so the settled
agent went back to prose. It now reads the current roster.
Test: the real `SoloDraft` with the mentions completion and a gated agent
inventory. Typing `@Aaron hi` holds the chip; releasing the inventory
settles it, and Send starts the chat with Aaron and sends his `p` tag.
It fails on the previous code, and separately with either the resolver's
wait or the settle-time roster reverted. The plugin test that mounts the
chooser without a roster is renamed for the invite scope it covers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ry loading period `PersonalComposer` created its roster deferred once per mount and resolved it the first time the agent inventory stopped loading. A failed library read stops loading too: the deferred settled with the empty roster, and a Retry (which does not remount the composer) put the choices back into loading, so `DraftMentionRoster.pending` advertised that same fulfilled promise. A Space typed during the retry held the chip, `resolveSpaceMention` awaited the promise, read the empty roster and returned the mention to prose at once, and the agent arriving later could not repair it: the new Solo message had no recipient to send with. The deferred is now tied to a loading period: created when loading begins, resolved with the first roster committed after it ends, and dropped, so the next period starts a new one. A composer mounted with the inventory ready never creates one. Test: the inventory read fails, Retry agents is clicked, and `@Aaron hi` is typed while the retry is gated; releasing it settles the chip and Send starts the chat with Aaron and sends his `p` tag. On the previous code the chip reverts to text before the pending assertion. The existing before-load case shares the mount through a local helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
`EditableInput.test.tsx > uses plain text when pasting rich content into
an existing code block` failed the Vitest flake check twice (runs
40281402855 and 40319121812) with a trailing `\n\n`: the document carried
an empty paragraph after the code block. Locally 34/34 green.
The paragraph is created by the first keystroke, not the fence. `mount`
focuses the editor natively; the editor's `onFocus` runs `editor.focus()`,
which places ProseMirror's caret at `(p, 0)`, and then jsdom's `focus()`
collapses the selection onto the focused element itself, `(editor root, 0)`
(HTMLOrSVGElement-impl.js), where a browser keeps a caret already inside
the host. jsdom reports that in a `selectionchange` task queued with
`setImmediate`, on which ProseMirror's DOMObserver puts its caret back. An
empty draft's `setSelectionRange(0, 0)` changes no selection, so nothing
re-places the caret earlier; a non-empty mount is safe because the move
syncs it synchronously. `user.keyboard` awaits a 1 ms timer before its
first key: locally the immediate wins, on a loaded runner the timer can
fire first, user-event inserts the character at the root, and ProseMirror
parses it as a new paragraph ahead of the empty one. 43 tests in the file
mount an empty editor and type at once; this one lost the race.
`mount` now dispatches the pending `selectionchange` inside its focus
`act`, so the editor has re-placed its caret before any test types. The
regression test makes the losing ordering deterministic: `userEvent.setup({
delay: null })` runs no task between mount and the key, and the first
character must land in the one paragraph. It fails on the previous helper
(`<p>`</p><p><br></p>`) and passes now; the file passes 242/242 and
`scripts/flake-check.mjs vitest --iterations=5` over it is 5/5.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ger name The pending gate in `MentionCompletion` held any admitted query whose `spaceId` was unset while evidence was still arriving. `spaceId` is unset for two reasons, and the gate did not tell them apart: the chooser cannot decide yet, or a choice it already has settles it. With a loaded member `Mira Example`, typing `@mira Example ` while the uncached directory read for `mira` was debouncing or in flight held a chip on the first Space, which ended the query and left `Example` as prose; the resolver then found the longer name, returned null, and the whole name went out untagged. Before the pending mention, that first Space stayed literal and the second selected the full name. A known name that continues the query is definitive: choices only arrive, so nothing that loads later can lift it, and the next word may complete that name. The longer-name step of `exactMention` is now the exported predicate `continuedMention(choices, query)` in mention-ranking, with the same name mapping it had: aliases and the label without its key qualifier or generated `(agent)` suffix, and nothing for a choice that itself has the exact name (one identity is not its own namesake, 9f9da13). `exactMention` calls it before `exactName`, so `spaceMention`, the headless resolver and the portable fixtures read one rule. The gate declines pending when it holds over `model.candidates`, the whole known set `spaceId` is decided over rather than the shown rows: members or roster, offered agents and the directory rows received so far. `teams.blocksSpace` is unchanged, and the resolver already returned null here, so it cannot disagree. Tests: the provider table gains the two-word member with a held read (`spaceId` and `spacePending` both undefined, before and after the page); a unit test pins the predicate and the self-alias exception the gate must not decline on; and the host, over the real `@` syntax with the real chooser and a held read, keeps the first Space literal with no chip, selects the member on the second and sends its `p` tag. Both behavioral tests fail with the gate term removed. No existing test changed. README section 5 and docs/agents.md state the declined case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…te a longer directory name Verified at HEAD (d116e3b) with a host test over the real `@` syntax: the real `MentionCompletion`, every member named, `Mira Example` only in the community directory, and the `people` read for `mira` held until after the user has typed. `@mira Example hi` typed at speed held a pending chip on the first Space (the gate's `continuedMention` had nothing to see: no page yet), `Example hi` landed as prose after it, and when the page arrived the resolver found the longer name, returned null, and the chip reverted. Observed: value `@mira Example hi`, 0 recipients, no chip span, no Invite prompt, and `send("channel", "@mira Example hi", [], [])`. A slow typist, whose page arrives before the first Space, already got the mention through the declined Space and the second Space (pinned by a new host test beside the fix). The resolver now tells the one null that was only a longer name apart from every other null. `resolveSpaceMention` returns `SpaceMentionResolution`: the recipient; null (no match, namesake, team block, DM outsider, truncated page, failure, abort, timeout); or `{ continued: [{ name, resolve(signal) }] }` when, over the complete choice set, `continuingNames(choices, query)` (the longer-name step of `continuedMention`, now returning the names, each once) is what kept the name from selecting. Each continued name resolves under the same rule over the same settled page, with no further read: every name that equals or continues it also continues the query, so the prefix page is its complete set. A team block and the DM-outsider rule apply to the longer name as to the first. `CompletionPending.resolve` carries the same shape as `CompletionResolution`, with `continued` typed as `CompletionPending[]`. The host (`resolveChip`) runs one resolution per chip through the chain: on `continued` it reads the draft's projected text after the chip (`value.pending` gives `{ id, name, end }`) and takes the longest continued name that the chip's name plus the following words spell, case-folded, up to a `mentionBoundary`; it asks the editor to extend the chip over that remainder and resolves the longer name under the same signal and registry entry, so the reconcile effect and `send()`'s wait see one live resolution. Words that stop mid-name, complete no name, or changed before the extension applied settle the chip to text exactly as before; with the caret at the end of the restored span the chooser reopens over it, and `settled` stays the one confirmation point. The send-wait comment names the chained bound. The editor gains `extendPendingMention(id, remainder)`: it checks that the text node right after the chip begins with `remainder`, then in one transaction outside history replaces the chip with a pending chip named by both (source `@mira Example`, so a later settle to text restores what was typed) and deletes the remainder, while read-only too. The held-settle queue now holds any pending-chip mutation, so an extension landing under an IME composition replays at compositionend like a settle, and answers false when the IME changed the words. Undo after an extension takes the words typed after the chip first, then the chip's typed query, as for a settle. Tests: the host test above now passes (chip on `mira`, `Example hi` typed, page released, chip extends to `Mira Example` with `hi` outside, Enter shows the Invite / Do nothing prompt, the sent event carries Mira's tag per the answer) and fails again with `typedContinuation` stubbed out; a partial remainder and words that complete no name return to prose; the partial case reopens the chooser over `mira Exam` and selects on the next Space; two continuing names where the words complete only one; words changed under a composition before the extension applied; Enter while the first chip is pending with the page released during the send wait; undo after the extension. Editor: extend keeps the caret and the undo order, refuses a changed or missing remainder, works read-only, and is held under a composition. Resolver: `continued` for a longer name on a settled page, its names resolving over the same page without another read (and recursing when one continues another), a DM outsider's longer name resolving to prose, a roster's longer name without the directory, and plain null for a longer name on a truncated page. `continuingNames` is pinned in mention-ranking. README sections 2 and 5 and docs/agents.md state the extension. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ke its first resolution `resolveChip` awaited a chained `continued[].resolve(signal)` with no timer of its own. The first resolution is bounded by its provider (`resolveSpaceMention` races itself against `MENTION_RESOLVE_TIMEOUT_MS`), and the bundled provider answers a chained resolve at once over the page it already read, so nothing was lost there. But `CompletionResolution.continued[].resolve` is a host contract with no stated bound: a provider whose chained resolve does I/O that never answers left the extended chip spinning and `send()` waiting for it indefinitely, with the editor disabled under `sending`. The host now holds each chained link to the same bound: `bounded(resolve, signal)` races it against `MENTION_RESOLVE_TIMEOUT_MS` and treats expiry as null, so the chip returns to text as the resolver's own bound would return it, and the waiting send goes out with the prose. The constant moves to `features/conversation/completion.ts`, the completion contract's runtime home, since the bound now belongs to the contract and the host does not import from `bundled/`; the resolver and `solo-agents.test.tsx` import it from there. The send-wait comment states what actually holds: the provider bounds its own resolution, `resolveChip` bounds each extension, plus an open IME composition. README section 5 and docs/agents.md said the words must "complete exactly one" longer name while the next clause took the longest; `typedContinuation` takes the longest name the words spell up to a name boundary, and both now say so. Tests: a scripted provider whose chained resolve never settles, under fake timers, keeps the extended chip busy and the send waiting one ms short of the bound, then returns `@mira Example hi` to prose and sends it with no recipient (fails without the race: the chip stays `aria-busy`). The host recursion path is pinned with the real chooser: the directory holds `Mira Example` and `Mira Example Jr`, `@mira Example hi` extends to `Mira Example`, its resolution answers `continued` again over the same page, ` hi` completes nothing, and the whole span returns to prose with no recipient and sends as typed. The undo test's title now states its assertions: the first undo removes ` hi`, the second leaves `@mira`. Observed, no code change: with the bundled provider the intermediate `Resolving mention mira Example` label is never painted. Its chained resolve is synchronous over the already-read page, so React commits once in the settled state; in the browser-fixture recording the accessible name a screen reader hears goes straight from `Resolving mention mira` to `Person Mira Example`. The scripted test above is the one place the extended chip is observable while pending. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ge already read for its prefix Measured in the browser fixture at d9a1706: with `Mira Example` only in the directory and every member named, `@mira Example hi` typed at 50 ms/key lost the mention whenever the `mira` page landed between the first Space and the end of `Example`. The resolver saw only a partial remainder and returned the chip to text, as intended, but the reopened chooser could not recover the name: the resolver's read filled the exact-key `pages` cache under `mira`, so `mira E`, `mira Exam` and `mira Example` all missed it, the multi-word query was admitted by no known name, the 200 ms debounce never fired between keystrokes, and the second Space was literal. Pages landing before the first Space (declined by `continuedMention`) and after the full name (the chip's extension) already worked; only this window was lost. The relay matches names by prefix, so a complete page (no more pages) for `mira` holds every match for any query extending it, the fact the refuted prefix shortcut and the chained `continued[].resolve` already rely on. `useMentionDirectory` and `fetchMentionDirectoryPage` now share `prefixPage`: the longest cached complete page for a proper prefix of the query, filtered to the query by `matchPerson`, served synchronously as a settled page (`loading` false, no "Searching community…", no request, no debounce). The hook keys the derived page on the source page's identity so it is stable across renders, and like the refuted shortcut it serves only until a retry asks for a fresh read. A truncated page or a failed read is no evidence for a longer query, and an exact 64-hex key is an author lookup that never derives. At `@mira E` the candidate is present on the keystroke, the query is admitted, the row shows, and the Space after `Example` selects under the Space rule with `spaceId` set and no pending chip; the settled outsider still goes through the Invite / Do nothing prompt at send. The trailing-space query `mira ` is admitted by the same derived candidate, so admission needed no change. The chip's settle-to-text for a partial remainder is unchanged. Beyond this window the chooser reads less: once a complete page for a prefix is cached, every extension of it (a longer name, a trailing space, the browse page of a community with fewer than 15 profiles after a pause on `@`) is answered without a relay read, and the resolver reads a longer name's page from the same cache. Tests, with the real `MentionCompletion` over the real `@` syntax and a scripted `directMessages.people`: the page released after `@mira Exam` is typed returns the chip to text with no busy chip left, the chooser shows `Mira Example` at once with no search, `ple ` selects it on Space with one `people` call, and Enter sends Mira's tag after the non-member prompt; the page released right after the first Space leaves `@mira `, the chooser reopens over it, and `Example ` selects. Both fail with the derivation disabled. Unit: a complete cached prefix page serves a longer query with `loading` false, no request through the debounce and `spaceId` decided over it; a truncated prefix page does not and the request still goes out; the resolver reads a longer name's page from the chooser's complete page with no further read. The slow-typist host test and the two plugin tests that counted the reads this removes assert the derived page instead, or use a truncated page where a read is the behavior under test; the `@Hone` dismissal step of `completion-work.spec.mjs` now runs before any `Hon` search is cached so its held read is deterministic (6/6 on Chromium and WebKit). The extension and `continuedMention` decline tests pass unchanged. README sections 2, 5 and 6 and docs/agents.md state the rule. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…, and bound the first resolution in the host Three follow-ups from review of 17aeff3, which settled a longer query from the complete directory page cached for its prefix. Retry hung under a cached prefix page. `retry()` deletes the exact key and bumps `attempt`, and the hook's render-time derivation is gated on `attempt === 0`, but the effect's `fetchMentionDirectoryPage` still derived from the prefix page and returned without caching. The next render found no exact page, no `own.page`, `derived` and `refuted` gated off, and no error: "Searching community…" with no Retry and unchanged effect deps, so it never read again. Reachable when a transient failure on `@mira e` in one composer is followed by a successful `mira` read in another consumer of the same session (a thread composer, or the pending-mention resolver). The fetch takes `{ fresh }`, which skips the prefix page; the effect passes `attempt > 0`, the resolver keeps the default. README's "A retry searches afresh" and the hook comment now hold. The browse page derived every query. A pause on `@` reads the listing under the empty key, and once complete (a community with fewer than 15 profiles) it is a prefix of every query, so no name search reached the relay again for the session and a member who joined or renamed after it could not be found by `@name` until restart. `prefixPage` now derives only from a non-blank key, a finished name search; the listing is a plain profile read with a different limit and answers no query but its own. README section 6 says so. The first resolution was unbounded by the host. `CompletionPending.resolve` does not ask a provider to time itself; only the bundled resolver races its own first resolution against `MENTION_RESOLVE_TIMEOUT_MS`, and d9a1706 bounded the chained links only. A provider whose `spacePending.resolve` never settled left the chip busy and `send()` waiting with the editor disabled. `resolvePendingMention` now passes the first link through `bounded` too, so the host owns the bound for every link; the bundled resolver's internal race is redundant under it but harmless, and the `bounded` and send-wait comments say what holds. Tests, each failing at 17aeff3 at the stated assertion: a failed `@mira e` read, a complete `mira` page read through the shared fetch meanwhile, and Retry issues a fresh `mira e` read whose page renders with no status (HEAD: no third read); a complete listing for the empty query cached, `@mi` still searches (HEAD: served from the listing, no "Searching community…"), and the finished `mi` search then answers `mir` with no read; a scripted provider whose first resolve never settles, under fake timers set before Space, keeps the chip busy and the send waiting one ms short of the bound, then returns `@mira ` to prose and sends it with no recipient (HEAD: the chip stays `aria-busy`). The recorded scenarios (first `mira` search on an empty cache, no retry, no listing) take the same paths as before: `fresh` is false at `attempt` 0, the key filter excludes nothing they cache, and the host's bound equals the resolver's own. Verified: `src/bundled/mentions` and `MessageComposer.test.tsx` 594/594, `tsc --noEmit` clean, biome clean on the changed files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…d provider to stop Two follow-ups from review of d749984. A retry held the picker's whole session fresh. `useMentionDirectory` counted retries per hook instance and never reset, and `MentionPicker` keeps one hook mounted across openings, so after one Retry on a transiently failed `mira e` every later query of the session passed `fresh: true`, and the render-side prefix-page and refuted-prefix shortcuts (17aeff3, d749984) stayed off until restart. The count now belongs to the query it was asked for: `retried` is `{ query, attempt }`, `attempt` is read for the current query only, and a Retry on another query starts at one. Back on the retried query its failure and Retry stand, and a second Retry still reads afresh past a complete page cached for its prefix meanwhile. Inline completion remounts its provider per keystroke and is unchanged. `bounded` told the provider nothing. It passed `resolve` the chip's own signal, which nothing aborts when `settled` deletes the entry, so at the 8 s bound a third-party provider's in-flight work ran on under a live signal; and its timer was cleared only in `finally`, so a provider that ignores abort left an 8 s timer behind after Backspace, undo or unmount. The provider now gets a child signal that follows the chip's and is aborted at expiry, and the chip's abort clears the timer and aborts the child, so every end of a link (settle, expiry, chip removal) ends the provider's work and the timer. The bundled resolver already aborts itself at its own bound, so the recorded flows are unchanged. Tests, each failing at d749984 at the stated assertion: in one long-lived `MentionCompletion`, Retry on `mira e` fails again, then `lucy` is answered from a complete `lu` page cached meanwhile and `zedd` by an empty `zed` search, with no read (HEAD: "Searching community…"), and a second Retry on `mira e` reads it afresh past a complete `mira` page; in the host, a provider whose first resolution never settles has its signal aborted at the bound while the chip is still pending (HEAD: aborted only after the chip had left the draft, by the reconcile effect), and removing the chip before the bound aborts the provider's signal and leaves no timer behind under fake timers, once the editor's own short typing timer has fired (HEAD: the bound's timer remains to 8 s). Verified: solo-agents.test.tsx 111/111, MessageComposer.test.tsx 371/371, `tsc --noEmit` clean, biome clean on the changed files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…an open query Observed in the browser fixture (Chromium, `@mira Exam` at 50 ms/key, every member named, `Mira Example` directory-only, a MutationObserver on the chooser `section`): the popup left the DOM for 3-16 ms on every keystroke, nine absent states over ten keys, so about half the 25 fps frames taken while typing showed no chooser. Pre-existing on main: the merge-base keys and resets the display the same way, and the recording's Before arm blinked identically. The sequence. The editor calls `completion.observe(true)` synchronously in `dispatchTransaction`, so the new observation (revision and query) commits with the draft. `ComposerCompletions` keys its `ContributionBoundary` on the query text and revision, so that commit unmounts the old `OwnedCompletion`, portal and all, and mounts a new one with `result` undefined: popup gone. Its layout effect sets `publish`, a sync re-render mounts the provider, and React flushes that sync commit's passive effects at once, so the provider's mount `useEffect` publishes in the same task; but `setResult` from inside a passive flush is a default-lane update, so the rows commit in a later task, after the browser could paint. The host now owns what the popup shows across the remount. `ComposerCompletions` holds one display store, keyed by provider and trigger start; `OwnedCompletion` reads it through `useSyncExternalStore` and `publish` and its withdraw write to it. A new owner for a continuing query (same provider, same `@`) shows the previous query's result in its first commit, and its provider's first publication replaces it in the same task: a store update is a sync-lane render, flushed at the end of the passive flush. An owner whose provider has not published by the end of its mount effects clears the carried display (its own effect runs after its children's), so a provider that publishes later leaves the popup closed as a first query does, and a stale display is never painted. A query at another trigger or from another provider starts empty, as before. `latest` stays per lifetime: Space, Enter, Tab and click read only this query's publication (a682e4e), so carried rows are never acted on. Closing is unchanged: a settled empty result, Escape, a Space that selects or holds a chip; the pending gate and the `spaceId` publication on the keystroke are untouched. The `section` is still a new node per keystroke, replaced within one commit rather than across two. Tests: the real `@` syntax with a provider that publishes from its mount effect; outside act, each keystroke changes the draft inside a dispatched discrete `select` event so React commits the observation in a microtask, as under typing, and after one microtask the rows for the new query are in the document while no MutationObserver delivery saw the popup gone (fails at HEAD at the first continued keystroke with `null`). The popup still closes on `@hon `, on Escape and on a selecting Space. The browser probe after the fix: one absent state over the same keys, the Space that held the pending chip. Verified: MessageComposer, EditableInput, conversation, mentions, emoji and ChannelCompletion suites 995/995; `tsc --noEmit` clean; biome clean on the changed files; typeahead, completion-work, mention-rules and product-ui browser specs 34/34 on Chromium and 34/34 on WebKit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…oses Two follow-ups from review of aa2438b, which carried the previous query's result across the per-keystroke remount of `OwnedCompletion`. The display store was keyed `provider:query.start` with no lifetime and was never evicted, so it meant more than the comment said: a query closed by Escape, Backspace through its `@`, a send that cleared the draft, or a pending-mention hold, and reopened later at the same offset, read the old result in its first commit. The owner's guard effect cleared it before any task boundary, so nothing stale was painted, but the store did not mean "continuation of an open query". `ComposerCompletions` now evicts it from an effect in its closed render (an effect, not render time, so the owner's store listener is never fired during a render), and the store comment says so. A reopened query starts empty and its provider's first publication fills it in the same task, as a first query does; a continuing keystroke still carries. For the pending-mention hold this is the sequence Space -> chip held -> popup closed -> evicted -> chip reverts -> chooser reopens empty at the same `@`. The blink test's `deliveries` check re-queried the DOM in the observer callback, which runs at the microtask checkpoint and so could only ever see the final DOM of each synchronous chain. It now classifies each delivery by its `MutationRecord`s: whether any record removed a `section[aria-label$=" suggestions"]` node (or an ancestor holding one) and whether any added one. Every delivery that removed the popup must have added its replacement, and the four keystrokes each replace it once. Tests: the records check at aa2438b~1, with the inline rows assertion disabled, fails with the one delivery `{ removed: true, added: false }`; at HEAD, four replacing deliveries, none without an addition. New: the typeahead provider takes a `mounted` probe run in its mount effect before publishing, which runs before the owner's guard effect and so sees what the owner showed on its own; after `@hon`, `@hone`, Escape, `@honey` it records `hon` null, `hone` carried (`Honey hon`) and `honey` null, once per query under StrictMode. Without the eviction, `honey` shows `Honey hone`. Verified: MessageComposer.test.tsx and src/features/conversation 470/470, `tsc --noEmit` clean, biome clean on the changed files (one pre-existing `useTemplate` info elsewhere in the test file). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
… popup uses Since aa2438b a continuing `@` query shows the previous query's "Loading recipients…" status in its first commit, so the popup's portal mounts while the agent inventory is still loading. In jsdom that runs `useCompletionPosition`, whose `new ResizeObserver` throws, and the reveal of a selected retry row, whose `scrollIntoView` is not implemented. Either throw fails the completion's `ContributionBoundary`, so Space never held the pending chip in the two "typed with Space" tests (the retry case also left the failed owner's key handler installed with `live` false). Stub both in the suite, as MessageComposer and TerminalMentionEdit already do. No product change: both APIs exist in every webview. Verified: SoloDraft.test.tsx 40/40 (fails 38/40 at HEAD~); biome clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…r the typed words `typedContinuation` took the longest remainder after the chip that, joined to the chip's name, normalized to a continued name at a `mentionBoundary`. `normalizeName` trims, so with the draft ending at `@mira Example ` when the directory page landed, both `Example` and `Example ` spelled `Mira Example`, and the trailing-space remainder, found last, won. The editor's extension deleted that remainder, separator included: the settled draft was `@Mira Example`, typing `hi` produced `@Mira Examplehi`, and the joined word dropped the explicit recipient, so the message went out with none. Reported on PR #1623 (kalvinnchau), reproduced in Chromium and WebKit; the existing extension tests type `Example hi` before releasing the page and never met the boundary. A remainder that ends in whitespace is no longer a candidate: it spells the same name as the shorter remainder before it, which the loop already took, so the extension stops on the name's last character and the separator stays in the text. Punctuation needed no rule: `normalizeName` keeps it, so `Example,` never spelled the name. Test: the page is released while the draft ends at `Example `; the chip extends and settles to `@Mira Example ` with the recipient, `hi` typed after it reads `@Mira Example hi` with the recipient kept, and the send carries Mira's tag. It fails at HEAD~ at the settled-value assertion. Verified: MessageComposer.test.tsx 375/375, `tsc --noEmit` clean, biome clean on the changed files (one pre-existing `useTemplate` info in the test file). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…r returns to prose Two follow-ups from review of PR #1623. A send that waited on a pending chip went out as prose when the chip reverted: `@mira hi`, Enter on a slow relay, the resolver hits its bound or finds a namesake, and the message leaves with no `p` tag and no notice. The user saw a chip, waited, and notified no one. `send()` now learns how each chip it waited for settled: the resolution chain (`resolveChip` -> `settlePendingMention` -> `settled`) settles with the typed name when the chip returned to prose, under the name the chip had when it settled (an extended chip reports `mira Example`). Any such name keeps the draft and sets the composer's alert: "@mira could not be matched to a person or agent. Send again to send it as text.", or, where a recipient is required and none is left, the existing recipient hint in its place. The next send goes out as text, since nothing is pending by then. A send whose waited chips all became recipients continues as before. The settle path stored a provider's resolution verbatim, skipping the shape check `insertMention` applies, so `{ pubkey, name: undefined }` from a third-party provider became an `@undefined` chip. `wellFormedRecipient` (64-hex pubkey, non-empty name) is now one module-level guard used by both, and settle copies only `{ pubkey, name }`. Tests: the two bound tests now assert the kept draft and alert, then a second submit sends the text; the wait table splits into the recipient case and the prose case with its second send (alert gone after it); a required recipient gets the hint with Send `aria-disabled`; a name-less resolution returns the chip to prose. README section 5 and docs/agents.md state the rule. Verified: MessageComposer.test.tsx 377/377 (one earlier run under load average 90 failed `…pending chip to prose mid-word` once; it passes 3/3 alone and in the full rerun), SoloDraft.test.tsx 40/40, `tsc --noEmit` clean, biome clean on the changed files (one pre-existing `useTemplate` info in the test file). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…s undecorated tokens Since the rebase onto main, every pending mention reverted to plain text the moment Space held it: CI on c45830a failed all 25 pending-chip tests in MessageComposer.test.tsx, 13 in EditableInput.test.tsx and the two Solo draft cases, and the chip never appeared in the app. b6e1941 (#1558, GIF previews) changed the editor's normalize pass to demote any token that is neither a recipient nor a resource unless a decoration still covers it, so a presentation-only token whose renderer went away becomes editable source again. A pending chip has no recipient yet and no resource, and nothing decorates it: the token portal paints it. The pass demoted it in the transaction that inserted it. Nothing conflicted textually, so the rebase applied cleanly. A pending chip is a semantic atom like the other two: the host holds its resolver by the chip's id, and settling it is the only way it should leave. The pass now keeps it. Verified: MessageComposer.test.tsx 411/411; EditableInput.test.tsx, SoloDraft.test.tsx and src/bundled/mentions 533/533; `tsc --noEmit` clean. The first pending-chip case fails locally at HEAD~ as in CI, and every case CI failed passes here. In one combined run with src/features/conversation at load average ~70, an unrelated case ("leaves Space literal beside a loaded longer member name…") failed once; it passes alone, in the 411/411 rerun and in all 11 CI runs of c45830a. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…gged Review of PR #1623 (r4400503808): with 32 recipients already in the draft, `@honey ` held and Enter pressed, the resolver finds Honey but the recipient cap refuses it, and the alert said "@honey could not be matched to a person or agent". The same wording covered a recipient that is not admitted here and one whose current name would overrun the length limit. The user is told the name matched no one and retries it, when the fix is to remove a recipient or shorten the message. The settle path already makes each check in turn, so it now keeps the reason with the typed name: `settled` resolves `{ name, reason }`, where the reason is `unmatched` (no resolution, a malformed one, a timeout, a namesake or a continuation the words did not complete), `unavailable` (admission refused), `limit` (32 recipients), or `length` (accepted, but the editor kept the typed text because the name would overrun the limit). The send alert gives one sentence per reason, naming its chips: - "@honey could not be matched to a person or agent." - "@honey could not be tagged here." - "@honey could not be tagged: a message can tag at most 32 recipients." - "@honey could not be tagged: the message would be too long." followed, as before, by "Send again to send it as text." or the recipient hint. Draft retention and the second-send behavior are unchanged. README section 5 and docs/agents.md say the alert names why. Tests: a resolved recipient refused at send for each of the cap, admission and length, asserting the reason in the alert, the kept draft and no send. All three fail at HEAD~, which says the name could not be matched. Two chips refused for different reasons get a sentence each and "them". Verified: MessageComposer.test.tsx 411/411 at load average 112, including the four new cases; the combined run with EditableInput, SoloDraft, src/bundled/mentions and src/features/conversation passed 1040/1041, the one failure being the unrelated load-sensitive case noted in the previous commit; `tsc --noEmit` clean; biome clean on the changed files (one existing `useTemplate` info in the test file). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…nd alert Review of PR #1623 (r4401471378): every null resolution of a pending chip a send waited for was reported as "@mira could not be matched to a person or agent". The resolver returns null for more than no match: a namesake, a team block, a DM outsider, a truncated page, a failed search and the 8 s bound all end the same way, and for most of them a match may well exist. The alert told the user the name matched no one when the lookup was ambiguous or incomplete. The reason is now `unresolved` and says "@mira could not be confirmed as one person or agent.", true of every null and still a hint that picking the name from the list is the way to tag it. The other reasons (not taggable here, the recipient limit, the message length) and the draft retention are unchanged. README section 5 and docs/agents.md use the same wording. Tests: the two bound tests (first resolution and chained extension) assert the new wording for a timeout, and a new case over the real chooser holds `@mira`, sends, and answers the search with two people named Mira; the alert says the name could not be confirmed and the draft is kept. All three fail with the previous wording. Verified: MessageComposer.test.tsx 412/412, `tsc --noEmit` clean, biome clean on the changed files (one existing `useTemplate` info). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
bfa2081 to
a848853
Compare
`team-mentions.test.tsx > directory pages rebuild the name index only for people they add` (0bc0752, #1828) failed on every run of this branch since the rebase onto main: `people` was called once, not three times. It assumes every keystroke reads the directory. Its mock returned `hasMore: false`, so on this branch the complete `p` page answers `pe` and `per` through `prefixPage` (95050d1) with no read, and the later `pers` step, which swaps an outsider into the page, would be served from the cache too and never see it. The mock now returns truncated pages, as 95050d1 did for the other tests where a read is the behavior under test, so each keystroke still reads and the test checks what #1828 meant it to. No product change. Verified: team-mentions.test.tsx 13/13. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…dary Review of PR #1623 (r4385103855): a pending chip could settle into a recipient inside a longer word, sending a `p` tag for a word that only starts with the name. Every other path that creates a recipient (`mentionDraft`, the normalize pass, `promoteRecipients`, `typedContinuation`) requires a name boundary after it; the pending chip escaped it twice. - `applyExtend` checked only that the text after the chip still began with the words the host read. An extension held through an IME composition that committed ` Examples` over ` Example` absorbed ` Example` and left the `s` glued on, and the chip settled as `@Mira Example` inside `@Mira Examples`. It now also requires a name boundary after the words, and answers false otherwise, so the span returns to prose through the existing path. - The normalize pass kept every pending token unconditionally. `@honey`, Space, Backspace and `suckle` kept a pending chip on `honey`, the resolver answered Honey, and `@Honeysuckle` went out tagging Honey. A pending chip now stays only while a name boundary follows it; otherwise it returns to its typed query in the same keystroke's history event, the reconcile effect aborts its resolution (keeping the resolver for undo), and the chooser reopens over the whole word. Punctuation keeps the chip, as it keeps a recipient. README section 5 states that a pending mention must end at a name boundary. Tests: a held extension whose composition commits ` Examples` answers false and leaves the chip on `mira`; Backspace and `nda` after a chip leave `@miranda` with no chip, a settle that finds nothing, and undo restores the chip, while `,` keeps it; in the composer, `@honey`, Space, Backspace, `suckle` removes the chip, aborts its signal, reopens the chooser, and the late resolution changes nothing, so the send carries no `p` tag. All three fail without the production change. Verified: EditableInput, MessageComposer, SoloDraft and src/bundled/mentions 957/957; `tsc --noEmit` clean; biome clean on the changed files (one existing `useTemplate` info). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
… demotion Review of PR #1623 (r4407955350): c305533 gave pending tokens their own branch in the normalize pass, keeping one only while a name boundary follows it, and that branch skipped the containment check every other non-recipient token gets. A decoration formed around a pending chip adopts the chip into its own token first; the demotion then found no boundary after the chip and wrote its typed query at the deleted token's mapped position. Typing `[@honey`, Space, Backspace and pasting `suckle](nostr:npub1…)` left `[@honeysuckle](nostr:npub1…)@honey`. A pending chip is now kept when a name boundary follows it or when a larger decoration has already adopted it, the condition the pass applies to resources and presentation-only tokens. A chip glued to a longer word with no decoration around it still returns to its typed query, as before. Test: the paste above leaves exactly the link, adopted as one identity chip, with nothing pending and a later settle finding no chip. It fails at HEAD~ with `@honey` appended. Verified: EditableInput, MessageComposer, SoloDraft and src/bundled/mentions 958/958; `tsc --noEmit` clean; biome clean on the changed files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 The prior P2 cross-community namesake/admission gap remains: carry the chooser’s joinable agents into pending resolution and reuse the host’s current admission policy. The IME boundary, refusal wording and decoration-containment fixes address the other reported issues; no new actionable finding in this follow-up.
Star Lord’s automated source review via Wes’s account, integrating Rocket, Mantis and Groot. Source assessment: 8/10 pending the linked P2. Head 946f355b5fc3051b73a4db5ccaf10f8d64357a25, base 49fac0341e6ffeb1b9cae841d5ed8d96a6734aad, including rebase integration. No tests, app execution or CI assessment. Screenshot inspected; demo video, native/IME behavior and rendered focus/accessibility remain unverified.
…nts the chooser offers Review of PR #1623 (r4385103853, r4390638220): the chooser counts the owner's agents from their other communities (`Agents.joinable()`) as local choices, but the pending-mention resolver never received them, and the host's settle check left them out of admission. - With a member `Mira` and the owner's agent `Mira` from another community, the chooser saw a namesake and held a chip on Space; the resolver saw only the member and tagged them, notifying a recipient the rule says needs an explicit pick. `SpaceMentionScope.elsewhere` now carries `joinable()`, read when the host starts the resolution, and the resolver passes it to `mentionCandidates` for the local step and to `namedCandidates` for every decision, continued names included. It is synchronous and never loading, so the resolver waits on nothing new. - `settlePendingMention` called `mentionAdmission` without the bringable set `admits()` passes, so an other-community agent archived here was refused at settle although Tab would insert it, and the chip went back to prose. `admits()` now reads the roster through `currentRoster` (the settle runs in a closure from an earlier render) and the settle uses it, so "exactly what Tab would have inserted" holds. Tests, through the real chooser in a stream channel: a member and an other-community agent both named Mira, Space held while the search is pending, an empty page returns `@mira ` to prose and Send carries no `p` tag (fails at HEAD~: the member is tagged); an other-community `Rocket` archived here with one member's name unloaded settles to `@Rocket ` with the recipient and Send opens the "Bring Rocket into …" prompt (fails at HEAD~, and still fails with only the resolver change). Verified: MessageComposer.test.tsx, src/bundled/mentions and SoloDraft.test.tsx 689/689; `tsc --noEmit` clean; biome clean on the changed files (one existing `useTemplate` info in the test file). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 No further changes requested: the prior cross-community namesake/admission finding is repaired, and the four-file follow-up introduces no new actionable finding. The added tests cover ambiguous-name fallback and reaching import consent; successful import/delivery remains unverified.
Star Lord’s automated source follow-up via Wes’s account, integrating Rocket, Mantis and Groot; source assessment 9/10. Head 175ab31f5bd714e3bb628111bc8cf8ffecc04b51, base 49fac0341e6ffeb1b9cae841d5ed8d96a6734aad, compared with 946f355b; screenshot and sampled demo frames inspected. No tests, app execution or CI assessment; native/IME behavior and rendered focus/accessibility remain unverified.
Typing
@nameand pressing Space before the mention chooser has decided (members, names or agents still loading, or the debounced directory search still waiting on the relay) silently sends@nameas plain text: no recipient, noptag. By spec nothing downstream re-parses text, so the mention is lost.What the branch does:
@slot instead of a literal space. It resolves in the background under the same rule Space uses once the full choice set is known, then becomes an ordinary recipient chip or reverts to plain text (failed or truncated search, namesake, team block, DM outsider, abort). Send waits for in-flight resolutions, bounded at 8 s; if a chip the send waited for reverts to text, the draft stays with a notice (@mira could not be matched to a person or agent. Send again to send it as text.) and the next send goes out as text, so a name the user saw held never leaves silently unaddressed. Wire format is unchanged: a settled chip is a normal recipient token, and a pending chip is never a tag, a reference or a persisted draft. The transparent@keeps its advance, so settling shifts no layout.Example/Example Agent) no longer blocks its exact match, and a member whose profile read completed with no kind 0 is recorded as known-unnamed, so one profile-less member no longer disables fast local selection for the whole channel.Demo
before-after-realtime.mp4
Both panes play at natural speed against the browser fixture, aligned at the first keystroke: a
#Alphamember types@example hiat 50 ms/key, then Enter.Exampleis a channel member with a profile; another member has no profile. Left: plain text goes out with noptag. Right: Space selects@Exampleand the sent row carries the person chip. No artificial latency; here the chooser decides on the keystroke, so no spinner is visible — the pending chip appears when the directory search is still in flight.Testing
cd083895dMessageComposer.test.tsxpasses 377/377 andSoloDraft.test.tsx40/40 (solo-agents.test.tsx111,EditableInput.test.tsx242,InlineChip.test.tsx7 andprofile-details.test.ts13 as of their last change), plus the portablespacefixturealias_of_same_identity_does_not_block.biome checkon the changed files,tsc --noEmitand the pre-push design checks are green atcd083895d.tests/browser/fixture.mjsin headless Chromium; not yet exercised in the WKWebView desktop build, so this stays a draft.Spec wording:
src/bundled/mentions/README.md§5 (pending mention) and §2 (the pending chip's@opens no query);docs/agents.mdstates it beside both Space statements.🤖 Generated with Claude Code