Skip to content

Replies and mentions - #85

Merged
david-sling merged 4 commits into
mainfrom
replies-and-mentions
Sep 16, 2026
Merged

david-sling merged 4 commits into
mainfrom
replies-and-mentions

Conversation

@david-sling

@david-sling david-sling commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

Closes #63, closes #64.

Two things the transcript could not show. reply_to has been accepted, validated and stored since the API was written, and nothing ever read it. And a channel with four agents in it gives a participant no way to say which of them it is talking to except prose — which is what the other three are also reading.

Replies (#63)

Reading. The transcript draws reply_to as one quoted line above the message: a ↳, the answered participant's name, and a line of what they said. A reply to a system event drops the name and quotes the event's own sentence — the UI shows it plainly rather than the API forbidding it. The quote is flattened and cut to 140 characters in lib/reply-quote.ts before it reaches the DOM, not clipped with CSS: the thing quoted may be 64 KB of Markdown, and a half-open code fence is not a quote.

Writing. A Reply control on each row sets the target; the composer shows the quote it will send until the message goes or is cancelled; the draft carries that quote while in flight rather than growing one on arrival. Escape cancels, but only once the mention menu has closed. The target clears on success only — a failed post hands its text back, and it would otherwise hand back a reply that no longer knew what it answered.

The control is its own client component reading a context rather than an onReply prop. A prop would have forced "use client" onto transcript.tsx, which is a server component on six /uses pages, and shipped it, its seating and its marks into bundles with no composer to reply into. Outside a provider it renders nothing, and there is a test on the nothing.

The join prompt gains one line about when not to set reply_to.

Not a thread. A rendered quote keeps one stream and one sequence; a thread view splits the transcript into many, against the single-seq model and the resume-from-last_seq contract.

The quote is not a link. The channel page keeps its invite in the URL fragment, so anything that sets location.hash throws the invite away.

Mentions (#64)

  • @name highlights in the transcript when it matches someone in the room, in that participant's identity colour — the same hue as their roster tile.
  • Typing @ in the composer offers the roster: arrows move, Enter or Tab accepts, Escape closes, ⌘↵ still sends whatever is open.
  • What it inserts is plain text. Nothing is stored beside it, nothing resolves it server-side, poll is unchanged.

The roster is the dictionary. A mention resolves only against a name in this channel — that is what makes @Windows agent one mention rather than a mention of Windows followed by prose, and what degrades @Nobody to plain text without a rule of its own.

Addressing is not filtering. Items are still returned to everyone alike; any skipping happens in the reader.

The prompt says nothing about mentions, deliberately. Teaching an agent to look for its own name is where "not being mentioned is permission to stop polling" comes from.

Verified

npm test (517 passing, 38 new), lint, typecheck, production build. New tests cover the matcher (emails, hyphenated handles, longest-name-wins, case, unknown names), the quote helper, the composer's token and suggestion logic, the reply control's inertness outside a provider, and — rendered through react-dom/server — that the mention highlight leaves inline code, fenced blocks and raw HTML alone. vitest.config.mts now includes .tsx tests so those can exist.

Driven end to end in a real channel at desktop and 375px: Reply → quote strip → send → confirmed via the API that reply_to was stored → confirmed it renders as a quote on reload.

Two agents independently black-box probed reply_to and mentions against this build and their findings are consistent with it. Worth recording from that run: reply_to validation is 0 < n <= last_seq with no constraint on the target's type or author (so a system event is a legal parent, as intended); sibling replies to one parent are the only possible behaviour, since nothing on the server reads reply_to after validation; and .from is frozen into the item at post time while dedupeName dedupes against the full roster including departed participants — so a name cannot come to mean a different person for the life of a channel, which is what makes name-based mention matching safe rather than lucky.

Docs

DESIGN.md records both renderings, the reply control and the composer menu; docs/PRODUCT.md 6.2 and the Post section say what the browser does with reply_to and what it does not; docs/IDEAS.md sections 4 and 5 are marked shipped, with everything a structured field would still have to answer left standing.

🤖 Generated with Claude Code

Two things the transcript could not show. A message has carried reply_to
since the API was written and nothing ever read it, which trains clients
to send a field that does nothing; and a channel with four agents in it
gives every participant no way to say which of them it is talking to
except prose, which is what the others are reading too.

Both are display only, and both stay on the near side of the same line.
A reply is a quoted line above the message, not a thread: one stream and
one sequence is what resume-from-last_seq and "a channel is one readable
conversation" are both built on. A mention is ordinary text in the body,
resolved by nobody server-side, and poll returns the same items to
everyone named or not — addressing is not filtering, or seq stops
meaning the same thing to every reader.

The roster is the dictionary for @name, which is what makes a name with
spaces resolve as one mention and a name nobody has stay plain without
needing a rule of its own. The quote is flattened and cut before it is
drawn rather than clipped with CSS: the thing quoted may be 64 KB of
Markdown, and a half-open code fence is not a quote. Neither is a link
— the channel page keeps its invite in the URL fragment, so setting
location.hash would throw the invite away.

The join prompt gains one line, about when not to set reply_to. It gains
nothing about mentions: teaching an agent to look for its own name is
where "not being mentioned is permission to stop polling" comes from,
and a display-only version has not earned that risk.

Closes #63
Closes #64

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
wave Ready Ready Preview Sep 16, 2026 6:40pm UTC

This was referenced Sep 16, 2026
Replies shipped render-only: the transcript drew reply_to and agents set it.
That left the one participant who is actually looking at the transcript as
the only one who could not point at a message in it, which is the wrong way
round — a human watching four agents is exactly who needs to say which of
them they mean.

Reply on a row sets the target, the composer shows the quote it will send
until it goes or is cancelled, and the draft carries that quote while it is
in flight rather than growing one on arrival. Cleared on success only: a
failed post hands its text back, and it would otherwise hand back a reply
that no longer knew what it answered.

The control is its own client component reading a context, rather than an
onReply prop. A prop would have forced "use client" onto the transcript,
which is a server component on six marketing pages, and shipped it, its
seating and its marks into bundles that have no composer to reply into.
Outside a provider it renders nothing, which is the right answer for an
example conversation, and there is a test on the nothing.

Refs #63

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@david-sling david-sling changed the title Replies and mentions, both display-only Replies and mentions Sep 16, 2026
Three gaps in the reply work, all of them about the reader rather than the
protocol.

The quote was inert. It is cut to one line, which is an invitation to see
the rest of it, so the whole line is now the control that goes there — and
the row it lands on holds lilac-soft for a moment afterwards, because a
scroll that lands you mid-transcript with no indication of what you were
brought to see has answered the wrong half of the question. The highlight
is added and removed from script rather than run as a keyframe: the global
reduced-motion rule collapses every animation to 0.01ms, which would have
left exactly the readers who most need the marker with none. Still a click
and not an anchor — the invite lives in the URL fragment.

Pressing Reply now puts the caret in the composer, since pressing Reply is
the start of writing one.

And the control was hidden behind hover, which a touch screen cannot do:
an affordance revealed by a gesture the hardware cannot make is not an
affordance. Where the device reports (hover: none) it stands there on every
row — which is also why it is the drawn arrow rather than the word. Eight
instances of "Reply" down the edge compete with the names beside them;
eight small arrows read as one repeated affordance. The author moves into
the accessible name and the tooltip, where an icon leaves a gap.

Refs #63

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 2 tells an agent to leave "$W/seq" alone, and then step 3 hands it a
seq and teaches it to compare post seqs to detect a dropped message. Three
steps apart, in the same number space, with nothing at the second point
saying the number is not a position.

An agent following this prompt did exactly what that invites: read to seq
22, posted, got {"seq":26} back, and armed its watcher from that. Seq 23,
24 and 25 had been posted by peers while its own message was in flight.
All three were marked read and never seen, and one was a question addressed
to it by name — the failure the rest of this prompt spends its length
preventing, reached by the one route it did not close.

The test has two halves. The sentence is pinned, and so is the structure
behind it: the cursor file is written in exactly two places, both from a
last_seq of a response that was actually read. The second half is the one
that matters, because it fails when someone adds a third write rather than
when someone rewords a paragraph. Both were checked by reintroducing each
regression and watching them fail.

Refs #63

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@david-sling
david-sling merged commit 706dbd8 into main Sep 16, 2026
4 checks passed
@david-sling
david-sling deleted the replies-and-mentions branch September 16, 2026 18:58

This branch was successfully deployed

1 active deployment
Preview — e757a17a Deployed Sep 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mentions Replies

1 participant