Skip to content

Commit 6fc9481

Browse files
authored
fix: debouncing mobile scroll to bottom (#51)
1 parent e30085e commit 6fc9481

2 files changed

Lines changed: 58 additions & 11 deletions

File tree

RxCode/App/AppState+MobileSync.swift

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -510,13 +510,10 @@ extension AppState {
510510
switch request.operation {
511511
case .switchExisting:
512512
try await switchToExistingBranch(trimmed, in: window)
513+
updateMobilePendingWorktree(from: window, projectID: project.id)
513514
case .createNew:
514515
try await attachWorktree(branch: trimmed, in: window)
515-
if let path = window.pendingWorktreePath,
516-
let branch = window.pendingWorktreeBranch
517-
{
518-
mobilePendingWorktrees[project.id] = MobilePendingWorktree(path: path, branch: branch)
519-
}
516+
updateMobilePendingWorktree(from: window, projectID: project.id)
520517
}
521518
} catch {
522519
await replyBranchOpResult(
@@ -532,6 +529,16 @@ extension AppState {
532529
scheduleMobileSnapshotBroadcast()
533530
}
534531

532+
private func updateMobilePendingWorktree(from window: WindowState, projectID: UUID) {
533+
if let path = window.pendingWorktreePath,
534+
let branch = window.pendingWorktreeBranch
535+
{
536+
mobilePendingWorktrees[projectID] = MobilePendingWorktree(path: path, branch: branch)
537+
} else {
538+
mobilePendingWorktrees.removeValue(forKey: projectID)
539+
}
540+
}
541+
535542
func replyBranchOpResult(
536543
request: BranchOpRequestPayload,
537544
ok: Bool,

RxCodeMobile/Views/MobileChatView.swift

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ struct MobileChatView: View {
5050
/// Re-asserts the first scroll-to-bottom while the lazy stack and composer
5151
/// geometry settle on thread entry.
5252
@State private var initialScrollTask: Task<Void, Never>?
53+
/// Owns coalesced automatic bottom-follow while streamed content grows.
54+
@State private var autoBottomScrollTask: Task<Void, Never>?
5355
/// Prevents repeated scheduling while the delayed initial scroll is
5456
/// waiting for the first loaded page to finish laying out.
5557
@State private var isEstablishingInitialScroll = false
@@ -94,6 +96,7 @@ struct MobileChatView: View {
9496
/// new programmatic top-pin before it has settled.
9597
@State private var canReleasePinnedTurnByScroll = false
9698
@State private var distanceFromBottom: CGFloat = 0
99+
@State private var lastAutoBottomScrollDate = Date.distantPast
97100
@State private var minimumThreadLoadElapsed = false
98101
@State private var isThreadLoadingOverlayVisible = true
99102
@State private var threadLoadingHideTask: Task<Void, Never>?
@@ -115,6 +118,8 @@ struct MobileChatView: View {
115118
/// Approximate indicator height plus LazyVStack spacing. Cleared once the
116119
/// tail marker reports the indicator in measured geometry.
117120
private static let streamingIndicatorEstimatedHeight: CGFloat = 36
121+
/// Maximum automatic bottom-follow cadence while content streams in.
122+
private static let autoBottomScrollInterval: TimeInterval = 2
118123
private static let pinToTopAnimationDuration: Duration = .milliseconds(320)
119124
private static let pinToTopAnimationSeconds: Double = 0.32
120125

@@ -498,14 +503,14 @@ struct MobileChatView: View {
498503
} else if repinActiveTurnIfNeeded(proxy: proxy) {
499504
return
500505
} else if autoScrollEnabled {
501-
withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) }
506+
scrollToBottomDebounced(proxy: proxy, reason: "lastMessage")
502507
}
503508
}
504509
.onChange(of: messages.last?.content) { _, _ in
505510
guard didEstablishInitialScroll else { return }
506511
if repinActiveTurnIfNeeded(proxy: proxy) { return }
507512
guard autoScrollEnabled else { return }
508-
withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) }
513+
scrollToBottomDebounced(proxy: proxy, reason: "messageContent")
509514
}
510515
.onChange(of: isStreaming) { _, streaming in
511516
// Keep the newly appeared loading indicator in view.
@@ -518,7 +523,7 @@ struct MobileChatView: View {
518523
}
519524
if repinActiveTurnIfNeeded(proxy: proxy) { return }
520525
guard autoScrollEnabled else { return }
521-
withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) }
526+
scrollToBottomDebounced(proxy: proxy, reason: "streamingStarted")
522527
}
523528
.onChange(of: isLoadingMore) { _, loading in
524529
guard !loading, let anchor = pendingTopAnchorID else { return }
@@ -798,9 +803,7 @@ struct MobileChatView: View {
798803
Task { @MainActor in
799804
try? await Task.sleep(for: .milliseconds(16))
800805
guard didEstablishInitialScroll, autoScrollEnabled else { return }
801-
withAnimation(.easeInOut(duration: 0.2)) {
802-
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
803-
}
806+
scrollToBottomDebounced(proxy: proxy, reason: "layout")
804807
}
805808
}
806809

@@ -883,6 +886,8 @@ struct MobileChatView: View {
883886
// The sent message round-trips through the desktop; when it comes back
884887
// it is pinned to the top. Suppress bottom-follow so the streaming
885888
// reply fills the space below the question instead of yanking past it.
889+
autoBottomScrollTask?.cancel()
890+
autoBottomScrollTask = nil
886891
awaitingSentUserMessage = true
887892
activeTurnUserMessageID = nil
888893
pendingIndicatorSpacerReduction = 0
@@ -938,6 +943,8 @@ struct MobileChatView: View {
938943

939944
/// Pin a freshly sent user message to the top of the viewport.
940945
private func pinSentMessageToTop(_ id: UUID, proxy: ScrollViewProxy, animated: Bool) {
946+
autoBottomScrollTask?.cancel()
947+
autoBottomScrollTask = nil
941948
pinToTopTask?.cancel()
942949
canReleasePinnedTurnByScroll = false
943950
pinToTopTask = Task { @MainActor in
@@ -1036,6 +1043,8 @@ struct MobileChatView: View {
10361043
)
10371044
autoScrollEnabled = true
10381045
isUserDragging = false
1046+
autoBottomScrollTask?.cancel()
1047+
autoBottomScrollTask = nil
10391048
scrollToBottomFromButton(proxy)
10401049
} label: {
10411050
Image(systemName: "arrow.down")
@@ -1063,6 +1072,7 @@ struct MobileChatView: View {
10631072
pinToTopTask?.cancel()
10641073
canReleasePinnedTurnByScroll = false
10651074
isPinningLatestTurnToTop = false
1075+
lastAutoBottomScrollDate = Date()
10661076
withAnimation(.easeInOut(duration: 0.2)) {
10671077
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
10681078
}
@@ -1074,6 +1084,36 @@ struct MobileChatView: View {
10741084
}
10751085
}
10761086

1087+
/// Coalesces automatic bottom-follow while streaming content grows. Explicit
1088+
/// user actions and initial thread positioning still scroll immediately.
1089+
private func scrollToBottomDebounced(proxy: ScrollViewProxy, reason: String) {
1090+
guard autoBottomScrollTask == nil else { return }
1091+
let elapsed = Date().timeIntervalSince(lastAutoBottomScrollDate)
1092+
let delay = max(0, Self.autoBottomScrollInterval - elapsed)
1093+
mobileChatLogger.debug(
1094+
"[AutoScroll] scheduled reason=\(reason, privacy: .public) delay=\(delay, privacy: .public) session=\(sessionID, privacy: .public)"
1095+
)
1096+
autoBottomScrollTask = Task { @MainActor in
1097+
if delay > 0 {
1098+
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
1099+
}
1100+
guard !Task.isCancelled else { return }
1101+
autoBottomScrollTask = nil
1102+
guard didEstablishInitialScroll,
1103+
autoScrollEnabled,
1104+
!isUserDragging,
1105+
!isPinningLatestTurnToTop
1106+
else { return }
1107+
lastAutoBottomScrollDate = Date()
1108+
mobileChatLogger.debug(
1109+
"[AutoScroll] fired reason=\(reason, privacy: .public) session=\(sessionID, privacy: .public)"
1110+
)
1111+
withAnimation(.easeInOut(duration: 0.2)) {
1112+
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
1113+
}
1114+
}
1115+
}
1116+
10771117
// MARK: - Queued preview pill
10781118

10791119
private var queuedPreviewPill: some View {

0 commit comments

Comments
 (0)