eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev d39c30990ef95b12894a690fc08cc26bef5a09a4 parent a8f9d8d
9 files changed, +207 −209
  • changelog/2026-08-28-thread-pane.md +3 −0
  • ios/LChat/Views/NativeChatScreen.swift +7 −15
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +18 −5
  • ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift +57 −105
  • ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift +20 −1
  • ios/LChatMac/NativeFeedView.swift +5 −14
  • test/apps.test.mjs +19 −4
  • web/static/apps/chat.js +66 −47
  • web/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