eleven / ocho Public

ios+mac: a Threads list — every thread in this chat, freshest first (#1494)

* core: every thread in a chat, derived once and pinned by a vector

Paul: "there's no way to see a list of threads — it's hard to find a thread
if I miss clicking the notification." The web grew a ▤ Threads list for
that (#1491); this is the shared half the native apps build theirs on.

ChatFeedModel.threadListItems(_:) is the Swift twin of chat.js's
threadListItems: one row per thread ROOT, membership through
threadRootID(of:) — the same read the "N Replies" tally makes, so a row's
count and its root's tally can never disagree — ordered by each thread's
LAST message, because the list answers "where is the conversation", not
"which root is newest". A thread whose root has scrolled out of the loaded
window keeps its row, headed by the snapshot its flagged replies carry;
a converted chain, whose replies quote something that isn't the root, is
headed by nothing rather than by the wrong message.

The ordering comparator both threadItems and threadListItems needed is now
one function (feedPrecedes) instead of two copies of the same rule, so a
thread's rows and the list's order can't drift apart.

The list re-derives in `messages`'s didSet as `threadListRows`, assigned
only when it actually changed — the native form of the web's
threadListSignature repaint gate, and what lets the containers ask "does
this chat have a thread?" without walking the window per menu.

Vectors, not parallel hand-written tests: nine sequence vectors in
test/event-vectors/chat.json gain expected_thread_list, and both suites
read it — the web's in test/event-vectors.test.mjs, Swift's in
ReplyThreadVectorTests. They cover the empty case (an inline quote chain
lists nothing), the unloaded root, every conversion shape, and the
ordering rule: convert_conflicting_claims_oldest_wins puts the OLDER
root's row first, because its thread holds the newer message.

NO unread dot, deliberately — the same call the web made. Read state here
is per CHAT and nothing else, and opening a chat advances that watermark
past every thread reply in it, so a dot derived from it would read "all
caught up" the instant you glanced at the chat — the very lie this list
exists to fix.

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)

* ios: a Threads row in the chat kebab, opening this chat's threads

The list itself is ReplyThreadListView in LChatCore, shared with the Mac so
the rows, their wording and their accessible names are the same on both —
author, the line you recognise the thread by, and its tally through
replyCountLabel, the one string a thread's size is ever written in (so a
row, the root's bubble and the thread header always agree, pluralised and
locale-grouped). A converted chain whose root has paged out says "The
original message isn't available" rather than showing a blank line, which
is what the thread view says in exactly that case.

Where it goes: the chat's ⋯ kebab, under the Chat/Interface pickers and
above the management verbs — a view switch belongs with the view switches
(and the project's rule is kebab rows, never bar tabs). The row is there
exactly while the chat HAS a thread, so a chat where nobody has used
"Reply in thread" is untouched, and one someone else starts mid-conversation
surfaces the row without a reload.

A sheet, not a dock: the iPhone has no room for a second column, and a
tapped row opens the thread where a tally tap does — the docked pane on
iPad, the lifted overlay on iPhone — so the list is the transient index you
come out of, the same shape Media, Links & Docs has.

Accessibility: rows are buttons named for what they DO ("Open thread,
4 Replies, James: …") with both visible strings carried through verbatim,
so voice control still matches the words on screen; ≥34pt targets that fill
on press; and at the accessibility type sizes the tally moves under the
line rather than being squeezed off it.

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)

* mac: the Threads list borrows the thread pane's dock

Same list, same shared view (ReplyThreadListView), as a flush 340pt right
column — the same slot the docked thread pane uses, because there is only
one right dock and the two are never both up. Opening a thread from a row
therefore hands the dock straight to that thread, which is what you came
for anyway.

Where it goes: the header's ⋯ kebab, first, above the management verbs —
the Mac has no ▤ Views menu, so this is where a "views" section starts.
Memory and the bot views the web already lists have a home there when they
land natively. The row observes the chat's own model, so it appears with
the chat's first thread and goes with its last.

The list only BORROWS the dock. A thread open behind it keeps its
half-typed reply, its staged files and its in-flight edit — that state
lives on the feed view, not on the pane — so closing the list hands the
pane back with the caret where the reader left it. (The web's panel evicts
the thread outright, because its pane composer dies with the DOM node; we
don't have to.)

The header is painted a view up from the dock, so the row's request crosses
on MacAppState the way a search hit's jump does: one-shot, consumed by the
feed it names, since every visited space stays mounted.

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)

* docs: the Threads list's icon, and where it isn't drawn

Both new kebab rows go in the mapping table, with the platform split spelled
out: the web's ▤ menu and the iOS ⋯ menu draw ICON_THREAD /
bubble.left.and.bubble.right, while the Mac header kebab is text-only rows
by construction — the same reason "Convert to thread" maps nowhere there.

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)

* mac+core: closing the list never leaves the caret nowhere

Two small follow-ons to the slice:

closeThreadList hands focus back explicitly — to the thread pane's composer
when a thread was open behind the list (where the reader left it), else to
the channel bar. Both gated on the pane being on screen, the same isActive
guard every focus claim in this file wears: every visited space stays
mounted, and a parked one must never take first responder from the visible
one.

And the row's tally takes its width before the line does, so a long snippet
truncates rather than squeezing the number off the row — the line already
expands to fill, so the spacer between them was doing nothing.

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)

* mac: the list lays OVER the pane, owns the caret, and can't strand its request

Review of the slice turned up five real faults, all here:

**The request could go permanently dead.** The chat header is drawn in
states where NativeFeedView is NOT mounted — a group in Interface mode,
every app thread's pane — so a Threads click there had no consumer, and
because the channel carried a bare chat id, the NEXT click wrote the same
value, onChange saw no change, and the row was inert for that chat for the
rest of the session. The channel now carries a tick (so a repeat is always a
change) and, like consumeJump, drains on appear too — a click made while the
conversation was hidden opens the list when it comes back, rather than being
swallowed.

**Covering the pane by replacing it lost the draft's formatting.** The slice
claimed an open thread keeps its half-typed reply while you look something
up, and that was only half true: paneDraft/paneStaged live on this view, but
the composer's bold/italic runs, caret and undo stack live in its own text
storage, which dies with the unmounted view — the reply would come back
plain, caret at the end. So the list now lays OVER the pane instead of
replacing it, and the covered pane leaves the accessibility tree.

**The list had no focus, so Escape was dead.** Opened over a thread, focus
fell to the window; opened over the channel, the composer's own Escape
(cancel reply) won. It's now the focus owner while it's up, so .onExitCommand
lands where the reader expects, and the covered composer can't keep first
responder underneath.

**A row for the already-open thread returned the dock without the caret** —
openThread's same-root guard left before the focus restore.

**And a stray Sparkle pin** had crept into LChatCore's Package.resolved,
which declares no such dependency.

Also: the web-side vector assertion for the repaint gate was tautological
(it compared a signature with a shallow copy of itself, which can never
differ). It now moves `last`, which the row never shows, and asserts NO
repaint; then moves count/name/snippet and asserts one. And the divergence
that exposed — the web's signature omits rootLoaded, which the row does show
— is written down beside ThreadListRow as the web-side follow-up it is; this
change stays scoped to native.

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)

* mac: one right column, and no ring around the threads list

Two things Paul caught testing the list on the Mac.

