From 372ae76ff9522f973a5cd9cefd34f461349ab0ae Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 02:18:19 +0800 Subject: [PATCH 1/3] fix: duplicate activity items when creating new thread --- RxCode/Services/MobileSyncService.swift | 22 ++++++++++++++++++++-- RxCodeMobile/State/MobileAppState.swift | 1 + RxCodeWidget/RxCodeJobActivity.swift | 24 +++++++++++++++++++++++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 47e9d1c..cc20be0 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -437,7 +437,13 @@ final class MobileSyncService: ObservableObject { ) await client.broadcast(.sessionUpdate(payload)) } - updateJobTracking(sessionID: sessionID, kind: kind, isStreaming: isStreaming, summary: summary) + updateJobTracking( + sessionID: sessionID, + kind: kind, + isStreaming: isStreaming, + summary: summary, + previousSessionID: previousSessionID + ) } /// Mirror the desktop's current `AskUserQuestion` queue to every paired @@ -464,8 +470,20 @@ final class MobileSyncService: ObservableObject { sessionID: String, kind: SessionUpdatePayload.Kind, isStreaming: Bool?, - summary: RxCodeSync.SessionSummary? + summary: RxCodeSync.SessionSummary?, + previousSessionID: String? ) { + if let previousSessionID, previousSessionID != sessionID { + streamingSessionIDs.remove(previousSessionID) + if let previousIdx = trackedJobs.firstIndex(where: { $0.sessionID == previousSessionID }) { + if let summary { + trackedJobs[previousIdx] = makeJobContent(from: summary) + } else { + trackedJobs.remove(at: previousIdx) + } + lastPushedJobsSignature = "" + } + } let streaming: Bool? switch kind { case .streamingStarted: streaming = true diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index 561ca63..c3d766e 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -1693,6 +1693,7 @@ final class MobileAppState: ObservableObject { if activeSessionID == previous { activeSessionID = update.sessionID } + sessions.removeAll { $0.id == previous } } if let summary = update.summary { diff --git a/RxCodeWidget/RxCodeJobActivity.swift b/RxCodeWidget/RxCodeJobActivity.swift index 284fac5..457048b 100644 --- a/RxCodeWidget/RxCodeJobActivity.swift +++ b/RxCodeWidget/RxCodeJobActivity.swift @@ -65,6 +65,16 @@ struct RxCodeJobActivityAttributes: ActivityAttributes { guard todoTotal > 0 else { return 0 } return min(1, max(0, Double(todoDone) / Double(todoTotal))) } + + var displayIdentity: String { + [ + phase.rawValue, + projectName, + title, + "\(todoDone)/\(todoTotal)", + currentStep ?? "", + ].joined(separator: "\u{1F}") + } } /// Every tracked job: those still running plus recently finished ones, @@ -88,7 +98,19 @@ struct RxCodeJobActivityAttributes: ActivityAttributes { ordered.append(job) } } - return ordered + + var visible: [Job] = [] + var indicesByDisplay: [String: Int] = [:] + for job in ordered { + let key = job.displayIdentity + if let index = indicesByDisplay[key] { + visible[index] = job + } else { + indicesByDisplay[key] = visible.count + visible.append(job) + } + } + return visible } /// Jobs still being worked on. From fd7f7c8b4db0c15086b739ffc1259ca825bdbc41 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 02:52:09 +0800 Subject: [PATCH 2/3] feat: improve the scrolling for desktop app by using markdown ui --- .github/workflows/test.yaml | 1 + Packages/Package.resolved | 29 +- Packages/Package.swift | 2 + .../Sources/RxCodeChatKit/MarkdownView.swift | 218 +++++++++++-- Packages/Sources/RxCodeChatKit/Settings.swift | 8 + .../RxCodeCore/Models/ChatMessage.swift | 28 ++ .../xcshareddata/swiftpm/Package.resolved | 27 ++ RxCode/App/AppState.swift | 19 +- .../Views/Sidebar/MarkdownPreviewView.swift | 231 +------------- RxCodeMobile/Views/GlassThreadCard.swift | 287 ++++++++++++++++++ RxCodeMobile/Views/SessionsList.swift | 210 +++++++------ UITestplan.xctestplan | 1 + UnitTestPlan.xctestplan | 1 + 13 files changed, 717 insertions(+), 345 deletions(-) create mode 100644 Packages/Sources/RxCodeChatKit/Settings.swift create mode 100644 RxCodeMobile/Views/GlassThreadCard.swift diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c666e0f..f8a0472 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -57,6 +57,7 @@ jobs: -configuration Debug \ -destination platform=macOS \ -testPlan UnitTestPlan \ + -enableCodeCoverage NO \ CODE_SIGNING_ALLOWED=NO \ test | xcpretty diff --git a/Packages/Package.resolved b/Packages/Package.resolved index 9674e39..2b055bd 100644 --- a/Packages/Package.resolved +++ b/Packages/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "aae110058c54c9409d4188d720ecdaedf50f07b0863df25d2e481376a6b7e748", + "originHash" : "b5e2db87d744b0386db13e49f7a31484bb89dcefe6309652625575513faae4ab", "pins" : [ + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", + "version" : "0.8.0" + } + }, { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", @@ -10,6 +28,15 @@ "version" : "1.3.2" } }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, { "identity" : "swiftui-math", "kind" : "remoteSourceControl", diff --git a/Packages/Package.swift b/Packages/Package.swift index c4d9508..02dfbd4 100644 --- a/Packages/Package.swift +++ b/Packages/Package.swift @@ -13,6 +13,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/nalexn/ViewInspector", from: "0.10.0"), .package(url: "https://github.com/gonzalezreal/textual", from: "0.3.1"), + .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"), ], targets: [ .target( @@ -24,6 +25,7 @@ let package = Package( dependencies: [ "RxCodeCore", .product(name: "Textual", package: "textual"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), ], path: "Sources/RxCodeChatKit", resources: [ diff --git a/Packages/Sources/RxCodeChatKit/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index ebd4c4c..b401aaa 100644 --- a/Packages/Sources/RxCodeChatKit/MarkdownView.swift +++ b/Packages/Sources/RxCodeChatKit/MarkdownView.swift @@ -1,23 +1,48 @@ import SwiftUI import Foundation +import MarkdownUI import RxCodeCore import Textual // MARK: - Markdown Content View -/// Renders markdown text — headings, lists, blockquotes, tables, and rich text — -/// using the Textual rendering engine (https://github.com/gonzalezreal/textual). -struct MarkdownContentView: View { +/// Renders markdown text with the renderer selected in `Settings.swift`. +public struct MarkdownContentView: View { let text: String let showsTrailingCursor: Bool let isCursorVisible: Bool - init(text: String, showsTrailingCursor: Bool = false, isCursorVisible: Bool = true) { + public init(text: String, showsTrailingCursor: Bool = false, isCursorVisible: Bool = true) { self.text = text self.showsTrailingCursor = showsTrailingCursor self.isCursorVisible = isCursorVisible } + public var body: some View { + switch RxCodeChatKitSettings.markdownRenderer { + case .textual: + TextualMarkdownContentView( + text: text, + showsTrailingCursor: showsTrailingCursor, + isCursorVisible: isCursorVisible + ) + case .markdownUI: + MarkdownUIMarkdownContentView( + text: text, + showsTrailingCursor: showsTrailingCursor, + isCursorVisible: isCursorVisible + ) + } + } +} + +// MARK: - Textual Renderer + +private struct TextualMarkdownContentView: View { + let text: String + let showsTrailingCursor: Bool + let isCursorVisible: Bool + var body: some View { StructuredText(markdown: renderedMarkdown) .id(renderedMarkdown) @@ -34,20 +59,10 @@ struct MarkdownContentView: View { ) .textual.headingStyle(RxCodeHeadingStyle()) .textual.codeBlockStyle(RxCodeBlockStyle()) - // Keep Textual's text-selection overlay permanently disabled. - // When enabled it installs a geometry-dependent - // `onChange(of: AnyTextLayoutCollection)` that fires many times - // per frame while the chat List scrolls, dropping frames and - // making the scroll bumpy. Toggling it per scroll-phase is worse: - // flipping selectability swaps Textual's view-tree branch and - // rebuilds every visible markdown row. Whole-message and - // per-code-block Copy buttons cover copying instead. .textual.textSelection(.disabled) .frame(maxWidth: .infinity, alignment: .leading) } - /// The markdown actually passed to the renderer: bare URLs auto-linked and, - /// while streaming, a trailing cursor glyph appended. private var renderedMarkdown: String { let processed = preprocessMarkdown(text) if showsTrailingCursor && isCursorVisible { @@ -57,10 +72,166 @@ struct MarkdownContentView: View { } } +// MARK: - MarkdownUI Renderer + +private struct MarkdownUIMarkdownContentView: View { + let text: String + let showsTrailingCursor: Bool + let isCursorVisible: Bool + + var body: some View { + Markdown(renderedMarkdown) + .id(renderedMarkdown) + .markdownTheme(.rxCodeChat) + .markdownTextStyle(\.code) { + FontFamilyVariant(.monospaced) + FontSize(.em(0.93)) + ForegroundColor(ClaudeTheme.textPrimary) + BackgroundColor(ClaudeTheme.surfaceTertiary) + } + .markdownBlockStyle(\.codeBlock) { configuration in + MarkdownUICodeBlock( + language: configuration.language, + content: configuration.content + ) { + configuration.label + } + } + .tint(ClaudeTheme.accent) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var renderedMarkdown: String { + let processed = preprocessMarkdown(text) + if showsTrailingCursor && isCursorVisible { + return processed + "\u{2009}\u{25CF}" + } + return processed + } +} + +private struct MarkdownUICodeBlock: View { + let language: String? + let content: String + @ViewBuilder let label: () -> Label + @State private var isCopied = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + if let language, !language.isEmpty { + Text(language) + .font(.system(size: ClaudeTheme.messageSize(11), weight: .medium, design: .monospaced)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + Spacer() + Button { + copyToClipboard(content, feedback: $isCopied) + } label: { + HStack(spacing: 4) { + Image(systemName: isCopied ? "checkmark" : "doc.on.doc") + .font(.caption2) + Text(isCopied ? String(localized: "Copied", bundle: .module) : String(localized: "Copy", bundle: .module)) + .font(.caption2) + } + .foregroundStyle(isCopied ? ClaudeTheme.statusSuccess : ClaudeTheme.textTertiary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(ClaudeTheme.codeHeaderBackground) + + Rectangle() + .fill(ClaudeTheme.border) + .frame(height: 0.5) + + ScrollView(.horizontal, showsIndicators: false) { + label() + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.88)) + ForegroundColor(ClaudeTheme.textPrimary) + BackgroundColor(nil) + } + .fixedSize() + .padding(12) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(ClaudeTheme.codeBackground) + } + .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .strokeBorder(ClaudeTheme.border, lineWidth: 0.5) + ) + .markdownMargin(top: .em(0.88), bottom: .em(0.4)) + } +} + +private extension Theme { + static let rxCodeChat = Theme.gitHub + .text { + FontSize(ClaudeTheme.messageSize(15)) + ForegroundColor(ClaudeTheme.textPrimary) + } + .link { + ForegroundColor(ClaudeTheme.accent) + } + .heading1 { configuration in + configuration.label + .markdownTextStyle { + FontSize(.em(1.33)) + FontWeight(.bold) + ForegroundColor(ClaudeTheme.textPrimary) + } + .markdownMargin(top: .em(1.2), bottom: .em(0.4)) + } + .heading2 { configuration in + configuration.label + .markdownTextStyle { + FontSize(.em(1.2)) + FontWeight(.bold) + ForegroundColor(ClaudeTheme.textPrimary) + } + .markdownMargin(top: .em(1.2), bottom: .em(0.4)) + } + .heading3 { configuration in + configuration.label + .markdownTextStyle { + FontSize(.em(1.07)) + FontWeight(.semibold) + ForegroundColor(ClaudeTheme.textPrimary) + } + .markdownMargin(top: .em(1.2), bottom: .em(0.4)) + } + .blockquote { configuration in + configuration.label + .markdownTextStyle { + ForegroundColor(ClaudeTheme.textSecondary) + } + .padding(.leading, 12) + .overlay(alignment: .leading) { + Rectangle() + .fill(ClaudeTheme.accent) + .frame(width: 3) + } + .markdownMargin(top: .em(0.8), bottom: .em(0.4)) + } + .paragraph { configuration in + configuration.label + .relativeLineSpacing(.em(0.2)) + .markdownMargin(top: .em(0), bottom: .em(0.5)) + } + .listItem { configuration in + configuration.label + .markdownMargin(top: .em(0.15), bottom: .em(0.15)) + } +} + // MARK: - Markdown Preprocessing -/// Applies bare-URL auto-linking and link sanitization, skipping fenced code blocks -/// so URLs inside code samples are left untouched. private func preprocessMarkdown(_ text: String) -> String { var lines: [String] = [] var inFence = false @@ -78,9 +249,8 @@ private func preprocessMarkdown(_ text: String) -> String { return lines.joined(separator: "\n") } -// MARK: - Heading Style +// MARK: - Textual Styles -/// Heading style tuned to RxCode's chat typography (15pt body text). private struct RxCodeHeadingStyle: StructuredText.HeadingStyle { private static let fontScales: [CGFloat] = [1.333, 1.2, 1.067, 1.0, 1.0, 1.0] private static let fontWeights: [Font.Weight] = [.bold, .bold, .semibold, .semibold, .medium, .medium] @@ -94,10 +264,6 @@ private struct RxCodeHeadingStyle: StructuredText.HeadingStyle { } } -// MARK: - Code Block Style - -/// Code block style that keeps RxCode's chrome — language label and copy button — -/// while delegating syntax highlighting to Textual. private struct RxCodeBlockStyle: StructuredText.CodeBlockStyle { func makeBody(configuration: Configuration) -> some View { RxCodeBlockBody(configuration: configuration) @@ -169,7 +335,6 @@ private struct RxCodeBlockBody: View { // MARK: - Markdown Link Helpers -/// Removes incorrectly included characters (such as backticks) from URLs inside markdown links `[text](url)` func sanitizeMarkdownLinkURLs(_ text: String) -> String { let pattern = #"\[([^\]]*)\]\(([^)]*`[^)]*)\)"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } @@ -186,17 +351,12 @@ func sanitizeMarkdownLinkURLs(_ text: String) -> String { return result } -/// Converts bare URLs not already inside a markdown link into `[url](url)` form func autoLinkURLs(_ text: String) -> String { - // Leave URLs already inside markdown links untouched - // Pattern: match only bare URLs that are not in ](url) or [text](url) form let pattern = #"(?\[\]`]+"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } let range = NSRange(text.startIndex..., in: text) var result = text - // Substitute from back to front to prevent index shifting - let matches = regex.matches(in: text, range: range).reversed() - for match in matches { + for match in regex.matches(in: text, range: range).reversed() { guard let swiftRange = Range(match.range, in: result) else { continue } let url = String(result[swiftRange]) result.replaceSubrange(swiftRange, with: "[\(url)](\(url))") diff --git a/Packages/Sources/RxCodeChatKit/Settings.swift b/Packages/Sources/RxCodeChatKit/Settings.swift new file mode 100644 index 0000000..30d8283 --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/Settings.swift @@ -0,0 +1,8 @@ +enum RxCodeChatKitSettings { + static let markdownRenderer: MarkdownRendererKind = .markdownUI +} + +enum MarkdownRendererKind { + case textual + case markdownUI +} diff --git a/Packages/Sources/RxCodeCore/Models/ChatMessage.swift b/Packages/Sources/RxCodeCore/Models/ChatMessage.swift index 8e28912..b8246bf 100644 --- a/Packages/Sources/RxCodeCore/Models/ChatMessage.swift +++ b/Packages/Sources/RxCodeCore/Models/ChatMessage.swift @@ -169,6 +169,34 @@ public struct ChatMessage: Identifiable, Codable, Sendable, Equatable { return toolCall.result?.isEmpty == true && !toolCall.isError } } + + /// Cancel-time finalizer for a paused assistant turn. + /// + /// Unlike `finalizeToolCalls()`, tool calls still awaiting a result are + /// kept rather than dropped, so pausing a stream leaves the in-progress + /// work visible instead of making the whole bubble disappear: + /// - Keep-always tools (Edit, Write, Agent, …) keep a `nil` result so the + /// UI renders them with its built-in "Interrupted" badge. + /// - Other tools (Bash, Read, Grep, MCP, …) — which the chat list hides + /// unless they carry a result — are given an explicit interrupted + /// result so they still render. + /// Completed tools with an empty result are still discarded, matching + /// `finalizeToolCalls()` so a paused turn looks consistent with a finished one. + public mutating func markStreamInterrupted() { + isStreaming = false + for index in blocks.indices { + guard let toolCall = blocks[index].toolCall, + toolCall.result == nil, + !toolCall.isKeepAlways else { continue } + blocks[index].toolCall?.result = "Interrupted" + blocks[index].toolCall?.isError = true + } + blocks.removeAll { block in + guard let toolCall = block.toolCall, toolCall.result != nil else { return false } + if toolCall.keepsEmptyResult { return false } + return toolCall.result?.isEmpty == true && !toolCall.isError + } + } } // MARK: - Attachment Info diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 78fc9f5..3a01ad1 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "originHash" : "cd0f1869aec772d2717b6f2ddf4927d69ca9a55e0482f7b012a30bb999a17484", "pins" : [ + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -19,6 +28,15 @@ "version" : "1.7.1" } }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", + "version" : "0.8.0" + } + }, { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", @@ -28,6 +46,15 @@ "version" : "1.3.2" } }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, { "identity" : "swiftterm", "kind" : "remoteSourceControl", diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 7ca7b34..f999d51 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -4822,13 +4822,19 @@ final class AppState { /// it entirely. Called at turn-finalization sites — the marker is the /// model's response when a turn arrives without a user prompt /// (ScheduleWakeup, hook re-entry) and reads as noise in the chat UI. - private static func stripNoOpText(at idx: Int, in messages: inout [ChatMessage]) { + /// Strip CLI no-op meta text ("no response requested") from a message. + /// + /// `removeIfEmpty` controls whether a message left with no blocks is also + /// deleted. The normal stream path passes `true` to discard pure no-op + /// envelopes; the cancel path passes `false` so pausing a turn never makes + /// the partial assistant bubble disappear. + private static func stripNoOpText(at idx: Int, in messages: inout [ChatMessage], removeIfEmpty: Bool = true) { guard messages.indices.contains(idx) else { return } messages[idx].blocks.removeAll { block in guard let text = block.text else { return false } return CLIMetaEnvelope.isNoResponseRequested(text.trimmingCharacters(in: .whitespacesAndNewlines)) } - if messages[idx].blocks.isEmpty { + if removeIfEmpty, messages[idx].blocks.isEmpty { messages.remove(at: idx) } } @@ -5803,12 +5809,15 @@ final class AppState { if let idx = state.messages.indices.reversed().first(where: { state.messages[$0].role == .assistant && state.messages[$0].isStreaming }) { - state.messages[idx].isStreaming = false - state.messages[idx].finalizeToolCalls() + // The user paused this turn — keep the partial assistant bubble + // visible. markStreamInterrupted() retains in-progress tool calls + // (flagged as interrupted) instead of dropping them, and the + // no-op strip below is told not to delete an emptied message. + state.messages[idx].markStreamInterrupted() if let start = state.streamingStartDate { state.messages[idx].duration = Date().timeIntervalSince(start) } - Self.stripNoOpText(at: idx, in: &state.messages) + Self.stripNoOpText(at: idx, in: &state.messages, removeIfEmpty: false) } state.streamingStartDate = nil state.inFlightUserAttachments = [] diff --git a/RxCode/Views/Sidebar/MarkdownPreviewView.swift b/RxCode/Views/Sidebar/MarkdownPreviewView.swift index f17dfbe..135da83 100644 --- a/RxCode/Views/Sidebar/MarkdownPreviewView.swift +++ b/RxCode/Views/Sidebar/MarkdownPreviewView.swift @@ -1,229 +1,16 @@ import SwiftUI -import WebKit +import RxCodeChatKit +import RxCodeCore -struct MarkdownPreviewView: NSViewRepresentable { +struct MarkdownPreviewView: View { let content: String - func makeNSView(context: Context) -> WKWebView { - let webView = WKWebView(frame: .zero) - webView.underPageBackgroundColor = .clear - webView.loadHTMLString(Self.buildHTML(content: content), baseURL: nil) - context.coordinator.lastHash = content.hashValue - return webView - } - - func updateNSView(_ webView: WKWebView, context: Context) { - let hash = content.hashValue - guard context.coordinator.lastHash != hash else { return } - context.coordinator.lastHash = hash - webView.loadHTMLString(Self.buildHTML(content: content), baseURL: nil) - } - - func makeCoordinator() -> Coordinator { Coordinator() } - - final class Coordinator { - var lastHash: Int = 0 - } - - private static func buildHTML(content: String) -> String { - let jsonString: String - if let data = try? JSONEncoder().encode(content), - let s = String(data: data, encoding: .utf8) { - jsonString = s - } else { - jsonString = "\"\"" + var body: some View { + ScrollView { + MarkdownContentView(text: content) + .padding(.horizontal, 28) + .padding(.vertical, 24) } - - return """ - - - - - - - -
- - - - - """ - } - - // Theme colors selected via CSS media query so a single HTML build covers - // both appearances and live-updates if the user switches system mode. - private static let cssScaffold: String = """ - :root { - --bg:#FFFFFF; --text:#1A1A1A; --text2:#666666; - --codeBg:#F5F2EF; --border:#E8E3DC; --link:#D97757; - --quoteBg:#FDF6F0; --evenRow:#FAFAF8; - } - @media (prefers-color-scheme: dark) { - :root { - --bg:#1E1E1E; --text:#E8E3DC; --text2:#A09880; - --codeBg:#2A2A2A; --border:#3A3A3A; --link:#D97757; - --quoteBg:#2A2520; --evenRow:#242424; - } - } - * { box-sizing:border-box; margin:0; padding:0; } - html,body { background:var(--bg); } - body { - font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",sans-serif; - font-size:14px; line-height:1.7; color:var(--text); - padding:24px 28px; - } - h1,h2,h3,h4,h5,h6 { color:var(--text); margin:1.4em 0 .5em; line-height:1.3; font-weight:600; } - h1 { font-size:1.75em; padding-bottom:.3em; border-bottom:1px solid var(--border); } - h2 { font-size:1.4em; padding-bottom:.2em; border-bottom:1px solid var(--border); } - h3 { font-size:1.15em; } h4 { font-size:1em; } - p { margin:.7em 0; } - a { color:var(--link); text-decoration:none; } - a:hover { text-decoration:underline; } - strong { font-weight:600; } em { font-style:italic; } - del { text-decoration:line-through; color:var(--text2); } - code { - font-family:"SF Mono",Menlo,Monaco,Consolas,monospace; - font-size:.875em; background:var(--codeBg); - padding:.15em .4em; border-radius:4px; color:var(--text); + .background(ClaudeTheme.background) } - pre { - background:var(--codeBg); border:1px solid var(--border); - border-radius:8px; padding:16px; overflow-x:auto; margin:1em 0; - } - pre code { background:none; padding:0; font-size:.85em; line-height:1.6; } - blockquote { - border-left:3px solid var(--link); background:var(--quoteBg); - margin:1em 0; padding:10px 16px; border-radius:0 6px 6px 0; color:var(--text2); - } - blockquote p { margin:0; } - ul,ol { padding-left:1.5em; margin:.7em 0; } - li { margin:.25em 0; } - table { width:100%; border-collapse:collapse; margin:1em 0; font-size:.9em; } - th,td { border:1px solid var(--border); padding:8px 12px; text-align:left; } - th { background:var(--codeBg); font-weight:600; } - tr:nth-child(even) td { background:var(--evenRow); } - img { max-width:100%; border-radius:6px; margin:.5em 0; } - hr { border:none; border-top:1px solid var(--border); margin:1.5em 0; } - .task-item { list-style:none; margin-left:-1.2em; } - .task-item input { margin-right:6px; } - """ - - private static let jsParser: String = #""" - (function() { - function escHTML(s) { - return s.replace(/&/g,'&').replace(//g,'>'); - } - - function parseBlocks(md) { - const lines = md.split('\n'); - const out = []; - let i = 0; - while (i < lines.length) { - const line = lines[i]; - const fence = line.match(/^```(\w*)\s*$/); - if (fence) { - const lang = fence[1]; - i++; - const buf = []; - while (i < lines.length && !/^```\s*$/.test(lines[i])) { buf.push(lines[i]); i++; } - i++; - out.push('
' + escHTML(buf.join('\n')) + '
'); - continue; - } - if (/^[-*_]{3,}\s*$/.test(line)) { out.push('
'); i++; continue; } - const h = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/); - if (h) { - const lvl = h[1].length; - out.push('' + inline(h[2]) + ''); - i++; continue; - } - if (/^>\s?/.test(line)) { - const buf = []; - while (i < lines.length && /^>\s?/.test(lines[i])) { - buf.push(lines[i].replace(/^>\s?/, '')); - i++; - } - out.push('
' + parseBlocks(buf.join('\n')) + '
'); - continue; - } - if (/^\s*([-*+]|\d+\.)\s+/.test(line)) { - const ordered = /^\s*\d+\.\s+/.test(line); - const buf = []; - while (i < lines.length && /^\s*([-*+]|\d+\.)\s+/.test(lines[i])) { buf.push(lines[i]); i++; } - out.push(renderList(buf, ordered)); - continue; - } - if (/^\|.+\|\s*$/.test(line) && i + 1 < lines.length && /^\|[\s:\-|]+\|\s*$/.test(lines[i + 1])) { - const tableLines = []; - while (i < lines.length && /^\|.+\|\s*$/.test(lines[i])) { tableLines.push(lines[i]); i++; } - out.push(renderTable(tableLines)); - continue; - } - if (/^\s*$/.test(line)) { i++; continue; } - const pbuf = []; - while ( - i < lines.length && - !/^\s*$/.test(lines[i]) && - !/^(```|#{1,6}\s|>\s?|\s*([-*+]|\d+\.)\s|[-*_]{3,}\s*$)/.test(lines[i]) && - !(/^\|.+\|\s*$/.test(lines[i]) && i + 1 < lines.length && /^\|[\s:\-|]+\|\s*$/.test(lines[i + 1])) - ) { - pbuf.push(lines[i]); i++; - } - if (pbuf.length) out.push('

' + inline(pbuf.join(' ')) + '

'); - } - return out.join('\n'); - } - - function renderList(buf, ordered) { - const items = buf.map(l => l.replace(/^\s*(?:[-*+]|\d+\.)\s+/, '')); - const tag = ordered ? 'ol' : 'ul'; - const lis = items.map(it => { - const tx = it.match(/^\[([ xX])\]\s+(.*)$/); - if (tx) { - const checked = tx[1].toLowerCase() === 'x' ? ' checked' : ''; - return '
  • ' + inline(tx[2]) + '
  • '; - } - return '
  • ' + inline(it) + '
  • '; - }).join(''); - return '<' + tag + '>' + lis + ''; - } - - function renderTable(rows) { - if (rows.length < 2) return ''; - const header = rows[0].split('|').slice(1, -1).map(c => c.trim()); - const body = rows.slice(2); - let out = ''; - header.forEach(h => { out += ''; }); - out += ''; - body.forEach(r => { - const cells = r.split('|').slice(1, -1).map(c => c.trim()); - out += ''; - cells.forEach(c => { out += ''; }); - out += ''; - }); - return out + '
    ' + inline(h) + '
    ' + inline(c) + '
    '; - } - - function inline(s) { - const codes = []; - s = s.replace(/`([^`]+)`/g, (_, c) => { - codes.push('' + escHTML(c) + ''); - return '\u0000I' + (codes.length - 1) + '\u0000'; - }); - s = escHTML(s); - s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, '$1'); - s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, '$1'); - s = s.replace(/\*\*\*([^*]+)\*\*\*/g, '$1'); - s = s.replace(/\*\*([^*]+)\*\*/g, '$1'); - s = s.replace(/__([^_]+)__/g, '$1'); - s = s.replace(/(^|[^\*])\*([^*\n]+)\*/g, '$1$2'); - s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1$2'); - s = s.replace(/~~([^~]+)~~/g, '$1'); - codes.forEach((c, i) => { s = s.split('\u0000I' + i + '\u0000').join(c); }); - return s; - } - - document.getElementById('root').innerHTML = parseBlocks(__RXCODE_MD__); - })(); - """# } diff --git a/RxCodeMobile/Views/GlassThreadCard.swift b/RxCodeMobile/Views/GlassThreadCard.swift new file mode 100644 index 0000000..64c553f --- /dev/null +++ b/RxCodeMobile/Views/GlassThreadCard.swift @@ -0,0 +1,287 @@ +import SwiftUI +import RxCodeCore +import RxCodeSync + +/// A thread card using Liquid Glass design for the mobile threads list. +/// Features translucent glass background with subtle morphing animations. +struct GlassThreadCard: View { + let session: SessionSummary + let isSelected: Bool + /// When true, uses NavigationLink for stack-based navigation (iPhone). + /// When false, uses Button with onSelect callback for selection-based navigation (iPad). + var usesNavigationLink: Bool = true + var onSelect: (() -> Void)? + + @Environment(\.colorScheme) private var colorScheme + + private var displayTitle: String { + let cleaned = ChatSession.stripAttachmentMarkers(from: session.title) + return cleaned.isEmpty ? ChatSession.defaultTitle : cleaned + } + + var body: some View { + if usesNavigationLink { + NavigationLink(value: session.id) { + cardContent + } + .buttonStyle(GlassCardButtonStyle(isSelected: isSelected)) + } else { + Button { + onSelect?() + } label: { + cardContent + } + .buttonStyle(GlassCardButtonStyle(isSelected: isSelected)) + } + } + + private var cardContent: some View { + HStack(spacing: 12) { + // Status indicator + statusIndicator + + // Content + VStack(alignment: .leading, spacing: 6) { + // Title row + HStack(spacing: 6) { + Text(displayTitle) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + + if session.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + } + } + + // Metadata row + HStack(spacing: 8) { + // Time + Label { + Text(compactElapsed(since: session.updatedAt)) + .font(.system(size: 12)) + } icon: { + Image(systemName: "clock") + .font(.system(size: 10)) + } + .foregroundStyle(.secondary) + + // Todo progress if available + if let todos = session.todos, !todos.isEmpty { + let completed = todos.filter { $0.status == .completed }.count + let inProgress = todos.contains { $0.status == .inProgress } + + HStack(spacing: 4) { + if inProgress { + ProgressView() + .controlSize(.mini) + } else { + Image(systemName: completed == todos.count ? "checkmark.circle.fill" : "circle.dotted") + .font(.system(size: 10)) + } + Text("\(completed)/\(todos.count)") + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(completed == todos.count ? Color.green : .secondary) + } + } + } + + Spacer(minLength: 0) + + // Streaming indicator + if session.isStreaming { + streamingIndicator + } + + // Chevron + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + + // MARK: - Status Indicator + + @ViewBuilder + private var statusIndicator: some View { + let size: CGFloat = 10 + + if let attention = session.attention { + Circle() + .fill(attention == .question ? Color.yellow : Color.orange) + .frame(width: size, height: size) + .glassEffect(.regular.tint(attention == .question ? .yellow : .orange), in: .circle) + } else if session.hasUncheckedCompletion && !session.isStreaming { + Circle() + .fill(ClaudeTheme.statusSuccess) + .frame(width: size, height: size) + .glassEffect(.regular.tint(.green), in: .circle) + } else { + Circle() + .fill(.clear) + .frame(width: size, height: size) + } + } + + // MARK: - Streaming Indicator + + private var streamingIndicator: some View { + HStack(spacing: 3) { + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(ClaudeTheme.accent) + .frame(width: 4, height: 4) + .opacity(0.6) + } + } + .modifier(PulsingAnimation()) + } + + // MARK: - Helpers + + private func compactElapsed(since date: Date, now: Date = Date()) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + if seconds < 60 { return "now" } + + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m" } + + let hours = minutes / 60 + if hours < 24 { return "\(hours)h" } + + let days = hours / 24 + if days < 7 { return "\(days)d" } + + let weeks = days / 7 + if weeks < 52 { return "\(weeks)w" } + + return "\(days / 365)y" + } +} + +// MARK: - Glass Card Button Style + +private struct GlassCardButtonStyle: ButtonStyle { + let isSelected: Bool + @Environment(\.colorScheme) private var colorScheme + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(backgroundColor(isPressed: configuration.isPressed)) + } + .glassEffect( + glassConfig(isPressed: configuration.isPressed), + in: .rect(cornerRadius: 16) + ) + .scaleEffect(configuration.isPressed ? 0.98 : 1.0) + .animation(.spring(duration: 0.2), value: configuration.isPressed) + } + + private func backgroundColor(isPressed: Bool) -> Color { + if isSelected { + return ClaudeTheme.accent.opacity(0.15) + } else if isPressed { + return Color.primary.opacity(0.05) + } else { + return .clear + } + } + + private func glassConfig(isPressed: Bool) -> Glass { + if isSelected { + return .regular.tint(ClaudeTheme.accent.opacity(0.3)).interactive() + } else { + return .regular.interactive() + } + } +} + +// MARK: - Pulsing Animation + +private struct PulsingAnimation: ViewModifier { + @State private var isAnimating = false + + func body(content: Content) -> some View { + content + .opacity(isAnimating ? 0.4 : 1.0) + .animation( + .easeInOut(duration: 0.8) + .repeatForever(autoreverses: true), + value: isAnimating + ) + .onAppear { + isAnimating = true + } + } +} + +// MARK: - Preview + +#Preview { + ScrollView { + VStack(spacing: 12) { + GlassThreadCard( + session: SessionSummary( + id: "1", + projectId: UUID(), + title: "Implement Liquid Glass UI", + updatedAt: Date().addingTimeInterval(-300), + isPinned: true, + isArchived: false, + isStreaming: false, + attention: nil, + todos: [ + TodoItem(id: 1, content: "Task 1", activeForm: "Doing task 1", status: .completed), + TodoItem(id: 2, content: "Task 2", activeForm: "Doing task 2", status: .inProgress), + TodoItem(id: 3, content: "Task 3", activeForm: "Doing task 3", status: .pending) + ], + hasUncheckedCompletion: false + ), + isSelected: true + ) + + GlassThreadCard( + session: SessionSummary( + id: "2", + projectId: UUID(), + title: "Fix scrolling performance", + updatedAt: Date().addingTimeInterval(-3600), + isPinned: false, + isArchived: false, + isStreaming: true, + attention: nil, + todos: nil, + hasUncheckedCompletion: false + ), + isSelected: false + ) + + GlassThreadCard( + session: SessionSummary( + id: "3", + projectId: UUID(), + title: "Review pull request", + updatedAt: Date().addingTimeInterval(-86400), + isPinned: false, + isArchived: false, + isStreaming: false, + attention: .permission, + todos: nil, + hasUncheckedCompletion: false + ), + isSelected: false + ) + } + .padding() + } + .background(Color(.systemGroupedBackground)) +} diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index 1e55615..d358367 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -1,6 +1,6 @@ -import SwiftUI import RxCodeCore import RxCodeSync +import SwiftUI enum MobileDraftSessionID { private static let prefix = "draft-new" @@ -27,6 +27,7 @@ struct SessionsList: View { var usesSelection = true @State private var searchText = "" @State private var showingNewThread = false + @Namespace private var glassNamespace /// Number of rows currently materialized. The list grows in `pageSize` /// increments as the user scrolls so we never render every thread at once. @@ -34,66 +35,141 @@ struct SessionsList: View { private static let pageSize = 20 var body: some View { - list - .navigationTitle("Threads") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showingNewThread = true - } label: { - Image(systemName: "square.and.pencil") + glassThreadList + .navigationTitle("Threads") + .toolbar { toolbarContent } + .sheet(isPresented: $showingNewThread) { + NewThreadSheet(projectID: projectID) { newSessionID in + selected = newSessionID } + .environmentObject(state) } - } - .sheet(isPresented: $showingNewThread) { - NewThreadSheet(projectID: projectID) { newSessionID in - selected = newSessionID + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .automatic), + prompt: "Search threads" + ) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .onChange(of: searchText) { _, _ in + // Restart paging so search results always begin at the top. + displayLimit = Self.pageSize + } + .overlay { + if filtered.isEmpty && !searchText.isEmpty { + ContentUnavailableView.search(text: searchText) + } else if filtered.isEmpty && searchText.isEmpty { + emptyStateView + } + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingNewThread = true + } label: { + Image(systemName: "square.and.pencil") + .font(.system(size: 16, weight: .medium)) } - .environmentObject(state) - } - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .automatic), - prompt: "Search threads" - ) - .autocorrectionDisabled(true) - .textInputAutocapitalization(.never) - .onChange(of: searchText) { _, _ in - // Restart paging so search results always begin at the top. - displayLimit = Self.pageSize } - .overlay { - if filtered.isEmpty && !searchText.isEmpty { - ContentUnavailableView.search(text: searchText) + } + + // MARK: - Glass Thread List + + private var glassThreadList: some View { + ScrollView { + LazyVStack(spacing: 10) { + GlassEffectContainer(spacing: 12) { + ForEach(visible) { session in + GlassThreadCard( + session: session, + isSelected: selected == session.id, + usesNavigationLink: !usesSelection, + onSelect: usesSelection ? { selected = session.id } : nil + ) + .glassEffectID(session.id, in: glassNamespace) + .onAppear { + if session.id == visible.last?.id { loadMore() } + } + .contextMenu { + threadContextMenu(for: session) + } + } + } + + if displayLimit < filtered.count { + loadingIndicator + } } + .padding(.horizontal, 16) + .padding(.vertical, 12) } + .scrollDismissesKeyboard(.interactively) + .animation(.spring(duration: 0.3), value: filtered.map(\.id)) } + // MARK: - Context Menu + @ViewBuilder - private var list: some View { - if usesSelection { - List(selection: $selected) { sessionRows } - } else { - List { sessionRows } + private func threadContextMenu(for session: SessionSummary) -> some View { + Button { + Task { await state.archiveThread(sessionID: session.id) } + } label: { + Label("Archive", systemImage: "archivebox") + } + + Divider() + + Button(role: .destructive) { + Task { await state.deleteThread(sessionID: session.id) } + } label: { + Label("Delete", systemImage: "trash") } } - @ViewBuilder - private var sessionRows: some View { - ForEach(visible) { session in - sessionLink(session) - .onAppear { - if session.id == visible.last?.id { loadMore() } - } + // MARK: - Loading Indicator + + private var loadingIndicator: some View { + HStack { + Spacer() + ProgressView() + .padding(.vertical, 20) + Spacer() } - if displayLimit < filtered.count { - HStack { - Spacer() - ProgressView() - Spacer() + } + + // MARK: - Empty State + + private var emptyStateView: some View { + VStack(spacing: 16) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 48, weight: .light)) + .foregroundStyle(.tertiary) + + Text("No Threads") + .font(.title3.weight(.medium)) + .foregroundStyle(.secondary) + + Text("Start a new conversation to get help with your project.") + .font(.subheadline) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Button { + showingNewThread = true + } label: { + Label("New Thread", systemImage: "plus") + .font(.subheadline.weight(.medium)) } - .listRowSeparator(.hidden) + .buttonStyle(.glass) + .padding(.top, 8) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } /// The slice of `filtered` currently rendered. @@ -106,36 +182,6 @@ struct SessionsList: View { displayLimit = min(displayLimit + Self.pageSize, filtered.count) } - private func sessionLink(_ session: SessionSummary) -> some View { - NavigationLink(value: session.id) { - HStack(spacing: 6) { - leadingDot(for: session) - - SessionSidebarRow( - title: rowTitle(for: session), - updatedAt: session.updatedAt, - isPinned: session.isPinned, - isBackgroundStreaming: session.isStreaming - ) - } - } - } - - /// Status dot shown ahead of the row. A pending permission or question - /// takes precedence; otherwise a green dot marks a thread whose run - /// finished while unread, mirroring the desktop sidebar. - @ViewBuilder - private func leadingDot(for session: SessionSummary) -> some View { - if let attention = session.attention { - statusDot(for: attention) - } else if session.hasUncheckedCompletion, !session.isStreaming { - Circle() - .fill(ClaudeTheme.statusSuccess) - .frame(width: 7, height: 7) - .accessibilityLabel("Finished, unread") - } - } - private var filtered: [SessionSummary] { let query = searchText.lowercased() return state.sessions @@ -150,18 +196,6 @@ struct SessionsList: View { return lhs.updatedAt > rhs.updatedAt } } - - private func rowTitle(for session: SessionSummary) -> String { - let cleaned = ChatSession.stripAttachmentMarkers(from: session.title) - return cleaned.isEmpty ? ChatSession.defaultTitle : cleaned - } - - private func statusDot(for attention: SessionAttentionKind) -> some View { - Circle() - .fill(attention == .question ? Color.yellow : Color.orange) - .frame(width: 7, height: 7) - .accessibilityLabel(attention == .question ? "Question pending" : "Permission pending") - } } struct MobileRunProfilesView: View { diff --git a/UITestplan.xctestplan b/UITestplan.xctestplan index 35c779e..8a2cb5b 100644 --- a/UITestplan.xctestplan +++ b/UITestplan.xctestplan @@ -20,6 +20,7 @@ } ], "defaultOptions" : { + "codeCoverage" : false, "targetForVariableExpansion" : { "containerPath" : "container:RxCode.xcodeproj", "identifier" : "E67335372F7356F600FD26C7", diff --git a/UnitTestPlan.xctestplan b/UnitTestPlan.xctestplan index 1353de8..9dad88d 100644 --- a/UnitTestPlan.xctestplan +++ b/UnitTestPlan.xctestplan @@ -9,6 +9,7 @@ } ], "defaultOptions" : { + "codeCoverage" : false, "testTimeoutsEnabled" : true }, "testTargets" : [ From dcb47055c3dc420f64270e148c7edb824c91c2b5 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 03:35:24 +0800 Subject: [PATCH 3/3] fix: prevent ViewInspector crash in PlanSheetView tests PlanSheetView.planBody renders the plan markdown through MarkdownUI. ViewInspector's findAll(ViewType.Text.self) crashes ("Index out of range") while traversing the MarkdownUI view tree, which made testSheet_decidedPlan_showsSummaryAndHidesButtons and testSheet_sendFeedbackFromComposer_dispatchesRejectWithFeedback fail the test plan. Those two tests only assert on the sheet chrome (decision buttons, footer, feedback composer), never the rendered body. Build their plans with empty markdown so planBody renders its plain Text fallback, keeping the inspected view tree fully traversable. Co-Authored-By: Claude Opus 4.7 --- .../RxCodeChatKit/MessageListView.swift | 27 +++++++++++++++- RxCode/App/AppState.swift | 31 ++++++++++--------- RxCodeTests/PlanCardViewTests.swift | 9 +++++- 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index 21af677..ba4f7eb 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -11,6 +11,10 @@ struct MessageListView: View { @Environment(WindowState.self) private var windowState @State private var settledItems: [ChatMessage] = [] @State private var scrollTask: Task? + /// Owns the post-stream "pin to bottom" sweep. Kept separate from + /// `scrollTask` so a concurrent `scrollToBottomDebounced()` — driven by + /// scroll-geometry changes during the same handoff — can't cancel it. + @State private var settleScrollTask: Task? /// Separate handle from `scrollTask`. Owns the fade-in / scroll-on-switch /// sequence so a concurrent content-growth `scrollToBottomDebounced()` /// (which also writes to `scrollTask`) can't cancel the session-ready flip. @@ -99,6 +103,7 @@ struct MessageListView: View { } isSessionReady = false scrollTask?.cancel() + settleScrollTask?.cancel() readyTask?.cancel() rebuildSettledItems() Self.log.info("[MessageList.task] post-rebuild settled=\(settledItems.count) sid=\(sid, privacy: .public) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)") @@ -154,7 +159,8 @@ struct MessageListView: View { // Only update when streaming ends — settled list doesn't change at start, so skip if old && !new { rebuildSettledItems() - scrollToBottomDebounced(proxy) + anchor.resetToBottom() + pinScrollToBottomDuringHandoff(proxy) } } .onChange(of: isSessionReady) { _, new in @@ -219,6 +225,25 @@ struct MessageListView: View { scrollToBottom(proxy) } } + + /// When a stream ends, the just-completed assistant turn moves out of + /// `StreamingMessageView` and into the settled list. That row handoff makes + /// `List` reload, which can momentarily snap the scroll offset to the top — + /// the user sees the list jump up, then jump back down. A single debounced + /// scroll can't fix it reliably: the streaming-insertion animation keeps + /// emitting scroll-geometry changes that re-arm and starve the debounce. + /// Re-assert the bottom on every frame for a short window so the snap is + /// corrected within one frame, before it becomes visible. + private func pinScrollToBottomDuringHandoff(_ proxy: ScrollViewProxy) { + settleScrollTask?.cancel() + settleScrollTask = Task { @MainActor in + for _ in 0..<12 { + guard !Task.isCancelled else { return } + scrollToBottom(proxy) + try? await Task.sleep(for: .milliseconds(16)) + } + } + } } // MARK: - Streaming Message (isolated view — chatBridge.messages dependency confined to this view) diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index f999d51..dc0b6d8 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -5781,18 +5781,15 @@ final class AppState { let key = window.currentSessionId ?? window.newSessionKey let streamToCancel = sessionStates[key]?.activeStreamId sessionStates[key]?.streamTask?.cancel() - sessionStates[key]?.streamTask = nil - // Set isStreaming=false before suspending so that processStream — which may run - // on the MainActor while we await — does not call finalizeStreamSession and - // incorrectly mark the cancelled message as isResponseComplete=true. - sessionStates[key]?.isStreaming = false - sessionStates[key]?.activeStreamId = nil - - if let streamToCancel { - let provider = sessionStates[key]?.agentProvider ?? effectiveModelSelection(in: window).provider - await backend(for: provider).cancel(streamId: streamToCancel) - } + // Finalize the in-progress turn *before* the await below, in a single + // state mutation. The session `isStreaming` flag and the streaming + // message's own `isStreaming` flag must flip to false together: if they + // disagree when the message list rebuilds (which is driven by the + // session flag), the paused bubble lands in neither the settled list + // nor the streaming view and vanishes until the next turn. Clearing + // isStreaming here also stops processStream's end-of-stream cleanup + // from re-finalizing the cancelled message while we suspend. flushPendingUpdates(for: key) stopFlushTimer(for: key) @@ -5810,9 +5807,10 @@ final class AppState { state.messages[$0].role == .assistant && state.messages[$0].isStreaming }) { // The user paused this turn — keep the partial assistant bubble - // visible. markStreamInterrupted() retains in-progress tool calls - // (flagged as interrupted) instead of dropping them, and the - // no-op strip below is told not to delete an emptied message. + // visible. markStreamInterrupted() clears the message's streaming + // flag and retains in-progress tool calls (flagged as interrupted) + // instead of dropping them; the no-op strip below is told not to + // delete an emptied message. state.messages[idx].markStreamInterrupted() if let start = state.streamingStartDate { state.messages[idx].duration = Date().timeIntervalSince(start) @@ -5826,6 +5824,11 @@ final class AppState { window.showError = false window.errorMessage = nil + if let streamToCancel { + let provider = sessionStates[key]?.agentProvider ?? effectiveModelSelection(in: window).provider + await backend(for: provider).cancel(streamId: streamToCancel) + } + // Save messages accumulated up to the point of cancellation to disk (prevent data loss). // The placeholder session (if any) is left in place so partial messages remain visible; // it will be promoted to the real CLI session id on the next user turn. diff --git a/RxCodeTests/PlanCardViewTests.swift b/RxCodeTests/PlanCardViewTests.swift index 7a6b49d..7b96c30 100644 --- a/RxCodeTests/PlanCardViewTests.swift +++ b/RxCodeTests/PlanCardViewTests.swift @@ -236,7 +236,14 @@ final class PlanCardViewTests: XCTestCase { // MARK: - Helpers - private let planMd = "# My plan\n- step 1\n- step 2" + // PlanSheetView's body renders the plan markdown through MarkdownUI, whose + // view tree crashes ViewInspector's `findAll(ViewType.Text.self)` traversal + // with "Index out of range". The sheet tests below only assert on the sheet + // chrome (decision buttons, footer, feedback composer) — never the rendered + // body — so they build the plan with empty markdown. An empty body renders + // the plain `Text` fallback in `PlanSheetView.planBody`, keeping the whole + // inspected tree traversable. + private let planMd = "" private func makeChip( result: String?,