eleven / messenger Public

mac: ClickLab — the dead-composer diagnostic rig, resident in Debug builds (#1088)

* mac: ClickLab — the dead-composer diagnostic rig, resident in Debug builds

The click-eating bug class (#930, #1023) is intermittent and not
SwiftUI-debuggable; a repro is only evidence on a build that can say who
received the click. Debug builds log each left mouse-down's hit-test
chain + a 0.25s first-responder timeline to the container's
Logs/ClickLab.log; Release compiles the rig out.

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

* mac: ClickLab v2 — activation timeline + main-thread stall watchdog

'Dead right after loading into the window' needs two more discriminators:
app/window focus transitions stamped into the log (correlate dead spells
against activation), and a 200ms background ping of the main thread that
reports any >400ms servicing lag — separating clicks-eaten (responsive,
wrong hit chain) from actually-hung (main thread wedged). Mouse-downs also
note when the clicked window isn't key.

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

* mac: the composer placeholder must be click-through

The placeholder NSTextField spans the field's full width and hit-tests,
but a label's mouseDown neither focuses nor forwards — so clicking an
EMPTY composer's text line was swallowed whenever focus sat elsewhere
(after clicking message text, etc). Caught by ClickLab: hit chain ended
at the label, first responder never moved. hitTest nil, the FrostedBand
rule: decoration is click-through.

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

* mac: ClickLab v3 — heartbeat file + composer-geometry audit

The heartbeat (main-thread rewrite every ~1s) lets an external watcher
sample the process WHILE a wedge is in progress — capture 2 logged a
141s main-thread stall that only reported after draining. The audit
answers capture 1's mystery clicks that hit only NSHostingView in the
composer region: every mounted composer's visibility + clickable frame,
flagged when a click inside the frame misses the field.

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

* mac: ClickLab v4 — flag only no-composer hits, blame-walk the gate

With parked panes mounted, the overlapping twin's frame contains every
composer click — v3's flag cried wolf. v4 flags only clicks inside a
composer frame that reach NO composer (capture 1's true signature), then
hit-tests each ancestor of the field at the point to name the view that
kills the walk. Successful composer clicks log their instance tag so a
ghost caret (parked pane's field taking input) is correlatable.

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

* mac: the composer accepts the activation click

Clicking into the backgrounded app on the composer only activated the
app — macOS swallows the activation click unless the view opts in, so
the click that felt ignored never reached any view (ClickLab: dead-spell
activations with ZERO mouseDowns logged). With the post-activation
render hitch delaying the caret, that swallow reads as a dead composer.
acceptsFirstMouse, like Messages' input.

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

* mac: feed rows skip re-render when nothing they draw changed (#1071)

Every model tick (typing, sender names, status) re-ran every visible
MessageBubble body — SwiftUI can't prove rows unchanged when each pass
hands the bubble ~nine fresh closures. FeedRow gates each row behind an
Equatable key listing exactly what the row draws (message + previous for
grouping, muted fold, sender name, canEdit, theme, stage geometry,
video-download state); == short-circuits the body. The wedge samples
behind the 1-5s activation freezes fingerprinted exactly this path.

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

* mac: thread rows skip re-render when nothing they draw changed (#1071)

The sidebar half of the render storm: per-pass Button/menu closures made
every thread row unprovable-as-unchanged, so any store tick re-ran every
row body (threadList → ThreadAvatar in the wedge samples). Equatable key
= thread + selection + rendered time label; == short-circuits the body.

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

* mac: FeedRow constructs lazily — a skipped row builds nothing

Wedge sample 214800 (post-gate): per-pass VALUE construction of every
materialized row (bubble + closures + actions, then AnyView boxing)
still burned main-thread time at -Onone. Content becomes a closure the
body calls — == skipping now skips construction too.

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

* mac: ThreadRowCell constructs lazily — a skipped row builds nothing

Same amendment as FeedRow's: content becomes a closure so == skipping
skips value construction, not just body evaluation.

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

* mac: ClickLab compiles under CLICKLAB too — for optimized rig builds

A Debug daily driver multiplies every wedge ~severalfold at -Onone; a
Release alt with SWIFT_ACTIVE_COMPILATION_CONDITIONS=CLICKLAB keeps the
rig recording at real-world speed.

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

* mac+tools: ClickLab build-modes comment + wedge-watcher joins tools/

The sampling half of the rig lived only in a session scratchpad —
operational knowledge belongs in the repo (CLAUDE.md's second rule).
Parameterised: bundle-id suffix, out-dir, hours.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 24e8da66257579fda00f089ad4fde6c602038d72 parent d31a51c
4 files changed, +269 −1
  • ios/LChatMac/ClickLab.swift +231 −0
  • ios/LChatMac/LChatMacApp.swift +1 −0
  • ios/LChatMac/MacComposerField.swift +0 −1
  • tools/wedge-watcher.sh +37 −0
diff --git a/ios/LChatMac/ClickLab.swift b/ios/LChatMac/ClickLab.swift
new file mode 100644
index 0000000..dbb0aed
--- /dev/null
+++ b/ios/LChatMac/ClickLab.swift
@@ -0,0 +1,231 @@
+import AppKit
+
+// 🤖 ClickLab — the click-eating diagnostic rig behind the two dead-composer
+// fixes (#930 focus-stealing, #1023 FrostedBand hitTest), made a permanent
+// resident. The bug class is intermittent and not SwiftUI-debuggable (SwiftUI
+// does not keep representable NSViews' z-order in step with its own stack, and
+// NSView.hitTest walks NSView order), so a repro is only worth anything if the
+// build that shows it can say who actually received the click. Debug builds
+// carry the rig; plain Release builds compile it out. For an OPTIMIZED build
+// that still records (a Debug daily driver multiplies every wedge at -Onone),
+// build Release with SWIFT_ACTIVE_COMPILATION_CONDITIONS=CLICKLAB — and add
+// CODE_SIGN_INJECT_BASE_ENTITLEMENTS=YES so tools/wedge-watcher.sh can still
+// `sample` it mid-wedge.
+//
+// Log: <container>/Library/Logs/ClickLab.log (the app is sandboxed, so from
+// the outside that's ~/Library/Containers/<bundle id>/Data/Library/Logs/).
+// A launch rotates the previous session's log to ClickLab.prev.log.
+// Opt out with `defaults write <bundle id> clickLab -bool NO`.
+enum ClickLab {
+#if DEBUG || CLICKLAB
+ private static var mouseMonitor: Any?
+ private static var pollTimer: Timer?
+ private static var observers: [NSObjectProtocol] = []
+ private static var watchdog: DispatchSourceTimer?
+ private static var lastResponder = ""
+ private static var handle: FileHandle?
+
+ private static let logURL: URL = {
+ let logs = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
+ .appendingPathComponent("Logs", isDirectory: true)
+ try? FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true)
+ return logs.appendingPathComponent("ClickLab.log")
+ }()
+
+ // The heartbeat: rewritten from the MAIN thread every ~1s, so an outside
+ // watcher can `sample` the process while a wedge is still in progress
+ // (the in-log watchdog only reports a stall after it drains — too late
+ // to capture the blocked stack).
+ private static let beatURL = logURL.deletingLastPathComponent()
+ .appendingPathComponent("ClickLab.heartbeat")
+ private static var beatCounter = 0
+
+ private static let timeFmt: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "HH:mm:ss.SSS"
+ return f
+ }()
+
+ static func start() {
+ if UserDefaults.standard.object(forKey: "clickLab") != nil,
+ !UserDefaults.standard.bool(forKey: "clickLab") { return }
+ // Keep exactly one previous session — the interesting window is always
+ // "the run where it just happened", and unbounded append only grows.
+ let prev = logURL.deletingPathExtension().appendingPathExtension("prev.log")
+ try? FileManager.default.removeItem(at: prev)
+ try? FileManager.default.moveItem(at: logURL, to: prev)
+
+ let info = Bundle.main.infoDictionary
+ let ver = "\(info?["CFBundleShortVersionString"] ?? "?") (\(info?["CFBundleVersion"] ?? "?"))"
+ log("=== ClickLab start — \(Bundle.main.bundleIdentifier ?? "?") \(ver)")
+
+ mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { event in
+ logMouseDown(event)
+ return event
+ }
+ // The caret ping-pong in #1023 played out over ~a second (click resigns
+ // to the window, the focus binding re-grabs a beat later) — a 0.25s
+ // change-only timeline is fine-grained enough to show that dance.
+ pollTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in
+ pollFirstResponder()
+ }
+ RunLoop.main.add(pollTimer!, forMode: .common)
+
+ // The activation timeline — "dead right after loading into the
+ // window" implicates the focus gauntlet (app-activate → pullHome,
+ // window-key → the composer focus binding re-asserts), so stamp
+ // every transition the dead spell can be correlated against.
+ let nc = NotificationCenter.default
+ let acts: [(Notification.Name, String)] = [
+ (NSApplication.didBecomeActiveNotification, "app ACTIVE"),
+ (NSApplication.didResignActiveNotification, "app inactive"),
+ (NSWindow.didBecomeKeyNotification, "window KEY"),
+ (NSWindow.didResignKeyNotification, "window unkey"),
+ ]
+ for (name, label) in acts {
+ observers.append(nc.addObserver(forName: name, object: nil, queue: .main) { note in
+ let w = (note.object as? NSWindow).map { " w#\($0.windowNumber)" } ?? ""
+ log("\(label)\(w)")
+ })
+ }
+
+ // The stall watchdog — distinguishes "clicks are being eaten" (main
+ // thread fine, hit-test chain wrong) from "the app is hung" (main
+ // thread blocked, nothing responds). A background ticker pings main
+ // every 200ms; any ping serviced >400ms late is a stall, and the
+ // printed lag ≈ how long main was wedged.
+ let ticker = DispatchSource.makeTimerSource(queue: DispatchQueue(label: "clicklab.watchdog"))
+ ticker.schedule(deadline: .now() + 1, repeating: .milliseconds(200))
+ ticker.setEventHandler {
+ let sent = Date()
+ DispatchQueue.main.async {
+ let lag = -sent.timeIntervalSinceNow
+ if lag > 0.4 { log(String(format: "⏳ main-thread stall: ping serviced %.2fs late", lag)) }
+ }
+ }
+ ticker.resume()
+ watchdog = ticker
+ }
+
+ private static func logMouseDown(_ event: NSEvent) {
+ guard let window = event.window, let root = window.contentView else {
+ log("mouseDown w#\(event.windowNumber) — no contentView")
+ return
+ }
+ // hitTest takes the point in the receiver's SUPERVIEW's coordinates
+ // (the theme frame, for a window's contentView).
+ let p = (root.superview ?? root).convert(event.locationInWindow, from: nil)
+ let hit = root.hitTest(p)
+ var chain: [String] = []
+ var v: NSView? = hit
+ while let cur = v, chain.count < 10 {
+ chain.append(String(describing: type(of: cur)))
+ if cur === root { break }
+ v = cur.superview
+ }
+ let at = "(\(Int(event.locationInWindow.x)),\(Int(event.locationInWindow.y)))"
+ let notKey = window.isKeyWindow ? "" : " [window NOT key]"
+ if let hit {
+ let eater = hit is NSVisualEffectView ? " ⚠️ decorative view took the click" : ""
+ log("mouseDown \(at) w#\(window.windowNumber)\(notKey) → \(chain.joined(separator: " < "))\(eater)")
+ } else {
+ log("mouseDown \(at) w#\(window.windowNumber)\(notKey) → hitTest nil (falls to the window)")
+ }
+ composerAudit(in: root, click: event.locationInWindow, hit: hit)
+ }
+
+ // Capture 1 (2026-07-17 10:14) showed clicks in the composer region
+ // reaching only NSHostingView — no composer took them, while programmatic
+ // focus still worked. With several spaces mounted, parked panes overlap
+ // the active one (their AppKit alpha/hidden are untouched by SwiftUI's
+ // opacity — only the .allowsHitTesting gate hides them from clicks), so
+ // "some OTHER composer's frame contains the click" is the NORMAL case.
+ // The one true anomaly: a click inside a composer frame that reaches NO
+ // composer at all — and then the blame walk names the ancestor whose
+ // hitTest kills it.
+ private static func composerAudit(in root: NSView, click: NSPoint, hit: NSView?) {
+ var composers: [ComposerTextView] = []
+ func walk(_ v: NSView) {
+ if let c = v as? ComposerTextView { composers.append(c) }
+ for s in v.subviews { walk(s) }
+ }
+ walk(root)
+ if let taken = composers.first(where: { c in
+ guard let s = c.enclosingScrollView else { return false }
+ return hit?.isDescendant(of: s) ?? false
+ }) {
+ // Tag which instance took the click — a caret that lands in a
+ // tag the responder timeline never shows as the working composer
+ // is a ghost (a parked pane's field swallowing input).
+ log(" → composer #\(tag(taken))")
+ return
+ }
+ let containing = composers.filter { c in
+ guard let s = c.enclosingScrollView else { return false }
+ return s.convert(s.bounds, to: nil).contains(click)
+ }
+ for c in containing {
+ guard let scroll = c.enclosingScrollView else { continue }
+ let f = scroll.convert(scroll.bounds, to: nil)
+ log(String(format: "⚠️ click (%.0f,%.0f) inside composer #%@'s frame (%.0f,%.0f %.0f×%.0f) hit NO composer — blame walk:",
+ click.x, click.y, tag(c), f.origin.x, f.origin.y, f.width, f.height))
+ blameWalk(from: scroll, click: click)
+ }
+ }
+
+ private static func tag(_ v: AnyObject) -> String {
+ String(UInt(bitPattern: ObjectIdentifier(v).hashValue) & 0xFFFF, radix: 16)
+ }
+
+ // Hit-test every ancestor of the field's scroll view directly at the
+ // click point: the first level that geometrically contains the point but
+ // hit-tests to nil (or outside itself) is the view gating the click —
+ // a stale SwiftUI allowsHitTesting on a PlatformViewHost, a mis-framed
+ // container, whatever it is, this prints its name.
+ private static func blameWalk(from scroll: NSView, click: NSPoint) {
+ var v: NSView? = scroll
+ while let cur = v, let sup = cur.superview {
+ let p = sup.convert(click, from: nil)
+ let ht = cur.hitTest(p)
+ log(String(format: " %@#%@ frameContains=%@ hitTest=%@ alpha=%.2f hidden=%@",
+ "\(type(of: cur))", tag(cur),
+ cur.frame.contains(p) ? "yes" : "NO",
+ ht.map { "\(type(of: $0))" } ?? "nil",
+ cur.alphaValue, cur.isHidden ? "YES" : "no"))
+ v = sup
+ }
+ }
+
+ private static func pollFirstResponder() {
+ beatCounter += 1
+ if beatCounter % 4 == 0 { try? Data("♥".utf8).write(to: beatURL) }
+ let window = NSApp.keyWindow
+ let fr = window?.firstResponder
+ let desc: String
+ if let fr {
+ let ptr = UInt(bitPattern: ObjectIdentifier(fr).hashValue) & 0xFFFF
+ desc = "\(type(of: fr))#\(String(ptr, radix: 16)) w#\(window?.windowNumber ?? -1)"
+ } else {
+ desc = window == nil ? "no key window" : "nil"
+ }
+ guard desc != lastResponder else { return }
+ log("responder: \(lastResponder.isEmpty ? "—" : lastResponder) → \(desc)")
+ lastResponder = desc
+ }
+
+ private static func log(_ line: String) {
+ NSLog("[ClickLab] %@", line)
+ let data = Data("\(timeFmt.string(from: Date())) \(line)\n".utf8)
+ if handle == nil {
+ if !FileManager.default.fileExists(atPath: logURL.path) {
+ FileManager.default.createFile(atPath: logURL.path, contents: nil)
+ }
+ handle = try? FileHandle(forWritingTo: logURL)
+ _ = try? handle?.seekToEnd()
+ }
+ try? handle?.write(contentsOf: data)
+ }
+#else
+ static func start() {}
+#endif
+}
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index d71f776..3f1eca6 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -164,6 +164,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
func applicationDidFinishLaunching(_ notification: Notification) {
+ ClickLab.start()
// "cmd and plus": the View menu's ⌘+ equivalent strictly needs the
// shifted key (⌘⇧=), but people press ⌘= and expect bigger text —
// Messages and Safari both accept it, so map it here. (⌘− needs no
diff --git a/ios/LChatMac/MacComposerField.swift b/ios/LChatMac/MacComposerField.swift
index c344a68..dd27a6f 100644
--- a/ios/LChatMac/MacComposerField.swift
+++ b/ios/LChatMac/MacComposerField.swift
@@ -319,7 +319,6 @@ final class ComposerTextView: NSTextView {
// Messages accepts its composer's first mouse; so do we.
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
-
// The placeholder is decoration, and decoration must be click-through
// (the FrostedBand rule): a plain NSTextField label hit-tests but its
// mouseDown neither focuses anything nor forwards up the chain — and the
diff --git a/tools/wedge-watcher.sh b/tools/wedge-watcher.sh
new file mode 100755
index 0000000..82f7f3a
--- /dev/null
+++ b/tools/wedge-watcher.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+# 🤖 wedge-watcher — samples a ClickLab-instrumented Mac build while its main
+# thread is wedged. ClickLab (ios/LChatMac/ClickLab.swift) rewrites a
+# heartbeat file from the MAIN thread every ~1s; when the file goes stale
+# while the process is alive, main is blocked (or the app is napping — idle
+# stacks are discarded at analysis time), and `sample` captures the live
+# stack. The in-app watchdog can only report a stall AFTER it drains — this
+# is how you catch the blocked stack DURING one.
+#
+# Usage: tools/wedge-watcher.sh [bundle-id-suffix] [out-dir] [hours]
+# e.g. tools/wedge-watcher.sh alt-clicklab ~/Desktop/wedges 8
+# The build must be sample-able: Debug builds are (get-task-allow); Release
+# rig builds need CODE_SIGN_INJECT_BASE_ENTITLEMENTS=YES at build time.
+# Analysis: read the sample's main-thread block — an idle run loop
+# (__CFRunLoopRun → mach_msg) means App Nap, anything else names the wedge.
+SUFFIX="${1:-alt-clicklab}"
+HB="$HOME/Library/Containers/com.elevenmessenger.messenger.mac.$SUFFIX/Data/Library/Logs/ClickLab.heartbeat"
+OUT="${2:-./wedges}"
+HOURS="${3:-8}"
+mkdir -p "$OUT"
+END=$((SECONDS + HOURS * 3600))
+LAST=0
+echo "watching $HB → $OUT for ${HOURS}h"
+while [ $SECONDS -lt $END ]; do
+ sleep 2
+ PID=$(pgrep -f "$SUFFIX" | head -1)
+ [ -z "$PID" ] && continue
+ [ -f "$HB" ] || continue
+ AGE=$(( $(date +%s) - $(stat -f %m "$HB") ))
+ if [ "$AGE" -ge 4 ] && [ $((SECONDS - LAST)) -ge 30 ]; then
+ TS=$(date +%H%M%S)
+ sample "$PID" 3 -file "$OUT/wedge-$TS.txt" >/dev/null 2>&1
+ echo "$(date +%H:%M:%S) heartbeat ${AGE}s stale — sampled pid $PID → wedge-$TS.txt"
+ LAST=$SECONDS
+ fi
+done
+echo "wedge-watcher: ${HOURS}h window elapsed, exiting"