web: thread replies live only in the thread + fix the thread-view blink (#1474)
Change 1 — Slack model. A reply composed IN a thread (the docked pane's
composer, the mobile overlay's staged root reply, the "Reply in thread"
verb) no longer renders in the main feed: the root shows just its
"N Replies" tally. Plain quote replies render inline exactly as before.
Wire marker (additive, cross-platform — native implements the same names):
replyTo: { id, name, snippet, thread: true }. Absent/false = inline quote
reply (old clients, existing history, the Reply verb). The chat.msg fold
carries it as reply.thread (default false); raw keeps it so edits re-send it.
- inMainFeed(m, bySid) — the exported predicate, ROOT-AWARE: a flagged
reply hides only while its resolved root (rootRef, legacy chains
included) is in the loaded window (bySidIndex(msgs)). The tally lives on
the root's bubble, so with the root paged out — a fresh reply to an old
message on a cold open — hiding it would render it NOWHERE; it degrades
to an ordinary inline bubble and collapses into the tally when the root
pages in.
- viewItems(msgs, { mainFeed: true }) — the transcript filter: tallies run
over every message (a hidden reply still counts on its root), separators
and sender runs derive over the VISIBLE list. threadItems and the plain
viewItems call (the thread view) keep every reply.
- The flag is set at the compose seam only: threadReplyTarget() (the pane's
fixedReply + the overlay's stageThreadReply); replyTarget stays pure. Under
the overlay the registry's startReply stages the root WITH the flag when
the tapped bubble resolves to the open thread (every bubble in the view
does), so a pane/overlay-originated send is always flagged and a main-feed
one never is.
- Jumps to a hidden reply (search, permalink, mediaview): scrollToMessage's
fallback opens the thread on its root via threads.openForMessage(sid) and
lands on the bubble inside the view; searchui's focus/wave lookup covers
the whole pane.
- A thread reply never arms pinNext (nothing visible lands in the feed);
cmd-T / cmd-R "latest" and ↑-to-edit skip thread-only replies.
Change 2 — the blink. threadItems sorted by bare sid, so a just-sent
reply's optimistic copy (sid 0) sorted AHEAD of the root; refreshThread then
read "root not loaded" for the whole server round-trip — wiped the pinned
root for the snapshot fallback, showed the "original message isn't
available" note, listed the root as a body bubble, and snapped it all back
on the echo. threadItems now sorts confirmed-by-sid, pending LAST
(bySidPendingLast, the feed's own order), so the root stays items[0] and
patches in place.
Vectors: message_in_thread (new, expected_main_feed_visible:false —
evaluated with the quoted root seeded into the loaded window),
message_with_reply gains expected_main_feed_visible:true,
reply_thread_grouping gains a flagged reply (104) + expected_main_feed
(hidden iff the resolved root is in the sequence), and
thread_reply_root_unloaded (new: only a flagged reply, no root →
expected_main_feed { "105": true }); the vector test asserts all of it
against inMainFeed / viewItems mainFeed.
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+433
−43
changelog/2026-08-28-thread-pane.md+5 −1test/apps.test.mjs+118 −2test/event-vectors.test.mjs+53 −2test/event-vectors/chat.json+110 −5web/static/apps/chat.js+143 −31web/static/searchui.js+4 −2
diff --git a/changelog/2026-08-28-thread-pane.md b/changelog/2026-08-28-thread-pane.md| index 7d97806..0d8f2f9 100644 |
| --- a/changelog/2026-08-28-thread-pane.md |
| +++ b/changelog/2026-08-28-thread-pane.md |
| @@ -4,4 +4,8 @@ docks as a column on the right, with its own reply box at the bottom — so you |
| can follow the main conversation and the thread at the same time. The message |
| menu has a new "Reply in thread" next to "Reply", and while a thread is open |
| you'll find it under the ▤ Views button in the header. On a phone the thread |
| -still lifts over the chat the way it always has. |
| +still lifts over the chat the way it always has. Replies you make in a thread |
| +now stay in the thread: the chat itself shows just the "N Replies" tally under |
| +the original message (tap it to read them), so a busy thread no longer crowds |
| +the conversation. A plain Reply still lands in the chat as before, and a |
| +search hit or link to a thread reply opens the thread for you. |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index f952529..f8abac0 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -90,7 +90,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, threadItems, newlyDirtyMentionSids } from "../web/static/apps/chat.js"; // the chat app |
| +import { app as chatApp, viewItems, threadItems, inMainFeed, bySidIndex, newlyDirtyMentionSids } from "../web/static/apps/chat.js"; // the chat app |
| import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault) |
| import { app as emojiApp, searchEmoji } from "../web/static/apps/emoji.js"; // registers /emoji (the command-panel reference app) |
| import { app as gifApp, pickRendition, renditionCandidates, gifItems, GIF_MAX_BYTES } from "../web/static/apps/gif.js"; // registers /gif |
| @@ -1126,7 +1126,8 @@ await test("chat: folds original-shaped messages (text, attachments, replies), d |
| assert.deepEqual(s.msgs[1].raw.attachments, [att], "raw payload kept for { ...orig, text } edits"); |
| // A reply carries its parent link + snippet through (the id feeds tallies). |
| s = reduce(s, { type: "chat.msg", text: "yes!", replyTo: { id: 10, name: "Al", snippet: "hi" } }, { id: 12 }); |
| - assert.deepEqual(s.msgs[2].reply, { id: 10, name: "Al", snippet: "hi" }); |
| + // thread:false — the flag absent on the wire is an inline quote reply (inMainFeed). |
| + assert.deepEqual(s.msgs[2].reply, { id: 10, name: "Al", snippet: "hi", thread: false }); |
| // The parent's viewItems row tallies it. The tally rides the SOFT rev (srev), |
| // so it patches the reply-count in place — it must NOT be in the hard vrev, |
| // which would rebuild the whole bubble (and reload a photo). |
| @@ -1212,6 +1213,121 @@ await test("chat: threadItems selects a root's whole thread — legacy chains re |
| assert.deepEqual(threadItems(msgs, 99), [], "an unknown root has no thread"); |
| }); |
| +await test("chat: threadItems keeps the root FIRST while an optimistic reply is still pending (the thread-view blink)", () => { |
| + // A just-sent reply folds pending with sid 0 until its echo lands. Sorted |
| + // by bare sid it landed AHEAD of the root, so refreshThread read "root not |
| + // loaded" for one round-trip — pinned root wiped for the snapshot fallback, |
| + // root listed as a body bubble, then all snapped back: the blink. |
| + const msgs = [ |
| + { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, rev: 0 }, |
| + { id: "m3", sid: 3, name: "Cy", text: "re", ts: 3, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }, |
| + { id: "cabc", sid: 0, cid: "abc", pending: true, name: "Me", text: "sending…", ts: 9, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| + ]; |
| + const items = threadItems(msgs, 1); |
| + assert.deepEqual(items.map((m) => m.id), ["m1", "m3", "cabc"], "root, confirmed replies by sid, then the pending copy last — feed order"); |
| + assert.equal(items[0].sid, 1, "the root stays items[0], so the view never flips to its unloaded fallback mid-send"); |
| +}); |
| + |
| +await test("chat: inMainFeed — a reply made in a thread lives only there (while its root is loaded); quote replies and plain messages stay inline", () => { |
| + const root = { sid: 1, name: "Al", text: "hi", reply: null }; |
| + const withRoot = bySidIndex([root]); |
| + const noRoot = bySidIndex([]); |
| + assert.equal(inMainFeed({ sid: 1, reply: null }, withRoot), true, "a plain message shows"); |
| + assert.equal(inMainFeed({ sid: 2, reply: { id: 1, name: "Al", snippet: "hi" } }, withRoot), true, "a quote reply (no flag — old clients, existing history, the Reply verb) shows inline"); |
| + assert.equal(inMainFeed({ sid: 3, reply: { id: 1, name: "Al", snippet: "hi", thread: false } }, withRoot), true, "thread:false is the same as absent"); |
| + assert.equal(inMainFeed({ sid: 4, reply: { id: 1, name: "Al", snippet: "hi", thread: true } }, withRoot), false, "thread:true hides it from the main feed — its root carries the tally"); |
| + // Root-aware: with the root paged out there is no bubble to carry a tally, |
| + // so hiding would render the reply NOWHERE — it degrades to an inline bubble. |
| + assert.equal(inMainFeed({ sid: 4, reply: { id: 1, name: "Al", snippet: "hi", thread: true } }, noRoot), true, "root not loaded — the thread reply shows inline until the root pages in"); |
| + // The root is the RESOLVED one: a flagged reply chained off a loaded reply |
| + // whose own root is paged out is still rootless here. |
| + const mid = { sid: 3, name: "Cy", text: "re", reply: { id: 1, name: "Al", snippet: "hi" } }; |
| + assert.equal(inMainFeed({ sid: 5, reply: { id: 3, name: "Cy", snippet: "re", thread: true } }, bySidIndex([mid])), true, "chain parent loaded but the root isn't — shown"); |
| + assert.equal(inMainFeed({ sid: 5, reply: { id: 3, name: "Cy", snippet: "re", thread: true } }, bySidIndex([root, mid])), false, "chain resolves to a loaded root — hidden"); |
| + // Flagged but ref-less: id 0 is the pending-copy key, so without the guard |
| + // this would hide itself behind any in-flight send, with no root tally to |
| + // carry it. Native's threadRoots skips id 0 the same way. |
| + const pending = bySidIndex([{ id: "cabc", sid: 0, pending: true }]); |
| + assert.equal(inMainFeed({ sid: 6, reply: { id: 0, name: "", snippet: "", thread: true } }, pending), true, "flagged with no resolvable root shows inline, even mid-send"); |
| +}); |
| + |
| +await test("chat: the chat.msg fold carries the thread flag (default false) and keeps it in raw for edits", () => { |
| + const reduce = chatApp.reduce; |
| + let s = reduce(undefined, { type: "chat.msg", text: "root", name: "Al", uid: "u-al", ts: 1 }, { id: 10 }); |
| + s = reduce(s, { type: "chat.msg", text: "inline", name: "Bo", replyTo: { id: 10, name: "Al", snippet: "root" } }, { id: 11 }); |
| + s = reduce(s, { type: "chat.msg", text: "in thread", name: "Cy", replyTo: { id: 10, name: "Al", snippet: "root", thread: true } }, { id: 12 }); |
| + assert.equal(s.msgs[1].reply.thread, false, "absent on the wire folds to false — never undefined, so old history compares cleanly"); |
| + assert.equal(s.msgs[2].reply.thread, true); |
| + assert.equal(s.msgs[2].raw.replyTo.thread, true, "raw keeps the flag, so an edit ({ ...raw, text }) re-sends it and the reply stays in its thread"); |
| +}); |
| + |
| +await test("chat: viewItems({ mainFeed: true }) hides thread replies but still tallies them on the root", () => { |
| + const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| + const msgs = [ |
| + { id: "m1", sid: 1, uid: "u1", name: "Al", text: "root", ts: base, reply: null, rev: 0 }, |
| + { id: "m2", sid: 2, uid: "u2", name: "Bo", text: "quote reply", ts: base + 1000, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }, |
| + { id: "m3", sid: 3, uid: "u3", name: "Cy", text: "in thread", ts: base + 2000, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| + // A legacy chain off the thread reply still resolves to root 1 — and, |
| + // flagged, still hides. |
| + { id: "m4", sid: 4, uid: "u1", name: "Al", text: "in thread too", ts: base + 3000, reply: { id: 3, name: "Cy", snippet: "in thread", thread: true }, rev: 0 }, |
| + { id: "m5", sid: 5, uid: "u2", name: "Bo", text: "aside", ts: base + 4000, reply: null, rev: 0 }, |
| + ]; |
| + const main = viewItems(msgs, { mainFeed: true }); |
| + assert.deepEqual(main.map((m) => m.sid), [1, 2, 5], "thread replies drop out; the quote reply and the aside stay"); |
| + assert.equal(main[0].replyCount, 3, "the root tallies every reply in its thread, hidden ones included"); |
| + assert.ok(main[0].srev.endsWith("|3|ok|1"), "the tally still rides the soft rev — the root patches in place, never rebuilds"); |
| + const all = viewItems(msgs); |
| + assert.deepEqual(all.map((m) => m.sid), [1, 2, 3, 4, 5], "the plain call (the thread view) lists everything"); |
| + assert.equal(all[3].replyRoot, 1, "the chained thread reply resolves to the root either way"); |
| + assert.equal(all[0].replyCount, 3, "same tally either way"); |
| +}); |
| + |
| +await test("chat: viewItems({ mainFeed: true }) shows a thread reply inline while its root is paged out, then collapses it into the tally", () => { |
| + const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| + // Cold-open a busy chat: the loaded page starts AFTER the old root a fresh |
| + // thread reply points at. Hiding it would leave the newest message of the |
| + // chat rendered nowhere. |
| + const page = [ |
| + { id: "m50", sid: 50, uid: "u2", name: "Bo", text: "recent chatter", ts: base, reply: null, rev: 0 }, |
| + { id: "m51", sid: 51, uid: "u3", name: "Cy", text: "reply to an old root", ts: base + 1000, reply: { id: 7, name: "Al", snippet: "old root", thread: true }, rev: 0 }, |
| + ]; |
| + const main = viewItems(page, { mainFeed: true }); |
| + assert.deepEqual(main.map((m) => m.sid), [50, 51], "root not loaded — the thread reply degrades to an ordinary inline bubble"); |
| + assert.equal(main[1].replyRoot, 7, "it still carries its root, so its quote card opens the thread (and pages the root in)"); |
| + assert.equal(main[1].reply.thread, true, "the flag is untouched — only the render decision changed"); |
| + // The root pages in (scroll-back / the thread view's pageInRoot): the same |
| + // messages now hide the reply and the root's bubble carries the tally. |
| + const withRoot = [{ id: "m7", sid: 7, uid: "u1", name: "Al", text: "old root", ts: base - 86400000, reply: null, rev: 0 }, ...page]; |
| + const collapsed = viewItems(withRoot, { mainFeed: true }); |
| + assert.deepEqual(collapsed.map((m) => m.sid), [7, 50], "root loaded — the reply collapses out of the transcript"); |
| + assert.equal(collapsed[0].replyCount, 1, "and the root's tally carries it"); |
| +}); |
| + |
| +await test("chat: viewItems({ mainFeed: true }) derives separators and sender runs over the VISIBLE list", () => { |
| + const DAY = 24 * 3600 * 1000; |
| + const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| + const msgs = [ |
| + { id: "m1", sid: 1, uid: "u1", name: "Al", text: "root", ts: base, reply: null, rev: 0 }, |
| + // Bo's hidden reply sits between Al's two messages… |
| + { id: "m2", sid: 2, uid: "u2", name: "Bo", text: "in thread", ts: base + 1000, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| + { id: "m3", sid: 3, uid: "u1", name: "Al", text: "more", ts: base + 2000, reply: null, rev: 0 }, |
| + // …and a hidden reply alone opens the next day: the separator must land |
| + // on the next VISIBLE message, not vanish with the hidden one. |
| + { id: "m4", sid: 4, uid: "u2", name: "Bo", text: "next-day thread reply", ts: base + DAY, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| + { id: "m5", sid: 5, uid: "u2", name: "Bo", text: "next-day aside", ts: base + DAY + 1000, reply: null, rev: 0 }, |
| + ]; |
| + const main = viewItems(msgs, { mainFeed: true }); |
| + assert.deepEqual(main.map((m) => m.sid), [1, 3, 5]); |
| + assert.equal(main[1].cont, true, "Al's second message continues Al's run — the hidden reply between them doesn't break it"); |
| + assert.equal(main[1].timeSep, null); |
| + assert.deepEqual(main[2].timeSep, { withDay: true }, "the day separator lands on the first VISIBLE message of the day"); |
| + assert.equal(main[2].cont, false, "a separator restarts the run"); |
| + // Unfiltered (the thread view) the same messages break the run as before. |
| + const all = viewItems(msgs); |
| + assert.equal(all[2].cont, false, "with the reply shown, Bo's bubble splits Al's run"); |
| + assert.deepEqual(all[3].timeSep, { withDay: true }, "and the hidden reply itself opens the day there"); |
| +}); |
| + |
| 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, uid: "alice" }, { id: 20 }); |
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs| index bd16382..b8348ef 100644 |
| --- a/test/event-vectors.test.mjs |
| +++ b/test/event-vectors.test.mjs |
| @@ -3,7 +3,7 @@ import { allVectors } from "./event-vectors/index.js"; |
| // The reply-thread rollup is tested against the REAL implementation (chat.js |
| // exports both as pure functions), not a reconstruction — grouping rules are |
| // too easy to fork silently. |
| -import { viewItems, replyTarget } from "../web/static/apps/chat.js"; |
| +import { viewItems, replyTarget, inMainFeed, bySidIndex } from "../web/static/apps/chat.js"; |
| // Views fold against the REAL app (views.js exports it) — its whole job is |
| // clamping hostile events, so a reconstruction would test the wrong thing. |
| import { viewsApp } from "../web/static/apps/views.js"; |
| @@ -71,7 +71,7 @@ function testChatMsgFold(state, event, meta) { |
| uid: event.uid || "", |
| ts: raw.ts, |
| attachments: raw.attachments || [], |
| - reply: event.replyTo ? { id: event.replyTo.id || 0, name: event.replyTo.name || "", snippet: event.replyTo.snippet || "" } : null, |
| + reply: event.replyTo ? { id: event.replyTo.id || 0, name: event.replyTo.name || "", snippet: event.replyTo.snippet || "", thread: !!event.replyTo.thread } : null, |
| actions: raw.actions || [], |
| action: !!event.action, |
| warn: !!event.warn, |
| @@ -295,9 +295,60 @@ allVectors.chat.forEach((vector) => { |
| assert.strictEqual(msg.reply.id, vector.expected_fold_event.replyTo.id, "reply id should match"); |
| assert.strictEqual(msg.reply.name, vector.expected_fold_event.replyTo.name, "reply name should match"); |
| assert.strictEqual(msg.reply.snippet, vector.expected_fold_event.replyTo.snippet, "reply snippet should match"); |
| + // The thread flag (additive): true only when the payload says so — |
| + // absent folds to false, so pre-flag history stays an inline reply. |
| + assert.strictEqual(msg.reply.thread, !!vector.expected_fold_event.replyTo.thread, "reply.thread should match the payload's flag"); |
| } else { |
| assert.strictEqual(msg.reply, null, "reply should be null"); |
| } |
| + // Main-feed visibility — against the REAL predicate and the REAL |
| + // transcript filter. expected_main_feed_visible is evaluated WITH the |
| + // quoted root in the loaded window (seeded here from the replyTo |
| + // snapshot): a thread-only reply then never lists in the feed (its |
| + // root's tally is its only trace there); a quote reply lists as always. |
| + // With the root paged out EVERY reply shows — the degradation the |
| + // thread_reply_root_unloaded vector pins. |
| + if (vector.expected_main_feed_visible !== undefined) { |
| + const rt = vector.decrypted_payload.replyTo; |
| + const seeded = rt |
| + ? testChatMsgFold(result, { type: "chat.msg", name: rt.name, text: rt.snippet, ts: msg.ts - 1000 }, { id: rt.id, ts: msg.ts - 1000 }) |
| + : result; |
| + assert.strictEqual(inMainFeed(msg, bySidIndex(seeded.msgs)), vector.expected_main_feed_visible, "inMainFeed (root loaded) should match expected_main_feed_visible"); |
| + const shown = viewItems(seeded.msgs, { mainFeed: true }).some((i) => i.sid === msg.sid); |
| + assert.strictEqual(shown, vector.expected_main_feed_visible, "the transcript filter (root loaded) should agree with expected_main_feed_visible"); |
| + assert.strictEqual(viewItems(seeded.msgs).some((i) => i.sid === msg.sid), true, "the unfiltered list (thread view) always keeps the reply"); |
| + assert.strictEqual(inMainFeed(msg, bySidIndex(result.msgs)), true, "root NOT loaded — every reply shows inline (nowhere else could carry it)"); |
| + assert.strictEqual(viewItems(result.msgs, { mainFeed: true }).some((i) => i.sid === msg.sid), true, "the transcript filter agrees: rootless → shown"); |
| + } |
| + }); |
| + } |
| + |
| + // Main-feed visibility over a folded SEQUENCE, against the REAL filter: a |
| + // thread-flagged reply hides only while its resolved root is in the |
| + // sequence (root loaded → its bubble carries the tally); rootless it shows |
| + // inline; every other message shows. |
| + if (vector.message_sequence && vector.expected_main_feed) { |
| + test(`chat main feed: ${vector.name} — thread replies hide only under a loaded root`, () => { |
| + let state = { msgs: [] }; |
| + for (const step of vector.message_sequence) { |
| + const event = { type: "chat.msg", ...step.decrypted_payload }; |
| + const meta = { id: step.server_message.id, ts: step.server_message.created_at * 1000 }; |
| + state = testChatMsgFold(state, event, meta); |
| + } |
| + const bySid = bySidIndex(state.msgs); |
| + const main = viewItems(state.msgs, { mainFeed: true }); |
| + const shown = new Set(main.map((i) => i.sid)); |
| + for (const [sid, visible] of Object.entries(vector.expected_main_feed)) { |
| + assert.strictEqual(shown.has(Number(sid)), visible, `message ${sid} should be ${visible ? "shown in" : "hidden from"} the main feed`); |
| + assert.strictEqual(inMainFeed(state.msgs.find((x) => x.sid === Number(sid)), bySid), visible, `inMainFeed(${sid}) should be ${visible}`); |
| + } |
| + assert.strictEqual(viewItems(state.msgs).length, state.msgs.length, "the unfiltered list (thread view) keeps every message"); |
| + if (vector.expected_threads) { |
| + const mainBySid = new Map(main.map((i) => [i.sid, i])); |
| + for (const [root, count] of Object.entries(vector.expected_threads.reply_counts)) { |
| + assert.strictEqual(mainBySid.get(Number(root))?.replyCount, count, `root ${root}'s transcript tally should still count hidden thread replies`); |
| + } |
| + } |
| }); |
| } |
diff --git a/test/event-vectors/chat.json b/test/event-vectors/chat.json| index c2933a7..6350961 100644 |
| --- a/test/event-vectors/chat.json |
| +++ b/test/event-vectors/chat.json |
| @@ -88,7 +88,8 @@ |
| }, |
| { |
| "name": "message_with_reply", |
| - "description": "A reply-to message with quoted context.", |
| + "description": "A reply-to message with quoted context — a plain quote reply (no thread flag), which renders inline in the main feed exactly as it always has.", |
| + "note": "replyTo.thread is ABSENT here: that is the wire form of every reply from before the flag existed and of the ordinary Reply verb. Clients fold it as thread:false and list the message in the main feed (expected_main_feed_visible). Contrast message_in_thread.", |
| "decrypted_payload": { |
| "name": "Charlie", |
| "text": "I agree!", |
| @@ -117,6 +118,84 @@ |
| "snippet": "Hello everyone" |
| }, |
| "attachments": [] |
| + }, |
| + "expected_main_feed_visible": true |
| + }, |
| + { |
| + "name": "message_in_thread", |
| + "description": "A reply composed IN a thread (the docked pane's or mobile overlay's composer, or the 'Reply in thread' verb): replyTo carries thread:true, and the message lives only in its thread — the main feed shows just the root's 'N Replies' tally, never this bubble (Slack model).", |
| + "note": "The thread flag is ADDITIVE and rides INSIDE replyTo ({ id, name, snippet, thread: true }) — the same shape old clients already decode, which ignore the extra key and show the reply inline (harmless degradation). Absent/false = an inline quote reply (message_with_reply). Web: chat.msg fold sets reply.thread (default false); inMainFeed(m, bySid) is the predicate, viewItems(msgs, { mainFeed: true }) the transcript filter — the reply still counts in its root's replyCount and still lists in threadItems. The hide is ROOT-AWARE: expected_main_feed_visible is evaluated WITH the quoted root (replyTo.id) present in the loaded window — the test seeds it from the snapshot — because the tally lives on the root's bubble. With the root paged out the reply renders inline like a quote reply, and collapses into the tally when the root loads (thread_reply_root_unloaded). Native mirrors: MessagePayload.replyTo.thread, the root-aware feed filter, the tally, and the thread view. The flag is set at the compose seam (thread view / verb), never by replyTarget, and survives edits ({ ...raw, text }).", |
| + "decrypted_payload": { |
| + "name": "Bea", |
| + "text": "In the thread", |
| + "ts": 1700000006000, |
| + "uid": "user-bea-1", |
| + "replyTo": { |
| + "id": 100, |
| + "name": "Ann", |
| + "snippet": "Root text", |
| + "thread": true |
| + } |
| + }, |
| + "server_message": { |
| + "id": 12351, |
| + "created_at": 1700000006, |
| + "blob": "encrypted(decrypted_payload)" |
| + }, |
| + "expected_fold_event": { |
| + "type": "chat.msg", |
| + "name": "Bea", |
| + "text": "In the thread", |
| + "ts": 1700000006000, |
| + "uid": "user-bea-1", |
| + "replyTo": { |
| + "id": 100, |
| + "name": "Ann", |
| + "snippet": "Root text", |
| + "thread": true |
| + }, |
| + "attachments": [] |
| + }, |
| + "expected_native_fields": { |
| + "name": "Bea", |
| + "text": "In the thread", |
| + "uid": "user-bea-1", |
| + "replyTo": { |
| + "id": 100, |
| + "name": "Ann", |
| + "snippet": "Root text", |
| + "thread": true |
| + }, |
| + "isRenderableChat": true |
| + }, |
| + "expected_main_feed_visible": false |
| + }, |
| + { |
| + "name": "thread_reply_root_unloaded", |
| + "description": "A reply made IN a thread whose root is older than the loaded history: the sequence holds ONLY the flagged reply (replyTo.id = 100 with its name/snippet snapshot) and no message 100. It must render in the main feed as an ordinary inline bubble, quote card and all.", |
| + "note": "The thread hide is ROOT-AWARE. The 'N Replies' tally lives on the root's bubble, so with the root paged out a hidden reply would render NOWHERE — cold-open a busy chat and its newest message would be invisible until the reader blind-scrolled back past the root. So a thread-flagged reply is hidden only when its RESOLVED root (rootRef / replyRoot, legacy chains included) is present in the loaded list; otherwise it degrades to an inline bubble (its quote card still opens the thread and pages the root in), and it collapses into the tally the moment the root loads — the same message flips from expected_main_feed true to false purely on what is loaded. Web: inMainFeed(m, bySid) over bySidIndex(msgs); viewItems(msgs, { mainFeed: true }). Native mirrors the same rule in its feed filter.", |
| + "message_sequence": [ |
| + { |
| + "server_message": { |
| + "id": 105, |
| + "created_at": 1700000005 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Finn", |
| + "text": "Replying in a thread whose root is older than the loaded history", |
| + "ts": 1700000005000, |
| + "uid": "user-finn-1", |
| + "replyTo": { |
| + "id": 100, |
| + "name": "Alice", |
| + "snippet": "Hello everyone", |
| + "thread": true |
| + } |
| + } |
| + } |
| + ], |
| + "expected_main_feed": { |
| + "105": true |
| } |
| }, |
| { |
| @@ -155,8 +234,8 @@ |
| }, |
| { |
| "name": "reply_thread_grouping", |
| - "description": "Reply threads group on the resolved ROOT message: new replies point straight at the root; a legacy chain (a reply quoting another reply) resolves transitively through loaded messages. The root's tally counts every message in its thread, and the first reply is the tally's jump target.", |
| - "note": "Multi-message vector: fold the sequence, then derive the thread rollup (web viewItems replyRoot/replyCount in chat.js; native ChatFeedModel.threadRoots/refreshReplyCounts). Message 102 is a LEGACY chain — it quotes reply 101, not the root — and must still resolve to root 100.", |
| + "description": "Reply threads group on the resolved ROOT message: new replies point straight at the root; a legacy chain (a reply quoting another reply) resolves transitively through loaded messages. The root's tally counts every message in its thread — including a reply made IN the thread (replyTo.thread), which the main feed hides — and the first reply is the tally's jump target.", |
| + "note": "Multi-message vector: fold the sequence, then derive the thread rollup (web viewItems replyRoot/replyCount in chat.js; native ChatFeedModel.threadRoots/refreshReplyCounts). Message 102 is a LEGACY chain — it quotes reply 101, not the root — and must still resolve to root 100. Message 104 carries replyTo.thread:true — it groups and tallies like any reply and lists in the thread view, but expected_main_feed says it is HIDDEN from the main feed (web inMainFeed / viewItems mainFeed; native's feed filter) while every other message shows. expected_main_feed semantics: a thread-flagged reply is hidden iff its RESOLVED root is present in the sequence (root loaded → its bubble carries the tally); rootless it shows inline — see thread_reply_root_unloaded. Separators and sender runs in the main feed derive over the shown messages only.", |
| "message_sequence": [ |
| { |
| "server_message": { |
| @@ -220,21 +299,47 @@ |
| "snippet": "Hello everyone" |
| } |
| } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 104, |
| + "created_at": 1700000004 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Erin", |
| + "text": "Replying from inside the thread", |
| + "ts": 1700000004000, |
| + "uid": "user-erin-1", |
| + "replyTo": { |
| + "id": 100, |
| + "name": "Alice", |
| + "snippet": "Hello everyone", |
| + "thread": true |
| + } |
| + } |
| } |
| ], |
| "expected_threads": { |
| "roots": { |
| "101": 100, |
| "102": 100, |
| - "103": 100 |
| + "103": 100, |
| + "104": 100 |
| }, |
| "reply_counts": { |
| - "100": 3 |
| + "100": 4 |
| }, |
| "first_reply": { |
| "100": 101 |
| } |
| }, |
| + "expected_main_feed": { |
| + "100": true, |
| + "101": true, |
| + "102": true, |
| + "103": true, |
| + "104": false |
| + }, |
| "expected_reply_target": { |
| "note": "Replying to ANY message in the thread (here the legacy chain reply 102) stages the root's id with a fresh snapshot of the root's name/text.", |
| "for_message": 102, |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index a16b77c..f3f6486 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -275,7 +275,7 @@ const composers = new Map(); // threadId → { refreshSend(), startReply(item), |
| // paneComposer() hands out the docked pane's composer api while one is mounted |
| // (null otherwise) — how setConnected and bubble actions reach the second |
| // composer without a second registry. |
| -const threads = new Map(); // threadId → { open(rootSid, ref, opener), close(), isOpen(), paneComposer() } |
| +const threads = new Map(); // threadId → { open(rootSid, ref, opener), close(), isOpen(), paneComposer(), openForMessage(sid) } |
| // composerFor: the composer a bubble action (Reply, Edit, swipe-reply) lands |
| // in — the docked thread pane's own when the acting bubble lives inside that |
| @@ -438,7 +438,48 @@ export function threadItems(msgs, rootSid) { |
| 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); |
| + // Same order as the feed: confirmed by sid, a still-pending optimistic reply |
| + // (sid 0) LAST. A plain by-sid sort put the pending copy first, ahead of the |
| + // root — and for the whole server round-trip the thread view read "root not |
| + // loaded", swapped its pinned root for the snapshot fallback, listed the |
| + // root as a body bubble, then snapped back on the echo: the blink on every |
| + // reply sent from the thread. |
| + return out.sort(bySidPendingLast); |
| +} |
| + |
| +// bySidIndex: the loaded window keyed by sid — what rootRef walks and what |
| +// inMainFeed checks a thread reply's root against. Exported so callers |
| +// outside viewItems (jump fallback, hotkeys, ↑-to-edit, tests) build the |
| +// same index rather than each re-deriving "is the root loaded". |
| +export function bySidIndex(msgs) { |
| + return new Map(msgs.map((m) => [m.sid, m])); |
| +} |
| + |
| +// inMainFeed: whether a message renders in the main transcript. A reply |
| +// composed IN a thread (`reply.thread` — the pane's or overlay's composer, the |
| +// "Reply in thread" verb) lives only in that thread, Slack-style: the |
| +// transcript shows just its root's "N Replies" tally. Plain quote replies |
| +// (the flag absent — every message from before the flag, and the ordinary |
| +// Reply verb) stay inline exactly as they always did. |
| +// |
| +// ROOT-AWARE: the hide only holds while the reply's resolved root is in the |
| +// loaded window (`bySid` — the index viewItems already builds). The tally |
| +// lives on the root's bubble, so with the root paged out (a fresh reply to an |
| +// old message, cold-opening a busy chat) hiding the reply would leave it |
| +// rendered NOWHERE until the reader blind-scrolled back past the root. So it |
| +// degrades to an ordinary inline bubble — quote card and all — and collapses |
| +// into the tally the moment the root pages in. Pure and exported: the feed |
| +// filter, the jump fallbacks and the event vectors all read this one |
| +// predicate (native mirrors it — pinned by message_in_thread and |
| +// thread_reply_root_unloaded). |
| +export function inMainFeed(m, bySid) { |
| + // Flagged but pointing nowhere resolvable is a feed bubble, same as |
| + // unflagged: id 0 is the pending-copy key, so without this guard a |
| + // ref-less thread reply would hide itself behind any in-flight send — |
| + // and no root tally would carry it. (Native's threadRoots skips id 0 |
| + // for the same reason; the two predicates must agree.) |
| + if (!(m.reply && m.reply.thread && m.reply.id)) return true; |
| + return !bySid.has(rootRef(bySid, m.reply).id); |
| } |
| // Message order: confirmed messages by server id (the stable identity), with any |
| @@ -450,11 +491,18 @@ function bySidPendingLast(a, b) { |
| return a.sid ? -1 : 1; |
| } |
| -export function viewItems(msgs) { |
| +// `mainFeed: true` is the transcript's call: replies that live only in their |
| +// thread (inMainFeed false — root loaded) drop out of the list — they still |
| +// tally on their root, and the separators + sender runs are derived over |
| +// what's actually shown, so a hidden reply never breaks a run or strands a |
| +// day separator. The thread view calls it plain (every reply shows there). |
| +export function viewItems(msgs, { mainFeed = false } = {}) { |
| // Reply threads group on the resolved ROOT message: every reply tallies on |
| // its thread's top message (legacy chains resolve transitively), and each |
| // reply carries its root sid (`replyRoot`) so taps/jumps land on the thread. |
| - const bySid = new Map(msgs.map((m) => [m.sid, m])); |
| + // Tallies run over EVERY message — a thread-only reply counts even though |
| + // the transcript never lists it (the tally is its only trace there). |
| + const bySid = bySidIndex(msgs); |
| const replyCounts = new Map(); // thread root sid → messages in its thread |
| const roots = new Map(); // reply sid → its thread's root sid |
| for (const m of msgs) { |
| @@ -467,6 +515,7 @@ export function viewItems(msgs) { |
| const out = []; |
| let prev = null; |
| for (const m of msgs) { |
| + if (mainFeed && !inMainFeed(m, bySid)) continue; // lives in its (loaded) thread; `prev` stays the last VISIBLE message |
| let timeSep = null; |
| if (!prev) timeSep = { withDay: true }; // the oldest message opens with a dated separator |
| else { |
| @@ -572,7 +621,10 @@ export const app = defineApp({ |
| uid: e.uid || "", |
| ts: raw.ts, |
| attachments: raw.attachments || [], |
| - reply: e.replyTo ? { id: e.replyTo.id || 0, name: e.replyTo.name || "", snippet: e.replyTo.snippet || "" } : null, |
| + // `thread` (additive, cross-platform — see inMainFeed): the reply was |
| + // composed IN its thread, so it lives only there. Absent/false is a |
| + // quote reply that renders inline — old clients, existing history. |
| + reply: e.replyTo ? { id: e.replyTo.id || 0, name: e.replyTo.name || "", snippet: e.replyTo.snippet || "", thread: !!e.replyTo.thread } : null, |
| actions: raw.actions || [], |
| card: raw.card || null, |
| action: !!e.action, // an app-thread /me-style action line, not a bubble |
| @@ -814,7 +866,9 @@ export const app = defineApp({ |
| if (tm) tm.delete(last.name); |
| } |
| const firstBuild = !feed.childElementCount; |
| - const items = viewItems(msgs); |
| + // The transcript: thread-only replies fold away into their root's tally |
| + // (an open thread view lists them — root._refreshThread below). |
| + const items = viewItems(msgs, { mainFeed: true }); |
| // Client-only command replies ("only visible to you") ride in the feed as keyed |
| // rows, interleaved by their creation-time seq (see addLocalNotice) so they |
| // stack like bubbles and stay put as the conversation continues. Merge + sort |
| @@ -1507,8 +1561,20 @@ function agentTag(kind) { |
| // inside the target feed (the feed itself works). |
| export function scrollToMessage(near, sid) { |
| const feed = near.closest(".chatapp-feed"); |
| - const target = feed && feed.querySelector(`.msg[data-id="${sid}"]`); |
| - if (!target) return; |
| + let target = feed && feed.querySelector(`.msg[data-id="${sid}"]`); |
| + if (!target) { |
| + // Not in the transcript. A reply made in a thread lives only in that |
| + // thread (its root shows just the tally — inMainFeed), so a jump to one |
| + // (search, a permalink) opens the thread on its root and lands on the |
| + // bubble inside the view rather than scrolling to nothing. The mount's |
| + // thread controls resolve the root from state; the view renders |
| + // synchronously on open, so its body already holds the bubble. |
| + const root = near.closest(".chatapp-root"); |
| + const t = root && threads.get(root.dataset.thread); |
| + if (!t || !t.openForMessage(Number(sid))) return; |
| + target = root.querySelector(`.chatapp-thread-msgs .msg[data-id="${sid}"]`); |
| + if (!target) return; |
| + } |
| target.scrollIntoView({ behavior: "smooth", block: "center" }); |
| target.classList.remove("flash"); |
| void target.offsetWidth; // restart the animation if it's still mid-flash |
| @@ -3150,8 +3216,10 @@ function buildComposerBlock(ctx, host, cfg) { |
| // ↑ on an empty composer edits your latest still-editable message. |
| if (e.key === "ArrowUp" && !composer.getText() && !editing && !cfg.fixedReply) { |
| const state = ctx.getState(); |
| - const mine = ((state && state.msgs) || []) |
| - .filter((m) => ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName) && m.text.trim() && Date.now() - m.ts < EDIT_WINDOW_MS) |
| + const msgs = (state && state.msgs) || []; |
| + const bySid = bySidIndex(msgs); |
| + const mine = msgs |
| + .filter((m) => inMainFeed(m, bySid) && ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName) && m.text.trim() && Date.now() - m.ts < EDIT_WINDOW_MS) // main feed only — never an invisible thread reply |
| .pop(); |
| if (mine) { |
| e.preventDefault(); |
| @@ -3458,8 +3526,11 @@ function buildSkeleton(el, ctx) { |
| // OVERLAY thread view's reply staged — sending from it continues the same |
| // thread, like iMessage — else the armed reply is spent. Docked, the pane |
| // owns the thread's composer, so the main one always just spends its reply. |
| - onSent: () => { |
| - pinNext.add(ctx.threadId); |
| + // A thread reply (the overlay's staged root) never pins: it lands in the |
| + // thread view, not the transcript, and snapping the transcript would only |
| + // yank a reader who'd scrolled it for nothing they can see. |
| + onSent: (payload) => { |
| + if (!(payload.replyTo && payload.replyTo.thread)) pinNext.add(ctx.threadId); |
| if (threadSid && !threadDocked) stageThreadReply(); |
| else block.api.cancelReply(); |
| }, |
| @@ -3515,7 +3586,22 @@ function buildSkeleton(el, ctx) { |
| // off the block's api (tickTyping rides along for render()'s beat ticker). |
| composers.set(ctx.threadId, { |
| refreshSend: api.refreshSend, |
| - startReply: api.startReply, |
| + // Under the OVERLAY the main composer IS the open thread's reply box, so a |
| + // Reply tapped on a bubble inside the view (every bubble there is in the |
| + // thread) stages the root WITH the thread flag — the same staging the |
| + // view's open uses — not a plain quote reply to the root, which would |
| + // render inline in the transcript. Docked, pane bubbles route to the |
| + // pane's own composer (composerFor), so this only ever sees the |
| + // transcript's replies. Closures over consts declared further down, run |
| + // on user input only — the hoisted-handlers pattern buildSkeleton uses. |
| + startReply: (m) => { |
| + if (threadSid && !threadDocked && replyTarget(((ctx.getState() || {}).msgs) || [], m).id === threadSid) { |
| + stageThreadReply(); |
| + if (!inEmbed) composer.focus(); // a deliberate Reply tap wants the caret, touch included (startReply's rule) |
| + return; |
| + } |
| + api.startReply(m); |
| + }, |
| startEdit: api.startEdit, |
| tickTyping: api.tickTyping, |
| }); |
| @@ -3804,19 +3890,27 @@ function buildSkeleton(el, ctx) { |
| let threadOpener = null; // the element that opened the view — focus returns there on close |
| let paneBlock = null; // the docked pane's composer block (buildComposerBlock), docked only |
| + // The reply target for a message composed IN this thread: the root (a |
| + // fresh snapshot when loaded, the quoted fallback until then) plus the |
| + // thread flag — the wire marker that keeps the reply out of the transcript |
| + // (inMainFeed). replyTarget itself stays pure and flag-free; the flag is |
| + // added here, at the one seam every thread-view send passes through. |
| + const threadReplyTarget = () => { |
| + const msgs = ((ctx.getState() || {}).msgs) || []; |
| + const rootMsg = msgs.find((m) => m.sid === threadSid); |
| + const target = rootMsg |
| + ? replyTarget(msgs, rootMsg) |
| + : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" }; |
| + return { ...target, thread: true }; |
| + }; |
| + |
| // Stage the composer's reply on the thread's root — the whole point of the |
| // view's composer contract ("Replying to <root author>"). api.stageReply |
| // mirrors startReply (a reply displaces an in-flight edit; native shells |
| // mirror the banner); the focus is skipped on touch, where popping the |
| // keyboard would cover the thread the reader just opened. |
| const stageThreadReply = () => { |
| - const msgs = ((ctx.getState() || {}).msgs) || []; |
| - const rootMsg = msgs.find((m) => m.sid === threadSid); |
| - api.stageReply( |
| - rootMsg |
| - ? replyTarget(msgs, rootMsg) |
| - : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" } |
| - ); |
| + api.stageReply(threadReplyTarget()); |
| if (!inEmbed && !coarse) composer.focus(); |
| }; |
| @@ -3890,9 +3984,12 @@ function buildSkeleton(el, ctx) { |
| if (docked) { |
| // The pane's own composer, pinned to the thread root: a SECOND |
| // buildComposerBlock instance. fixedReply derives the target fresh per |
| - // send (the root may load mid-thread — same derivation stageThreadReply |
| - // uses), so no banner ever shows and nothing is staged on the main |
| - // composer. commands off: "/" text sends verbatim, Slack-style (v1 — |
| + // send (the root may load mid-thread — the same threadReplyTarget the |
| + // overlay stages, thread flag included), so no banner ever shows and |
| + // nothing is staged on the main composer. Every pane send passes |
| + // through it — a Reply/swipe on a pane bubble only stages the block's |
| + // (ignored) replyTo, the send still derives from here — so no pane |
| + // reply can land in the transcript. commands off: "/" text sends verbatim, Slack-style (v1 — |
| // a pane-summoned command panel has nowhere to dock; see the spec's |
| // follow-ups). The whole 4-node fragment mounts INSIDE the wrapper — |
| // including the block's float, which carries the attachment staging |
| @@ -3904,13 +4001,7 @@ function buildSkeleton(el, ctx) { |
| const composerWrap = document.createElement("div"); |
| composerWrap.className = "chatapp-thread-composer"; |
| paneBlock = buildComposerBlock(ctx, host, { |
| - fixedReply: () => { |
| - const msgs = ((ctx.getState() || {}).msgs) || []; |
| - const rootMsg = msgs.find((m) => m.sid === threadSid); |
| - return rootMsg |
| - ? replyTarget(msgs, rootMsg) |
| - : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" }; |
| - }, |
| + fixedReply: threadReplyTarget, |
| commands: false, |
| // Escape in the pane's box: an in-flight edit cancels first (return |
| // false — the block's own cancel path runs, then a second Escape |
| @@ -4101,16 +4192,37 @@ function buildSkeleton(el, ctx) { |
| // The docked pane's composer api, while one is mounted — how setConnected |
| // (and PR 3's composerFor routing) reach the second composer. |
| paneComposer: () => (threadDocked && paneBlock ? paneBlock.api : null), |
| + // openForMessage(sid): a message that lives only in its thread (a reply |
| + // made there — inMainFeed false) has no transcript bubble to scroll to, |
| + // so a jump to it opens the thread on the resolved root instead |
| + // (scrollToMessage's fallback). False when the message isn't loaded or |
| + // belongs in the transcript — then it's a plain scroll miss, not ours. |
| + // (A thread reply whose root is paged out renders inline, so the |
| + // scroll finds it directly and never gets here.) |
| + openForMessage: (sid) => { |
| + const msgs = ((ctx.getState() || {}).msgs) || []; |
| + const m = msgs.find((x) => x.sid === sid); |
| + if (!m || inMainFeed(m, bySidIndex(msgs))) return false; |
| + const ref = replyTarget(msgs, m); |
| + if (!ref.id) return false; |
| + openThread(ref.id, ref, null); |
| + return true; |
| + }, |
| }); |
| // 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) |
| // and cmd/ctrl-R starts a reply to the latest message. Escape also cancels |
| - // an armed reply/edit even when the composer isn't focused. |
| + // an armed reply/edit even when the composer isn't focused. "Latest" means |
| + // the latest in the TRANSCRIPT: a reply that lives only in its thread has |
| + // no bubble here to anchor a picker on, and the shortcuts act on the main |
| + // feed by definition. |
| const latest = (othersOnly) => { |
| const msgs = (ctx.getState() || {}).msgs || []; |
| + const bySid = bySidIndex(msgs); |
| for (let i = msgs.length - 1; i >= 0; i--) { |
| + if (!inMainFeed(msgs[i], bySid)) continue; |
| if (othersOnly && msgs[i].uid && msgs[i].uid === ctx.me) continue; |
| return msgs[i]; |
| } |
diff --git a/web/static/searchui.js b/web/static/searchui.js| index 6a88f1f..c7f2dad 100644 |
| --- a/web/static/searchui.js |
| +++ b/web/static/searchui.js |
| @@ -409,8 +409,10 @@ export async function jumpToMessage(tid, sid) { |
| // Move focus with the navigation (the result button may now be hidden — |
| // mobile swaps the sidebar for the thread — and focus must never strand on |
| // an offscreen control). The bubble itself takes it; a deleted/never-found |
| - // target falls back to the composer. |
| - const target = feed.querySelector(`.msg[data-id="${sid}"]`); |
| + // target falls back to the composer. Looked up across the whole pane, not |
| + // just the feed: a reply made in a thread has no transcript bubble — |
| + // scrollToMessage opened its thread view, and the bubble is in there. |
| + const target = pane.querySelector(`.msg[data-id="${sid}"]`); |
| const focusEl = target || document.querySelector(".chatapp-root .composer .composer-input"); |
| if (focusEl) { |
| if (target) target.tabIndex = -1; |