Skip to content

Commit 12336f5

Browse files
authored
fix: message list to revert previous change (#43)
1 parent dcd808e commit 12336f5

1 file changed

Lines changed: 77 additions & 151 deletions

File tree

Packages/Sources/RxCodeChatKit/MessageListView.swift

Lines changed: 77 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,32 @@ import os
55

66
#if os(macOS)
77

8-
/// Message scroll area — extracted from ChatView to isolate @Observable dependencies on `messages`.
8+
/// Message scroll area — extracted from ChatView to isolate @Observable
9+
/// dependencies on `messages`.
10+
///
11+
/// Behavior: the transcript stays anchored to the bottom. A freshly opened
12+
/// thread, a new send, and a streaming response all keep the latest message in
13+
/// view. `AutoScrollAnchor` releases that anchor only when the user deliberately
14+
/// scrolls up; `scrollPhase` keeps auto-scroll from fighting an active drag.
915
struct MessageListView: View {
1016
@Environment(ChatBridge.self) private var chatBridge
1117
@Environment(WindowState.self) private var windowState
1218
@State private var settledItems: [ChatMessage] = []
19+
/// Owns the debounced content-growth scroll.
1320
@State private var scrollTask: Task<Void, Never>?
14-
/// Owns the post-stream "pin to bottom" sweep. Kept separate from
15-
/// `scrollTask` so a concurrent `scrollToBottomDebounced()` — driven by
16-
/// scroll-geometry changes during the same handoff — can't cancel it.
21+
/// Owns the multi-frame "settle at the bottom" sweep used on session open
22+
/// and the streaming→settled handoff. Separate from `scrollTask` so a
23+
/// concurrent `scrollToBottomDebounced()` can't cancel it.
1724
@State private var settleScrollTask: Task<Void, Never>?
18-
/// Separate handle from `scrollTask`. Owns the fade-in / scroll-on-switch
19-
/// sequence so a concurrent content-growth `scrollToBottomDebounced()`
20-
/// (which also writes to `scrollTask`) can't cancel the session-ready flip.
25+
/// Owns the fade-in. A separate handle so the content-growth path
26+
/// (`scrollToBottomDebounced`, which owns `scrollTask`) can't cancel the
27+
/// session-ready flip.
2128
@State private var readyTask: Task<Void, Never>?
2229
@State private var anchor = AutoScrollAnchor()
2330
@State private var isSessionReady = false
24-
/// Visible height of the message `List` — drives the dynamic tail spacer.
25-
@State private var viewportHeight: CGFloat = 0
26-
/// `minY` of the latest user message in `chatContentCoordinateSpace`.
27-
@State private var latestUserMinY: CGFloat = 0
28-
/// `minY` of the tail spacer in `chatContentCoordinateSpace`.
29-
@State private var tailSpacerMinY: CGFloat = 0
30-
/// Latest user message id already seen — distinguishes a genuine new send
31-
/// from a session switch / disk load.
32-
@State private var lastTrackedUserID: UUID?
33-
/// Session the `lastTrackedUserID` baseline belongs to.
34-
@State private var pinSessionID: String?
31+
/// Latest scroll phase — gates auto-scroll so it never fires while the user
32+
/// is driving the scroll.
33+
@State private var scrollPhase: ScrollPhase = .idle
3534

3635
private static let log = Logger(subsystem: "com.claudework", category: "MessageListView")
3736
private static let bottomAnchorID = "message-list-bottom-anchor"
@@ -51,8 +50,6 @@ struct MessageListView: View {
5150
// Streaming view is outside VStack — text deltas don't affect settled layout
5251
if !windowState.focusMode {
5352
StreamingMessageView {
54-
// While streaming, the dynamic tail spacer keeps the pinned
55-
// question in place — no scroll-to-bottom.
5653
rebuildSettledItems()
5754
}
5855
// Suppress layout animations when switching sessions so the pulse indicator
@@ -83,24 +80,18 @@ struct MessageListView: View {
8380
.chatMessageListRowStyle()
8481
}
8582

86-
tailSpacer
83+
bottomAnchor
8784
}
8885

89-
/// Dynamic tail spacer: pads the latest turn so the user message can always
90-
/// be scrolled to the very top, and shrinks as the assistant response grows
91-
/// so the pinned message stays put.
92-
private var tailSpacer: some View {
86+
/// A 1pt sentinel row at the end of the transcript — the target every
87+
/// scroll-to-bottom aims at.
88+
private var bottomAnchor: some View {
9389
Color.clear
94-
.frame(height: tailSpacerHeight)
90+
.frame(height: 1)
9591
.id(Self.bottomAnchorID)
9692
.listRowInsets(EdgeInsets())
9793
.listRowSeparator(.hidden)
9894
.listRowBackground(Color.clear)
99-
.onGeometryChange(for: CGFloat.self) { proxy in
100-
proxy.frame(in: .named(chatContentCoordinateSpace)).minY
101-
} action: { newValue in
102-
updateTailSpacerMinY(newValue)
103-
}
10495
}
10596

10697
private func messageList(proxy: ScrollViewProxy) -> some View {
@@ -110,30 +101,23 @@ struct MessageListView: View {
110101
.scrollContentBackground(.hidden)
111102
.environment(\.defaultMinListRowHeight, 0)
112103
.opacity(isSessionReady ? 1 : 0)
113-
.coordinateSpace(.named(chatContentCoordinateSpace))
114-
.environment(\.chatTrackedMessageID, trackedUserMessageID)
115-
.environment(\.chatTrackedMessageGeometry, updateLatestUserMinY)
116-
.onGeometryChange(for: CGFloat.self) { proxy in
117-
proxy.size.height
118-
} action: { newValue in
119-
viewportHeight = newValue
120-
}
121-
let scrolling = base
122104
.onScrollGeometryChange(for: ScrollSample.self) { geo in
123105
ScrollSample(contentHeight: geo.contentSize.height, visibleMaxY: geo.visibleRect.maxY)
124106
} action: { _, sample in
125107
// Route the geometry change through AutoScrollAnchor so content
126-
// growth (e.g. an Edit/Bash card expanding) doesn't un-stick the
127-
// anchor — only deliberate user scrolling does. Suppressed while
128-
// streaming: the dynamic spacer keeps the question pinned.
108+
// growth (streaming text, an Edit/Bash card expanding) keeps the
109+
// list glued to the bottom — only a deliberate user scroll
110+
// un-sticks it. Suppressed while the user drives the scroll so
111+
// auto-scroll never fights a drag.
129112
let decision = anchor.apply(contentHeight: sample.contentHeight, visibleMaxY: sample.visibleMaxY)
130-
if decision == .scrollToBottom && !chatBridge.isStreaming {
113+
if decision == .scrollToBottom, !isUserDrivenScroll {
131114
scrollToBottomDebounced(proxy)
132115
}
133116
}
134-
.onChange(of: trackedUserMessageID) { _, newID in
135-
handleTrackedUserChange(newID, proxy: proxy)
117+
.onScrollPhaseChange { _, newPhase in
118+
scrollPhase = newPhase
136119
}
120+
let scrolling = base
137121
.task(id: windowState.currentSessionId) {
138122
await handleSessionTask(proxy: proxy)
139123
}
@@ -171,7 +155,7 @@ struct MessageListView: View {
171155
if chatBridge.isStreaming {
172156
rebuildSettledItems()
173157
anchor.resetToBottom()
174-
syncPinTracking()
158+
settleAtBottom(proxy: proxy, reason: "sessionTask.streaming")
175159
if !isSessionReady { isSessionReady = true }
176160
Self.log.info("[MessageList.task] streaming-path settled=\(settledItems.count) sid=\(sid, privacy: .public)")
177161
return
@@ -181,12 +165,10 @@ struct MessageListView: View {
181165
settleScrollTask?.cancel()
182166
readyTask?.cancel()
183167
rebuildSettledItems()
184-
syncPinTracking()
185168
Self.log.info("[MessageList.task] post-rebuild settled=\(settledItems.count) sid=\(sid, privacy: .public) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)")
186-
// Skip scroll/fade delay for empty sessions — appear instantly,
187-
// unless we're still loading persisted messages from disk (in which
188-
// case the onChange handler below will fade the list in once messages
189-
// arrive, avoiding the empty → populated "blink").
169+
// Empty sessions appear instantly — unless we're still loading persisted
170+
// messages from disk, in which case `handleLoadingChange` fades the list
171+
// in once messages arrive, avoiding the empty → populated "blink".
190172
guard !settledItems.isEmpty else {
191173
if !chatBridge.isLoadingFromDisk {
192174
isSessionReady = true
@@ -196,10 +178,12 @@ struct MessageListView: View {
196178
}
197179
return
198180
}
199-
try? await Task.sleep(for: .milliseconds(16)) // 1 frame: scroll after VStack layout is committed
200-
scrollToBottom(proxy)
181+
// Re-assert the bottom across several frames: a single scroll can fire
182+
// before `List` has realized the freshly-rebuilt rows, stranding the
183+
// view at the top — which the fade-in would then reveal.
184+
settleAtBottom(proxy: proxy, reason: "sessionOpen")
201185
anchor.resetToBottom()
202-
try? await Task.sleep(for: .milliseconds(32)) // 2 frames: fade-in after scroll settles
186+
try? await Task.sleep(for: .milliseconds(48)) // let the first re-asserts land before fade-in
203187
withAnimation(.easeIn(duration: 0.15)) { isSessionReady = true }
204188
}
205189

@@ -210,35 +194,28 @@ struct MessageListView: View {
210194
// rebuild the settled list and fade in — same sequence as the .task above.
211195
guard !isLoading else { return }
212196
rebuildSettledItems()
213-
syncPinTracking()
214197
Self.log.info("[MessageList.onLoadChange] post-rebuild settled=\(settledItems.count) sid=\(sid, privacy: .public)")
198+
readyTask?.cancel()
199+
settleAtBottom(proxy: proxy, reason: "loadFinished")
200+
anchor.resetToBottom()
215201
// Fade-in lives on `readyTask` so the content-growth path
216202
// (`scrollToBottomDebounced`, which owns `scrollTask`) cannot cancel it.
217-
readyTask?.cancel()
218203
readyTask = Task { @MainActor in
219-
try? await Task.sleep(for: .milliseconds(16))
220-
guard !Task.isCancelled else { return }
221-
scrollToBottom(proxy)
222-
anchor.resetToBottom()
223-
guard !isSessionReady else { return }
224-
try? await Task.sleep(for: .milliseconds(32))
225-
guard !Task.isCancelled else { return }
204+
try? await Task.sleep(for: .milliseconds(48))
205+
guard !Task.isCancelled, !isSessionReady else { return }
226206
withAnimation(.easeIn(duration: 0.15)) { isSessionReady = true }
227207
}
228208
}
229209

230210
private func handleStreamingChange(old: Bool, new: Bool, proxy: ScrollViewProxy) {
231-
// Only update when streaming ends — settled list doesn't change at start.
211+
// Only react when streaming ends — the settled list doesn't change at start.
232212
guard old && !new else { return }
233213
rebuildSettledItems()
234214
anchor.resetToBottom()
235-
// Keep the question pinned to the top through the row handoff; fall back
236-
// to the bottom only when there is no user message.
237-
if let pinned = trackedUserMessageID {
238-
pinScrollDuringHandoff(to: pinned, anchor: .top, proxy: proxy)
239-
} else {
240-
pinScrollDuringHandoff(to: Self.bottomAnchorID, anchor: .bottom, proxy: proxy)
241-
}
215+
// The just-finished turn moves out of `StreamingMessageView` and into
216+
// the settled list. That row handoff makes `List` reload and can
217+
// momentarily snap the offset; re-assert the bottom across the handoff.
218+
settleAtBottom(proxy: proxy, reason: "streamingEnded")
242219
}
243220

244221
// MARK: - Helpers
@@ -248,8 +225,6 @@ struct MessageListView: View {
248225
ChatMessageListView(messages: Array(messages))
249226
}
250227

251-
// MARK: - Message Grouping
252-
253228
// MARK: - Settled Items
254229

255230
private func rebuildSettledItems() {
@@ -276,102 +251,53 @@ struct MessageListView: View {
276251
return chatSuppressPlanReadyFollowups(in: settled)
277252
}
278253

254+
// MARK: - Scrolling
255+
256+
/// `true` while the user is driving the scroll — finger/trackpad down or a
257+
/// post-flick glide. Our own programmatic `.animating` scroll and the
258+
/// settled `.idle` state are not user-driven.
259+
private var isUserDrivenScroll: Bool {
260+
switch scrollPhase {
261+
case .interacting, .tracking, .decelerating: return true
262+
case .idle, .animating: return false
263+
@unknown default: return false
264+
}
265+
}
266+
279267
private func scrollToBottom(_ proxy: ScrollViewProxy) {
280268
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
281269
}
282270

271+
/// Debounced scroll-to-bottom for content growth. Re-armed on every geometry
272+
/// change, so a burst of streaming deltas collapses into a single scroll.
283273
private func scrollToBottomDebounced(_ proxy: ScrollViewProxy) {
284274
scrollTask?.cancel()
285275
scrollTask = Task { @MainActor in
286276
try? await Task.sleep(for: .milliseconds(50))
287-
guard !Task.isCancelled else { return }
277+
guard !Task.isCancelled, !isUserDrivenScroll else { return }
288278
scrollToBottom(proxy)
289279
}
290280
}
291281

292-
/// When a stream ends, the just-completed assistant turn moves out of
293-
/// `StreamingMessageView` and into the settled list. That row handoff makes
294-
/// `List` reload, which can momentarily snap the scroll offset to the top —
295-
/// the user sees the list jump up, then jump back down. A single debounced
296-
/// scroll can't fix it reliably: the streaming-insertion animation keeps
297-
/// emitting scroll-geometry changes that re-arm and starve the debounce.
298-
/// Re-assert the desired anchor on every frame for a short window so the
299-
/// snap is corrected within one frame, before it becomes visible.
300-
private func pinScrollDuringHandoff(to id: some Hashable, anchor: UnitPoint, proxy: ScrollViewProxy) {
282+
/// Re-assert the bottom on every frame for a short window.
283+
///
284+
/// A single scroll can land before `List` has realized the freshly-rebuilt
285+
/// rows (session open / disk load), or while the streaming→settled row
286+
/// handoff is still reloading the list — in both cases the offset snaps to
287+
/// the top. Re-asserting corrects the snap within a frame, before it becomes
288+
/// visible. Bails the moment the user grabs the scroll so it never fights a
289+
/// drag — our own `.animating` scroll is not user-driven.
290+
private func settleAtBottom(proxy: ScrollViewProxy, reason: String) {
301291
settleScrollTask?.cancel()
292+
Self.log.info("[ScrollSettle] reason=\(reason, privacy: .public) sid=\(windowState.currentSessionId ?? "<nil>", privacy: .public) settled=\(settledItems.count)")
302293
settleScrollTask = Task { @MainActor in
303294
for _ in 0..<12 {
304-
guard !Task.isCancelled else { return }
305-
proxy.scrollTo(id, anchor: anchor)
295+
guard !Task.isCancelled, !isUserDrivenScroll else { return }
296+
scrollToBottom(proxy)
306297
try? await Task.sleep(for: .milliseconds(16))
307298
}
308299
}
309300
}
310-
311-
// MARK: - Pin to top
312-
313-
/// The latest user message — the row pinned to the top on send and the
314-
/// reference point for the dynamic tail spacer.
315-
private var trackedUserMessageID: UUID? {
316-
chatBridge.messages.last(where: { $0.role == .user })?.id
317-
}
318-
319-
/// Height of the tail spacer: pads the latest turn so the user message can
320-
/// always be scrolled to the very top. Shrinks as the response grows, so the
321-
/// pinned message stays put without any further scrolling.
322-
private var tailSpacerHeight: CGFloat {
323-
guard viewportHeight > 0 else { return 1 }
324-
let latestTurnHeight = max(0, tailSpacerMinY - latestUserMinY)
325-
return max(1, viewportHeight - latestTurnHeight)
326-
}
327-
328-
/// Re-baseline the new-send detector to the current session so a session
329-
/// switch or disk load is never mistaken for a freshly sent message.
330-
private func syncPinTracking() {
331-
pinSessionID = windowState.currentSessionId
332-
lastTrackedUserID = trackedUserMessageID
333-
}
334-
335-
/// React to the latest user message changing: pin a genuine new send to the
336-
/// top, but ignore the change when it is really a session switch.
337-
private func handleTrackedUserChange(_ newID: UUID?, proxy: ScrollViewProxy) {
338-
let sid = windowState.currentSessionId
339-
guard pinSessionID == sid else {
340-
// Session switch — re-baseline without pinning.
341-
pinSessionID = sid
342-
lastTrackedUserID = newID
343-
return
344-
}
345-
guard let newID, newID != lastTrackedUserID else { return }
346-
lastTrackedUserID = newID
347-
guard !chatBridge.isLoadingFromDisk else { return }
348-
// A genuine new user message in the active session — pin it to the top.
349-
scrollTask?.cancel()
350-
scrollTask = Task { @MainActor in
351-
try? await Task.sleep(for: .milliseconds(16)) // 1 frame: spacer + row layout
352-
guard !Task.isCancelled else { return }
353-
withAnimation(.easeOut(duration: 0.28)) {
354-
proxy.scrollTo(newID, anchor: .top)
355-
}
356-
}
357-
}
358-
359-
/// `minY` of the latest user message — fed back from `ChatMessageListView`.
360-
private func updateLatestUserMinY(_ value: CGFloat) {
361-
guard abs(value - latestUserMinY) > 0.5 else { return }
362-
var t = Transaction()
363-
t.animation = nil
364-
withTransaction(t) { latestUserMinY = value }
365-
}
366-
367-
/// `minY` of the tail spacer — its distance from the user message is the
368-
/// height of the latest turn.
369-
private func updateTailSpacerMinY(_ value: CGFloat) {
370-
guard abs(value - tailSpacerMinY) > 0.5 else { return }
371-
var t = Transaction()
372-
t.animation = nil
373-
withTransaction(t) { tailSpacerMinY = value }
374-
}
375301
}
376302

377303
// MARK: - Streaming Message (isolated view — chatBridge.messages dependency confined to this view)

0 commit comments

Comments
 (0)