From 6265b523defd3d7cb9e2cc46d8a13b9cf0fca630 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 18:40:11 +0800 Subject: [PATCH] fix: add animation transition to chat message on mobile --- .../State/MobileAppState+Inbound.swift | 4 + .../State/MobileAppState+Intents.swift | 21 +- RxCodeMobile/State/MobileAppState.swift | 3 + RxCodeMobile/Views/MobileBriefingView.swift | 4 +- RxCodeMobile/Views/MobileChatView.swift | 36 +++ .../Views/MobileThreadLoadingOverlay.swift | 248 ++++++++++++++++++ RxCodeMobile/Views/RootView.swift | 25 +- 7 files changed, 330 insertions(+), 11 deletions(-) create mode 100644 RxCodeMobile/Views/MobileThreadLoadingOverlay.swift diff --git a/RxCodeMobile/State/MobileAppState+Inbound.swift b/RxCodeMobile/State/MobileAppState+Inbound.swift index 440f2a61..542b73e6 100644 --- a/RxCodeMobile/State/MobileAppState+Inbound.swift +++ b/RxCodeMobile/State/MobileAppState+Inbound.swift @@ -106,6 +106,7 @@ extension MobileAppState { } else if messagesBySession[active] == nil { messagesBySession[active] = [] } + loadingThreadMessageSessions.remove(active) activeSessionID = active } hasReceivedInitialSnapshot = true @@ -362,6 +363,9 @@ extension MobileAppState { if loadingMoreSessions.remove(previous) != nil { loadingMoreSessions.insert(update.sessionID) } + if loadingThreadMessageSessions.remove(previous) != nil { + loadingThreadMessageSessions.insert(update.sessionID) + } if activeSessionID == previous { activeSessionID = update.sessionID } diff --git a/RxCodeMobile/State/MobileAppState+Intents.swift b/RxCodeMobile/State/MobileAppState+Intents.swift index d8915f03..fdfdb268 100644 --- a/RxCodeMobile/State/MobileAppState+Intents.swift +++ b/RxCodeMobile/State/MobileAppState+Intents.swift @@ -47,6 +47,12 @@ extension MobileAppState { loadingMoreSessions.contains(sessionID) } + /// Whether the initial message window for a newly opened thread is still + /// being refreshed from the desktop. + func isLoadingThreadMessages(sessionID: String) -> Bool { + loadingThreadMessageSessions.contains(sessionID) + } + /// Mirror of the desktop's per-session queue, surfaced via `SessionSummary`. func queuedMessages(sessionID: String) -> [QueuedUserMessage] { sessions.first(where: { $0.id == sessionID })?.queuedMessages ?? [] @@ -187,6 +193,7 @@ extension MobileAppState { /// Archive a thread. Optimistically flips `isArchived` so the row drops out /// of the active list right away. func archiveThread(sessionID: String) async { + loadingThreadMessageSessions.remove(sessionID) replaceSession(sessionID: sessionID) { current in SessionSummary( id: current.id, @@ -210,6 +217,7 @@ extension MobileAppState { messagesBySession.removeValue(forKey: sessionID) sessionsWithMoreMessages.remove(sessionID) loadingMoreSessions.remove(sessionID) + loadingThreadMessageSessions.remove(sessionID) if activeSessionID == sessionID { activeSessionID = nil } await sendThreadAction(sessionID: sessionID, action: .delete) } @@ -276,7 +284,18 @@ extension MobileAppState { func subscribe(to sessionID: String?) async { activeSessionID = sessionID guard isPaired else { return } + if let sessionID { + loadingThreadMessageSessions = [sessionID] + } else { + loadingThreadMessageSessions.removeAll() + } let payload = SubscribeSessionPayload(sessionID: sessionID) - try? await client.send(.subscribeSession(payload), toHex: pairedDesktopPubkey) + do { + try await client.send(.subscribeSession(payload), toHex: pairedDesktopPubkey) + } catch { + if let sessionID { + loadingThreadMessageSessions.remove(sessionID) + } + } } } diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index 599c4a36..aeda979f 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -152,6 +152,9 @@ final class MobileAppState: ObservableObject { @Published var sessionsWithMoreMessages: Set = [] /// Sessions with an in-flight `load_more_messages` request. @Published var loadingMoreSessions: Set = [] + /// Sessions whose initial message page is being refreshed after opening the + /// chat detail view. + @Published var loadingThreadMessageSessions: Set = [] /// Maps an outstanding load-more request ID to its session, so a late /// `more_messages` reply lands on the right thread. var pendingLoadMoreRequests: [UUID: String] = [:] diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index f42f6b12..3de0e115 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -26,6 +26,7 @@ struct GroupedBriefing: Identifiable { struct MobileBriefingView: View { @EnvironmentObject private var state: MobileAppState @Namespace private var glassNamespace + var onCloseChat: () -> Void = {} /// Selected project ids for filtering. Empty = show every project. @State private var selectedProjectIds: Set = [] @@ -84,8 +85,9 @@ struct MobileBriefingView: View { MobileBriefingDetailView(groupKey: key) } .navigationDestination(for: String.self) { sessionID in - MobileChatView(sessionID: sessionID) + MobileChatView(sessionID: sessionID, onClose: onCloseChat) .id(sessionID) + .toolbar(.hidden, for: .tabBar) .task(id: sessionID) { if !MobileDraftSessionID.isDraft(sessionID) { await state.subscribe(to: sessionID) diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 78affd12..9255d7b6 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -63,6 +63,7 @@ struct MobileChatView: View { /// and should be pinned to the top of the viewport. @State private var awaitingSentUserMessage = false @State private var distanceFromBottom: CGFloat = 0 + @State private var minimumThreadLoadElapsed = false private static let bottomAnchorID = "message-list-bottom" /// Distance from the bottom past which the "scroll to bottom" button shows. @@ -83,9 +84,13 @@ struct MobileChatView: View { .animation(.easeInOut(duration: 0.2), value: queuedMessages.count) .animation(.easeInOut(duration: 0.2), value: sessionQuestions.count) .animation(.easeInOut(duration: 0.2), value: pendingPlans.count) + .animation(.easeInOut(duration: 0.25), value: shouldShowThreadLoading) .navigationBarTitleDisplayMode(.inline) .navigationTitle(title) .toolbar { threadActionsToolbar } + .task(id: sessionID) { + await runThreadLoadingGate() + } .sheet(isPresented: $showingQueueSheet) { QueuedMessagesSheet( messages: queuedMessages, @@ -488,6 +493,17 @@ struct MobileChatView: View { } action: { newValue in composerMinY = newValue } + + if shouldShowThreadLoading { + MobileThreadLoadingOverlay() + .transition( + .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.98)), + removal: .opacity.combined(with: .move(edge: .bottom)) + ) + ) + .zIndex(3) + } } } @@ -540,6 +556,11 @@ struct MobileChatView: View { state.isLoadingMoreMessages(sessionID: sessionID) } + private var shouldShowThreadLoading: Bool { + !MobileDraftSessionID.isDraft(sessionID) + && (!minimumThreadLoadElapsed || state.isLoadingThreadMessages(sessionID: sessionID)) + } + private var queuedMessages: [QueuedUserMessage] { state.queuedMessages(sessionID: sessionID) } @@ -560,6 +581,21 @@ struct MobileChatView: View { sessionPlans.filter { !$0.isDecided && !$0.isStreaming } } + // MARK: - Initial loading + + private func runThreadLoadingGate() async { + minimumThreadLoadElapsed = false + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return + } + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 0.25)) { + minimumThreadLoadElapsed = true + } + } + // MARK: - Message paging /// Spinner shown at the top of the list while an older page is loading. diff --git a/RxCodeMobile/Views/MobileThreadLoadingOverlay.swift b/RxCodeMobile/Views/MobileThreadLoadingOverlay.swift new file mode 100644 index 00000000..e2a1ff8a --- /dev/null +++ b/RxCodeMobile/Views/MobileThreadLoadingOverlay.swift @@ -0,0 +1,248 @@ +import RxCodeCore +import SwiftUI + +struct MobileThreadLoadingOverlay: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Group { + if reduceMotion { + content(phase: 0) + } else { + TimelineView(.animation) { timeline in + content(phase: timeline.date.timeIntervalSinceReferenceDate) + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Loading messages") + } + + @ViewBuilder + private func content(phase: TimeInterval) -> some View { + GeometryReader { proxy in + let maxWidth = min(proxy.size.width - 32, 420) + let topGap = max(22, proxy.size.height * 0.08) + + VStack(spacing: 0) { + MobileThreadLoadingStatusChip(phase: phase, reduceMotion: reduceMotion) + .padding(.top, 18) + + Spacer() + .frame(height: topGap) + + MobileThreadLoadingMessageStack( + maxWidth: maxWidth, + phase: phase, + reduceMotion: reduceMotion + ) + + Spacer(minLength: 26) + + MobileThreadLoadingComposerBar( + maxWidth: maxWidth, + phase: phase, + reduceMotion: reduceMotion + ) + .padding(.bottom, 18) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 16) + } + .background(.regularMaterial) + } +} + +private struct MobileThreadLoadingStatusChip: View { + let phase: TimeInterval + let reduceMotion: Bool + + var body: some View { + HStack(spacing: 10) { + MobileThreadLoadingGlyph(phase: phase, reduceMotion: reduceMotion) + Text("Loading thread") + .font(.callout.weight(.semibold)) + .foregroundStyle(.primary) + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(.thinMaterial, in: Capsule()) + .overlay { + Capsule() + .stroke(Color.primary.opacity(0.08), lineWidth: 0.5) + } + } +} + +private struct MobileThreadLoadingGlyph: View { + let phase: TimeInterval + let reduceMotion: Bool + + var body: some View { + let rotation = Angle.degrees(reduceMotion ? 0 : phase * 220) + let pulse = reduceMotion ? CGFloat(1) : CGFloat(0.9 + 0.1 * sin(phase * 3.2)) + + ZStack { + Circle() + .fill(ClaudeTheme.accent.opacity(0.12)) + .scaleEffect(pulse) + + Circle() + .trim(from: 0.15, to: 0.72) + .stroke( + LinearGradient( + colors: [ + ClaudeTheme.accent, + Color.blue.opacity(0.9) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + style: StrokeStyle(lineWidth: 3, lineCap: .round) + ) + .rotationEffect(rotation) + + Image(systemName: "text.bubble.fill") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(ClaudeTheme.accent) + } + .frame(width: 24, height: 24) + } +} + +private struct MobileThreadLoadingMessageStack: View { + let maxWidth: CGFloat + let phase: TimeInterval + let reduceMotion: Bool + + var body: some View { + VStack(spacing: 16) { + MobileThreadLoadingMessageBubble( + width: maxWidth * 0.86, + alignment: .leading, + rows: [0.62, 0.94, 0.72], + phase: phase, + reduceMotion: reduceMotion + ) + MobileThreadLoadingMessageBubble( + width: maxWidth * 0.64, + alignment: .trailing, + rows: [0.88, 0.52], + phase: phase + 0.22, + reduceMotion: reduceMotion + ) + MobileThreadLoadingMessageBubble( + width: maxWidth * 0.9, + alignment: .leading, + rows: [0.46, 0.98, 0.82, 0.58], + phase: phase + 0.44, + reduceMotion: reduceMotion + ) + } + .frame(width: maxWidth) + } +} + +private struct MobileThreadLoadingMessageBubble: View { + let width: CGFloat + let alignment: Alignment + let rows: [CGFloat] + let phase: TimeInterval + let reduceMotion: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(rows.indices, id: \.self) { index in + MobileThreadLoadingLine( + widthRatio: rows[index], + phase: phase + Double(index) * 0.08, + reduceMotion: reduceMotion + ) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 13) + .frame(width: width, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.primary.opacity(0.055)) + ) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.primary.opacity(0.055), lineWidth: 0.5) + } + .frame(maxWidth: .infinity, alignment: alignment) + } +} + +private struct MobileThreadLoadingComposerBar: View { + let maxWidth: CGFloat + let phase: TimeInterval + let reduceMotion: Bool + + var body: some View { + HStack(spacing: 10) { + MobileThreadLoadingLine( + widthRatio: 0.72, + height: 14, + phase: phase + 0.65, + reduceMotion: reduceMotion + ) + .frame(maxWidth: .infinity, alignment: .leading) + + Circle() + .fill(ClaudeTheme.accent.opacity(0.18)) + .frame(width: 34, height: 34) + .overlay { + Image(systemName: "arrow.up") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(ClaudeTheme.accent.opacity(0.7)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(width: maxWidth) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 0.5) + } + } +} + +private struct MobileThreadLoadingLine: View { + let widthRatio: CGFloat + var height: CGFloat = 10 + let phase: TimeInterval + let reduceMotion: Bool + + var body: some View { + GeometryReader { proxy in + RoundedRectangle(cornerRadius: height / 2, style: .continuous) + .fill(Color.primary.opacity(0.085)) + .overlay { + if !reduceMotion { + GeometryReader { lineProxy in + let cycle = phase.truncatingRemainder(dividingBy: 1.45) / 1.45 + let travel = CGFloat(cycle) * (lineProxy.size.width + 100) - 70 + + LinearGradient( + colors: [ + .clear, + Color.white.opacity(0.32), + .clear + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: 70) + .offset(x: travel) + } + .mask(RoundedRectangle(cornerRadius: height / 2, style: .continuous)) + } + } + .frame(width: proxy.size.width * widthRatio, height: height) + } + .frame(height: height) + } +} diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift index 0126c9a0..473429da 100644 --- a/RxCodeMobile/Views/RootView.swift +++ b/RxCodeMobile/Views/RootView.swift @@ -185,8 +185,8 @@ struct RootView: View { private var phoneTabs: some View { TabView(selection: $selectedTab) { - NavigationStack { - MobileBriefingView() + NavigationStack(path: $briefingDetailPath) { + MobileBriefingView(onCloseChat: { closeBriefingChat() }) } .tabItem { Label("Briefing", systemImage: "doc.text") @@ -342,13 +342,21 @@ struct RootView: View { /// desktop-driven focus changes). private func openActiveSession(_ sessionID: String?) { guard let sessionID else { return } - // Skip navigation if we're already viewing this session in briefing detail - if showingBriefing && briefingDetailPath.count > 0 { + // Skip navigation if we're already inside the briefing detail flow. + if isViewingBriefingDetail { return } navigate(toSession: sessionID, projectID: nil) } + private var isViewingBriefingDetail: Bool { + guard !briefingDetailPath.isEmpty else { return false } + if compactClass == .compact { + return selectedTab == .briefing + } + return showingBriefing + } + /// Consume a pending APNs deep link (set by a notification tap) and navigate /// to its thread. Called both when the link changes and when the paired view /// first appears, since a link can already be set at cold launch. @@ -367,21 +375,20 @@ struct RootView: View { /// view is driven by `selectedSession`. Keeping the two mechanisms separate /// avoids pushing the same chat page twice. private func navigate(toSession sessionID: String, projectID: UUID?) { - guard let projectID = projectID - ?? state.sessions.first(where: { $0.id == sessionID })?.projectId - else { return } - selectedTab = .projects showingBriefing = false if compactClass == .compact { var path = NavigationPath() - path.append(projectID) path.append(sessionID) if projectsPath != path { projectsPath = path } } else { + guard let projectID = projectID + ?? state.sessions.first(where: { $0.id == sessionID })?.projectId + else { return } + selectedProject = projectID selectedSession = sessionID }