eleven / ocho Public

mac: per-thread + client-wide debug event windows (Debug builds) (#1394)

* mac: per-thread + client-wide debug event windows (Debug builds)

Every WebSocket frame an instance socket receives is now recorded to an
in-memory log (ThreadEventLog, capped at 2,000 entries). Two windows read
it, both Debug-build-only like ClickLab:

- "Debug Events" in a thread's context menu: that thread's frames, newest
  first, each expandable to the raw wire JSON — plus a band showing the
  live inputs of the sidebar's unread rule (lastAt / seen watermark /
  manualUnreadAt and which clause fires), since the dot is computed at
  refresh, never set by a frame, and "unread with no events" is otherwise
  undiagnosable.
- Debug ▸ All Received Events: everything from every mounted space,
  including thread-less frames (presence, apps_changed), tagged
  "host › thread".

ChatSocket.Frame gains a `raw` field carrying the original frame text,
attached after decode and excluded from CodingKeys so decoding is
unchanged. Blobs stay ciphertext — the windows never decrypt anything.

Mac-only by design — a multi-window debug rig has no iOS shape.

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

* mac: debug windows show decrypted payloads

The blob was displayed as ciphertext only by first-cut caution, not
necessity — the E2E rule binds the server; the client holds every thread
key and decrypts all of this for the UI anyway. Frames now decrypt at
record time with the thread's key from the store: the summary line shows
the payload's inner event type ("reaction", "todo.add", or "chat" for a
plain message), and the expanded row shows the decrypted JSON above the
wire frame. Threads without a key in the sidebar store (the space
thread) still show ciphertext, marked "(no key)".

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

* mac: debug windows surface unread flips with the rule's inputs