The 340pt column is focusable so Escape reaches it rather than the
composer it covers — but focusable() also asks macOS to ring it, and it
outlined the whole column in blue. An index is not a control: the ring
belongs on the rows, which are real buttons and keep theirs, so the
container's is disabled.

And the list and the thread pane were separate branches of the layout,
so opening a thread from a row removed one column and inserted another
— between them the feed reflowed out to full width and back, which read
as the pane shrinking and restoring under the animation. They are one
container now, holding whichever is up: the column never leaves, only
its contents cross-fade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ

* mac: a thread opening into a standing column is a cross-fade, not a mount

Paul's repro was exact: the shrink only happened opening a thread from
the list when NO pane was there yet, and again after closing and
reopening one — never when switching threads with the pane already up.
That difference is the pane's first mount.

Inserted inside a withAnimation, SwiftUI animates the new subtree's own
geometry — the composer measuring its field, the scroll view taking its
size — so the pane grew into place instead of simply appearing. Two
changes: both children of the column carry an explicit .opacity
transition, so a swap is a fade and never interpolates size; and the
root is set WITHOUT animation when the column is already standing,
since the fade is the whole transition there. The column arriving from
nothing still animates, which is the one case that should.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ

---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev d8b58897a9eb584b6ad4af6f113fb51173be0f82 parent 6ac10f2
12 files changed, +797 −16
  • docs/icons.md +6 −0
  • ios/LChat/Views/NativeChatScreen.swift +29 −0
  • ios/LChat/Views/ThreadListSheet.swift +41 −0
  • ios/LChatCore/Package.resolved +9 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +126 −11
  • ios/LChatCore/Sources/LChatCore/ReplyThreadListView.swift +227 −0
  • ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift +82 −0
  • ios/LChatMac/LChatMacApp.swift +21 −0
  • ios/LChatMac/NativeChatView.swift +24 −0
  • ios/LChatMac/NativeFeedView.swift +152 −4
  • test/event-vectors.test.mjs +29 −1
  • test/event-vectors/chat.json +51 −0
