eleven / ocho Public

mac: instant chat switching — no press-dim, no reveal fade, parked feeds show frame one (#597)

Three feel fixes to the native index/feed switch:
- Index rows and the collapsed avatar rail no longer dim on mousedown
  (NoPressDimButtonStyle replaces .plain, whose pressed state reads as a
  translucency flicker).
- The feed's settle reveal is a hard cut, not a 0.12s fade.
- A parked ChatFeedModel (history already folded) starts settled: with
  .defaultScrollAnchor(.bottom) the first layout already sits at the
  bottom, so the feed is visible from its first frame instead of hiding
  behind the ~50ms pin-verify round-trip — switching chats is instant,
  Messages.app-style. Cold feeds keep the invisible-until-settled hold
  (they show the loader anyway), and the pin still verifies either way.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8e61e3de22ee1600202f26527689c718dd8d640b parent 0dd18fb
2 files changed, +25 −8
  • ios/LChatMac/NativeChatView.swift +11 −2
  • ios/LChatMac/NativeFeedView.swift +14 −6
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 2029f69..ff7d7d5 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -309,7 +309,7 @@ struct NativeChatView: View {
// button unclickable.
.contentShape(Circle())
}
- .buttonStyle(.plain)
+ .buttonStyle(NoPressDimButtonStyle())
.help(thread.title)
.contextMenu { threadMenu(thread) }
}
@@ -1174,7 +1174,7 @@ struct NativeChatView: View {
.fill(t.id == selectedID ? Color.accentColor.opacity(0.16) : .clear))
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
- .buttonStyle(.plain)
+ .buttonStyle(NoPressDimButtonStyle())
.contextMenu { threadMenu(t) }
}
@@ -1415,3 +1415,12 @@ private struct GroupThreadSurface: View {
}
}
}
+
+// Chat-index buttons (rows, rail avatars): selection is the only highlight.
+// The stock .plain style dims the label on mousedown, which reads as a
+// translucency flicker on every chat switch — this style just doesn't.
+struct NoPressDimButtonStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ }
+}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 7b75e69..c79aa33 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -52,6 +52,12 @@ struct NativeFeedView: View {
// The model arrives PARKED (NativeFeedModelCache) — folded messages and
// a live socket survive leaving the chat, so re-opening never flashes.
_model = StateObject(wrappedValue: model)
+ // A parked model shows from the FIRST frame: history is folded and
+ // .defaultScrollAnchor(.bottom) makes the first layout land at the
+ // bottom, so there's nothing to hide — holding it behind the settle
+ // verify (a ~50ms round-trip) blinked on every chat switch. Only a
+ // cold model starts hidden; the pin still verifies either way.
+ _settled = State(initialValue: model.ready)
self.isGroup = isGroup
self.isActive = isActive
self.roster = roster
@@ -359,16 +365,17 @@ struct NativeFeedView: View {
// races layout and loses (the land-at-top bug, same as iOS).
.defaultScrollAnchor(.bottom)
// The feed reveals only once history has folded AND the pin has
- // verifiably landed — a parked chat settles in a frame or two (an
- // imperceptible blink), a cold one shows the loader; either way
- // the reader finds it already at the bottom, never mid-snap.
+ // verifiably landed — a parked chat settles in a frame or two, a
+ // cold one shows the loader; either way the reader finds it
+ // already at the bottom, never mid-snap. The reveal is a hard cut
+ // (no animation): with messages in hand, switching chats must
+ // read as instant, not a cross-fade.
.opacity(model.ready && settled ? 1 : 0)
.overlay {
if !model.ready {
ProgressView().controlSize(.small)
}
}
- .animation(.easeOut(duration: 0.12), value: model.ready && settled)
}
// The thread view lifts over the FEED pane only (the web's
// .chatapp-feedwrap overlay) — applied before the composer inset, so
@@ -440,9 +447,10 @@ struct NativeFeedView: View {
static let bottomSentinel = "bottom-sentinel"
- // The feed stays INVISIBLE until the pin is verified flush — the reader
+ // A COLD feed stays invisible until the pin is verified flush — the reader
// should find the chat already at the bottom, never watch it snap there.
- @State private var settled = false
+ // A parked one starts settled (see init) so switching chats is instant.
+ @State private var settled: Bool
// Pin to the bottom sentinel and VERIFY it landed: the frame store knows
// where the last row actually is, so re-pin (a few frames apart) until its