Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions RxCode/App/AppState+MobileSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -510,13 +510,10 @@ extension AppState {
switch request.operation {
case .switchExisting:
try await switchToExistingBranch(trimmed, in: window)
updateMobilePendingWorktree(from: window, projectID: project.id)
case .createNew:
try await attachWorktree(branch: trimmed, in: window)
if let path = window.pendingWorktreePath,
let branch = window.pendingWorktreeBranch
{
mobilePendingWorktrees[project.id] = MobilePendingWorktree(path: path, branch: branch)
}
updateMobilePendingWorktree(from: window, projectID: project.id)
}
} catch {
await replyBranchOpResult(
Expand All @@ -532,6 +529,16 @@ extension AppState {
scheduleMobileSnapshotBroadcast()
}

private func updateMobilePendingWorktree(from window: WindowState, projectID: UUID) {
if let path = window.pendingWorktreePath,
let branch = window.pendingWorktreeBranch
{
mobilePendingWorktrees[projectID] = MobilePendingWorktree(path: path, branch: branch)
} else {
mobilePendingWorktrees.removeValue(forKey: projectID)
}
}

func replyBranchOpResult(
request: BranchOpRequestPayload,
ok: Bool,
Expand Down
52 changes: 46 additions & 6 deletions RxCodeMobile/Views/MobileChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
/// Re-asserts the first scroll-to-bottom while the lazy stack and composer
/// geometry settle on thread entry.
@State private var initialScrollTask: Task<Void, Never>?
/// Owns coalesced automatic bottom-follow while streamed content grows.
@State private var autoBottomScrollTask: Task<Void, Never>?
/// Prevents repeated scheduling while the delayed initial scroll is
/// waiting for the first loaded page to finish laying out.
@State private var isEstablishingInitialScroll = false
Expand Down Expand Up @@ -94,6 +96,7 @@
/// new programmatic top-pin before it has settled.
@State private var canReleasePinnedTurnByScroll = false
@State private var distanceFromBottom: CGFloat = 0
@State private var lastAutoBottomScrollDate = Date.distantPast
@State private var minimumThreadLoadElapsed = false
@State private var isThreadLoadingOverlayVisible = true
@State private var threadLoadingHideTask: Task<Void, Never>?
Expand All @@ -115,6 +118,8 @@
/// Approximate indicator height plus LazyVStack spacing. Cleared once the
/// tail marker reports the indicator in measured geometry.
private static let streamingIndicatorEstimatedHeight: CGFloat = 36
/// Maximum automatic bottom-follow cadence while content streams in.
private static let autoBottomScrollInterval: TimeInterval = 2
private static let pinToTopAnimationDuration: Duration = .milliseconds(320)
private static let pinToTopAnimationSeconds: Double = 0.32

Expand Down Expand Up @@ -498,14 +503,14 @@
} else if repinActiveTurnIfNeeded(proxy: proxy) {
return
} else if autoScrollEnabled {
withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) }
scrollToBottomDebounced(proxy: proxy, reason: "lastMessage")
}
}
.onChange(of: messages.last?.content) { _, _ in
guard didEstablishInitialScroll else { return }
if repinActiveTurnIfNeeded(proxy: proxy) { return }
guard autoScrollEnabled else { return }
withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) }
scrollToBottomDebounced(proxy: proxy, reason: "messageContent")
}
.onChange(of: isStreaming) { _, streaming in
// Keep the newly appeared loading indicator in view.
Expand All @@ -518,7 +523,7 @@
}
if repinActiveTurnIfNeeded(proxy: proxy) { return }
guard autoScrollEnabled else { return }
withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) }
scrollToBottomDebounced(proxy: proxy, reason: "streamingStarted")
}
.onChange(of: isLoadingMore) { _, loading in
guard !loading, let anchor = pendingTopAnchorID else { return }
Expand Down Expand Up @@ -798,9 +803,7 @@
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(16))
guard didEstablishInitialScroll, autoScrollEnabled else { return }
withAnimation(.easeInOut(duration: 0.2)) {
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
}
scrollToBottomDebounced(proxy: proxy, reason: "layout")
}
}

Expand Down Expand Up @@ -883,6 +886,8 @@
// The sent message round-trips through the desktop; when it comes back
// it is pinned to the top. Suppress bottom-follow so the streaming
// reply fills the space below the question instead of yanking past it.
autoBottomScrollTask?.cancel()
autoBottomScrollTask = nil
awaitingSentUserMessage = true
activeTurnUserMessageID = nil
pendingIndicatorSpacerReduction = 0
Expand Down Expand Up @@ -938,6 +943,8 @@

/// Pin a freshly sent user message to the top of the viewport.
private func pinSentMessageToTop(_ id: UUID, proxy: ScrollViewProxy, animated: Bool) {
autoBottomScrollTask?.cancel()
autoBottomScrollTask = nil
pinToTopTask?.cancel()
canReleasePinnedTurnByScroll = false
pinToTopTask = Task { @MainActor in
Expand Down Expand Up @@ -1036,6 +1043,8 @@
)
autoScrollEnabled = true
isUserDragging = false
autoBottomScrollTask?.cancel()
autoBottomScrollTask = nil
scrollToBottomFromButton(proxy)
} label: {
Image(systemName: "arrow.down")
Expand Down Expand Up @@ -1063,6 +1072,7 @@
pinToTopTask?.cancel()
canReleasePinnedTurnByScroll = false
isPinningLatestTurnToTop = false
lastAutoBottomScrollDate = Date()
withAnimation(.easeInOut(duration: 0.2)) {
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
}
Expand All @@ -1074,6 +1084,36 @@
}
}

/// Coalesces automatic bottom-follow while streaming content grows. Explicit
/// user actions and initial thread positioning still scroll immediately.
private func scrollToBottomDebounced(proxy: ScrollViewProxy, reason: String) {
guard autoBottomScrollTask == nil else { return }
let elapsed = Date().timeIntervalSince(lastAutoBottomScrollDate)
let delay = max(0, Self.autoBottomScrollInterval - elapsed)
mobileChatLogger.debug(
"[AutoScroll] scheduled reason=\(reason, privacy: .public) delay=\(delay, privacy: .public) session=\(sessionID, privacy: .public)"
)
autoBottomScrollTask = Task { @MainActor in
if delay > 0 {
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
guard !Task.isCancelled else { return }
autoBottomScrollTask = nil
guard didEstablishInitialScroll,
autoScrollEnabled,
!isUserDragging,
!isPinningLatestTurnToTop
else { return }
Comment on lines +1096 to +1106
lastAutoBottomScrollDate = Date()
mobileChatLogger.debug(
"[AutoScroll] fired reason=\(reason, privacy: .public) session=\(sessionID, privacy: .public)"
)
withAnimation(.easeInOut(duration: 0.2)) {
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
}
}
}

// MARK: - Queued preview pill

private var queuedPreviewPill: some View {
Expand Down Expand Up @@ -1203,4 +1243,4 @@
let count = pendingPlans.count
return count == 1 ? "Plan ready to review" : "\(count) plans ready to review"
}
}

Check warning on line 1246 in RxCodeMobile/Views/MobileChatView.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 800 lines or less excluding comments and whitespaces: currently contains 1005 (file_length)
Loading