diff --git a/docs/design/threading-surface-ruling.md b/docs/design/threading-surface-ruling.md index 68085a488..c4f35e57f 100644 --- a/docs/design/threading-surface-ruling.md +++ b/docs/design/threading-surface-ruling.md @@ -14,7 +14,7 @@ 1. **Headline card.** A thread shows in the channel as one card at the root message's position: root author + first line, reply count, last-activity time, and the avatars of up to three participants. No reply bodies in the channel column. 2. **Inline expand, indented rail.** Opening a thread expands it *in place* under the headline card, replies indented along a rail on the left; the channel column does not scroll-jump. Collapse returns to the card, and **collapse state is persisted** per user per thread (55852 verbatim: "collapse state persisted") — a reload does not re-expand what you closed. 3. **No side panel.** There is no thread pane in the inspector and no route change. (Mobile at 390px is the reason this option lost: a side panel has no 390px form; inline expand does.) -4. **The composer stays one element.** Posting inside an expanded thread uses the same composer, re-targeted **to the thread, not to a message**: an ordinary in-thread post sets `thread_root_id` only and leaves `reply_to` null, so it is ambient and the wake-scoping test ("`thread_root_id` alone enqueues no `chat.mention`") holds. `reply_to` keeps its existing meaning — the explicit "reply to this person" gesture — and is set only when the user invokes it on a specific message; that post is addressed to that author (routed, as today) and ambient to everyone else. No second composer is mounted. Write-path consequence: an in-thread post carries `thread_root_id` explicitly from the client, validated server-side (root exists in this pod; the root's own `thread_root_id` is null — no nesting); derivation from `reply_to` remains for explicit replies and for the backfill. Explicit wins when present, derivation fills when absent, and a mismatch between the two is a 400 — **but agreement is not refused**: a reply edge and an explicit root that point at the same thread pass the resolver, so the composer's one-target-two-kinds exclusivity is the *enforcing* layer for "an in-thread post addresses nobody", not a convenience in front of a server that would catch it (#1153). That is why both send paths — text and upload — must carry the same target, and why each is pinned. And **a send consumes the target on every path**: after a successful text or image send the composer clears both kinds (pod 57483/57486) — the chip is a promise about the *next* send, and a chip left standing after the message it described has gone is a lie that costs more than one click to re-aim. *(Amended 2026-08-22 after pod-architect found the collision at 56871: the first wording set `reply_to` on every threaded reply, which made every thread post routed and skipped ambient delivery entirely.)* +4. **The composer stays one element.** A thread is aimed from any message, the headline card, or the expanded rail (#1169 — there is no privileged entry point), and posting into it uses the same composer, re-targeted **to the thread, not to a message**: an ordinary in-thread post sets `thread_root_id` only and leaves `reply_to` null, so it is ambient and the wake-scoping test ("`thread_root_id` alone enqueues no `chat.mention`") holds. `reply_to` keeps its existing meaning — the explicit "reply to this person" gesture — and is set only when the user invokes it on a specific message; that post is addressed to that author (routed, as today) and ambient to everyone else. No second composer is mounted. Write-path consequence: an in-thread post carries `thread_root_id` explicitly from the client, validated server-side (root exists in this pod; the root's own `thread_root_id` is null — no nesting); derivation from `reply_to` remains for explicit replies and for the backfill. Explicit wins when present, derivation fills when absent, and a mismatch between the two is a 400 — **but agreement is not refused**: a reply edge and an explicit root that point at the same thread pass the resolver, so the composer's one-target-two-kinds exclusivity is the *enforcing* layer for "an in-thread post addresses nobody", not a convenience in front of a server that would catch it (#1153). That is why both send paths — text and upload — must carry the same target, and why each is pinned. And **a send consumes the target on every path**: after a successful text or image send the composer clears both kinds (pod 57483/57486) — the chip is a promise about the *next* send, and a chip left standing after the message it described has gone is a lie that costs more than one click to re-aim. *(Amended 2026-08-22 after pod-architect found the collision at 56871: the first wording set `reply_to` on every threaded reply, which made every thread post routed and skipped ambient delivery entirely.)* 5. **The rail is the context; no quote block for the visible parent** (Sam, pod 57487). Quote-and-thread are independent mechanisms: a `reply_to` renders its quote block only when the quoted message is *not already visible* as the thread parent in the same expanded view. Independence is full, both ways (Sam, 57491): inside an expanded thread the quote mechanism stays entirely available — a participant may quote **any** message, in-thread or out, and that quote renders, because it adds information the rail does not show. What is suppressed is only the redundant case: the auto-carried quote of the thread's own root when the reply sits directly under it on the rail. Predicate: hide the quote iff `replyTo.id === thread_root_id` **and** the reply is rendering inside that root's expanded rail; every other quote shows. **Scoping and follow (from the #1045 ruling)**