Copy a link to one message

The Classification / Provenance panel grows a link button in its top-right corner, and what it copies is the URL the thread already knows how to open at a message.

9 Sept 2026 · branch feat/message-permalink-copy · captured against propflow-stage · at 58041c60f2+local

Data note. Every name and balance below comes from propflow-stage, which is anonymized by construction (ADR-0097/0110 — phones in the impossible +1000 NPA, emails @example.test). The identical capture against prod would carry real tenants' names and balances and is not safe to attach anywhere, which is why the generator refuses to run against one.

1 · The dock: the button in the panel's top-right corner

Clicking a bubble's avatar opens the per-message audit panel — two labeled blocks, CLASSIFICATION and PROVENANCE. Gera, 2026-09-09: "maybe there's like a little button on the top right, I can copy link so it highlights that message." The link glyph sits in that corner, and this capture is here for the GEOMETRY, which is the one thing no unit test reaches.

The panel has two shapes to survive. This grouped one gives its FIRST block heading a 24px right-hand gutter, so the button never lands on the word CLASSIFICATION or on the hairline rule under it — visible here as the gap between the heading and the glyph. The ungrouped shape has no heading to share a line with, so the button earns a 24px band of its own instead. The gutter was chosen over a header row because it costs the panel no height.

The affordance lives INSIDE the panel rather than in the meta row on purpose: the meta row is on screen for every bubble in the thread, so a copy glyph there would be a hundred affordances for something you do once. Opening the panel is already the gesture that says this message specifically.

Asserted in the captured DOMCLASSIFICATION · PROVENANCE · SENT BY · Incoming call. Both block headings, a Provenance row, and the call card above — so the panel really is open in the picture, which is the only state the button exists in.

The dock: the button in the panel's top-right corner
A voice thread in the log's dock, one message's audit panel expanded, the link button in its top-right corner

2 · The host is told when the copy FAILS, too

This section is the failure branch, and it is captured rather than staged: a headless capture browser holds no clipboard permission, so navigator.clipboard.writeText rejects and the button takes its error path. What renders is the host's own "Couldn't copy — try again." toast.

That is worth a section because it proves the hand-off a refactor drops silently. ChatSurface raises no notification of its own — one Toast system per page, owned by the host — so a toast on screen at all means the primitive's onCopyPermalinkError reached ConversationsLog. The success path runs through the identical seam, one callback over.

The success path itself was verified in a permission-granted browser rather than inferred: the clipboard was read back after the click and held http://…/conversations?item=conv_voice_ea32e9db…&msg=call_ended_CAfdefc…%23t0 — absolute, and percent-encoded, because a real msgId on this row contains a #. A relative path is not something you can paste into Slack, which is the whole reason the button resolves it.

Asserted in the captured DOMCouldn't copy — try again.. The host's error toast, captured after a click whose clipboard write really did reject. An assertion on the affordance's EFFECT, not on its pixels.

The host is told when the copy FAILS, too
The error branch, reached honestly — no clipboard permission in a headless browser

What the captures add — and the one surface they could not reach

The tests pin the contract — no permalink means no button and an otherwise-identical panel; a relative value leaves absolute; the host hears about both success and failure; a grouped burst's one panel links its first message; a row with only a positional id gets no button at all. None of them can see a button sitting on top of a heading, which is the only way the two placement strategies go wrong. That is what these are for.

The person-keyed page is not captured here, and the reason is measured rather than assumed. /people/<personId>/conversation?thread=<id> renders "This conversation is no longer available" on propflow-stage for this conversation — and it does so on a branch that does NOT contain this PR's change, under the same signed-in user, as well as on this one. So it is a pre-existing gap in what the anonymized mirror carries for the person-keyed loader, not something this diff caused, and no capture of that surface is possible from a sanctioned source today. Its half of the feature is pinned instead by the ?message= round-trip tests and by a source guard that both ends of that parameter are actually connected — the failure mode with precedent here being ?thread=, which once shipped with its builder emitting the param and its reader dropping it.

Captured against propflow-stage — anonymized by construction (ADR-0097/0110), which is why the names and the +1 (000) phone numbers in these shots are not real people.

PropFlow Docs