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>
7 files changed,
+450
−3
ios/LChatCore/Sources/LChatCore/ChatSocket.swift+10 −0ios/LChatCore/Sources/LChatCore/Models.swift+5 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+27 −3ios/LChatCore/Tests/LChatCoreTests/ReadSyncTests.swift+6 −0ios/LChatMac/LChatMacApp.swift+13 −0ios/LChatMac/NativeChatView.swift+12 −0ios/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 |