diff --git a/docs/icons.md b/docs/icons.md
index 8772c53..cf2a044 100644
--- a/docs/icons.md
+++ b/docs/icons.md
@@ -42,6 +42,7 @@ Three kinds of mark, three sources, one rule about emoji.
|---|---|---|
| Reply | `ICON_REPLY` (hand-drawn, pre-Lucide) | `arrowshape.turn.up.left` |
| Reply in thread | `ICON_THREAD` (messages-square) | `bubble.left.and.bubble.right` |
+| Threads list | `ICON_THREAD` (messages-square) | `bubble.left.and.bubble.right` (iOS kebab; the Mac kebab's rows are text-only — see below) |
| Convert to thread | `ICON_FOLD_IN` (fold-vertical) | `arrow.down.right.and.arrow.up.left` |
| Close / dismiss | `ICON_X` | `xmark` |
| Edit | `ICON_EDIT` | `pencil` |
@@ -91,6 +92,11 @@ second `arrowshape.turn.up.left` (`StockChatFeed.swift`, `MessageBubble.swift`)
icon-only and two arrows read as one control drawn twice. Swap them to
`bubble.left.and.bubble.right` on the next native touch.
+The **Threads list** follows the same platform split as every other kebab
+row: the web's ▤ menu and the iOS ⋯ menu draw the icon (iOS menu rows are
+`Label`s on one icon grid), while the Mac header kebab is text-only rows by
+construction — nothing else in it carries a glyph either.
+
**Convert to thread** carries a glyph on exactly two of its four surfaces: the
web's picker (icon-only rows, so it needs one) and the iOS long-press sheet.
The iOS context menu and the Mac `NSMenu` are text-only rows by construction,
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 1fb116b..4d9990d 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -270,6 +270,9 @@ struct NativeChatScreen: View {
// DMs and groups alike, hidden on app threads (they have no chat feed
// of shared content to inventory).
@State private var showMediaView = false
+ // The Threads list — this chat's threads, freshest first, so one you
+ // missed is findable rather than lost behind a notification.
+ @State private var showThreadList = false
// End conversation (#578): the destructive confirm, and the closed
// banner's in-flight / refusal state (inline copy, never an alert).
@State private var showEndConfirm = false
@@ -590,6 +593,21 @@ struct NativeChatScreen: View {
}
.pickerStyle(.inline)
}
+ // …then this chat's threads, listed. A view switch, so
+ // it rides the kebab with the pickers above it and
+ // ahead of the management verbs — the order the web's
+ // ▤ menu uses. Present exactly while the chat HAS a
+ // thread, so one someone else starts mid-conversation
+ // surfaces the row (the model republishes) and a chat
+ // where nobody has ever used "Reply in thread" is
+ // exactly as it was.
+ if !model.threadListRows.isEmpty {
+ Button {
+ showThreadList = true
+ } label: {
+ Label("Threads", systemImage: "bubble.left.and.bubble.right")
+ }
+ }
// Group management (Mac parity: View Members / Rename).
if isGroup {
Button {
@@ -677,6 +695,17 @@ struct NativeChatScreen: View {
path.append(.thread(id))
})
}
+ // The Threads list. A sheet, not a dock: iPhone has no room for a
+ // second column, and the iPad's docked pane is where a tapped row
+ // lands anyway — so the list is the transient index you come out
+ // of, the same way Media, Links & Docs works.
+ .sheet(isPresented: $showThreadList) {
+ ThreadListSheet(model: model,
+ onOpen: { root in
+ showThreadList = false
+ openThread(root)
+ })
+ }
.sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) }
.sheet(isPresented: $showGroupApps) { GroupAppsSheet(model: model) }
.sheet(isPresented: $showMediaView) {
diff --git a/ios/LChat/Views/ThreadListSheet.swift b/ios/LChat/Views/ThreadListSheet.swift
new file mode 100644
index 0000000..4ed0551
--- /dev/null
+++ b/ios/LChat/Views/ThreadListSheet.swift
@@ -0,0 +1,41 @@
+import SwiftUI
+import LChatCore
+
+// 🤖 The Threads list, as an iOS sheet: this chat's threads, freshest first,
+// one row per thread root. The list itself is LChatCore's ReplyThreadListView
+// (shared with the Mac's docked column, so the rows and their accessible names
+// are word for word the same); this file is only the sheet chrome around it —
+// the title, the Done button, and the dismissal a tapped row rides out on.
+//
+// Why a sheet rather than a column: the iPhone has no room for a second one,
+// and a tapped row lands in the thread pane (iPad) or the lifted thread
+// overlay (iPhone) — so the list is a transient index you come out of, exactly
+// like Media, Links & Docs. That also matches the web's single-right-dock
+// rule: finding the thread and reading it are never both on screen.
+struct ThreadListSheet: View {
+ @ObservedObject var model: ChatFeedModel
+ /// Open this thread. The screen dismisses the sheet first, then opens the
+ /// thread the same way a "N Replies" tally tap does.
+ let onOpen: (Int64) -> Void
+ @Environment(\.dismiss) private var dismiss
+ // The reader's chosen colour theme, so a row highlights in the same
+ // bubble tint the feed behind the sheet uses.
+ @AppStorage("nativeTheme") private var themeName = "classic"
+
+ var body: some View {
+ NavigationStack {
+ // The sheet's navigation bar carries the title and Done, so the
+ // shared view draws no header of its own.
+ ReplyThreadListView(model: model, theme: .named(themeName), showsHeader: false,
+ onOpen: onOpen,
+ onClose: { dismiss() })
+ .navigationTitle("Threads")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ }
+ }
+}
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved
index d82f91f..fe25d1c 100644
--- a/ios/LChatCore/Package.resolved
+++ b/ios/LChatCore/Package.resolved
@@ -1,5 +1,14 @@
{
"pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
+ "version" : "2.9.4"
+ }
+ },
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 79f60fd..f723f2a 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -136,8 +136,22 @@ public final class ChatFeedModel: ObservableObject {
/// unread/notification state all read this. The main feed does NOT: it
/// renders `feedMessages` below.
@Published public private(set) var messages: [Message] = [] {
- didSet { feedMessages = Self.mainFeedItems(messages) }
- }
+ didSet {
+ feedMessages = Self.mainFeedItems(messages)
+ // The Threads list is re-derived here rather than in a view body so
+ // the menu's row and the open list read one answer. Assigned only
+ // when it actually changed: a fold that leaves the list looking
+ // identical must not invalidate the header (the web guards the same
+ // repaint with threadListSignature).
+ let rows = Self.threadListItems(messages)
+ if rows != threadListRows { threadListRows = rows }
+ }
+ }
+ /// This chat's threads, newest activity first — the Threads list's rows,
+ /// and (empty or not) the gate on offering the list at all. Kept in step
+ /// with `messages` by the didSet above; the rule itself is the pure
+ /// `threadListItems(_:)`, vector-pinned.
+ @Published public private(set) var threadListRows: [ThreadListRow] = []
/// The MAIN FEED's list: `messages` minus the replies made in a thread
/// (replyTo.thread — see ReplyRef), the Slack model: those live only in
/// the thread view, and the feed shows their root's "N replies" tally
@@ -2218,15 +2232,116 @@ public final class ChatFeedModel: ObservableObject {
var out: [Message] = []
if let root = list.first(where: { $0.id == rootID }) { out.append(root) }
out.append(contentsOf: list.filter { threadRootID(of: $0) == rootID })
- // Pending (optimistic) copies sort LAST, never ahead of the root or a
- // confirmed reply — the web's bySidPendingLast rule, and the cause of
- // its thread-view "blink" when a just-sent reply sorted above the
- // root. Native pending ids already sit above every real id (1<<62,
- // foldOptimisticSend), so this is the explicit form of that order.
- return out.sorted { a, b in
- if a.pending != b.pending { return !a.pending }
- return a.id < b.id
- }
+ return out.sorted(by: feedPrecedes)
+ }
+
+ /// Feed order between two messages: confirmed ones by server id (the stable
+ /// identity), with any still-pending optimistic copy LAST — never ahead of
+ /// the root or of a confirmed reply. That was the cause of the thread
+ /// view's "blink" when a just-sent reply sorted above the root. Native
+ /// pending ids already sit above every real id (1<<62, foldOptimisticSend),
+ /// so this is the explicit form of that order. Mirrors chat.js's
+ /// `bySidPendingLast`, and is shared by threadItems and threadListItems so
+ /// a thread's rows and the list's ordering can't fall out of step.
+ nonisolated static func feedPrecedes(_ a: Message, _ b: Message) -> Bool {
+ if a.pending != b.pending { return !a.pending }
+ return a.id < b.id
+ }
+
+ /// One row of the Threads list: a thread as its index shows it — the line
+ /// you recognise the thread by (its root's author + snippet), how many
+ /// replies it holds, and whether that root is actually loaded. Equatable so
+ /// SwiftUI repaints a row exactly when what it shows changed, which is what
+ /// the web achieves with `threadListSignature`.
+ ///
+ /// Not quite the same fields, though: the web's signature is
+ /// `rootSid:count:name:snippet` and omits `rootLoaded`, which IS shown —
+ /// an unheaded row (a converted chain whose root paged out, so both name
+ /// and snippet are empty) reads "The original message isn't available" off
+ /// that flag alone. So a root paging in as a nameless, textless message
+ /// changes the web's rendered line without changing its signature, and its
+ /// open panel skips a repaint native would make. A corner of a corner, and
+ /// a WEB-side fix (add rootLoaded to threadListSignature) — noted here
+ /// rather than fixed, because this change is scoped to native.
+ public struct ThreadListRow: Identifiable, Equatable, Sendable {
+ /// The thread's root message id — the row's identity, and what a tap opens.
+ public let rootID: Int64
+ /// Is that root in the loaded window? False = the row is headed by the
+ /// snapshot its replies carry, or by nothing at all (see below).
+ public let rootLoaded: Bool
+ public let name: String
+ public let snippet: String
+ /// Replies in the thread — the same number its root's tally wears.
+ public let count: Int
+ public var id: Int64 { rootID }
+
+ public init(rootID: Int64, rootLoaded: Bool, name: String, snippet: String, count: Int) {
+ self.rootID = rootID
+ self.rootLoaded = rootLoaded
+ self.name = name
+ self.snippet = snippet
+ self.count = count
+ }
+ }
+
+ /// Every thread in the loaded window, newest activity first — what the
+ /// Threads list shows. One row per thread ROOT.
+ ///
+ /// Membership is threadRootID(of:) — the same read the "N Replies" tally
+ /// makes, so the count on a row and the count on its root can never
+ /// disagree. Plain quote replies therefore never appear: an inline chain is
+ /// a conversation already visible in the feed, not a thread anyone opened,
+ /// and it has no thread to come back to.
+ ///
+ /// Ordering is by LAST REPLY, not root age: the list answers "where is the
+ /// conversation", so this morning's root nobody answered sits below a
+ /// week-old one someone replied to a minute ago. The last message (and the
+ /// row order) is picked with the feed's own comparator, so a still-pending
+ /// optimistic reply counts as the newest thing in its thread — as it
+ /// visibly is.
+ ///
+ /// A thread whose root has scrolled out of the loaded window keeps its row,
+ /// headed by the quoted snapshot its replies carry — the same fallback the
+ /// thread view's own header uses (`rootLoaded` says which one a row is).
+ /// Mirrors chat.js's `threadListItems`; pinned for both platforms by
+ /// `expected_thread_list` in test/event-vectors/chat.json.
+ nonisolated public static func threadListItems(_ list: [Message]) -> [ThreadListRow] {
+ var order: [Int64] = [] // roots in first-seen order
+ var counts: [Int64: Int] = [:]
+ var last: [Int64: Message] = [:] // newest message in each thread
+ var stand: [Int64: ReplyRef] = [:] // the header a rootless row wears
+ for m in list {
+ guard let rootID = threadRootID(of: m) else { continue }
+ if counts[rootID] == nil {
+ order.append(rootID)
+ counts[rootID] = 0
+ last[rootID] = m
+ // Unloaded root: only a FLAGGED reply's ref is the root's own
+ // snapshot (see threadTarget) — a CONVERTED message quotes
+ // something else, so its row is headed by nothing rather than
+ // by the wrong message.
+ if isThreadOnly(m), let ref = m.reply { stand[rootID] = ref }
+ }
+ counts[rootID]! += 1
+ if feedPrecedes(last[rootID]!, m) { last[rootID] = m }
+ }
+ // The overwhelmingly common chat has no threads at all: leave without
+ // building an index of the window (this runs on every publish).
+ guard !order.isEmpty else { return [] }
+ var byID: [Int64: Message] = [:]
+ byID.reserveCapacity(list.count)
+ for m in list { byID[m.id] = m }
+ let rows = order.map { rootID -> ThreadListRow in
+ let root = byID[rootID]
+ return ThreadListRow(rootID: rootID,
+ rootLoaded: root != nil,
+ name: root?.name ?? stand[rootID]?.name ?? "",
+ snippet: root.map { replySnippet($0.raw) } ?? stand[rootID]?.snippet ?? "",
+ count: counts[rootID] ?? 0)
+ }
+ // Newest activity first. Every row's `last` is a different message, so
+ // the comparator is a strict total order and no two rows can tie.
+ return rows.sorted { feedPrecedes(last[$1.rootID]!, last[$0.rootID]!) }
}
/// The ReplyRef the open thread view stages on the composer (always the
diff --git a/ios/LChatCore/Sources/LChatCore/ReplyThreadListView.swift b/ios/LChatCore/Sources/LChatCore/ReplyThreadListView.swift
new file mode 100644
index 0000000..c5d23c8
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ReplyThreadListView.swift
@@ -0,0 +1,227 @@
+import SwiftUI
+
+// 🤖 The Threads list — the native twin of the web's ▤ Threads panel
+// (apps/chat.js paintThreadList): every thread in this chat, freshest first,
+// one row per thread ROOT. The gap the thread pane left, in Paul's words:
+// "there's no way to see a list of threads — it's hard to find a thread if I
+// miss clicking the notification." A thread only exists where someone chose
+// one, so a chat has a handful and they're findable — if there is somewhere to
+// look. This is that somewhere.
+//
+// Split of responsibilities, exactly as ReplyThreadView's: the CONTAINER owns
+// presentation (the Mac's right-hand column, iOS's sheet) and what a row does
+// (open that thread, and dismiss this list on the way — one right dock); this
+// view owns the panel. It renders LIVE from the model's published rows through
+// the pure `threadListItems` selector, so a thread someone else starts while
+// you're looking arrives without a reload, and the freshest thread re-sorts to
+// the top as replies land.
+//
+// NO UNREAD DOT, deliberately — the same call the web made, for the same
+// reason. Read state in this product is per CHAT and nothing else: one
+// `read_marks(user_id, thread_id, last_read_at)` watermark server-side,
+// `ReadState.lastSeen` natively. Opening the chat advances it past every
+// thread reply in it (thread replies are ordinary messages, so they move the
+// chat's last_at even though the transcript never shows them), so a dot
+// derived from that would read "all caught up" the instant you glanced at the
+// chat — exactly the lie this list exists to fix. Per-thread read state is a
+// real feature (a watermark per root, synced like the chat one); half-inventing
+// it here would put a wrong dot in front of members. Rows carry activity order
+// instead — the freshest thread is simply top.
+public struct ReplyThreadListView: View {
+ @ObservedObject private var model: ChatFeedModel
+ private let theme: ChatTheme
+ /// Mac draws its own inline header (title + ✕) because the column has no
+ /// other chrome; iOS passes false — the sheet's navigation bar carries the
+ /// title and its Done button.
+ private let showsHeader: Bool
+ /// Open this thread. The container dismisses the list first (see the
+ /// one-dock note above) and then opens the pane/overlay, exactly as a
+ /// tally tap does.
+ private let onOpen: (Int64) -> Void
+ private let onClose: () -> Void
+
+ @Environment(\.colorScheme) private var scheme
+ @Environment(\.dynamicTypeSize) private var typeSize
+ /// Hover IS the highlight on Mac (the web row's :hover), so a pointer user
+ /// can see which row they're about to open.
+ @State private var hovered: Int64?
+
+ public init(model: ChatFeedModel, theme: ChatTheme = .classic,
+ showsHeader: Bool = true,
+ onOpen: @escaping (Int64) -> Void,
+ onClose: @escaping () -> Void) {
+ self.model = model
+ self.theme = theme
+ self.showsHeader = showsHeader
+ self.onOpen = onOpen
+ self.onClose = onClose
+ }
+
+ public var body: some View {
+ VStack(spacing: 0) {
+ if showsHeader {
+ header
+ Divider()
+ }
+ if model.threadListRows.isEmpty {
+ // Only reachable with the list already open — the containers
+ // hide the menu entry at zero — so this is what a member sees
+ // when the last thread's replies are deleted under them.
+ Text(Self.emptyNote)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
+ Spacer(minLength: 0)
+ } else {
+ ScrollView {
+ LazyVStack(spacing: 2) {
+ // Rows are keyed by their root, so a re-sort MOVES a row
+ // rather than rebuilding it — the keyboard stays where it
+ // was when a live thread jumps to the top (the web guards
+ // the same thing with threadListSignature + a focus
+ // restore).
+ ForEach(model.threadListRows) { row($0) }
+ }
+ .padding(EdgeInsets(top: 6, leading: 8, bottom: 10, trailing: 8))
+ }
+ }
+ }
+ }
+
+ static let emptyNote: String = {
+ #if os(macOS)
+ "No threads in this chat. Choose “Reply in Thread” on a message to start one."
+ #else
+ "No threads in this chat. Choose “Reply in thread” on a message to start one."
+ #endif
+ }()
+
+ private var header: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text("Threads")
+ .font(.system(size: 15, weight: .bold))
+ Spacer()
+ Button(action: onClose) {
+ Image(systemName: "xmark")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 28, height: 28)
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Close the threads list")
+ }
+ .padding(EdgeInsets(top: 12, leading: 14, bottom: 10, trailing: 10))
+ }
+
+ // One row: who started the thread, the line you recognise it by, and how
+ // many replies it holds — the web's .thread-list-row, tally in its own
+ // trailing column so the numbers line up down the list instead of trailing
+ // ragged after snippets of every length.
+ private func row(_ r: ChatFeedModel.ThreadListRow) -> some View {
+ Button { onOpen(r.rootID) } label: {
+ rowBody(r)
+ .padding(EdgeInsets(top: 7, leading: 10, bottom: 7, trailing: 10))
+ .frame(maxWidth: .infinity, minHeight: 34, alignment: .leading)
+ }
+ .buttonStyle(RowStyle(fill: theme.bubbleThem.resolved(scheme),
+ hovered: hovered == r.rootID))
+ .onHover { inside in
+ if inside { hovered = r.rootID } else if hovered == r.rootID { hovered = nil }
+ }
+ // The accessible name says what the row DOES before the size and the
+ // line, so a screen reader announces a destination rather than a
+ // fragment of someone's sentence. Both visible strings are carried
+ // through verbatim, so voice control still matches on the words a
+ // sighted user can see (WCAG 2.5.3).
+ .accessibilityLabel(Self.rowLabel(r))
+ .accessibilityAddTraits(.isButton)
+ }
+
+ @ViewBuilder private func rowBody(_ r: ChatFeedModel.ThreadListRow) -> some View {
+ // At the accessibility type sizes a name, a snippet and a tally cannot
+ // share one line without the tally being squeezed off it, so the tally
+ // moves under the line rather than truncating — the count is the thing
+ // a reader is scanning for.
+ if typeSize.isAccessibilitySize {
+ VStack(alignment: .leading, spacing: 2) {
+ line(r)
+ count(r)
+ }
+ } else {
+ HStack(alignment: .firstTextBaseline, spacing: 10) {
+ // The tally gets its width first and the line takes what's
+ // left, so a long snippet truncates rather than squeezing the
+ // number — and the numbers stay in one column down the list.
+ line(r)
+ count(r).layoutPriority(1)
+ }
+ }
+ }
+
+ private func line(_ r: ChatFeedModel.ThreadListRow) -> some View {
+ (Text(Self.who(r)).font(whoFont) + Text(Self.snippet(r)).font(lineFont).foregroundColor(.secondary))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private func count(_ r: ChatFeedModel.ThreadListRow) -> some View {
+ Text(ChatFeedModel.replyCountLabel(r.count)) // the same string the root's tally wears
+ .font(countFont)
+ .foregroundStyle(.secondary)
+ .monospacedDigit() // the tallies line up down the column
+ .fixedSize(horizontal: true, vertical: false)
+ }
+
+ /// The author prefix, with its separator — "" when the row has no name to
+ /// show (a converted chain whose root paged out).
+ static func who(_ r: ChatFeedModel.ThreadListRow) -> String {
+ r.name.isEmpty ? "" : r.name + ": "
+ }
+
+ /// The line the row is headed by. A CONVERTED chain whose root has scrolled
+ /// out of the loaded window has no truthful stand-in (threadListItems: only
+ /// a flagged reply's ref is its root's own snapshot), so the row would be a
+ /// blank line above its tally — and would read "Open thread, 3 Replies," to
+ /// a screen reader. Say what the thread view says in exactly the same case.
+ static func snippet(_ r: ChatFeedModel.ThreadListRow) -> String {
+ if !r.snippet.isEmpty { return r.snippet }
+ return r.rootLoaded ? "" : "The original message isn't available"
+ }
+
+ /// The row's accessible name — the web row's aria-label, word for word.
+ static func rowLabel(_ r: ChatFeedModel.ThreadListRow) -> String {
+ "Open thread, \(ChatFeedModel.replyCountLabel(r.count)), \(who(r))\(snippet(r))"
+ }
+
+ // The row's own highlight: filled while the pointer is over it (the web
+ // row's :hover) AND while a finger is down on it, so a tap on touch has
+ // the feedback a plain button gives nowhere. ≥34pt tall, so the target
+ // clears the 24px minimum with room to spare (WCAG 2.5.8).
+ private struct RowStyle: ButtonStyle {
+ let fill: Color
+ let hovered: Bool
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .contentShape(Rectangle())
+ .background(RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(configuration.isPressed || hovered ? fill : .clear))
+ }
+ }
+
+ // macOS has no Dynamic Type, so it keeps the fixed sizes the thread pane
+ // and the web row use (13px line, 11px tally); iOS uses text styles, which
+ // scale all the way to the accessibility sizes.
+ #if os(macOS)
+ private var whoFont: Font { .system(size: 13, weight: .semibold) }
+ private var lineFont: Font { .system(size: 13) }
+ private var countFont: Font { .system(size: 11) }
+ #else
+ private var whoFont: Font { .subheadline.weight(.semibold) }
+ private var lineFont: Font { .subheadline }
+ private var countFont: Font { .caption }
+ #endif
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift
index 5a228d6..81fafdb 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift
@@ -442,6 +442,88 @@ final class ReplyThreadVectorTests: XCTestCase {
XCTAssertEqual(convertLine("", 2), "Someone moved 2 replies into a thread")
}
+ // ---- the Threads list (expected_thread_list) -----------------------------
+
+ /// Every vector carrying `expected_thread_list`, against the pure selector:
+ /// one row per thread root, newest activity first, with the line each row is
+ /// headed by. Its own loop rather than a clause in the grouping test above,
+ /// because `thread_reply_root_unloaded` carries no `expected_threads` — and
+ /// that vector is the whole point of `root_loaded`.
+ ///
+ /// This is the twin of the web's "chat threads list: …" tests in
+ /// test/event-vectors.test.mjs: same file, same sequences, same expected
+ /// rows, so a Threads list that drifts on one platform fails on the other.
+ func testThreadListVectorsPinTheListAndItsOrder() throws {
+ let all = try ChatVectors.all().filter { $0["expected_thread_list"] != nil }
+ XCTAssertFalse(all.isEmpty, "chat.json must carry expected_thread_list vectors")
+ for v in all {
+ let name = (v["name"] as? String) ?? "?"
+ let folded = try ChatVectors.sequence(v)
+ var list = folded.messages
+ ChatFeedModel.applyConverts(&list, folded.converts)
+ ChatFeedModel.refreshReplyCounts(&list)
+
+ let spec = try XCTUnwrap(v["expected_thread_list"] as? [String: Any])
+ let want = try XCTUnwrap(spec["rows"] as? [[String: Any]], "\(name) expected_thread_list.rows")
+ let got = ChatFeedModel.threadListItems(list)
+ XCTAssertEqual(got.count, want.count, "\(name): the list's row count")
+ for (row, expected) in zip(got, want) {
+ let root = (expected["root"] as? NSNumber)?.int64Value
+ XCTAssertEqual(row.rootID, root, "\(name): row order / root")
+ XCTAssertEqual(row.rootLoaded, expected["root_loaded"] as? Bool, "\(name): row \(row.rootID) root_loaded")
+ XCTAssertEqual(row.name, expected["name"] as? String, "\(name): row \(row.rootID) name")
+ XCTAssertEqual(row.snippet, expected["snippet"] as? String, "\(name): row \(row.rootID) snippet")
+ XCTAssertEqual(row.count, (expected["count"] as? NSNumber)?.intValue, "\(name): row \(row.rootID) count")
+ }
+ // A row's tally IS its root's tally — the same number the bubble wears,
+ // so the list and the transcript can never disagree about a thread's size.
+ for row in got where row.rootLoaded {
+ XCTAssertEqual(list.first { $0.id == row.rootID }?.replyCount, row.count,
+ "\(name): row \(row.rootID) counts what its root tallies")
+ }
+ }
+ }
+
+ /// A still-pending optimistic reply is the newest thing in its thread, so the
+ /// thread you just replied in goes to the top — the same rule that keeps a
+ /// pending copy last inside a thread's own list. Hand-driven: the vectors
+ /// fold server frames and have no pending copies to carry. Web twin:
+ /// "chat: threadListItems floats a thread with a still-pending reply to the
+ /// top" in test/apps.test.mjs.
+ func testThreadListFloatsAPendingReplyToTheTop() {
+ func raw(_ text: String) -> MessagePayload {
+ var p = MessagePayload()
+ p.text = text
+ return p
+ }
+ var a = Message(id: 1, name: "Al", text: "first root", mine: false, at: 1, edited: false, raw: raw("first root"))
+ var b = Message(id: 2, name: "Bo", text: "second root", mine: false, at: 2, edited: false, raw: raw("second root"))
+ let older = Message(id: 3, name: "Cy", text: "in thread 1", mine: false, at: 3, edited: false,
+ reply: ReplyRef(id: 1, name: "Al", snippet: "first root", thread: true), raw: raw("in thread 1"))
+ let newer = Message(id: 4, name: "Cy", text: "in thread 2", mine: false, at: 4, edited: false,
+ reply: ReplyRef(id: 2, name: "Bo", snippet: "second root", thread: true), raw: raw("in thread 2"))
+ a.replyCount = 1
+ b.replyCount = 1
+ XCTAssertEqual(ChatFeedModel.threadListItems([a, b, older, newer]).map(\.rootID), [2, 1],
+ "newest reply first: thread 2 leads")
+
+ // Now answer the older thread, optimistically — no server id yet.
+ var pending = Message(id: 1 << 62, name: "Me", text: "just sent", mine: true, at: 5, edited: false,
+ reply: ReplyRef(id: 1, name: "Al", snippet: "first root", thread: true), raw: raw("just sent"))
+ pending.pending = true
+ let rows = ChatFeedModel.threadListItems([a, b, older, newer, pending])
+ XCTAssertEqual(rows.map(\.rootID), [1, 2], "the thread you just replied in is top of the list")
+ XCTAssertEqual(rows.first?.count, 2, "the pending copy counts — it is visibly in the thread")
+ }
+
+ /// The empty case the containers gate on: no thread, no rows, so the menu
+ /// offers no Threads entry at all.
+ func testThreadListIsEmptyWithoutThreads() {
+ XCTAssertTrue(ChatFeedModel.threadListItems([]).isEmpty, "an empty window has no threads")
+ XCTAssertTrue(ChatFeedModel.threadListItems(chainFixture()).isEmpty,
+ "a chat of nothing but inline quote replies has no threads at all")
+ }
+
private func assertRef(_ got: ReplyRef, _ want: [String: Any], _ label: String) {
XCTAssertEqual(got.id, (want["id"] as? NSNumber)?.int64Value, "\(label) — id")
XCTAssertEqual(got.name, want["name"] as? String, "\(label) — name")
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index d0762c7..f8c47f2 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -378,6 +378,27 @@ final class MacAppState: ObservableObject {
}
var nativeChatsOwner: String? // instance id, so a deactivating view only clears its own rows
@Published var nativeOpenThread: String?
+ // "Show the Threads list in this chat" — the header kebab's Threads row.
+ // The header is painted by NativeChatView and the right-hand dock lives in
+ // NativeFeedView, so the request crosses on this channel: the matching feed
+ // consumes it and nils it.
+ //
+ // It carries a TICK, unlike nativeJump, because the header outlives the
+ // feed: a group in Interface mode (and every app thread) draws the header
+ // over a pane with no NativeFeedView mounted, so a click there strands the
+ // request until the feed next appears. Without the tick the second click
+ // would write the same chat id, onChange would see no change, and the row
+ // would be dead for that chat for the rest of the session.
+ @Published var nativeReplyThreadList: NativeThreadListRequest?
+ struct NativeThreadListRequest: Equatable {
+ let thread: String
+ let tick: Int
+ }
+ private var threadListTick = 0
+ func showReplyThreadList(_ thread: String) {
+ threadListTick += 1
+ nativeReplyThreadList = .init(thread: thread, tick: threadListTick)
+ }
struct NativePaletteChat {
let id: String
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 09fc06f..25bcf5d 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -775,6 +775,12 @@ struct NativeChatView: View {
}
}
Menu {
+ // The chat's VIEWS come first — the surfaces you switch into —
+ // then the management verbs, the same order the web's ▤ menu
+ // lists them in. Today that's one row (Threads); Memory and bot
+ // views, which the web already lists, have a home here when
+ // they land natively.
+ ThreadsMenuItem(model: feedModel(t)) { app.showReplyThreadList(t.id) }
threadMenu(t)
} label: {
Image(systemName: "ellipsis")
@@ -1938,6 +1944,24 @@ private struct GroupAppsHeaderControls: View {
}
}
+// The kebab's Threads row, present exactly while this chat HAS a thread —
+// observing the chat's own model, so one someone else starts mid-conversation
+// surfaces the row without a reload, and the last one going takes it away
+// (the web's lchat:views-changed repaint). A chat where nobody has ever used
+// "Reply in Thread" is exactly as it was.
+private struct ThreadsMenuItem: View {
+ @ObservedObject var model: ChatFeedModel
+ let open: () -> Void
+
+ var body: some View {
+ if !model.threadListRows.isEmpty {
+ // Plain-text row: nothing else in this menu carries an icon.
+ Button("Threads…", action: open)
+ Divider()
+ }
+ }
+}
+
// The group feed surface: the plain native feed until an app view is picked,
// then the same split shape a companion app-thread uses — feed | handle |
// pane — with the pane showing the dropdown's selected app. Falls back to
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 217f4e5..56c1958 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -282,6 +282,11 @@ struct NativeFeedView: View {
@State private var editingID: Int64?
// The open reply-thread view's root id (nil = closed).
@State private var threadRootID: Int64?
+ // Is the Threads list showing in the right dock? (It and the thread pane
+ // share that one column — see the body.) It takes the caret while it's up,
+ // so Escape closes it rather than reaching the composer underneath.
+ @State private var showThreadList = false
+ @FocusState private var threadListFocused: Bool
// 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).
@@ -392,10 +397,48 @@ struct NativeFeedView: View {
// it. Insertion/removal animates with openThread/closeThread's
// withAnimation; the lifted-overlay presentation is the kept
// !threadDocked path inside feedAndComposer.
- if let rootID = threadRootID, threadDocked {
+ // The Threads list takes the SAME dock: there is only one, so the
+ // list and a thread are never both readable (the web made exactly
+ // this trade). Opening a thread from a row therefore drops the
+ // list as the thread arrives — which is what you'd want anyway:
+ // you came to find a thread, you found it.
+ //
+ // The list only BORROWS the dock, though: it lays OVER the open
+ // pane rather than replacing it, so a thread you were mid-reply in
+ // keeps its half-typed text — bold runs, caret and undo included —
+ // while you look something up, and closing the list hands it
+ // straight back. (Replacing the pane looked equivalent because the
+ // draft string lives on THIS view, but the rich content lives in
+ // the composer's own text storage, which dies with the view: the
+ // reply would come back plain, with the caret at the end and the
+ // undo stack gone. The web's panel evicts the thread outright for
+ // the same reason — its pane composer dies with the DOM node.)
+ // ONE right column, whatever is standing in it. The list and the
+ // pane used to be separate branches, so opening a thread from a
+ // row removed one column and inserted another — and the feed
+ // reflowed wide and back in the gap between them. Kept as a single
+ // container, the column never leaves: only its contents cross-fade,
+ // and the transition below is just for the column itself arriving
+ // or leaving.
+ if threadRootID != nil || showThreadList {
Divider()
- threadPane(rootID)
- .transition(.move(edge: .trailing).combined(with: .opacity))
+ ZStack {
+ if let rootID = threadRootID, threadDocked {
+ threadPane(rootID)
+ .accessibilityHidden(showThreadList) // covered: not in the reading order
+ // Explicitly a FADE, not the default. Inserted
+ // inside a withAnimation, SwiftUI otherwise
+ // animates the new subtree's own geometry — the
+ // composer measuring its field, the scroll view
+ // taking its size — and the pane visibly grows
+ // into place. The column is already the right
+ // size; only the contents should change.
+ .transition(.opacity)
+ }
+ if showThreadList { threadListPane.transition(.opacity) }
+ }
+ .frame(width: 340)
+ .transition(.move(edge: .trailing).combined(with: .opacity))
}
}
.alert("Integrity check", isPresented: .init(
@@ -742,6 +785,8 @@ struct NativeFeedView: View {
// Search hits land on their message: page history until the
// target is loaded, then jump-and-wave.
.onChange(of: app.nativeJump) { _, _ in consumeJump(proxy) }
+ // The header kebab's Threads row (a view up — see MacAppState).
+ .onChange(of: app.nativeReplyThreadList) { _, _ in consumeThreadListRequest() }
// ⌘R / ⌘T (Message ▸ Reply to / React to Latest Message, issue
// #392) — the web feed's two keyboard message verbs, which the Mac
// never had. Both act on the surface in FRONT of the reader: the
@@ -830,6 +875,9 @@ struct NativeFeedView: View {
pinUntilSettled(proxy)
}
consumeJump(proxy)
+ // A Threads click made while this feed wasn't mounted (the
+ // chat was showing an app's Interface) waits for this.
+ consumeThreadListRequest()
}
// Ends the mount span one turn after the reveal commits, so the
// revealed frame's layout is measured — cold fetch, pin/restore
@@ -1899,6 +1947,83 @@ struct NativeFeedView: View {
}
}
+ // ---- the Threads list (chat.js paintThreadList / the ▤ Threads row) -----
+
+ // This chat's threads as a right-hand column — the SAME 340pt dock the
+ // thread pane uses, with the same chrome, because it is the same slot.
+ // Rows open a thread, which swaps this column for that thread's pane.
+ private var threadListPane: some View {
+ // openThread drops the list itself (one dock), so the column swaps in
+ // one update rather than sliding out and back in.
+ ReplyThreadListView(model: model, theme: theme,
+ onOpen: { root in openThread(root) },
+ onClose: closeThreadList)
+ .frame(width: 340)
+ .background(theme.panel.resolved(scheme))
+ // The list OWNS the focus while it's up (openThreadList claims it),
+ // so Escape lands here rather than on the composer it covered —
+ // whose own Escape means "cancel the reply", not "close this".
+ // Never destructive: unlike a composer, an index holds nothing you
+ // could lose, so it needs no draft guard.
+ .focusable()
+ .focused($threadListFocused)
+ // …but it is not a tab stop, so it draws no ring: an index is not
+ // a control, and macOS would outline the whole 340pt column. The
+ // rows inside are real buttons and keep their own focus rings, so
+ // keyboard focus stays visible where it means something.
+ .focusEffectDisabled()
+ .onExitCommand { closeThreadList() }
+ // A complementary landmark beside the feed, never a modal — the
+ // same standing the thread pane has.
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("Threads")
+ }
+
+ // The list borrows the dock from whatever is in it (see the body) — an
+ // open thread is left alone, composer and all, and comes back when the
+ // list closes. It does take the CARET, though: a column you can't dismiss
+ // from the keyboard is a trap, and the covered composer must not keep
+ // first responder under it.
+ private func openThreadList() {
+ withAnimation(.easeOut(duration: 0.18)) { showThreadList = true }
+ guard isActive else { return }
+ paneFocused = false
+ composerFocused = false
+ threadListFocused = true
+ }
+
+ private func closeThreadList() {
+ guard showThreadList else { return }
+ withAnimation(.easeOut(duration: 0.15)) { showThreadList = false }
+ // Closing a column must never leave the caret nowhere. Handing the
+ // dock back to a thread that was already open puts it in that pane's
+ // composer, where the reader left it; otherwise it goes to the channel
+ // bar. Both gated on the pane actually being on screen — every visited
+ // space stays mounted, and a parked one must never steal first
+ // responder from the visible space.
+ guard isActive else { return }
+ if threadRootID != nil, threadDocked {
+ paneFocused = true
+ } else {
+ composerFocused = true
+ }
+ }
+
+ // The header kebab's Threads row, arriving over MacAppState (the header is
+ // painted a view up). Consumed once, like a search hit's jump — and only
+ // by the feed the request names, since every visited space stays mounted.
+ //
+ // Also drained on appear, for the same reason consumeJump is: the header
+ // is drawn in states where this feed isn't mounted at all (a group in
+ // Interface mode, an app thread's pane), so a click there waits here and
+ // opens the list when the conversation comes back — rather than being
+ // silently dropped.
+ private func consumeThreadListRequest() {
+ guard isActive, app.nativeReplyThreadList?.thread == threadID else { return }
+ app.nativeReplyThreadList = nil
+ openThreadList()
+ }
+
// ---- reply-thread view (chat.js openThread/closeThread/stageThreadReply) --
// The lifted thread panel over the dimmed feed pane. Rows reuse the exact
@@ -2264,6 +2389,21 @@ struct NativeFeedView: View {
}
private func openThread(_ rootID: Int64) {
+ // Whether the right column is ALREADY standing, read before
+ // closeThreadList() below clears the flag it depends on.
+ let columnStanding = showThreadList || threadRootID != nil
+ // One right dock: a thread takes it back from the Threads list,
+ // however the thread was opened (a row, a tally, a jump) — the list is
+ // never left over the pane. Ahead of the guard, so a row for the
+ // thread ALREADY open still returns you to it, caret and all: the
+ // guard below leaves early, and nothing after it would run.
+ if showThreadList {
+ // Same transaction as the open below where one follows the other,
+ // so the column's contents cross-fade once instead of racing two
+ // animations against each other.
+ closeThreadList()
+ if threadRootID == rootID { return }
+ }
guard rootID > 0, threadRootID != rootID else { return }
// A fresh open lands at the newest reply — never on a stale jump
// target from an earlier one (jumpIntoThread re-aims right after).
@@ -2271,7 +2411,15 @@ struct NativeFeedView: View {
// Switching roots with the pane open: the old thread's half-typed
// draft or in-flight edit must not leak into the new one.
if threadDocked, threadRootID != nil { resetPaneComposer() }
- withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID }
+ // Animate the ARRIVAL of the column, not a swap inside one that is
+ // already standing: coming from the list, the cross-fade above is the
+ // whole transition, and animating the mount as well is what made a
+ // first-time pane grow into place.
+ if columnStanding {
+ threadRootID = rootID
+ } else {
+ withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID }
+ }
if threadDocked {
// The channel bar stays on the channel — no staging; the pane's
// composer derives its reply fresh at send. Opening puts the
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs
index 8668093..39baa7e 100644
--- a/test/event-vectors.test.mjs
+++ b/test/event-vectors.test.mjs
@@ -3,7 +3,7 @@ import { allVectors } from "./event-vectors/index.js";
// The reply-thread rollup is tested against the REAL implementation (chat.js
// exports both as pure functions), not a reconstruction — grouping rules are
// too easy to fork silently.
-import { viewItems, quoteTarget, threadTarget, threadItems, inMainFeed, bySidIndex, claimConverts, applyConverts, inlineChainIds } from "../web/static/apps/chat.js";
+import { viewItems, quoteTarget, threadTarget, threadItems, threadListItems, threadListSignature, inMainFeed, bySidIndex, claimConverts, applyConverts, inlineChainIds } from "../web/static/apps/chat.js";
// Views fold against the REAL app (views.js exports it) — its whole job is
// clamping hostile events, so a reconstruction would test the wrong thing.
import { viewsApp } from "../web/static/apps/views.js";
@@ -388,6 +388,34 @@ allVectors.chat.forEach((vector) => {
});
}
+ // The Threads list, over the same folded sequences: one row per thread root,
+ // newest activity first. Its own loop (not folded into the grouping test
+ // below) so it also covers thread_reply_root_unloaded, which carries no
+ // expected_threads — and that vector is the whole point of `root_loaded`.
+ if (vector.expected_thread_list) {
+ test(`chat threads list: ${vector.name} — one row per thread, freshest first`, () => {
+ const state = foldSequence(vector.message_sequence);
+ const rows = threadListItems(state.msgs);
+ assert.deepStrictEqual(
+ rows.map((r) => ({ root: r.rootSid, root_loaded: r.rootLoaded, name: r.name, snippet: r.snippet, count: r.count })),
+ vector.expected_thread_list.rows,
+ "the Threads list's rows, in the order it shows them");
+ // The open panel's repaint gate reads the SHOWN fields and nothing else:
+ // a fold that only moves the row's `last` pointer (an ordinary reply
+ // landing in a thread already top of the list) must not rebuild the
+ // buttons under a keyboard user, while anything the row displays must.
+ if (rows.length) {
+ const sig = threadListSignature(rows);
+ assert.strictEqual(threadListSignature(rows.map((r) => ({ ...r, last: { sid: 999999 } }))), sig,
+ "a change to `last` alone — which the row never shows — must not repaint");
+ for (const [field, changed] of [["count", 42], ["name", "Someone else"], ["snippet", "different words"]]) {
+ assert.notStrictEqual(threadListSignature([{ ...rows[0], [field]: changed }, ...rows.slice(1)]), sig,
+ `a change to ${field} — which the row shows — must repaint`);
+ }
+ }
+ });
+ }
+
// Reply-thread grouping vectors: fold the whole sequence, then assert the
// thread rollup. A thread is its root plus the replies FLAGGED into it —
// an inline quote chain resolves to nothing and tallies nowhere — plus the
diff --git a/test/event-vectors/chat.json b/test/event-vectors/chat.json
index a101dc3..2ed64a5 100644
--- a/test/event-vectors/chat.json
+++ b/test/event-vectors/chat.json
@@ -194,6 +194,12 @@
}
}
],
+ "expected_thread_list": {
+ "note": "The root paged out, so the row is headed by the snapshot its FLAGGED reply carries (root_loaded false) — the same stand-in the thread view's own header uses. The thread is still listed: it is exactly what a member came looking for.",
+ "rows": [
+ { "root": 100, "root_loaded": false, "name": "Alice", "snippet": "Hello everyone", "count": 1 }
+ ]
+ },
"expected_main_feed": {
"105": true
}
@@ -353,6 +359,12 @@
],
"104": []
},
+ "expected_thread_list": {
+ "note": "The Threads list's rows, in the order it shows them (web threadListItems(msgs); native ChatFeedModel.threadListItems(_:)). One row per thread ROOT: the line you recognise it by, how many replies it holds, and whether that root is in the loaded window. Membership is exactly threadRootOf/threadRootID(of:) — the same read the root's 'N Replies' tally makes — so an inline quote chain contributes NO row. Order is by the thread's LAST message under the feed's own comparator (confirmed by id, a still-pending optimistic copy last), newest thread first. Here only 104 is in a thread, so there is ONE row (root 100, one reply) — the three inline replies 101/102/103 list nothing.",
+ "rows": [
+ { "root": 100, "root_loaded": true, "name": "Alice", "snippet": "Hello everyone", "count": 1 }
+ ]
+ },
"expected_main_feed": {
"100": true,
"101": true,
@@ -454,6 +466,10 @@
],
"302": []
},
+ "expected_thread_list": {
+ "note": "No thread anywhere, so NO rows — and with no rows the Threads entry is not offered at all (web hides the ▤ row; native hides the menu item). A chat full of inline replies looks exactly as it did before threads existed.",
+ "rows": []
+ },
"expected_main_feed": {
"300": true,
"301": true,
@@ -865,6 +881,12 @@
],
"note": "The converted chain reads in the thread in server order, root first — and 401, itself converted, has no thread of its own."
},
+ "expected_thread_list": {
+ "note": "A converted chain lists like any other thread: one row on root 400, counting the two messages the conversion moved.",
+ "rows": [
+ { "root": 400, "root_loaded": true, "name": "Alice", "snippet": "Where should we do dinner?", "count": 2 }
+ ]
+ },
"expected_main_feed": {
"400": true,
"401": false,
@@ -995,6 +1017,12 @@
502
]
},
+ "expected_thread_list": {
+ "note": "The conversion folded before its members arrived, and the row still counts both — the list reads the same projected state the feed does.",
+ "rows": [
+ { "root": 500, "root_loaded": true, "name": "Alice", "snippet": "Standup notes?", "count": 2 }
+ ]
+ },
"expected_main_feed": {
"500": true,
"501": false,
@@ -1172,6 +1200,12 @@
],
"note": "601's own thread is just 601: the messages a later conversion aimed at it flattened onto 600, because 601 had already been moved there."
},
+ "expected_thread_list": {
+ "note": "Overlapping conversions flatten onto the outermost root, so this is ONE row of three replies, never a row per conversion.",
+ "rows": [
+ { "root": 600, "root_loaded": true, "name": "Alice", "snippet": "Bug in the composer", "count": 3 }
+ ]
+ },
"expected_main_feed": {
"600": true,
"601": false,
@@ -1290,6 +1324,10 @@
701
]
},
+ "expected_thread_list": {
+ "note": "A cycle resolves to no conversion at all, so nothing is in a thread and the list is empty.",
+ "rows": []
+ },
"expected_main_feed": {
"700": true,
"701": true
@@ -1462,6 +1500,13 @@
],
"note": "802 is in 795's thread because 821 claimed it first — not in 800's, which 822 asked for a beat later."
},
+ "expected_thread_list": {
+ "note": "The ordering rule, in one vector: two threads, and the row for root 795 comes FIRST even though 800 is the newer root — 795's thread holds 802, which is the newer message. The list answers 'where is the conversation', not 'which root is newest'.",
+ "rows": [
+ { "root": 795, "root_loaded": true, "name": "Dana", "snippet": "Standup notes", "count": 1 },
+ { "root": 800, "root_loaded": true, "name": "Alice", "snippet": "Shall we move the standup?", "count": 1 }
+ ]
+ },
"expected_main_feed": {
"795": true,
"800": true,
@@ -1596,6 +1641,12 @@
],
"note": "Only the message that really replied to 900 is in its thread; naming the other two moved nothing."
},
+ "expected_thread_list": {
+ "note": "Only the id whose claim actually held (903) is in a thread, so the row counts one reply, not the three the event named.",
+ "rows": [
+ { "root": 900, "root_loaded": true, "name": "Alice", "snippet": "Kicking off the migration", "count": 1 }
+ ]
+ },
"expected_main_feed": {
"900": true,
"901": true,