eleven / messenger Public

web: reply thread view — tap a quote or tally to open the thread (#450)

* web: reply thread view — tap a quote or tally to open the thread

Tapping a reply's quote card or the root's "N Replies" tally now lifts the
thread over the transcript, iMessage-style, replacing both scroll-jumps: the
dimmed feed behind, the root message pinned at the top (a real keyed bubble —
reactions, attachments and message actions all live), its replies chronological
below, and the real composer beneath staged to reply to the root, so sending
continues the thread and the new reply folds into the open view live.

- threadItems(msgs, rootSid): pure, exported selector (root + every message
  whose resolved root is that sid, legacy chains included) + unit tests.
- The overlay bounds to a new .chatapp-feedwrap around the feed, so it covers
  exactly the transcript and never the composer; feed selectors everywhere are
  depth-independent, so the extra level is invisible to shell/native probes.
- Unloaded root: pages history backward (capped at 5 pages, sharing the
  scroll-back guards) with an inline loading note; falls back to rendering the
  quoted snapshot as the thread header.
- Close via ✕, Escape (desktop), or tapping the dim; the banner ✕ (web and the
  native mirror) closes the view too, since cancelling the staged reply means
  leaving the thread. Closing clears the staged reply.
- Message actions inside the view stage replies to the root via replyTarget;
  an edit borrows the composer and hands it back on completion/cancel.

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

* web: frosted thread panel — the conversation reads through the lifted thread

Translucent --panel over a backdrop blur (Safari prefix included), gated on
@supports for both backdrop-filter and color-mix so unsupporting browsers
keep the solid panel; the dim drops 0.38→0.25 to match.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 14328cbcb63cc8b8f5a1acac5032533a0970e8fe parent 82f0139
4 files changed, +439 −12
  • changelog/2026-07-08-reply-thread-view.md +6 −0
  • test/apps.test.mjs +24 −1
  • web/static/apps/chat.js +297 −9
  • web/static/style.css +112 −2
diff --git a/changelog/2026-07-08-reply-thread-view.md b/changelog/2026-07-08-reply-thread-view.md
new file mode 100644
index 0000000..d221ad0
--- /dev/null
+++ b/changelog/2026-07-08-reply-thread-view.md
@@ -0,0 +1,6 @@
+🤖 Reply threads now open in their own view. Tap the quote card on any reply —
+or the "N Replies" count under a message — and the thread lifts over the chat:
+the original message pinned at the top, every reply beneath it, and the message
+box already set to reply. Send to keep the conversation going right there, and
+tap ✕ (or anywhere outside) to drop back into the chat. On the web today; the
+apps will follow.
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index b01e64d..1af172b 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -60,7 +60,7 @@ import { app as dnsimpleApp } from "../web/static/apps/dnsimple.js"; // register
import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run setup conversation's app
import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner welcome conversation's app
import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin)
-import { app as chatApp, viewItems } from "../web/static/apps/chat.js"; // the chat app
+import { app as chatApp, viewItems, threadItems } from "../web/static/apps/chat.js"; // the chat app
// Each app's fold is synthesised from its `events` map by defineApp — the same
// (state, event) → state reducer the framework folds with. Test it directly.
@@ -1004,6 +1004,29 @@ await test("chat: viewItems derives time separators and sender runs (original fe
assert.notEqual(v[1].vrev, v[2].vrev);
});
+await test("chat: threadItems selects a root's whole thread — legacy chains resolve into it", () => {
+ const msgs = [
+ { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, rev: 0 },
+ { id: "m2", sid: 2, name: "Bo", text: "aside", ts: 2, reply: null, rev: 0 },
+ { id: "m3", sid: 3, name: "Cy", text: "re", ts: 3, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 },
+ // A legacy chain: this reply quotes the REPLY (m3), not the root — it must
+ // still resolve transitively into m1's thread.
+ { id: "m4", sid: 4, name: "Al", text: "re-re", ts: 4, reply: { id: 3, name: "Cy", snippet: "re" }, rev: 0 },
+ { id: "m5", sid: 5, name: "Bo", text: "other thread", ts: 5, reply: { id: 2, name: "Bo", snippet: "aside" }, rev: 0 },
+ ];
+ assert.deepEqual(
+ threadItems(msgs, 1).map((m) => m.sid),
+ [1, 3, 4],
+ "root first, then its thread in server order; the reply-to-a-reply resolves in; asides and other threads stay out",
+ );
+ assert.deepEqual(threadItems(msgs, 2).map((m) => m.sid), [2, 5], "the other thread folds on its own root");
+ // Root scrolled out of the loaded window: the thread still folds from the
+ // replies we can see (the view renders the quoted snapshot as its header —
+ // the chain resolves to the same unloaded sid either way).
+ assert.deepEqual(threadItems(msgs.slice(2), 1).map((m) => m.sid), [3, 4], "unloaded root — replies only, chain intact");
+ assert.deepEqual(threadItems(msgs, 99), [], "an unknown root has no thread");
+});
+
await test("chat: reactions, edits, deletes fold as host translations of server frames", () => {
const reduce = chatApp.reduce;
let s = reduce(undefined, { type: "chat.msg", text: "hello", name: "Al", ts: 1 }, { id: 20 });
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 9897a50..4ad2858 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -224,6 +224,11 @@ const myReactions = new Set();
// Edit) can reach the composer they share a pane with.
const composers = new Map(); // threadId → { refreshSend(), startReply(item), startEdit(item) }
+// The reply-thread view controller for each mounted thread, so bubble taps (a
+// quote card, a "N Replies" tally) can lift that thread over the pane they live
+// in. Built alongside the composer in buildSkeleton, overwritten per mount.
+const threads = new Map(); // threadId → { open(rootSid, ref), close(), isOpen() }
+
// Threads whose next render should snap to the newest message even if the reader
// had scrolled up — set when you send from the composer (a reply included), since
// there's no optimistic echo: your message only lands on the server round-trip's
@@ -322,6 +327,25 @@ export function replyTarget(msgs, m) {
: { id: ref.id || 0, name: ref.name || "", snippet: ref.snippet || "" };
}
+// threadItems: the reply thread rooted at `rootSid` — the root message (when
+// loaded) followed by every message whose RESOLVED thread root is that sid, in
+// server order. Legacy chains (a reply quoting another reply) resolve
+// transitively via rootRef, so they land in the same thread as their ancestors;
+// when the root itself has scrolled out of the loaded window the replies still
+// fold (the view renders the quoted snapshot as its header instead). Pure and
+// exported for tests — the thread view re-derives this from current state on
+// every fold, never a cached list.
+export function threadItems(msgs, rootSid) {
+ const bySid = new Map(msgs.map((m) => [m.sid, m]));
+ const out = [];
+ const root = bySid.get(rootSid);
+ if (root) out.push(root);
+ for (const m of msgs) {
+ if (m.reply && m.reply.id && rootRef(bySid, m.reply).id === rootSid) out.push(m);
+ }
+ return out.sort((a, b) => a.sid - b.sid);
+}
+
export function viewItems(msgs) {
// Reply threads group on the resolved ROOT message: every reply tallies on
// its thread's top message (legacy chains resolve transitively), and each
@@ -484,6 +508,13 @@ export const app = defineApp({
padding: 18px; display: flex; flex-direction: column; gap: 4px;
}
.chatapp-feed::before { content: ""; margin-top: auto; }
+/* The feed's positioning wrapper: the reply-thread view overlays exactly this
+ box (inset:0 in style.css), so it dims/covers the transcript but never the
+ composer stack below — the real composer stays usable under the thread. */
+.chatapp-feedwrap {
+ flex: 1 1 auto; min-height: 0; min-width: 0; position: relative;
+ display: flex; flex-direction: column;
+}
`,
// Incremental render: the skeleton (feed / banners / composer) is built once
@@ -609,6 +640,13 @@ export const app = defineApp({
feedGlideToBottom(feed, firstBuild || forcePin);
}
+ // An open reply-thread view folds from this same state — re-derive and
+ // re-render it every state change so new replies/reactions/edits land live
+ // (it reads current state via threadItems, never a cached list). No-op when
+ // closed; stashed on the root like _hotkeys so the instant-restore path
+ // (which skips buildSkeleton) keeps the live mount's view current.
+ if (root._refreshThread) root._refreshThread();
+
// The in-feed typing row, exactly like the original feed: a .typing element
// kept as the feed's last child (below the newest message). keyedList only
// manages keyed children, so it never touches this row.
@@ -760,7 +798,17 @@ function renderItem(m, ctx) {
txt.className = "reply-quote-text";
txt.textContent = m.reply.snippet;
q.append(who, txt);
- q.onclick = () => scrollToMessage(wrap, m.reply.id);
+ q.onclick = () => {
+ // A quote tap opens the whole thread, anchored on its resolved ROOT (the
+ // quoted ref may name a mid-chain parent on a legacy reply). replyTarget
+ // also hands us the root's name/snippet — the header fallback if the root
+ // has scrolled out of the loaded window. Old scroll-jump kept as a belt
+ // for a mount without thread controls.
+ const t = threads.get(ctx.threadId);
+ const ref = replyTarget(((ctx.getState() || {}).msgs) || [], m);
+ if (t && ref.id) t.open(ref.id, ref);
+ else scrollToMessage(wrap, m.reply.id);
+ };
content.appendChild(q);
}
@@ -847,7 +895,7 @@ function renderItem(m, ctx) {
const reactions = buildReactionsRow(m, ctx);
if (reactions) wrap.appendChild(reactions);
- if (m.replyCount) wrap.appendChild(buildReplyCount(m, wrap));
+ if (m.replyCount) wrap.appendChild(buildReplyCount(m, wrap, ctx));
if (m.edited) {
const e = document.createElement("div");
@@ -895,12 +943,20 @@ function buildReactionsRow(m, ctx) {
return row.childElementCount ? row : null;
}
-// The "N Replies" tally below a bubble, tappable to jump to the first reply.
-function buildReplyCount(m, wrap) {
+// The "N Replies" tally below a bubble, tappable to open the thread view. The
+// tally only ever renders under a loaded root (it IS this message), so no
+// paging is needed on this path. Old jump-to-first-reply kept as a belt for a
+// mount without thread controls.
+function buildReplyCount(m, wrap, ctx) {
const rc = document.createElement("div");
rc.className = "reply-count";
rc.textContent = m.replyCount === 1 ? "1 Reply" : m.replyCount + " Replies";
rc.onclick = () => {
+ const t = threads.get(ctx.threadId);
+ if (t) {
+ t.open(m.sid, { id: m.sid, name: m.name, snippet: replySnippet(m.raw) });
+ return;
+ }
const feed = wrap.closest(".chatapp-feed");
const first = feed && feed.querySelector(`.msg[data-reply-to="${m.sid}"]`);
if (first) scrollToMessage(wrap, first.dataset.id);
@@ -931,7 +987,7 @@ function patchBubble(node, m, ctx) {
if (m.replyCount) {
if (rc) rc.textContent = m.replyCount === 1 ? "1 Reply" : m.replyCount + " Replies";
else {
- rc = buildReplyCount(m, wrap);
+ rc = buildReplyCount(m, wrap, ctx);
const edited = wrap.querySelector(":scope > .edited-tag");
if (edited) edited.before(rc);
else wrap.appendChild(rc);
@@ -2280,6 +2336,13 @@ function buildSkeleton(el, ctx) {
const feed = document.createElement("div");
feed.className = "feed chatapp-feed"; // .feed for original descendant rules; .chatapp-feed mirrors #feed's layout
+ // The wrapper bounds the reply-thread overlay to exactly the feed's box (it
+ // dims the transcript, never the composer). Feed selectors everywhere are
+ // depth-independent (.chatapp-feed querySelectors, no child combinators), so
+ // the extra level is invisible to the rest of the shell + native probes.
+ const feedWrap = document.createElement("div");
+ feedWrap.className = "chatapp-feedwrap";
+ feedWrap.appendChild(feed);
const float = document.createElement("div");
float.className = "composer-float";
@@ -2347,7 +2410,7 @@ function buildSkeleton(el, ctx) {
attach.hidden = !!defaultCmd();
- root.append(surface, feed, float, replyBanner, editBanner, compose);
+ root.append(surface, feedWrap, float, replyBanner, editBanner, compose);
el.appendChild(root);
// Keep the feed pinned to the newest message when the composer, its staged
@@ -2426,7 +2489,11 @@ function buildSkeleton(el, ctx) {
x.className = "reply-bar-cancel";
x.innerHTML = icon("x");
x.setAttribute("aria-label", "Cancel reply");
- x.onclick = cancelReply;
+ // While the thread view is open its staged reply IS the view's composer
+ // state — cancelling it means leaving the thread, so close the whole view
+ // (which clears the reply) rather than stranding an open thread whose
+ // sends would land outside it.
+ x.onclick = () => (threadSid ? closeThread() : cancelReply());
bar.append(ic, body, x);
replyBanner.appendChild(bar);
replyBanner.classList.remove("hidden");
@@ -2444,6 +2511,9 @@ function buildSkeleton(el, ctx) {
editing = null;
refreshBanners();
if (inEmbed) host.native({ editing: null });
+ // An edit inside an open thread view borrowed the composer (startEdit clears
+ // replyTo); hand it back so the next send still continues the thread.
+ if (threadSid) stageThreadReply();
};
// Staged attachments as original .preview-chip tiles: a spinner while
@@ -2921,6 +2991,13 @@ function buildSkeleton(el, ctx) {
setSendQueued(false); // un-queue first; a second Escape cancels reply/edit
return;
}
+ // An open thread view closes on Escape (dropping its staged reply) — unless
+ // an edit is in flight, which the branch below cancels first (cancelEdit
+ // re-stages the thread reply, so a second Escape then closes the view).
+ if (e.key === "Escape" && threadSid && !editing) {
+ closeThread();
+ return;
+ }
if (e.key === "Escape" && (editing || replyTo)) {
const wasEditing = !!editing;
cancelReply();
@@ -3052,7 +3129,11 @@ function buildSkeleton(el, ctx) {
pinNext.add(ctx.threadId);
resetInput();
pending = [];
- cancelReply();
+ // Sending from an open thread view keeps it open and re-stages the reply
+ // to its root — the new reply folds in live and the next send continues
+ // the same thread, like iMessage.
+ if (threadSid) stageThreadReply();
+ else cancelReply();
refreshStaged();
input.focus();
// A scripted conversation awaiting an answer in this thread is told what
@@ -3081,6 +3162,198 @@ function buildSkeleton(el, ctx) {
loadingOlder = false;
});
+ // ---- reply-thread view: tap a quote or a "N Replies" tally to lift that
+ // thread over the transcript, iMessage-style ----
+ // The overlay occupies the feed's box only (see .chatapp-feedwrap), so the
+ // real composer below stays live with its reply state staged to the thread's
+ // root — sending from it continues the thread, and the new reply folds into
+ // the open view (render() → root._refreshThread re-derives from current
+ // state each fold; the view never caches its list).
+ let threadSid = 0; // the open thread's root sid (0 = closed)
+ let threadFallback = null; // { id, name, snippet } — header stand-in while the root isn't loaded
+ let threadEl = null; // { ov, count, rootBox, note, body } while open
+ let threadPaging = false; // paging history backward looking for the root
+
+ // Stage the composer's reply on the thread's root — the whole point of the
+ // view's composer contract ("Replying to <root author>"). Mirrors startReply
+ // (a reply displaces an in-flight edit; native shells mirror the banner) but
+ // skips the focus on touch, where popping the keyboard would cover the
+ // thread the reader just opened.
+ const stageThreadReply = () => {
+ editing = null;
+ const msgs = ((ctx.getState() || {}).msgs) || [];
+ const rootMsg = msgs.find((m) => m.sid === threadSid);
+ replyTo = rootMsg
+ ? replyTarget(msgs, rootMsg)
+ : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" };
+ refreshBanners();
+ if (inEmbed) host.native({ replying: { name: replyTo.name || "", snippet: replyTo.snippet || "" } });
+ else if (!coarse) input.focus();
+ };
+
+ const closeThread = () => {
+ const sid = threadSid;
+ if (!sid && !threadEl) return;
+ threadSid = 0;
+ threadFallback = null;
+ if (threadEl) {
+ threadEl.ov.remove();
+ threadEl = null;
+ }
+ // Leaving the thread drops its staged reply — never an unrelated one staged
+ // since (an edit clears replyTo itself, so that survives untouched).
+ if (sid && replyTo && replyTo.id === sid) cancelReply();
+ };
+
+ const buildThread = () => {
+ const ov = document.createElement("div");
+ ov.className = "chatapp-thread";
+ const dim = document.createElement("div");
+ dim.className = "chatapp-thread-dim";
+ dim.onclick = () => closeThread(); // tapping the dimmed transcript leaves the thread
+ const panel = document.createElement("div");
+ panel.className = "chatapp-thread-panel";
+ panel.setAttribute("role", "dialog");
+ panel.setAttribute("aria-label", "Reply thread");
+ const head = document.createElement("div");
+ head.className = "chatapp-thread-head";
+ const title = document.createElement("div");
+ title.className = "chatapp-thread-title";
+ title.textContent = "Thread";
+ const count = document.createElement("div");
+ count.className = "chatapp-thread-count";
+ const x = document.createElement("button");
+ x.type = "button";
+ x.className = "chatapp-thread-x";
+ x.innerHTML = icon("x"); // static in-code SVG from the host (safe)
+ x.setAttribute("aria-label", "Close thread");
+ x.onclick = () => closeThread();
+ head.append(title, count, x);
+ const rootBox = document.createElement("div");
+ rootBox.className = "chatapp-thread-rootmsg";
+ const note = document.createElement("div");
+ note.className = "chatapp-thread-note hidden";
+ const body = document.createElement("div");
+ body.className = "chatapp-thread-msgs";
+ panel.append(head, rootBox, note, body);
+ ov.append(dim, panel);
+ return { ov, count, rootBox, note, body };
+ };
+
+ // Thread rows reuse the feed's exact bubble machinery — renderItem +
+ // patchBubble under keyedList — so reactions, attachments, long-press
+ // actions, hover strips and edits all keep working inside the view (and a
+ // reaction patches in place instead of reloading a photo).
+ const threadKeyed = (container, items) =>
+ keyedList(container, items, {
+ key: (m) => m.id,
+ rev: (m) => m.vrev,
+ srev: (m) => m.srev,
+ render: (m) => renderItem(m, ctx),
+ update: (node, m) => patchBubble(node, m, ctx),
+ });
+
+ const refreshThread = () => {
+ if (!threadSid || !threadEl) return;
+ const msgs = ((ctx.getState() || {}).msgs) || [];
+ const items = threadItems(msgs, threadSid);
+ // The whole thread got deleted under the open view — nothing left to show.
+ if (!items.length && !threadPaging) return closeThread();
+ const rootLoaded = !!(items.length && items[0].sid === threadSid);
+ const replies = rootLoaded ? items.slice(1) : items;
+ threadEl.count.textContent = replies.length === 1 ? "1 reply" : replies.length + " replies";
+ if (rootLoaded) {
+ const m = items[0];
+ if (threadEl.rootBox.dataset.mode !== "msg") {
+ threadEl.rootBox.textContent = ""; // swap out the snapshot fallback
+ threadEl.rootBox.dataset.mode = "msg";
+ }
+ // The pinned root is a real keyed bubble, sans separator and sans its own
+ // tally (the replies ARE the panel). Same soft-rev split as viewItems so
+ // a reaction patches the row in place, never rebuilding the bubble.
+ const reactSig = (m.reactions || []).map((r) => `${r.emoji}${r.count}`).join(",");
+ threadKeyed(threadEl.rootBox, [{ ...m, timeSep: null, cont: false, replyCount: 0, replyRoot: 0, vrev: `${m.rev}|root`, srev: reactSig }]);
+ } else if (threadEl.rootBox.dataset.mode !== "fall") {
+ threadEl.rootBox.textContent = "";
+ threadEl.rootBox.dataset.mode = "fall";
+ // Root not loaded (yet): the quoted snapshot stands in as the header.
+ const fall = document.createElement("div");
+ fall.className = "chatapp-thread-rootfall";
+ const who = document.createElement("div");
+ who.className = "reply-quote-name";
+ who.textContent = (threadFallback && threadFallback.name) || "";
+ const txt = document.createElement("div");
+ txt.className = "chatapp-thread-fallsnippet";
+ txt.textContent = (threadFallback && threadFallback.snippet) || "";
+ fall.append(who, txt);
+ threadEl.rootBox.appendChild(fall);
+ }
+ const noteText = threadPaging
+ ? "Loading the start of this thread…"
+ : rootLoaded ? "" : "The original message isn't available — showing its replies.";
+ threadEl.note.textContent = noteText;
+ threadEl.note.classList.toggle("hidden", !noteText);
+ // Follow the newest reply like the feed does, but only when already there —
+ // a reader scrolled up a long thread isn't yanked down by a live arrival.
+ const follow = threadEl.body.scrollHeight - threadEl.body.scrollTop - threadEl.body.clientHeight < 80 || !threadEl.body.childElementCount;
+ threadKeyed(threadEl.body, viewItems(replies));
+ if (follow) threadEl.body.scrollTop = threadEl.body.scrollHeight;
+ };
+ root._refreshThread = refreshThread;
+
+ // Page history backward until the thread's root is loaded — bounded, so a
+ // reply deep in a huge history can't spin the loader forever. Shares the
+ // scroll-back path's loadingOlder/exhausted guards and keeps the dimmed
+ // transcript's scroll anchored across each prepend, like scroll-back does.
+ const THREAD_PAGE_CAP = 5;
+ const pageInRoot = async () => {
+ if (threadPaging || loadingOlder) return;
+ const sid = threadSid;
+ threadPaging = true;
+ refreshThread(); // show the inline loading note straight away
+ for (let i = 0; i < THREAD_PAGE_CAP && threadSid === sid && !exhausted; i++) {
+ const msgs = ((ctx.getState() || {}).msgs) || [];
+ if (msgs.some((m) => m.sid === sid)) break;
+ const oldest = msgs.length ? msgs[0].sid : 0;
+ // Pages are contiguous: once we've loaded past the root's sid without
+ // finding it, the root was deleted — more paging can't surface it.
+ if (!oldest || oldest < sid || !host || !host.loadOlder) break;
+ loadingOlder = true; // keep the feed's own scroll-back handler out
+ const prevHeight = feed.scrollHeight;
+ const n = await host.loadOlder(ctx.threadId, oldest);
+ feed.scrollTop += feed.scrollHeight - prevHeight;
+ loadingOlder = false;
+ if (n === 0) {
+ exhausted = true;
+ break;
+ }
+ }
+ threadPaging = false;
+ if (threadSid !== sid) return; // closed / re-opened elsewhere while paging
+ refreshThread();
+ // The root may have just loaded — refresh the banner to its live snapshot,
+ // but only while the staged reply is still this thread's (not an edit).
+ if (!editing && replyTo && replyTo.id === sid) stageThreadReply();
+ };
+
+ const openThread = (rootSid, ref) => {
+ if (!rootSid) return;
+ if (threadSid === rootSid && threadEl) return; // already open on this root
+ closeThread();
+ threadSid = rootSid;
+ threadFallback = ref || null;
+ threadEl = buildThread();
+ feedWrap.appendChild(threadEl.ov);
+ stageThreadReply();
+ // Render before any paging: the view must show what's loaded even when
+ // pageInRoot bails (e.g. a scroll-back already in flight). When paging does
+ // run, its own refresh follows synchronously, so no wrong-note flash.
+ refreshThread();
+ const msgs = ((ctx.getState() || {}).msgs) || [];
+ if (!msgs.some((m) => m.sid === rootSid)) pageInRoot();
+ };
+ threads.set(ctx.threadId, { open: openThread, close: closeThread, isOpen: () => !!threadSid });
+
// Keyboard shortcuts, mirroring the original feed: with an empty composer,
// cmd/ctrl-T opens the message menu on the latest message from someone else
// (number keys then toggle reactions, Escape dismisses — openMessagePicker)
@@ -3109,6 +3382,16 @@ function buildSkeleton(el, ctx) {
const m = latest(false);
if (m) composers.get(ctx.threadId).startReply(m);
}
+ // Escape closes an open thread view when the composer isn't focused (the
+ // focused case is handled in input.onkeydown). Skipped when a layer that
+ // owns Escape is up — the picker preventDefaults before us (capture phase),
+ // the sheet/lightbox are still in the DOM when we run — so one Escape peels
+ // one layer, never two.
+ if (e.key === "Escape" && threadSid && !editing && !e.defaultPrevented && document.activeElement !== input && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
+ e.preventDefault();
+ closeThread();
+ return;
+ }
if (e.key === "Escape" && document.activeElement !== input && (replyTo || editing) && !document.querySelector(".emoji-picker")) {
e.preventDefault();
const wasEditing = !!editing;
@@ -3150,7 +3433,12 @@ function buildSkeleton(el, ctx) {
stage(file) {
stageFiles([file]);
},
- cancelReply,
+ cancelReply() {
+ // The native banner's ✕ means the same as the web one: leaving an open
+ // thread view, not just dropping its staged reply.
+ if (threadSid) closeThread();
+ else cancelReply();
+ },
cancelEdit() {
cancelEdit();
},
diff --git a/web/static/style.css b/web/static/style.css
index 3acd467..c4eebe1 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1161,7 +1161,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
white-space: nowrap;
}
.msg.me .reply-quote-text { margin-left: auto; }
-/* "N Replies" tag under a message that's been replied to (jumps to the first reply) */
+/* "N Replies" tag under a message that's been replied to (opens the thread view) */
.reply-count {
font-size: 11px;
font-weight: 600;
@@ -1177,6 +1177,116 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
25% { background: color-mix(in srgb, var(--accent) 16%, transparent); }
}
+/* ---- reply-thread view (apps/chat.js): tap a quote or a "N Replies" tally and
+ the thread lifts over the transcript, iMessage-style — the root pinned on
+ top, its replies below, the real composer beneath staged to the root. The
+ overlay lives inside .chatapp-feedwrap, so it covers exactly the feed's box
+ and never the composer. All colors are theme vars → light/dark for free. */
+.chatapp-thread { position: absolute; inset: 0; z-index: 30; display: flex; }
+.chatapp-thread-dim {
+ position: absolute;
+ inset: 0;
+ /* lighter than the msg-sheet dim: the frosted panel reads the transcript
+ through itself, so the backdrop only needs to recede, not vanish */
+ background: rgba(0, 0, 0, 0.25);
+ animation: fade-in 0.15s ease;
+}
+.chatapp-thread-panel {
+ position: relative; /* above the dim */
+ margin: 10px;
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ box-shadow: 0 12px 40px var(--shadow);
+ overflow: hidden;
+ animation: thread-rise 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+@keyframes thread-rise { from { opacity: 0; transform: translateY(12px); } }
+/* Frosted glass: the conversation stays visible under the lifted thread — a
+ translucent panel over a backdrop blur, both themes via --panel. The solid
+ var(--panel) above is the fallback where backdrop-filter/color-mix aren't
+ supported (a see-through panel with NO blur would be unreadable). */
+@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) and (background: color-mix(in srgb, red 50%, transparent)) {
+ .chatapp-thread-panel {
+ background: color-mix(in srgb, var(--panel) 72%, transparent);
+ -webkit-backdrop-filter: blur(18px) saturate(1.15);
+ backdrop-filter: blur(18px) saturate(1.15);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .chatapp-thread-dim,
+ .chatapp-thread-panel { animation: none; }
+}
+/* embedded in the native shells: keep the panel clear of the notch/home bar */
+.native-embed .chatapp-thread-panel {
+ margin-top: calc(10px + var(--native-top-inset, 0px));
+ margin-bottom: calc(10px + var(--native-bottom-inset, 0px));
+}
+.chatapp-thread-head {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ padding: 12px 14px 10px;
+ border-bottom: 1px solid var(--border);
+}
+.chatapp-thread-title { font-weight: 700; font-size: 15px; color: var(--ink); }
+.chatapp-thread-count { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); }
+.chatapp-thread-x {
+ flex: none;
+ align-self: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--muted);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.chatapp-thread-x svg { width: 16px; height: 16px; }
+.chatapp-thread-x:hover { background: var(--bubble-them); }
+/* the pinned root message — capped so a long one can't swallow the replies */
+.chatapp-thread-rootmsg {
+ flex: none;
+ max-height: 40%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 12px 16px 10px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+}
+/* the quoted snapshot standing in for a root that isn't loaded */
+.chatapp-thread-rootfall { border-left: 2px solid var(--accent); padding-left: 10px; }
+.chatapp-thread-fallsnippet { font-size: var(--msg-font-size); color: var(--ink); overflow-wrap: anywhere; }
+.chatapp-thread-note {
+ padding: 8px 16px;
+ font-size: 12px;
+ color: var(--muted);
+ border-bottom: 1px solid var(--border);
+}
+.chatapp-thread-note.hidden { display: none; }
+.chatapp-thread-msgs {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden; /* clip mid swipe-to-reply, like the feed */
+ padding: 12px 16px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+/* inside the view, every row is already the thread — a per-row quote card or
+ tally would only point back at where you're standing */
+.chatapp-thread .reply-quote,
+.chatapp-thread .reply-count { display: none; }
+
/* composer "Replying to …" / "Editing message" banners, above the composer
(and any attachment preview). They share the .reply-bar inner markup. */
.composer-reply,
@@ -1374,7 +1484,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.composer.editing { padding-bottom: 0; }
/* Long-press opens the message menu, so suppress the native selection/callout that
would otherwise fight it (Copy lives in the menu instead). */
- .feed .bubble, .chatapp-feed .bubble { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
+ .feed .bubble, .chatapp-feed .bubble, .chatapp-thread .bubble { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
/* A phone is held further from the eye than a laptop screen sits — bump the
core reading + typing text up a notch so messages are comfortable. Desktop
(fine pointer) keeps its sizes. Composer stays ≥16px so iOS doesn't