The App Studio mystery in one window: the dot flips on with no frame for
the thread, so the flip itself is now an event. ThreadStore gains a
debug tap (debugUnreadChanged, nil outside the Mac Debug build) fired
when a refresh publishes a row whose unread state changed; ThreadDebug
wires it into the log as a synthetic "unread" entry carrying old/new
lastAt, the seen watermark, the manual flag, and the last-message-mine
attribution — beside whatever frames did (or didn't) arrive around it.
ChatThread carries the fold's lastMine (optional, snapshot-safe) so the
per-thread window's band shows the one input it couldn't before.

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

* core: an empty thread is never unread — the App Studio phantom dot

The unread rule's manual clause compared `manualUnreadAt == lastAt`
bare, and both are 0 for a thread with no messages (0 = "no manual
flag"; 0 = "no messages") — so every empty thread computed unread on
every refresh. Opening it cleared the dot in place; the next refresh
(any frame anywhere triggers one) recomputed it back on. Found via the
debug windows' unread-flip entries: lastAt 0→0, seen 0, manual 0,
flipping false→true. The clause now requires a real flag (> 0); pinned
by the empty-thread case in ReadSyncTests. Shared core, so this fixes
iOS too.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
James Adam c891a112a2b82a3d64543bcf08b08489d873e702 parent 8aedc69
7 files changed, +450 −3
  • ios/LChatCore/Sources/LChatCore/ChatSocket.swift +10 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +5 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +27 −3
  • ios/LChatCore/Tests/LChatCoreTests/ReadSyncTests.swift +6 −0
  • ios/LChatMac/LChatMacApp.swift +13 −0
  • ios/LChatMac/NativeChatView.swift +12 −0
  • ios/LChatMac/ThreadDebug.swift +377 −0
diff --git a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
index bb337e3..72c3490 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
@@ -23,6 +23,15 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
public let presence: String? // "presence" frames: "online" | "away" | "offline"
public let last_read_at: Int64? // "read" frames: our watermark, moved on another device
public let kind: String? // "apps_changed" frames: "created" | "updated" | "removed"
+ // The wire text this frame decoded from, attached after decode (never
+ // a server field — CodingKeys excludes it): debug surfaces show what
+ // actually arrived, fields the lenient decode ignores included.
+ public var raw: String? = nil
+
+ private enum CodingKeys: String, CodingKey {
+ case type, thread_id, id, created_at, blob, edited_at, ephemeral,
+ user_id, presence, last_read_at, kind
+ }
}
/// Delivered on the main thread.
@@ -168,6 +177,7 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
var frame: Frame?
if case .success(.string(let text)) = result {
frame = try? JSONDecoder().decode(Frame.self, from: Data(text.utf8))
+ frame?.raw = text
}
let failed: Bool
if case .failure = result { failed = true } else { failed = false }
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index 0079fa7..00e7626 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -372,6 +372,11 @@ public struct ChatThread: Identifiable, Equatable, Codable {
public var preview: String // last-message snippet
public var lastAt: Int64 // seconds; drives ordering + the relative time
public var unread: Bool
+ // The fold's "last message is yours" attribution — the `!lastMine` input
+ // of the unread rule, carried so debug surfaces (ThreadDebug.swift) can
+ // show why a dot is on. Optional so parked snapshots from before the
+ // field decode; nil reads as unknown.
+ public var lastMine: Bool? = nil
public var pinned: Bool // user pinned this chat to the top (set on the web/Mac client)
public var hidden: Bool // user "closed" this chat: dropped from the list, still findable in search
public var dmClosed = false // an ended DM (mutual, server-enforced): the composer gives way to the closed banner (#578)
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index a2c3b08..f028f9b 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -389,7 +389,21 @@ public final class ThreadStore: ObservableObject {
// we have and let the next refresh reconcile, rather than blanking a
// list the member is looking at (and its snapshot with it).
if !folded.rows.isEmpty || threads.isEmpty {
- if threads != folded.rows { threads = folded.rows }
+ if threads != folded.rows {
+ // Debug tap (Mac's ThreadDebug window): surface every unread
+ // flip a refresh causes, with the old and new rows — the dot
+ // is recomputed here from server truth + the local watermark,
+ // so a "phantom" flip is only diagnosable at this seam.
+ if let tap = Self.debugUnreadChanged {
+ let old = Dictionary(uniqueKeysWithValues: threads.map { ($0.id, $0) })
+ for row in folded.rows {
+ if let prev = old[row.id], prev.unread != row.unread {
+ tap(instance, prev, row)
+ }
+ }
+ }
+ threads = folded.rows
+ }
}
// Park the rows for next time: the hydrate() above is what makes
// re-entering a space instant across launches. Off-main, dedup-skipped
@@ -744,6 +758,7 @@ public final class ThreadStore: ObservableObject {
unread: Self.isUnread(lastAt: lastAt, lastMine: lastMine,
seen: ReadState.lastSeen(instance: instance.id, thread: t.id),
manualUnreadAt: ReadState.manualUnreadAt(instance: instance.id, thread: t.id)),
+ lastMine: lastMine,
pinned: t.pinned ?? false,
hidden: t.hidden ?? false,
dmClosed: t.dm_closed ?? false,
@@ -1561,6 +1576,11 @@ public final class ThreadStore: ObservableObject {
/// has no per-thread notification-centre entries yet.
public static var onMarkRead: ((String) -> Void)?
+ /// Debug tap: a refresh flipped a row's unread state (old row, new row).
+ /// Wired by the Mac's Debug-build event windows (ThreadDebug.swift);
+ /// nil everywhere else. Called on the main actor.
+ public static var debugUnreadChanged: ((SavedInstance, ChatThread, ChatThread) -> Void)?
+
// The server's last-known read watermark per thread, so markRead only POSTs
// when it actually advances the synced mark (opening an already-read chat,
// or a refresh-tail re-mark, stays a local no-op).
@@ -1569,9 +1589,13 @@ public final class ThreadStore: ObservableObject {
// The one unread rule, shared by refresh() and applyReadFrame(): someone
// else's message newer than the watermark — or an explicit device-local
// mark-as-unread of exactly the current newest message (a newer arrival or
- // the watermark passing it retires the manual flag naturally).
+ // the watermark passing it retires the manual flag naturally). The manual
+ // clause needs a real flag (> 0): 0 means "none", and a thread with no
+ // messages has lastAt 0 too, so a bare == made every EMPTY thread read as
+ // unread on every refresh — the App Studio phantom-dot bug (opening
+ // cleared the dot in place; the next refresh recomputed it back on).
nonisolated static func isUnread(lastAt: Int64, lastMine: Bool, seen: Int64, manualUnreadAt: Int64) -> Bool {
- !lastMine && (lastAt > seen || manualUnreadAt == lastAt)
+ !lastMine && (lastAt > seen || (manualUnreadAt > 0 && manualUnreadAt == lastAt))
}
/// Clear a thread's unread state once it's opened — locally, and via the
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ReadSyncTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ReadSyncTests.swift
index 84573de..f950c54 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/ReadSyncTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/ReadSyncTests.swift
@@ -25,6 +25,12 @@ final class ReadSyncTests: XCTestCase {
XCTAssertFalse(ThreadStore.isUnread(lastAt: 120, lastMine: false, seen: 120, manualUnreadAt: 100))
// …and never overrides the own-message rule.
XCTAssertFalse(ThreadStore.isUnread(lastAt: 100, lastMine: true, seen: 100, manualUnreadAt: 100))
+
+ // An EMPTY thread (no messages, so lastAt 0) is never unread: 0 also
+ // means "no manual flag", and the bare `manualUnreadAt == lastAt`
+ // matched 0 == 0 — every empty thread relit its dot on every refresh
+ // (the App Studio phantom dot, caught by the debug event window).
+ XCTAssertFalse(ThreadStore.isUnread(lastAt: 0, lastMine: false, seen: 0, manualUnreadAt: 0))
}
func testManualUnreadStorage() {
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 17ebcf8..5ea75c1 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -37,6 +37,11 @@ struct LChatMacApp: App {
// Sockets heartbeat "active" while the app is frontmost (presence +
// push suppression) — wired per platform because LChatCore can't see AppKit.
ChatSocket.appIsActive = { NSApp.isActive }
+#if DEBUG
+ // Unread flips land in the Debug Events windows beside the frames
+ // that did (or didn't) cause them (ThreadDebug.swift).
+ ThreadEventLog.installStoreTaps()
+#endif
}
var body: some Scene {
@@ -143,6 +148,14 @@ struct LChatMacApp: App {
CommandGroup(after: .appInfo) {
CheckForUpdatesView(updater: appDelegate.updaterController.updater)
}
+#if DEBUG
+ // Dev builds only: the client-wide event log — every WebSocket
+ // frame from every mounted space (ThreadDebug.swift). The
+ // per-thread view lives in the thread context menu.
+ CommandMenu("Debug") {
+ Button("All Received Events") { ThreadDebugWindows.showAll() }
+ }
+#endif
}
Settings {
TabView {
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 1288ba8..f2df4ee 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -816,6 +816,12 @@ struct NativeChatView: View {
if t.kind == "dm", !t.dmClosed {
Button("End Conversation…", role: .destructive) { endConversationTarget = t }
}
+#if DEBUG
+ Divider()
+ // Dev builds only (ClickLab's gate): a floating window of this
+ // thread's raw hub frames + its unread inputs — see ThreadDebug.swift.
+ Button("Debug Events") { ThreadDebugWindows.show(thread: t, store: store) }
+#endif
}
// The End-conversation confirm's target thread (#578) — nil = no dialog.
@@ -1645,6 +1651,12 @@ struct NativeChatView: View {
guard liveSocket == nil, let base = instance.baseURL else { return }
let socket = ChatSocket(baseURL: base, token: instance.deviceToken)
socket?.onFrame = { frame in
+#if DEBUG
+ // Every frame for the instance lands on this one socket — feed the
+ // Debug Events windows before any routing/early return. The store
+ // rides along for thread keys (payloads display decrypted).
+ ThreadEventLog.shared.record(frame, store: store)
+#endif
// Presence needs no refetch — fold the transition straight into the
// store (rows re-render off the published change).
if frame.type == "presence", let uid = frame.user_id, let state = frame.presence {
diff --git a/ios/LChatMac/ThreadDebug.swift b/ios/LChatMac/ThreadDebug.swift
new file mode 100644
index 0000000..83820b4
--- /dev/null
+++ b/ios/LChatMac/ThreadDebug.swift
@@ -0,0 +1,377 @@
+import LChatCore
+import SwiftUI
+import AppKit
+
+// 🤖 The event debugger (Debug builds only, like ClickLab): every hub frame
+// any instance socket receives is recorded here — thread-scoped frames and
+// instance-wide ones (presence, apps_changed, space_thread) alike. Two
+// windows read the log: "Debug Events" in a thread's context menu (that
+// thread's frames plus the live inputs of its unread computation), and
+// Debug ▸ All Received Events (everything, across every mounted space) —
+// the native counterpart of watching /ws traffic in the web inspector.
+// The raw wire text rides on each frame (ChatSocket.Frame.raw) so the
+// windows show exactly what arrived, including fields the lenient decode
+// drops. Ciphertext blobs stay ciphertext — nothing here decrypts anything.
+#if DEBUG
+
+@MainActor
+final class ThreadEventLog: ObservableObject {
+ static let shared = ThreadEventLog()
+
+ struct Entry: Identifiable {
+ let id: Int
+ let at: Date
+ let origin: String
+ let threadID: String?
+ let type: String
+ let summary: String
+ let raw: String
+ // The blob's plaintext JSON, decrypted at record time with the
+ // thread's key from the store (nil when there's no blob or no key —
+ // e.g. the space thread, which isn't in the sidebar's list). Local
+ // display of the user's own data on their own device; the E2E rule
+ // binds the server, not this window.
+ let decrypted: String?
+ }
+
+ // One global stream, newest last — the per-thread window filters it.
+ // Capped so a chatty space can't grow the log without limit for the
+ // app's whole run.
+ @Published private(set) var all: [Entry] = []
+ private var nextID = 0
+ private static let cap = 2000
+
+ /// Called from each instance socket's onFrame (main thread) with every
+ /// frame — several mounted spaces' sockets record here at once. The
+ /// store supplies the thread keys for payload decryption.
+ func record(_ frame: ChatSocket.Frame, store: ThreadStore) {
+ var decrypted: String?
+ var payloadType: String?
+ if let blob = frame.blob, let tid = frame.thread_id,
+ let keyB64 = store.threads.first(where: { $0.id == tid })?.threadKeyB64,
+ let key = try? GroupKey(base64url: keyB64),
+ let data = try? key.decryptData(blob: blob) {
+ decrypted = Self.pretty(String(data: data, encoding: .utf8))
+ // The payload's inner event type ("reaction", "todo.add", …) —
+ // plain chat messages carry none.
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ payloadType = obj?["type"] as? String
+ }
+ append(Entry(id: nextID, at: Date(), origin: store.instance.origin,
+ threadID: frame.thread_id, type: frame.type,
+ summary: Self.summary(frame, payloadType: payloadType,
+ decrypted: decrypted != nil),
+ raw: Self.pretty(frame.raw), decrypted: decrypted))
+ }
+
+ func clear() { all = [] }
+ func clear(origin: String, threadID: String) {
+ all.removeAll { $0.origin == origin && $0.threadID == threadID }
+ }
+
+ /// Wires the store's unread-flip tap into the log (called once at app
+ /// start): every dot a refresh turns on or off becomes a synthetic
+ /// "unread" entry carrying the rule's inputs, so a phantom flip shows up
+ /// in the same window as the frames that did (or didn't) cause it.
+ static func installStoreTaps() {
+ ThreadStore.debugUnreadChanged = { instance, prev, row in
+ let seen = ReadState.lastSeen(instance: instance.id, thread: row.id)
+ let manual = ReadState.manualUnreadAt(instance: instance.id, thread: row.id)
+ let mine = row.lastMine.map { $0 ? "yes" : "no" } ?? "unknown"
+ let detail: [String: Any] = [
+ "event": "refresh flipped unread",
+ "unread": ["was": prev.unread, "now": row.unread],
+ "lastAt": ["was": prev.lastAt, "now": row.lastAt],
+ "seen_watermark": seen,
+ "manual_unread_at": manual,
+ "last_message_mine": mine,
+ "title": row.title,
+ ]
+ let raw = (try? JSONSerialization.data(withJSONObject: detail,
+ options: [.prettyPrinted, .sortedKeys]))
+ .flatMap { String(data: $0, encoding: .utf8) } ?? "\(detail)"
+ let summary = "\(prev.unread ? "YES" : "no") → \(row.unread ? "YES" : "no")"
+ + " · lastAt \(row.lastAt)\(row.lastAt == prev.lastAt ? "" : " (was \(prev.lastAt))")"
+ + " · seen \(seen) · manual \(manual) · mine \(mine)"
+ shared.append(Entry(id: shared.nextID, at: Date(), origin: instance.origin,
+ threadID: row.id, type: "unread",
+ summary: summary, raw: raw, decrypted: nil))
+ }
+ }
+
+ private func append(_ entry: Entry) {
+ nextID += 1
+ all.append(entry)
+ if all.count > Self.cap { all.removeFirst(all.count - Self.cap) }
+ }
+
+ // One line of the frame's interesting fields, skipping what it doesn't
+ // carry. A decrypted blob shows its inner event type (or "chat" for a
+ // plain message); an undecryptable one stays a ciphertext length.
+ private static func summary(_ f: ChatSocket.Frame, payloadType: String?,
+ decrypted: Bool) -> String {
+ var parts: [String] = []
+ if let id = f.id { parts.append("id \(id)") }
+ if let uid = f.user_id { parts.append("user \(uid)") }
+ if let p = f.presence { parts.append(p) }
+ if let k = f.kind { parts.append(k) }
+ if let at = f.edited_at { parts.append("edited_at \(at)") }
+ if let at = f.last_read_at { parts.append("last_read_at \(at)") }
+ if f.ephemeral == true { parts.append("ephemeral") }
+ if let blob = f.blob {
+ if decrypted {
+ parts.append("payload \(payloadType ?? "chat")")
+ } else {
+ parts.append(blob.count == 1 ? "blob 1 char (no key)"
+ : "blob \(blob.count.formatted()) chars (no key)")
+ }
+ }
+ return parts.joined(separator: " · ")
+ }
+
+ private static func pretty(_ raw: String?) -> String {
+ guard let raw else { return "(no raw frame text captured)" }
+ guard let obj = try? JSONSerialization.jsonObject(with: Data(raw.utf8)),
+ let data = try? JSONSerialization.data(
+ withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
+ let s = String(data: data, encoding: .utf8) else { return raw }
+ return s
+ }
+}
+
+// One debug window per thread (plus one global), keyed so re-invoking the
+// menu item fronts the existing window instead of stacking duplicates.
+// Hand-rolled NSWindows, per LChatMacApp's single-Window rule — breakout
+// surfaces are their own windows, never extra copies of the app scene.
+@MainActor
+enum ThreadDebugWindows {
+ private static var windows: [String: NSWindow] = [:]
+ private static var closeObservers: [String: NSObjectProtocol] = [:]
+
+ static func show(thread: ChatThread, store: ThreadStore) {
+ let origin = store.instance.origin
+ present(key: "\(origin)|\(thread.id)", title: thread.title,
+ subtitle: "Events — \(thread.id)") {
+ AnyView(ThreadDebugView(origin: origin, threadID: thread.id, store: store))
+ }
+ }
+
+ static func showAll() {
+ present(key: "*all*", title: "All Received Events",
+ subtitle: "Every WebSocket frame, all spaces") {
+ AnyView(AllEventsDebugView())
+ }
+ }
+
+ private static func present(key: String, title: String, subtitle: String,
+ content: () -> AnyView) {
+ if let w = windows[key] {
+ w.makeKeyAndOrderFront(nil)
+ return
+ }
+ let w = NSWindow(contentViewController: NSHostingController(rootView: content()))
+ w.title = title
+ w.subtitle = subtitle
+ w.setContentSize(NSSize(width: 680, height: 480))
+ // The dict keeps the window alive; releasedWhenClosed would have
+ // AppKit deallocate it under us on close.
+ w.isReleasedWhenClosed = false
+ windows[key] = w
+ closeObservers[key] = NotificationCenter.default.addObserver(
+ forName: NSWindow.willCloseNotification, object: w, queue: .main
+ ) { _ in
+ Task { @MainActor in
+ windows[key] = nil
+ if let o = closeObservers.removeValue(forKey: key) {
+ NotificationCenter.default.removeObserver(o)
+ }
+ }
+ }
+ w.center()
+ w.makeKeyAndOrderFront(nil)
+ }
+}
+
+// The per-thread window: that thread's frames, under a band showing the live
+// inputs of the sidebar's unread rule (ThreadStore.isUnread) — the dot is
+// computed at refresh from these, never set directly by a frame, so "unread
+// with no events" is answered here, not in the list below.
+private struct ThreadDebugView: View {
+ let origin: String
+ let threadID: String
+ @ObservedObject var store: ThreadStore
+ @ObservedObject private var log = ThreadEventLog.shared
+
+ var body: some View {
+ let entries = log.all.filter { $0.origin == origin && $0.threadID == threadID }
+ VStack(spacing: 0) {
+ unreadBand
+ Divider()
+ DebugEventList(entries: entries, showSource: false) {
+ log.clear(origin: origin, threadID: threadID)
+ }
+ }
+ .frame(minWidth: 460, minHeight: 320)
+ }
+
+ // Re-renders whenever the store publishes (refresh, markRead), so the
+ // watermark values here are as fresh as the sidebar's own dot.
+ private var unreadBand: some View {
+ let row = store.threads.first { $0.id == threadID }
+ let seen = ReadState.lastSeen(instance: store.instance.id, thread: threadID)
+ let manual = ReadState.manualUnreadAt(instance: store.instance.id, thread: threadID)
+ let lastAt = row?.lastAt ?? 0
+ let why: String
+ if row == nil {
+ why = "row not in store"
+ } else if lastAt > seen && manual == lastAt {
+ why = "watermark behind AND manual flag"
+ } else if lastAt > seen {
+ why = "watermark behind lastAt"
+ } else if manual == lastAt, manual != 0 {
+ why = "manual mark-as-unread"
+ } else {
+ why = "read (or last message is yours)"
+ }
+ let mine = row?.lastMine.map { $0 ? "yes" : "no" } ?? "unknown"
+ return VStack(alignment: .leading, spacing: 2) {
+ Text("unread: \(row?.unread == true ? "YES" : "no") — \(why)")
+ .font(.system(size: 11, weight: .semibold, design: .monospaced))
+ Text("lastAt \(stamp(lastAt)) · seen \(stamp(seen)) · manualUnreadAt \(stamp(manual)) · lastMine \(mine)")
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ }
+
+ private func stamp(_ t: Int64) -> String {
+ guard t > 0 else { return "0" }
+ let d = Date(timeIntervalSince1970: TimeInterval(t))
+ return "\(t) (\(DebugEventList.timeFmt.string(from: d)))"
+ }
+}
+
+// The global window: every frame from every mounted space, tagged by source.
+private struct AllEventsDebugView: View {
+ @ObservedObject private var log = ThreadEventLog.shared
+
+ var body: some View {
+ DebugEventList(entries: log.all, showSource: true) { log.clear() }
+ .frame(minWidth: 560, minHeight: 320)
+ }
+}
+
+// The shared list: count + Clear header, newest-first rows, expandable raw.
+private struct DebugEventList: View {
+ let entries: [ThreadEventLog.Entry]
+ let showSource: Bool
+ let onClear: () -> Void
+
+ static let timeFmt: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "HH:mm:ss.SSS"
+ return f
+ }()
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack {
+ Text(entries.count == 1 ? "1 event"
+ : "\(entries.count.formatted()) events")
+ .foregroundStyle(.secondary)
+ Spacer()
+ Button("Clear", action: onClear)
+ .disabled(entries.isEmpty)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ Divider()
+ if entries.isEmpty {
+ VStack(spacing: 4) {
+ Spacer()
+ Text("No events received yet")
+ .foregroundStyle(.secondary)
+ Text("WebSocket frames appear here as they land.")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ } else {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ // Newest first, so live traffic lands at the top —
+ // no scroll pinning needed to watch it arrive.
+ ForEach(entries.reversed()) { entry in
+ DebugEventRow(entry: entry, showSource: showSource)
+ Divider()
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct DebugEventRow: View {
+ let entry: ThreadEventLog.Entry
+ let showSource: Bool
+ @State private var expanded = false
+
+ var body: some View {
+ DisclosureGroup(isExpanded: $expanded) {
+ VStack(alignment: .leading, spacing: 6) {
+ if let decrypted = entry.decrypted {
+ section("payload (decrypted)", decrypted)
+ }
+ section("frame (wire)", entry.raw)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 4)
+ } label: {
+ HStack(spacing: 8) {
+ Text(DebugEventList.timeFmt.string(from: entry.at))
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.secondary)
+ if showSource {
+ Text(source)
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+ Text(entry.type)
+ .font(.system(size: 11, weight: .semibold, design: .monospaced))
+ Text(entry.summary)
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 3)
+ }
+
+ private func section(_ title: String, _ body: String) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.system(size: 10, weight: .semibold, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ Text(body)
+ .font(.system(size: 11, design: .monospaced))
+ .textSelection(.enabled)
+ }
+ }
+
+ // "host › thread" — enough to tell streams apart without eating the row.
+ private var source: String {
+ let host = URL(string: entry.origin)?.host ?? entry.origin
+ guard let tid = entry.threadID else { return host }
+ return "\(host) › \(tid)"
+ }
+}
+
+#endif