web+native: a thread is one scrolling list — unpin the thread root (#1492)
* web: a thread is one scrolling list, root first — unpin the thread root
The thread view pinned the message a thread is about above a separate
scrolling replies pane, with a rule between. On a thread nobody had answered
that read as broken: a pinned message, a divider, and a large empty pane under
"0 replies".
Now the root is simply the FIRST message in the thread's own list — one scroll
region, no pinned box, no divider, no separate root rendering. A thread reads
as a small chat whose first message is the one it's about, and it fills from
the bottom like the feed does (the same margin-top:auto idiom), so it opens
showing everything on a short thread and the newest replies on a long one.
- the list goes straight through viewItems, so the root gets the same
separators, run-grouping and in-place reaction/edit patching every row has
- root not loaded: no snapshot stand-in any more — the existing note ("The
original message isn't available…" / "Loading the start of this thread…")
carries that alone and the list starts at the oldest loaded reply. When
paging surfaces the root it prepends, re-anchored the way the feed's
scroll-back is, so a reader who has scrolled isn't shunted down
- the header count still counts the ANSWERS, via the new pure threadReplyCount
(Node-tested): an unanswered thread reads "0 Replies", never "1 Reply" — and
through replyCountLabel, so it's pluralised and locale-grouped like the
tally that opened it
- dead code gone: the rootBox element, its data-mode msg/fall juggling, the
snapshot fallback DOM, and the .chatapp-thread-rootmsg/-rootfall/-fallsnippet
rules
Composer, ✕, Escape, focus handling and the pane-vs-overlay split are
untouched. No event-vector change — nothing about the wire or the fold moved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* core: the native thread panel drops its pinned root too (Mac + iOS)
The shared ReplyThreadView had the same split as the web: a height-capped
pinned root box, a divider, then the replies scroll. Now it renders ONE list —
the root as row 0 (with the dated separator that opens any transcript), its
replies under it — in both presentations, the iOS/iPad overlay and the docked
pane on Mac and iPad.
- panelContent takes the threadItems list whole; rootBox, its 40% cap, the
measured rootHeight and the GeometryReader that fed the cap are gone
- the quoted-snapshot stand-in goes with it, so the `fallback` and `theme`
parameters were dead — removed here and the now-unused threadFallback state
removed from both containers (their composers derive the target fresh from
model.threadReplyRef, they never read the frozen copy)
- header count via ChatFeedModel.threadReplyCount + replyCountLabel: the same
words as the web and as the tally that opened it, so an unanswered thread
reads "0 Replies" instead of the old hand-rolled "1 reply"
- the empty-thread auto-close still watches `items`, which INCLUDES the root —
so a thread someone just started (one row, no answers) never trips it
- root paging (.task(id:)/onRootPaged) and jump landing (jumpTarget/jumpTick)
are unchanged; .defaultScrollAnchor(.bottom) keeps the open position at the
newest replies, with a short thread shown whole
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+207
−209
changelog/2026-08-28-thread-pane.md+3 −0ios/LChat/Views/NativeChatScreen.swift+7 −15ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+18 −5ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift+57 −105ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift+20 −1ios/LChatMac/NativeFeedView.swift+5 −14test/apps.test.mjs+19 −4web/static/apps/chat.js+66 −47web/static/style.css+12 −18
diff --git a/changelog/2026-08-28-thread-pane.md b/changelog/2026-08-28-thread-pane.md| index 159c0d2..a4b64dd 100644 |
| --- a/changelog/2026-08-28-thread-pane.md |
| +++ b/changelog/2026-08-28-thread-pane.md |
| @@ -12,6 +12,9 @@ stay in the chat exactly where they are. |
| it's a button on the message itself: hover any message and it's beside the |
| reply arrow, drawn as two speech bubbles so the two are easy to tell apart. |
| +A thread reads as a small chat of its own: the message it's about is simply |
| +the first message in it, with the replies underneath, all scrolling together. |
| + |
| Threads now open beside the chat on a desktop-sized screen instead of over it. |
| Tap a message's "N Replies" tally and the thread docks as a column on the |
| right, with its own reply box at the bottom — so you can follow the main |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 7cfd4de..a0ba754 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -112,11 +112,8 @@ struct NativeChatScreen: View { |
| // the controller; the wire markdown is read from it at send (#890). |
| @State private var composerController = ComposerFieldController() |
| @AppStorage("formatAsYouType") private var formatAsYouType = true |
| - // The open reply-thread view's root id (nil = closed) and the header |
| - // stand-in while that root isn't loaded — frozen at open, like the web's |
| - // threadFallback (chat.js openThread's ref argument). |
| + // The open reply-thread view's root id (nil = closed). |
| @State private var threadRootID: Int64? |
| - @State private var threadFallback: ReplyRef? |
| // A jump whose target lives only in its thread (search hit, media-sheet |
| // jump, a notification's newest unread): the view's row to land on + its |
| // wave — the feed's danceID/danceTick twins for the thread surface |
| @@ -955,8 +952,7 @@ struct NativeChatScreen: View { |
| // long-press menu keep working inside the view; reply actions in here |
| // resolve to the thread's ROOT via the same startReply path. |
| private func threadOverlay(_ rootID: Int64) -> some View { |
| - ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback, |
| - theme: .named(themeName), isGroup: isGroup, |
| + ReplyThreadView(model: model, rootID: rootID, isGroup: isGroup, |
| swipeDownCloses: true, |
| jumpTarget: threadJump, jumpTick: threadJumpTick, |
| onClose: closeThread, |
| @@ -1085,9 +1081,6 @@ struct NativeChatScreen: View { |
| private func openThread(_ rootID: Int64) { |
| guard rootID > 0, threadRootID != rootID else { return } |
| - // Freeze the header stand-in NOW (web threadFallback) — the deepest |
| - // quoted snapshot in loaded state, in case the root never pages in. |
| - threadFallback = model.threadReplyRef(root: rootID) |
| // A fresh open lands at the newest reply — never on a stale jump |
| // target from an earlier one (jumpIntoThread re-aims right after). |
| threadJump = nil |
| @@ -1155,7 +1148,6 @@ struct NativeChatScreen: View { |
| private func closeThread() { |
| guard let rootID = threadRootID else { return } |
| withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil } |
| - threadFallback = nil |
| threadJump = nil |
| // The pane's composer dies with the thread (the web's docked pane |
| // block is destroyed on close) — in EITHER presentation: pane state |
| @@ -1180,16 +1172,16 @@ struct NativeChatScreen: View { |
| // ---- the docked pane (thread-pane spec §6, regular widths) ----------- |
| - // ReplyThreadView's panel content — header/✕, pinned root, paging note, |
| - // reply rows — as a flush right-hand column with its OWN composer at the |
| - // bottom, the web's .chatapp-thread-panel--docked. Root-paging and the |
| + // ReplyThreadView's panel content — header/✕, paging note, the root and |
| + // its replies as one list — as a flush right-hand column with its OWN |
| + // composer at the bottom, the web's .chatapp-thread-panel--docked. |
| + // Root-paging and the |
| // empty-thread auto-close live in ReplyThreadView and ride along |
| // unchanged. No onRootPaged: there's no staged banner to refresh — the |
| // pane's reply target derives fresh at send. |
| private func threadPane(_ rootID: Int64) -> some View { |
| VStack(spacing: 0) { |
| - ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback, |
| - theme: .named(themeName), isGroup: isGroup, docked: true, |
| + ReplyThreadView(model: model, rootID: rootID, isGroup: isGroup, docked: true, |
| // A jump into a thread lands on THAT reply here |
| // too (#1475): jumpIntoThread opens this pane and |
| // re-aims with the tick, the rows wave via |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index 5983b39..5f9a2ce 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -1884,8 +1884,9 @@ public final class ChatFeedModel: ObservableObject { |
| /// order. Membership is the flag and a direct pointer (threadRootID(of:)): |
| /// an inline quote chain hanging off the same root is NOT part of the |
| /// thread, it's ordinary feed conversation. 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). Mirrors chat.js's |
| + /// scrolled out of the loaded window the replies still fold — the list |
| + /// just starts at the oldest loaded reply, under the view's note. |
| + /// Mirrors chat.js's |
| /// threadItems — the thread view re-derives this from current state on |
| /// every publish, never a cached list. Pinned by the web twin in |
| /// test/apps.test.mjs and by expected_thread_items in chat.json. |
| @@ -2018,13 +2019,25 @@ public final class ChatFeedModel: ObservableObject { |
| } |
| /// The one wording for a thread's size, wherever it's shown: the root's |
| - /// tally and a quoted reply's thread link read as the same thing because |
| - /// they ARE the same string. Pluralised, and grouped for the locale |
| - /// (1,204 Replies). Mirrors chat.js's `replyCountLabel`. |
| + /// tally, a quoted reply's thread link and the thread view's own header |
| + /// read as the same thing because they ARE the same string. Pluralised, |
| + /// and grouped for the locale (1,204 Replies). Mirrors chat.js's |
| + /// `replyCountLabel`. |
| nonisolated public static func replyCountLabel(_ n: Int) -> String { |
| n == 1 ? "1 Reply" : "\(n.formatted(.number)) Replies" |
| } |
| + /// How many REPLIES a `threadItems` list holds — the list minus its root, |
| + /// when the root is loaded (it's the first item). The thread view renders |
| + /// root and replies as one list, so "how many rows" and "how many answers" |
| + /// are no longer the same number: a thread nobody has answered yet is one |
| + /// row and must still read "0 Replies", never "1 Reply". |
| + /// Mirrors chat.js's `threadReplyCount`. |
| + nonisolated public static func threadReplyCount(_ items: [Message], root rootID: Int64) -> Int { |
| + guard let first = items.first else { return 0 } |
| + return first.id == rootID ? items.count - 1 : items.count |
| + } |
| + |
| /// Does this reply's quote card double as a link into its thread, and with |
| /// what count? Only a THREAD reply ever wears one (an inline reply's |
| /// threadReplyCount is 0 — its card quotes and jumps, nothing more), and |
diff --git a/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift b/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift| index 5987d98..0313b1c 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift |
| @@ -1,11 +1,14 @@ |
| import SwiftUI |
| // The lifted reply-thread view — the native twin of chat.js's chatapp-thread |
| -// overlay, shared by the iOS and Mac containers: the thread's root pinned on |
| -// top (height-capped so a long one can't swallow the replies), its replies |
| -// chronological below, over a dimmed transcript. The panel is frosted material |
| -// so the conversation stays visible through it, like the web's |
| -// backdrop-filter glass. |
| +// overlay, shared by the iOS and Mac containers: ONE scrolling list whose |
| +// first message is the one the thread is about, its replies chronological |
| +// under it, over a dimmed transcript. The panel is frosted material so the |
| +// conversation stays visible through it, like the web's backdrop-filter glass. |
| +// |
| +// The root is a row, not a pinned box: a thread is a small chat window, and |
| +// pinning its opening message above a second scroll region split the panel in |
| +// two — a message, a rule, and an empty pane whenever nobody had replied yet. |
| // |
| // Split of responsibilities: the CONTAINER owns presentation (where the |
| // overlay sits, Escape/swipe wiring) and the composer contract (staging the |
| @@ -13,24 +16,19 @@ import SwiftUI |
| // view owns the panel. It renders LIVE from the model's published messages |
| // through the pure threadItems selector — never a cached list — so replies |
| // fold in as they arrive; it pages history backward looking for an unloaded |
| -// root (capped, with the inline loading note, falling back to the quoted |
| -// snapshot as its header); and it closes itself when the whole thread is |
| -// deleted underneath it. |
| +// root (capped, with the inline note standing in for it until it lands); and |
| +// it closes itself when the whole thread is deleted underneath it. |
| // |
| // A SECOND presentation (the Mac's docked thread pane, thread-pane spec §6): |
| -// `docked: true` renders the same panel CONTENT — header/✕, pinned root, |
| -// paging note, reply rows — flush, with no dim, no floating card chrome and |
| -// no drag, for the container to mount as a right-hand column beside its feed |
| -// (the container supplies the column's width, divider, background and its |
| -// own composer). The container contract above holds unchanged either way; |
| -// iOS never passes the flag, so its overlay presentation is untouched. |
| +// `docked: true` renders the same panel CONTENT — header/✕, paging note, the |
| +// message list — flush, with no dim, no floating card chrome and no drag, for |
| +// the container to mount as a right-hand column beside its feed (the container |
| +// supplies the column's width, divider, background and its own composer). The |
| +// container contract above holds unchanged either way; iOS never passes the |
| +// flag, so its overlay presentation is untouched. |
| public struct ReplyThreadView<Row: View>: View { |
| @ObservedObject private var model: ChatFeedModel |
| private let rootID: Int64 |
| - /// Frozen at open (the web's threadFallback): the header stand-in while |
| - /// the root message isn't in loaded history. |
| - private let fallback: ReplyRef? |
| - private let theme: ChatTheme |
| private let isGroup: Bool |
| /// iOS opts in: dragging the header down rides the panel with the finger |
| /// and releases into a close — the sheet gesture readers expect on touch. |
| @@ -56,15 +54,11 @@ public struct ReplyThreadView<Row: View>: View { |
| /// run-grouping are this view's job. |
| private let row: (ChatFeedModel.Message, _ showSender: Bool) -> Row |
| - @Environment(\.colorScheme) private var scheme |
| // Paging history backward looking for the root (drives the inline note). |
| @State private var paging = false |
| - // Whether the replies list sits at its bottom — gates the live auto-follow |
| - // (a reader scrolled up a long thread isn't yanked down by an arrival). |
| + // Whether the list sits at its bottom — gates the live auto-follow (a |
| + // reader scrolled up a long thread isn't yanked down by an arrival). |
| @State private var bottomVisible = false |
| - // The pinned root's measured content height, so its box hugs a short root |
| - // instead of always spending the full cap. |
| - @State private var rootHeight: CGFloat = 0 |
| // Header-drag offset for the swipe-down close. |
| @State private var dragOffset: CGFloat = 0 |
| // The jump this view has already landed (id + the tick that asked for |
| @@ -74,16 +68,14 @@ public struct ReplyThreadView<Row: View>: View { |
| // tick (the container re-aiming) is a new request and lands again. |
| @State private var landedJump: (id: Int64, tick: Int)? |
| - public init(model: ChatFeedModel, rootID: Int64, fallback: ReplyRef?, |
| - theme: ChatTheme, isGroup: Bool, swipeDownCloses: Bool = false, |
| + public init(model: ChatFeedModel, rootID: Int64, |
| + isGroup: Bool, swipeDownCloses: Bool = false, |
| docked: Bool = false, |
| jumpTarget: Int64? = nil, jumpTick: Int = 0, |
| onClose: @escaping () -> Void, onRootPaged: (() -> Void)? = nil, |
| @ViewBuilder row: @escaping (ChatFeedModel.Message, _ showSender: Bool) -> Row) { |
| self.model = model |
| self.rootID = rootID |
| - self.fallback = fallback |
| - self.theme = theme |
| self.isGroup = isGroup |
| self.swipeDownCloses = swipeDownCloses |
| self.docked = docked |
| @@ -98,14 +90,11 @@ public struct ReplyThreadView<Row: View>: View { |
| // Re-derived from current state on every model publish — the view |
| // never caches its list (chat.js refreshThread's rule). |
| let items = ChatFeedModel.threadItems(in: model.messages, root: rootID) |
| - let rootLoaded = items.first?.id == rootID |
| - let replies = rootLoaded ? Array(items.dropFirst()) : items |
| - GeometryReader { geo in |
| + Group { |
| if docked { |
| // Docked: the panel content fills the container's column |
| // flush — the container draws the column chrome around it. |
| - panelContent(root: rootLoaded ? items.first : nil, replies: replies, |
| - cap: geo.size.height * 0.4) |
| + panelContent(items) |
| } else { |
| ZStack { |
| // Lighter than a sheet dim: the frosted panel reads the |
| @@ -114,8 +103,7 @@ public struct ReplyThreadView<Row: View>: View { |
| Color.black.opacity(0.25) |
| .contentShape(Rectangle()) |
| .onTapGesture(perform: onClose) |
| - panel(root: rootLoaded ? items.first : nil, replies: replies, |
| - cap: geo.size.height * 0.4) |
| + panel(items) |
| .padding(10) |
| .offset(y: dragOffset) |
| .opacity(dragOffset > 0 ? Double(1 - min(0.5, dragOffset / 400)) : 1) |
| @@ -123,7 +111,9 @@ public struct ReplyThreadView<Row: View>: View { |
| } |
| } |
| // The whole thread got deleted under the open view — nothing left to |
| - // show (chat.js refreshThread's close-on-empty). |
| + // show (chat.js refreshThread's close-on-empty). `items` INCLUDES the |
| + // root, so a thread someone just started (one row, no answers yet) is |
| + // never empty and never auto-closes — only losing every loaded row is. |
| .onChange(of: items.isEmpty) { _, empty in |
| if empty, !paging { onClose() } |
| } |
| @@ -132,9 +122,8 @@ public struct ReplyThreadView<Row: View>: View { |
| // The overlay's floating card: the shared content wrapped in the frosted |
| // glass chrome. The docked presentation mounts panelContent directly. |
| - private func panel(root: ChatFeedModel.Message?, replies: [ChatFeedModel.Message], |
| - cap: CGFloat) -> some View { |
| - panelContent(root: root, replies: replies, cap: cap) |
| + private func panel(_ items: [ChatFeedModel.Message]) -> some View { |
| + panelContent(items) |
| // The frosted glass (web: color-mix(var(--panel)) + backdrop blur) — |
| // material adapts to light/dark for free. |
| .background(.ultraThinMaterial, |
| @@ -145,16 +134,19 @@ public struct ReplyThreadView<Row: View>: View { |
| .shadow(color: .black.opacity(0.25), radius: 20, y: 6) |
| } |
| - // The panel CONTENT both presentations share: header/✕, the pinned root, |
| - // the paging note, the reply rows. |
| - private func panelContent(root: ChatFeedModel.Message?, replies: [ChatFeedModel.Message], |
| - cap: CGFloat) -> some View { |
| + // The panel CONTENT both presentations share: header/✕, the paging note, |
| + // and the one message list — root first, replies under it. |
| + private func panelContent(_ items: [ChatFeedModel.Message]) -> some View { |
| VStack(spacing: 0) { |
| - header(replyCount: replies.count) |
| - Divider() |
| - rootBox(root, cap: cap) |
| + // The header counts the ANSWERS, never the root — the one place |
| + // the tally still appears in this view, in the same words as the |
| + // tally that opened it (replyCountLabel: pluralised + localised). |
| + header(replyCount: ChatFeedModel.threadReplyCount(items, root: rootID)) |
| Divider() |
| - if let note = note(rootLoaded: root != nil) { |
| + // With the root in the list there's no snapshot header to stand in |
| + // for it: while it's missing the note carries that job alone and |
| + // the list simply starts at the oldest loaded reply. |
| + if let note = note(rootLoaded: items.first?.id == rootID) { |
| Text(note) |
| .font(.system(size: 12)) |
| .foregroundStyle(.secondary) |
| @@ -163,7 +155,7 @@ public struct ReplyThreadView<Row: View>: View { |
| .padding(.vertical, 8) |
| Divider() |
| } |
| - repliesList(replies) |
| + messageList(items) |
| } |
| // Inside the view every row already IS the thread — quotes/tallies off. |
| .environment(\.inReplyThread, true) |
| @@ -173,7 +165,7 @@ public struct ReplyThreadView<Row: View>: View { |
| HStack(alignment: .firstTextBaseline, spacing: 8) { |
| Text("Thread") |
| .font(.system(size: 15, weight: .bold)) |
| - Text(replyCount == 1 ? "1 reply" : "\(replyCount) replies") |
| + Text(ChatFeedModel.replyCountLabel(replyCount)) |
| .font(.system(size: 12)) |
| .foregroundStyle(.secondary) |
| Spacer() |
| @@ -220,64 +212,23 @@ public struct ReplyThreadView<Row: View>: View { |
| } |
| } |
| - // The pinned root: the real bubble when loaded (a keyed row, same wiring |
| - // as the feed — reactions and edits keep working); the quoted snapshot as |
| - // a stand-in while paging hunts for it. Height-capped at ~40% of the |
| - // panel, scrolling within itself (web .chatapp-thread-rootmsg). |
| - @ViewBuilder private func rootBox(_ root: ChatFeedModel.Message?, cap: CGFloat) -> some View { |
| - ScrollView { |
| - Group { |
| - if let root { |
| - HStack(spacing: 0) { |
| - if root.mine { Spacer(minLength: 40) } |
| - row(root, isGroup) |
| - if !root.mine { Spacer(minLength: 40) } |
| - } |
| - } else { |
| - // Root not loaded (yet): the frozen quote stands in |
| - // (web .chatapp-thread-rootfall — accent left rule). |
| - VStack(alignment: .leading, spacing: 2) { |
| - if let name = fallback?.name, !name.isEmpty { |
| - Text(name) |
| - .font(.system(size: 11)) |
| - .foregroundStyle(.secondary) |
| - } |
| - Text(fallback?.snippet ?? "") |
| - .font(.system(size: 14)) |
| - } |
| - .padding(.leading, 10) |
| - .overlay(alignment: .leading) { |
| - RoundedRectangle(cornerRadius: 1.5) |
| - .fill(theme.accent.resolved(scheme)) |
| - .frame(width: 3) |
| - } |
| - .frame(maxWidth: .infinity, alignment: .leading) |
| - } |
| - } |
| - .padding(EdgeInsets(top: 12, leading: 12, bottom: 10, trailing: 12)) |
| - .background(GeometryReader { g in |
| - Color.clear |
| - .onAppear { rootHeight = g.size.height } |
| - .onChange(of: g.size.height) { _, h in rootHeight = h } |
| - }) |
| - } |
| - // Bootstrap at the cap until measured — a too-tall first frame that |
| - // shrinks beats a zero-height one whose content never lays out. |
| - .frame(height: min(rootHeight > 0 ? rootHeight : cap, max(cap, 44))) |
| - } |
| - |
| private func note(rootLoaded: Bool) -> String? { |
| if paging { return "Loading the start of this thread…" } |
| if !rootLoaded { return "The original message isn't available — showing its replies." } |
| return nil |
| } |
| - private func repliesList(_ replies: [ChatFeedModel.Message]) -> some View { |
| + // The thread's messages as ONE list: the root is simply row 0 (with the |
| + // dated separator that opens any transcript), its replies under it. Same |
| + // grouping the feeds use, and .defaultScrollAnchor(.bottom) fills it from |
| + // the composer up — a thread is a chat window, so it opens like one: a |
| + // short one shows whole, a long one at its newest replies. |
| + private func messageList(_ items: [ChatFeedModel.Message]) -> some View { |
| ScrollViewReader { proxy in |
| ScrollView { |
| LazyVStack(spacing: 0) { |
| - ForEach(Array(replies.enumerated()), id: \.element.id) { i, m in |
| - replyRow(m, previous: i > 0 ? replies[i - 1] : nil) |
| + ForEach(Array(items.enumerated()), id: \.element.id) { i, m in |
| + messageRow(m, previous: i > 0 ? items[i - 1] : nil) |
| .id(m.id) |
| } |
| // Fixed-height follow target, and the "at bottom" probe |
| @@ -291,7 +242,7 @@ public struct ReplyThreadView<Row: View>: View { |
| .defaultScrollAnchor(.bottom) |
| // Follow the newest reply like the feed does, but only when |
| // already there (web refreshThread's follow rule). |
| - .onChange(of: replies.last?.id) { _, id in |
| + .onChange(of: items.last?.id) { _, id in |
| if id != nil, bottomVisible { |
| withAnimation(.easeOut(duration: 0.15)) { |
| proxy.scrollTo("thread-bottom", anchor: .bottom) |
| @@ -307,8 +258,8 @@ public struct ReplyThreadView<Row: View>: View { |
| // Scroll the jump target into view, ONCE per request (see landedJump). |
| // Rows are lazy and the view may still be sliding in, so give layout a |
| - // beat first; a target not among the replies (paged out, deleted) is |
| - // left alone — the bottom anchor stands. |
| + // beat first; a target not in the list (paged out, deleted) is left |
| + // alone — the bottom anchor stands. |
| private func landJump(_ proxy: ScrollViewProxy) { |
| guard let id = jumpTarget else { return } |
| if let landed = landedJump, landed.id == id, landed.tick == jumpTick { return } |
| @@ -318,11 +269,12 @@ public struct ReplyThreadView<Row: View>: View { |
| } |
| } |
| - @ViewBuilder private func replyRow(_ m: ChatFeedModel.Message, |
| - previous: ChatFeedModel.Message?) -> some View { |
| + @ViewBuilder private func messageRow(_ m: ChatFeedModel.Message, |
| + previous: ChatFeedModel.Message?) -> some View { |
| // The feeds' exact grouping (chat.js viewItems, which the web thread |
| - // panel reuses): the first reply opens with a dated separator, then |
| - // day-change/15-minute rules; a run only continues uninterrupted. |
| + // panel reuses): the first row — the root, when it's loaded — opens |
| + // with a dated separator, then day-change/15-minute rules; a run only |
| + // continues uninterrupted. |
| let sep = timeSeparator(at: m.at, previous: previous?.at) |
| let startsRun = sep != nil || !ChatFeedModel.sameSender(previous, m) |
| if let sep { |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift| index 7db3466..57d7a26 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift |
| @@ -514,9 +514,28 @@ final class ReplyThreadVectorTests: XCTestCase { |
| "the root is always first, whatever a pending copy's id") |
| } |
| + func testThreadReplyCountCountsAnswersNotRows() { |
| + // The thread view renders root and replies as ONE list, so rows ≠ replies: |
| + // a thread nobody has answered is one row and must still read "0 Replies", |
| + // never "1 Reply". Web twin: threadReplyCount in test/apps.test.mjs. |
| + let msgs = threadItemsFixture() |
| + let thread = ChatFeedModel.threadItems(in: msgs, root: 1) // [1, 3, 4] |
| + XCTAssertEqual(ChatFeedModel.threadReplyCount(thread, root: 1), 2, "root + two answers") |
| + let unanswered = Array(thread.prefix(1)) |
| + XCTAssertEqual(ChatFeedModel.threadReplyCount(unanswered, root: 1), 0, |
| + "just the root — nobody has replied") |
| + XCTAssertEqual(ChatFeedModel.replyCountLabel(ChatFeedModel.threadReplyCount(unanswered, root: 1)), |
| + "0 Replies", "never '1 Reply' for an empty thread") |
| + // Root paged out (threadItems drops it): every loaded row IS a reply. |
| + XCTAssertEqual(ChatFeedModel.threadReplyCount(Array(thread.dropFirst()), root: 1), 2, |
| + "unloaded root — the whole list is replies") |
| + XCTAssertEqual(ChatFeedModel.threadReplyCount([], root: 1), 0, "nothing loaded at all") |
| + } |
| + |
| func testThreadItemsWithUnloadedRoot() { |
| // 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). |
| + // replies we can see (the view's list starts at the oldest loaded reply, |
| + // under its "the original message isn't available" note). |
| let msgs = Array(threadItemsFixture().dropFirst(2)) |
| XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).map(\.id), [3, 4], |
| "unloaded root — replies only") |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 4d9d8b9..4413caf 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -271,11 +271,8 @@ struct NativeFeedView: View { |
| // Staged reply target / message being edited (the composer banners). |
| @State private var replyTo: ReplyRef? |
| @State private var editingID: Int64? |
| - // The open reply-thread view's root id (nil = closed) and the header |
| - // stand-in while that root isn't loaded — frozen at open, like the web's |
| - // threadFallback (chat.js openThread's ref argument). |
| + // The open reply-thread view's root id (nil = closed). |
| @State private var threadRootID: Int64? |
| - @State private var threadFallback: ReplyRef? |
| // A jump whose target lives only in its thread (search hit, media-sheet |
| // jump): the pane's row to land on + its wave — the feed's danceID/ |
| // danceTick twins for the thread surface (jumpIntoThread). |
| @@ -1876,8 +1873,7 @@ struct NativeFeedView: View { |
| // here resolve to the thread's ROOT via the same startReply path. Escape |
| // closes it through the reply banner's ✕ (.cancelAction → cancelReply). |
| private func threadOverlay(_ rootID: Int64) -> some View { |
| - ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback, |
| - theme: theme, isGroup: isGroup, |
| + ReplyThreadView(model: model, rootID: rootID, isGroup: isGroup, |
| jumpTarget: threadJump, jumpTick: threadJumpTick, |
| onClose: closeThread, |
| onRootPaged: { |
| @@ -1889,8 +1885,8 @@ struct NativeFeedView: View { |
| } |
| // The DOCKED thread pane (spec §6): ReplyThreadView's panel content — |
| - // header/✕, pinned root, paging note, reply rows — as a flush right-hand |
| - // column with its OWN composer at the bottom, the web's |
| + // header/✕, paging note, the root and its replies as one list — as a |
| + // flush right-hand column with its OWN composer at the bottom, the web's |
| // .chatapp-thread-panel--docked. Root-paging and the empty-thread |
| // auto-close live in ReplyThreadView and ride along unchanged. No |
| // onRootPaged: there's no staged banner to refresh — the pane's reply |
| @@ -1898,8 +1894,7 @@ struct NativeFeedView: View { |
| // ⌘T pops the catcher on the surface in front of the reader. |
| private func threadPane(_ rootID: Int64) -> some View { |
| VStack(spacing: 0) { |
| - ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback, |
| - theme: theme, isGroup: isGroup, docked: true, |
| + ReplyThreadView(model: model, rootID: rootID, isGroup: isGroup, docked: true, |
| jumpTarget: threadJump, jumpTick: threadJumpTick, |
| onClose: closeThread, |
| row: { m, showSender in |
| @@ -2213,9 +2208,6 @@ struct NativeFeedView: View { |
| private func openThread(_ rootID: Int64) { |
| guard rootID > 0, threadRootID != rootID else { return } |
| - // Freeze the header stand-in NOW (web threadFallback) — the deepest |
| - // quoted snapshot in loaded state, in case the root never pages in. |
| - threadFallback = model.threadReplyRef(root: rootID) |
| // A fresh open lands at the newest reply — never on a stale jump |
| // target from an earlier one (jumpIntoThread re-aims right after). |
| threadJump = nil |
| @@ -2250,7 +2242,6 @@ struct NativeFeedView: View { |
| private func closeThread() { |
| guard let rootID = threadRootID else { return } |
| withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil } |
| - threadFallback = nil |
| threadJump = nil |
| if threadDocked { |
| // The pane's composer dies with the pane (the web's docked pane |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index fd30748..93508df 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, inMainFeed, bySidIndex, newlyDirtyMentionSids, threadLinkCount, replyCountLabel, quoteTarget, threadTarget, jumpReachNote, pageResult, feedNearBottom, feedGlideToBottom, feedCancelGlide } from "../web/static/apps/chat.js"; // the chat app |
| +import { app as chatApp, viewItems, threadItems, threadReplyCount, inMainFeed, bySidIndex, newlyDirtyMentionSids, threadLinkCount, replyCountLabel, quoteTarget, threadTarget, jumpReachNote, pageResult, feedNearBottom, feedGlideToBottom, feedCancelGlide } 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 |
| @@ -1260,8 +1260,9 @@ await test("chat: quoteTarget vs threadTarget — the two reply verbs", () => { |
| 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. |
| + // loaded" for one round-trip — the header count jumped, the "original |
| + // message isn't available" note flashed, the root sat below the reply that |
| + // answers it, 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", thread: true }, rev: 0 }, |
| @@ -1464,10 +1465,24 @@ await test("chat: jumpReachNote — a jump that can't land always says why, and |
| await test("chat: replyCountLabel pluralises and localises — one wording for the tally and the quote card's thread link", () => { |
| assert.equal(replyCountLabel(1), "1 Reply", "never '1 Replies'"); |
| assert.equal(replyCountLabel(2), "2 Replies"); |
| - assert.equal(replyCountLabel(0), "0 Replies", "the zero case reads as a plural too (never rendered — the tally hides)"); |
| + assert.equal(replyCountLabel(0), "0 Replies", "the zero case reads as a plural too — the thread view's header shows it (a message's tally hides at 0)"); |
| assert.equal(replyCountLabel(1204), (1204).toLocaleString() + " Replies", "digits are locale-grouped"); |
| }); |
| +await test("chat: threadReplyCount counts the ANSWERS, not the rows — an unanswered thread reads 0", () => { |
| + const root = { sid: 1 }; |
| + const a = { sid: 2 }; |
| + const b = { sid: 3 }; |
| + // The root is a row in the thread's list now, so rows ≠ replies: a thread |
| + // nobody has answered is one row and must still read "0 Replies". |
| + assert.equal(threadReplyCount([root], 1), 0, "just the root — nobody has replied"); |
| + assert.equal(replyCountLabel(threadReplyCount([root], 1)), "0 Replies", "never '1 Reply' for an empty thread"); |
| + assert.equal(threadReplyCount([root, a, b], 1), 2, "root + two answers"); |
| + // Root paged out (threadItems drops it): every loaded row IS a reply. |
| + assert.equal(threadReplyCount([a, b], 1), 2, "unloaded root — the whole list is replies"); |
| + assert.equal(threadReplyCount([], 1), 0, "nothing loaded at all"); |
| +}); |
| + |
| 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/web/static/apps/chat.js b/web/static/apps/chat.js| index bcb2712..f414471 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -452,9 +452,10 @@ export function threadTarget(msgs, m) { |
| // order. Thread membership is the flag and a direct pointer (threadRootOf): |
| // an inline quote chain hanging off the same root is NOT part of the thread, |
| // it's ordinary feed conversation. 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. |
| +// the loaded window the replies still fold — the view's list simply starts at |
| +// the oldest loaded reply, under its note. 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) { |
| if (!rootSid) return []; // 0 is the pending-copy key, never a thread root |
| const out = []; |
| @@ -466,9 +467,9 @@ export function threadItems(msgs, rootSid) { |
| // 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. |
| + // loaded": the count jumped, the "original message isn't available" note |
| + // flashed, the root sat below the reply answering it, then all snapped back |
| + // on the echo — the blink on every reply sent from the thread. |
| return out.sort(bySidPendingLast); |
| } |
| @@ -613,13 +614,26 @@ export function jumpReachNote(reach) { |
| return ""; |
| } |
| -// The one wording for a thread's size, wherever it's shown: the root's tally |
| -// and the quote-card thread link read as the same thing because they ARE the |
| -// same string. Pluralised, and grouped for the locale (1,204 Replies). |
| +// The one wording for a thread's size, wherever it's shown: the root's tally, |
| +// the quote-card thread link and the thread view's own header read as the same |
| +// thing because they ARE the same string. Pluralised, and grouped for the |
| +// locale (1,204 Replies). |
| export function replyCountLabel(n) { |
| return n === 1 ? "1 Reply" : `${n.toLocaleString()} Replies`; |
| } |
| +// How many REPLIES a threadItems list holds — the list minus its root, when |
| +// the root is loaded (it's the first item; see threadItems). The thread view |
| +// renders root and replies as one list now, so "how many rows" and "how many |
| +// answers" are no longer the same number: a thread nobody has answered yet is |
| +// one row and must still read "0 Replies", never "1 Reply". Pure and exported |
| +// so that invariant is pinned by a test rather than re-derived at each render |
| +// site — native's twin is ChatFeedModel.threadReplyCount. |
| +export function threadReplyCount(items, rootSid) { |
| + if (!items.length) return 0; |
| + return items[0].sid === rootSid ? items.length - 1 : items.length; |
| +} |
| + |
| // Does this reply's quote card double as a link into its thread, and with what |
| // count? Only a THREAD reply ever wears one (an inline reply's threadCount is |
| // 0 — its card quotes and jumps, nothing more), and only while its thread |
| @@ -4091,10 +4105,17 @@ function buildSkeleton(el, ctx) { |
| // carries its OWN root-pinned composer, leaving the main one alone. Either |
| // way the new reply folds into the open view (render() → root._refreshThread |
| // re-derives from current state each fold; the view never caches its list). |
| + // |
| + // ONE scrolling list, root first: the message a thread is about is simply the |
| + // thread's first message, not a pinned box above a second scroll region. A |
| + // thread reads as a small chat window — which is what it is — and an |
| + // unanswered one is a single bubble instead of a pinned message, a rule and |
| + // an empty pane. |
| 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, panel, count, rootBox, note, body } while open (ov null when docked) |
| + let threadFallback = null; // { id, name, snippet } — the composer's reply target while the root isn't loaded |
| + let threadEl = null; // { ov, panel, count, note, body } while open (ov null when docked) |
| let threadPaging = false; // paging history backward looking for the root |
| + let threadRootShown = false; // did the last fold render the root? (the one prepend the list ever sees) |
| let threadDocked = false; // this open used the docked presentation (decided at open time, like every width check) |
| 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 |
| @@ -4132,6 +4153,7 @@ function buildSkeleton(el, ctx) { |
| const opener = threadOpener; |
| threadSid = 0; |
| threadFallback = null; |
| + threadRootShown = false; |
| threadDocked = false; |
| threadOpener = null; |
| if (threadEl) { |
| @@ -4185,13 +4207,11 @@ function buildSkeleton(el, ctx) { |
| 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); |
| + panel.append(head, note, body); |
| if (docked) { |
| // The pane's own composer, pinned to the thread root: a SECOND |
| // buildComposerBlock instance. fixedReply derives the target fresh per |
| @@ -4234,7 +4254,7 @@ function buildSkeleton(el, ctx) { |
| }); |
| composerWrap.appendChild(paneBlock.el); |
| panel.appendChild(composerWrap); |
| - return { ov: null, panel, count, rootBox, note, body }; |
| + return { ov: null, panel, count, note, body }; |
| } |
| const ov = document.createElement("div"); |
| ov.className = "chatapp-thread"; |
| @@ -4242,7 +4262,7 @@ function buildSkeleton(el, ctx) { |
| dim.className = "chatapp-thread-dim"; |
| dim.onclick = () => closeThread(); // tapping the dimmed transcript leaves the thread |
| ov.append(dim, panel); |
| - return { ov, panel, count, rootBox, note, body }; |
| + return { ov, panel, count, note, body }; |
| }; |
| // Thread rows reuse the feed's exact bubble machinery — renderItem + |
| @@ -4263,36 +4283,20 @@ function buildSkeleton(el, ctx) { |
| const msgs = ((ctx.getState() || {}).msgs) || []; |
| const items = threadItems(msgs, threadSid); |
| // The whole thread got deleted under the open view — nothing left to show. |
| + // A loaded root with no replies is NOT that: items is [root], and a thread |
| + // someone just started must stay open to be replied to. |
| if (!items.length && !threadPaging) return closeThread(); |
| + // Length-guarded: the close above only fires on a SETTLED view, so an |
| + // empty list still reaches here while paging hunts for the root. |
| 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); |
| - } |
| + // The header counts the REPLIES, never the root — the one place the tally |
| + // still appears in this view, in the same words as the tally that opened it. |
| + threadEl.count.textContent = replyCountLabel(threadReplyCount(items, threadSid)); |
| + // With the root in the list there's no snapshot header to stand in for it: |
| + // while it's missing the note carries that job alone and the list simply |
| + // starts at the oldest loaded reply. (The frozen quote lives on in |
| + // threadFallback, which is what the composer replies TO until the root |
| + // pages in.) |
| const noteText = threadPaging |
| ? "Loading the start of this thread…" |
| : rootLoaded ? "" : "The original message isn't available — showing its replies."; |
| @@ -4300,9 +4304,24 @@ function buildSkeleton(el, ctx) { |
| 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; |
| + const body = threadEl.body; |
| + const follow = body.scrollHeight - body.scrollTop - body.clientHeight < 80 || !body.childElementCount; |
| + const prevHeight = body.scrollHeight; |
| + // One keyed list, root first, straight through viewItems — so the root |
| + // gets the same separators, run-grouping and in-place reaction/edit |
| + // patching every other row has. Its own "N Replies" tally comes back |
| + // non-zero here and is hidden by CSS, like every other row's: inside the |
| + // view they'd all point at where the reader is already standing. |
| + threadKeyed(body, viewItems(items)); |
| + if (follow) body.scrollTop = body.scrollHeight; |
| + else if (rootLoaded && !threadRootShown) { |
| + // The root just paged in ABOVE what the reader is looking at — the only |
| + // prepend this list ever sees. Re-anchor the way the feed's scroll-back |
| + // does, so the rows they're reading stay put instead of being shunted |
| + // down by the height the root (and its day separator) just added. |
| + body.scrollTop += body.scrollHeight - prevHeight; |
| + } |
| + threadRootShown = rootLoaded; |
| }; |
| root._refreshThread = refreshThread; |
diff --git a/web/static/style.css b/web/static/style.css| index fb6acb9..131f6bf 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1366,10 +1366,13 @@ select:disabled { opacity: 0.5; cursor: default; } |
| } |
| } |
| /* ---- 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. */ |
| + the thread lifts over the transcript, iMessage-style — one scrolling list |
| + whose FIRST message is the one the thread is about, its replies under it, |
| + the real composer beneath staged to the root. (No pinned root box: a thread |
| + is a small chat window, and pinning its opening message split the panel in |
| + two — a message, a rule, and an empty pane whenever nobody had replied yet.) |
| + 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; |
| @@ -1439,20 +1442,6 @@ select:disabled { opacity: 0.5; cursor: default; } |
| } |
| .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; |
| @@ -1470,6 +1459,11 @@ select:disabled { opacity: 0.5; cursor: default; } |
| flex-direction: column; |
| gap: 4px; |
| } |
| +/* A short thread sits at the BOTTOM of the list, against the composer, the way |
| + a short conversation does in the feed (.chatapp-feed's identical rule) and on |
| + native (.defaultScrollAnchor(.bottom)) — the thread is a chat window, so it |
| + fills like one. */ |
| +.chatapp-thread-msgs::before { content: ""; margin-top: auto; } |
| /* inside the view, every row is already the thread — a per-row quote card, a |
| tally, or a "Reply in thread" icon would only point back at where you're |
| standing (the verb resolves to this same root, so the button is a no-op that |