diff --git a/Packages/Package.resolved b/Packages/Package.resolved index 411dc00f..9674e39a 100644 --- a/Packages/Package.resolved +++ b/Packages/Package.resolved @@ -1,6 +1,33 @@ { - "originHash" : "470a15c875e109de11587ac48f6ba7af31bfe959e9de09c67fd6d9128253fdc8", + "originHash" : "aae110058c54c9409d4188d720ecdaedf50f07b0863df25d2e481376a6b7e748", "pins" : [ + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swiftui-math", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swiftui-math", + "state" : { + "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "version" : "0.1.0" + } + }, + { + "identity" : "textual", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/textual", + "state" : { + "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", + "version" : "0.3.1" + } + }, { "identity" : "viewinspector", "kind" : "remoteSourceControl", diff --git a/Packages/Package.swift b/Packages/Package.swift index 3c388297..c4d95083 100644 --- a/Packages/Package.swift +++ b/Packages/Package.swift @@ -12,6 +12,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"), ], targets: [ .target( @@ -20,7 +21,10 @@ let package = Package( ), .target( name: "RxCodeChatKit", - dependencies: ["RxCodeCore"], + dependencies: [ + "RxCodeCore", + .product(name: "Textual", package: "textual"), + ], path: "Sources/RxCodeChatKit", resources: [ .process("Resources"), diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift index 66d09f9f..2e5ba255 100644 --- a/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift @@ -100,14 +100,10 @@ private struct CompactChatMessageBubble: View { } private func assistantText(_ text: String) -> some View { - ChatTextContentView( - markdown: text, - size: ClaudeTheme.messageSize(14), - color: ClaudeTheme.textPrimary, - lineSpacing: 2 - ) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 2) + MarkdownContentView(text: text) + .foregroundStyle(ClaudeTheme.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 2) } private var compactBoundaryBubble: some View { diff --git a/Packages/Sources/RxCodeChatKit/ChatTextContentView.swift b/Packages/Sources/RxCodeChatKit/ChatTextContentView.swift index 1b407fd1..934ff928 100644 --- a/Packages/Sources/RxCodeChatKit/ChatTextContentView.swift +++ b/Packages/Sources/RxCodeChatKit/ChatTextContentView.swift @@ -1,9 +1,8 @@ import SwiftUI import RxCodeCore -#if os(macOS) -import AppKit -#endif +/// Renders inline chat text — plain strings, pre-built attributed strings, or +/// inline-only markdown — using native SwiftUI `Text`. public struct ChatTextContentView: View { enum Content { case plain(String) @@ -80,22 +79,6 @@ public struct ChatTextContentView: View { } public var body: some View { - #if os(macOS) - nativeBody - #else - swiftUIBody - #endif - } - - private static func markdownAttributedString(_ text: String) -> AttributedString { - (try? AttributedString( - markdown: text, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - )) ?? AttributedString(text) - } - - @ViewBuilder - private var swiftUIBody: some View { let text = { switch content { case .plain(let value): @@ -108,6 +91,7 @@ public struct ChatTextContentView: View { text .font(.system(size: size, weight: weight, design: design)) .foregroundStyle(color) + .tint(ClaudeTheme.accent) .lineSpacing(lineSpacing) .lineLimit(maximumNumberOfLines) .textSelection(.enabled) @@ -117,51 +101,10 @@ public struct ChatTextContentView: View { }) } - #if os(macOS) - @ViewBuilder - private var nativeBody: some View { - switch content { - case .plain(let text): - NativeChatTextView( - text, - font: nativeFont, - color: NSColor(color), - lineSpacing: lineSpacing, - maximumNumberOfLines: maximumNumberOfLines, - openURL: openURL - ) - case .attributed(let attributed): - NativeChatTextView( - attributed: attributed, - font: nativeFont, - color: NSColor(color), - lineSpacing: lineSpacing, - maximumNumberOfLines: maximumNumberOfLines, - openURL: openURL - ) - } - } - - private var nativeFont: NSFont { - if design == .monospaced { - return NSFont.monospacedSystemFont(ofSize: size, weight: nativeWeight) - } - return NSFont.systemFont(ofSize: size, weight: nativeWeight) - } - - private var nativeWeight: NSFont.Weight { - switch weight { - case .ultraLight: return .ultraLight - case .thin: return .thin - case .light: return .light - case .regular: return .regular - case .medium: return .medium - case .semibold: return .semibold - case .bold: return .bold - case .heavy: return .heavy - case .black: return .black - default: return .regular - } + private static func markdownAttributedString(_ text: String) -> AttributedString { + (try? AttributedString( + markdown: text, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(text) } - #endif } diff --git a/Packages/Sources/RxCodeChatKit/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index 2ba7e589..9f38490e 100644 --- a/Packages/Sources/RxCodeChatKit/MarkdownView.swift +++ b/Packages/Sources/RxCodeChatKit/MarkdownView.swift @@ -1,1036 +1,113 @@ import SwiftUI -#if os(macOS) -import AppKit +import Foundation import RxCodeCore - -// MARK: - Render Group Cache - -/// Shared cache that retains markdown parse results regardless of view recreation (.id changes). -/// NSCache is automatically purged under memory pressure. -private final class RenderGroupCache: @unchecked Sendable { - static let shared = RenderGroupCache() - private let cache = NSCache() - - private final class CacheEntry { - let groups: [RenderGroup] - init(_ groups: [RenderGroup]) { self.groups = groups } - } - - init() { - cache.countLimit = 200 - NotificationCenter.default.addObserver(forName: .rxcodeThemeDidChange, object: nil, queue: .main) { [weak self] _ in - self?.cache.removeAllObjects() - } - } - - func get(_ key: String) -> [RenderGroup]? { - cache.object(forKey: key as NSString)?.groups - } - - func set(_ key: String, _ groups: [RenderGroup]) { - cache.setObject(CacheEntry(groups), forKey: key as NSString) - } -} +import Textual // MARK: - Markdown Content View -/// Renders markdown text with styled code blocks, headers, lists, and rich text. +/// Renders markdown text — headings, lists, blockquotes, tables, and rich text — +/// using the Textual rendering engine (https://github.com/gonzalezreal/textual). struct MarkdownContentView: View { let text: String let showsTrailingCursor: Bool let isCursorVisible: Bool - @State private var cachedGroups: [RenderGroup] - @State private var cachedText: String init(text: String, showsTrailingCursor: Bool = false, isCursorVisible: Bool = true) { self.text = text self.showsTrailingCursor = showsTrailingCursor self.isCursorVisible = isCursorVisible - let groups: [RenderGroup] - if let cached = RenderGroupCache.shared.get(text) { - groups = cached - } else { - groups = Self.buildRenderGroups(for: text) - RenderGroupCache.shared.set(text, groups) - } - _cachedGroups = State(initialValue: groups) - _cachedText = State(initialValue: text) } var body: some View { - VStack(alignment: .leading, spacing: 10) { - ForEach(Array(cachedGroups.enumerated()), id: \.offset) { index, group in - switch group { - case .attributedText(let attrStr): - MarkdownAttributedTextView( - content: attrStr, - showsTrailingCursor: showsTrailingCursor && index == cachedGroups.count - 1, - isCursorVisible: isCursorVisible + StructuredText(markdown: renderedMarkdown) + .font(.system(size: 15)) + .tint(ClaudeTheme.accent) + .textual.inlineStyle( + InlineStyle() + .code( + .monospaced, + .fontScale(0.93), + .backgroundColor(ClaudeTheme.surfaceTertiary), + .foregroundColor(ClaudeTheme.textPrimary) ) - .frame(maxWidth: .infinity, alignment: .leading) - case .blockquote(let attrStr): - BlockquoteView(content: attrStr) - case .codeBlock(let language, let code): - CodeBlockView(language: language, code: code) - .padding(.vertical, 8) - case .table(let headers, let rows): - MarkdownTableView(headers: headers, rows: rows) - .padding(.vertical, 8) - case .horizontalRule: - ClaudeThemeDivider() - .padding(.vertical, 8) - } - } - } - .onChange(of: text) { _, newText in - guard newText != cachedText else { return } - cachedText = newText - if let cached = RenderGroupCache.shared.get(newText) { - cachedGroups = cached - } else { - let groups = Self.buildRenderGroups(for: newText) - RenderGroupCache.shared.set(newText, groups) - cachedGroups = groups - } - } - } - - // MARK: - Render Groups - - private static func buildRenderGroups(for text: String) -> [RenderGroup] { - let blocks = parseBlocks(from: text) - var groups: [RenderGroup] = [] - var current = AttributedString() - var hasContent = false - var inListOrQuote = false - // On encountering a spacer, just set a flag instead of flushing to keep content in the same Text view - // → prevents drag selection from breaking between paragraphs and bullets - var afterSpacer = false - - // Buffer for consecutive blockquote lines; rendered as a single group with a continuous left bar - var quoteBuffer = AttributedString() - var quoteHasContent = false - - func flushQuote() { - guard quoteHasContent else { return } - var trimmed = quoteBuffer - while trimmed.characters.last == "\n" { - let lastIdx = trimmed.characters.index(before: trimmed.endIndex) - trimmed.removeSubrange(lastIdx.. AttributedString { - parseInlineMarkdown(content) - } - - private static func fontForHeading(_ level: Int) -> Font { - switch level { - case 1: return .system(size: 20, weight: .bold) - case 2: return .system(size: 18, weight: .bold) - case 3: return .system(size: 16, weight: .semibold) - case 4: return .system(size: 15, weight: .semibold) - case 5: return .system(size: 15, weight: .medium) - default: return .system(size: 15, weight: .medium) - } - } - - // MARK: - Block Parsing - - private static func parseBlocks(from text: String) -> [MarkdownBlock] { - var blocks: [MarkdownBlock] = [] - let lines = text.components(separatedBy: "\n") - var currentText = "" - var inCodeBlock = false - var codeLanguage = "" - var codeContent = "" - var index = 0 - - func flushText() { - let trimmed = currentText.trimmingTrailingNewlines() - if !trimmed.isEmpty { - blocks.append(.text(trimmed)) - } - currentText = "" - } - - while index < lines.count { - let line = lines[index] - - // Code block handling - if !inCodeBlock && line.hasPrefix("```") { - flushText() - inCodeBlock = true - codeLanguage = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces) - codeContent = "" - index += 1 - continue - } - - if inCodeBlock { - if line.hasPrefix("```") { - blocks.append(.codeBlock(language: codeLanguage, code: codeContent.trimmingTrailingNewlines())) - inCodeBlock = false - codeLanguage = "" - codeContent = "" - } else { - if !codeContent.isEmpty { codeContent += "\n" } - codeContent += line - } - index += 1 - continue - } - - // Table detection: check if current line + next two lines form a table - if let table = parseTable(lines: lines, startIndex: index) { - flushText() - blocks.append(.table(headers: table.headers, rows: table.rows)) - index = table.endIndex - continue - } - - // Heading - if let headingMatch = parseHeading(line) { - flushText() - blocks.append(.heading(level: headingMatch.level, content: headingMatch.content)) - index += 1 - continue - } - - // Horizontal rule - let trimmedLine = line.trimmingCharacters(in: .whitespaces) - if trimmedLine.count >= 3, - (trimmedLine.allSatisfy({ $0 == "-" || $0 == " " }) && trimmedLine.contains("-")) || - (trimmedLine.allSatisfy({ $0 == "*" || $0 == " " }) && trimmedLine.contains("*")) || - (trimmedLine.allSatisfy({ $0 == "_" || $0 == " " }) && trimmedLine.contains("_")), - trimmedLine.filter({ $0 != " " }).count >= 3 { - flushText() - blocks.append(.horizontalRule) - index += 1 - continue - } - - // Unordered list item - if let listContent = parseUnorderedListItem(line) { - flushText() - blocks.append(.unorderedListItem(content: listContent)) - index += 1 - continue - } - - // Ordered list item - if let (number, listContent) = parseOrderedListItem(line) { - flushText() - blocks.append(.orderedListItem(number: number, content: listContent)) - index += 1 - continue - } - - // Blockquote - if trimmedLine.hasPrefix(">") { - flushText() - var quoteContent = String(trimmedLine.dropFirst()) - if quoteContent.hasPrefix(" ") { - quoteContent = String(quoteContent.dropFirst()) - } - blocks.append(.blockquote(content: quoteContent)) - index += 1 - continue - } - - // Empty line - if trimmedLine.isEmpty { - if !currentText.isEmpty { - flushText() - blocks.append(.spacer) - } - index += 1 - continue - } - - // Regular text - if !currentText.isEmpty { currentText += "\n" } - currentText += line - index += 1 - } - - // Handle remaining content - if inCodeBlock && !codeContent.isEmpty { - blocks.append(.codeBlock(language: codeLanguage, code: codeContent.trimmingTrailingNewlines())) - } else { - flushText() - } - - return blocks - } - - // MARK: - Table Parsing - - private static func parseTable(lines: [String], startIndex: Int) -> (headers: [String], rows: [[String]], endIndex: Int)? { - guard startIndex + 1 < lines.count else { return nil } - - let headerLine = lines[startIndex] - let separatorLine = lines[startIndex + 1] - - // Header must contain pipes - guard headerLine.contains("|") else { return nil } - - // Separator must be like |---|---| or ---|--- - let separatorTrimmed = separatorLine.trimmingCharacters(in: .whitespaces) - guard isTableSeparator(separatorTrimmed) else { return nil } - - let headers = parseTableRow(headerLine) - guard !headers.isEmpty else { return nil } - - // Collect data rows - var rows: [[String]] = [] - var currentIndex = startIndex + 2 - - while currentIndex < lines.count { - let rowLine = lines[currentIndex] - let trimmed = rowLine.trimmingCharacters(in: .whitespaces) - - // Stop if empty line or non-table line - guard !trimmed.isEmpty, trimmed.contains("|") else { break } - // Skip if it's another separator line - guard !isTableSeparator(trimmed) else { - currentIndex += 1 - continue - } - - let cells = parseTableRow(rowLine) - rows.append(cells) - currentIndex += 1 - } - - return (headers: headers, rows: rows, endIndex: currentIndex) - } - - private static func isTableSeparator(_ line: String) -> Bool { - let stripped = line.replacingOccurrences(of: " ", with: "") - // Must contain at least one -- pattern and only |, -, :, spaces (GFM: one or more dashes per cell) - guard stripped.contains("--") else { return false } - return stripped.allSatisfy { $0 == "|" || $0 == "-" || $0 == ":" } - } - - private static func parseTableRow(_ line: String) -> [String] { - var content = line.trimmingCharacters(in: .whitespaces) - // Remove leading/trailing pipes - if content.hasPrefix("|") { content = String(content.dropFirst()) } - if content.hasSuffix("|") { content = String(content.dropLast()) } - return content.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) } - } - - // MARK: - Line Parsers - - private static func parseHeading(_ line: String) -> (level: Int, content: String)? { - let trimmed = line.trimmingCharacters(in: .whitespaces) - var level = 0 - for char in trimmed { - if char == "#" { level += 1 } else { break } - } - guard level >= 1, level <= 6, trimmed.count > level else { return nil } - let rest = trimmed.dropFirst(level) - guard rest.first == " " else { return nil } - let content = String(rest.dropFirst()).trimmingCharacters(in: .whitespaces) - guard !content.isEmpty else { return nil } - return (level, content) - } - - private static func parseUnorderedListItem(_ line: String) -> String? { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if (trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ")) { - return String(trimmed.dropFirst(2)) - } - return nil - } - - private static func parseOrderedListItem(_ line: String) -> (Int, String)? { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard let dotIndex = trimmed.firstIndex(of: ".") else { return nil } - let numberPart = trimmed[trimmed.startIndex..= 0 else { return nil } - let afterDot = trimmed[trimmed.index(after: dotIndex)...] - guard afterDot.hasPrefix(" ") else { return nil } - return (number, String(afterDot.dropFirst())) + ) + .textual.headingStyle(RxCodeHeadingStyle()) + .textual.codeBlockStyle(RxCodeBlockStyle()) + .textual.textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) } -} - -// MARK: - Markdown Block - -// MARK: - Render Group -private enum RenderGroup { - case attributedText(AttributedString) - case blockquote(AttributedString) - case codeBlock(language: String, code: String) - case table(headers: [String], rows: [[String]]) - case horizontalRule -} - -private func parseInlineMarkdown(_ content: String) -> AttributedString { - let autoLinked = autoLinkURLs(sanitizeMarkdownLinkURLs(content)) - guard var result = try? AttributedString( - markdown: autoLinked, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) else { - return AttributedString(content) - } - // Apply base font per-run to preserve bold/italic emphasis from markdown parsing - var codeRanges: [Range] = [] - for run in result.runs { - guard let intent = run.inlinePresentationIntent else { - result[run.range].font = .system(size: 15) - continue - } - if intent.contains(.code) { - codeRanges.append(run.range) - } else { - let isBold = intent.contains(.stronglyEmphasized) - let isItalic = intent.contains(.emphasized) - switch (isBold, isItalic) { - case (true, true): result[run.range].font = .system(size: 15, weight: .bold).italic() - case (true, false): result[run.range].font = .system(size: 15, weight: .bold) - case (false, true): result[run.range].font = .system(size: 15).italic() - default: result[run.range].font = .system(size: 15) - } + /// 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 { + return processed + "\u{2009}\u{25CF}" } + return processed } - // Inline code spans: monospace font + background color - for range in codeRanges.reversed() { - result[range].font = .system(size: 14, design: .monospaced) - result[range].foregroundColor = ClaudeTheme.textPrimary - result[range].backgroundColor = ClaudeTheme.surfaceTertiary - result[range].baselineOffset = 0.5 - } - return result } -// MARK: - Markdown Attributed Text - -nonisolated(unsafe) private let inlineCodeBackgroundAttribute = NSAttributedString.Key("rxcode.inlineCodeBackground") - -struct NativeChatTextView: NSViewRepresentable { - let content: Content - var font: NSFont - var color: NSColor - var lineSpacing: CGFloat - var maximumNumberOfLines: Int? - var openURL: ((URL) -> Bool)? - - enum Content: Equatable { - case plain(String) - case attributed(AttributedString) - } - - init( - _ text: String, - font: NSFont = NSFont.systemFont(ofSize: ClaudeTheme.messageSize(14)), - color: NSColor = NSColor(ClaudeTheme.textPrimary), - lineSpacing: CGFloat = 0, - maximumNumberOfLines: Int? = nil, - openURL: ((URL) -> Bool)? = nil - ) { - self.content = .plain(text) - self.font = font - self.color = color - self.lineSpacing = lineSpacing - self.maximumNumberOfLines = maximumNumberOfLines - self.openURL = openURL - } - - init( - attributed content: AttributedString, - font: NSFont = NSFont.systemFont(ofSize: ClaudeTheme.messageSize(14)), - color: NSColor = NSColor(ClaudeTheme.textPrimary), - lineSpacing: CGFloat = 0, - maximumNumberOfLines: Int? = nil, - openURL: ((URL) -> Bool)? = nil - ) { - self.content = .attributed(content) - self.font = font - self.color = color - self.lineSpacing = lineSpacing - self.maximumNumberOfLines = maximumNumberOfLines - self.openURL = openURL - } - - func makeCoordinator() -> Coordinator { - Coordinator(openURL: openURL) - } - - func makeNSView(context: Context) -> InlineCodeTextView { - let textView = InlineCodeTextView() - textView.isEditable = false - textView.isSelectable = true - textView.drawsBackground = false - textView.backgroundColor = .clear - textView.textContainerInset = .zero - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.heightTracksTextView = false - textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = false - textView.delegate = context.coordinator - textView.linkTextAttributes = [ - .foregroundColor: NSColor(ClaudeTheme.accent), - .underlineStyle: NSUnderlineStyle.single.rawValue - ] - return textView - } - - func updateNSView(_ textView: InlineCodeTextView, context: Context) { - context.coordinator.openURL = openURL - textView.textContainer?.maximumNumberOfLines = maximumNumberOfLines ?? 0 - textView.textContainer?.lineBreakMode = maximumNumberOfLines == nil ? .byWordWrapping : .byTruncatingTail - textView.textStorage?.setAttributedString(Self.nsAttributedString( - from: content, - font: font, - color: color, - lineSpacing: lineSpacing - )) - textView.invalidateIntrinsicContentSize() - } - - func sizeThatFits(_ proposal: ProposedViewSize, nsView textView: InlineCodeTextView, context: Context) -> CGSize? { - guard let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer else { - return CGSize(width: proposal.width ?? 0, height: 0) - } - textContainer.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - layoutManager.ensureLayout(for: textContainer) - let idealWidth = ceil(layoutManager.usedRect(for: textContainer).width) +// MARK: - Markdown Preprocessing - let resolvedWidth: CGFloat - if let proposed = proposal.width, proposed.isFinite { - resolvedWidth = min(proposed, idealWidth) +/// 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 + for line in text.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") { + inFence.toggle() + lines.append(line) + } else if inFence { + lines.append(line) } else { - resolvedWidth = idealWidth - } - - textContainer.containerSize = NSSize(width: resolvedWidth, height: CGFloat.greatestFiniteMagnitude) - layoutManager.ensureLayout(for: textContainer) - let usedRect = layoutManager.usedRect(for: textContainer) - return CGSize(width: resolvedWidth, height: ceil(usedRect.height)) - } - - private static func nsAttributedString( - from content: Content, - font: NSFont, - color: NSColor, - lineSpacing: CGFloat - ) -> NSAttributedString { - let result: NSMutableAttributedString - switch content { - case .plain(let text): - result = NSMutableAttributedString(string: text) - case .attributed(let attributed): - result = NSMutableAttributedString(attributed) - } - guard result.length > 0 else { return result } - - let fullRange = NSRange(location: 0, length: result.length) - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = lineSpacing - paragraphStyle.lineBreakMode = .byWordWrapping - result.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) - result.enumerateAttribute(.font, in: fullRange) { value, range, _ in - guard value == nil else { return } - result.addAttribute(.font, value: font, range: range) - } - result.enumerateAttribute(.foregroundColor, in: fullRange) { value, range, _ in - guard value == nil else { return } - result.addAttribute(.foregroundColor, value: color, range: range) - } - return result - } - - final class Coordinator: NSObject, NSTextViewDelegate { - var openURL: ((URL) -> Bool)? - - init(openURL: ((URL) -> Bool)?) { - self.openURL = openURL - } - - func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { - let url: URL? - if let linkedURL = link as? URL { - url = linkedURL - } else if let raw = link as? String { - url = URL(string: raw) - } else { - url = nil - } - guard let url else { return false } - return openURL?(url) ?? false + lines.append(autoLinkURLs(sanitizeMarkdownLinkURLs(line))) } } + return lines.joined(separator: "\n") } -private struct MarkdownAttributedTextView: NSViewRepresentable { - let content: AttributedString - var showsTrailingCursor: Bool = false - var isCursorVisible: Bool = true - - func makeNSView(context: Context) -> InlineCodeTextView { - let textView = InlineCodeTextView() - textView.isEditable = false - textView.isSelectable = true - textView.drawsBackground = false - textView.backgroundColor = .clear - textView.textContainerInset = .zero - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.heightTracksTextView = false - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = false - textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) - return textView - } - - func updateNSView(_ textView: InlineCodeTextView, context: Context) { - let attributed = Self.nsAttributedString( - from: content, - showsTrailingCursor: showsTrailingCursor, - isCursorVisible: isCursorVisible - ) - textView.linkTextAttributes = [ - .foregroundColor: NSColor(ClaudeTheme.accent), - .underlineStyle: NSUnderlineStyle.single.rawValue - ] - textView.textStorage?.setAttributedString(attributed) - textView.invalidateIntrinsicContentSize() - } - - func sizeThatFits(_ proposal: ProposedViewSize, nsView textView: InlineCodeTextView, context: Context) -> CGSize? { - let width = proposal.width ?? 500 - guard let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer else { - return CGSize(width: width, height: 0) - } - textContainer.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) - layoutManager.ensureLayout(for: textContainer) - let usedRect = layoutManager.usedRect(for: textContainer) - return CGSize(width: width, height: ceil(usedRect.height)) - } - - private static func nsAttributedString( - from content: AttributedString, - showsTrailingCursor: Bool, - isCursorVisible: Bool - ) -> NSAttributedString { - let result = NSMutableAttributedString(content) - let fullRange = NSRange(location: 0, length: result.length) - guard fullRange.length > 0 else { - if showsTrailingCursor { - appendTrailingCursor(to: result, isVisible: isCursorVisible) - } - return result - } - - let defaultTextColor = NSColor(ClaudeTheme.textPrimary) - let defaultFont = NSFont.systemFont(ofSize: ClaudeTheme.messageSize(15)) - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 6 - result.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) - result.enumerateAttribute(.font, in: fullRange) { value, range, _ in - guard value == nil else { return } - result.addAttribute(.font, value: defaultFont, range: range) - } - result.enumerateAttribute(.foregroundColor, in: fullRange) { value, range, _ in - guard value == nil else { return } - result.addAttribute(.foregroundColor, value: defaultTextColor, range: range) - } - - var location = 0 - for run in content.runs { - let runText = String(content[run.range].characters) - let length = (runText as NSString).length - defer { location += length } - guard length > 0, - let intent = run.inlinePresentationIntent, - intent.contains(.code) else { continue } - - let range = NSRange(location: location, length: length) - result.addAttributes([ - .font: NSFont.monospacedSystemFont(ofSize: ClaudeTheme.messageSize(14), weight: .regular), - .foregroundColor: defaultTextColor, - inlineCodeBackgroundAttribute: true - ], range: range) - result.removeAttribute(.backgroundColor, range: range) - } - if showsTrailingCursor { - appendTrailingCursor(to: result, isVisible: isCursorVisible) - } - return result - } - - private static func appendTrailingCursor(to result: NSMutableAttributedString, isVisible: Bool) { - result.append(NSAttributedString(string: " ●", attributes: [ - .font: NSFont.systemFont(ofSize: ClaudeTheme.messageSize(8), weight: .regular), - .foregroundColor: isVisible ? NSColor(ClaudeTheme.accent) : NSColor.clear - ])) - } -} - -private final class InlineCodeLayoutManager: NSLayoutManager, @unchecked Sendable { - nonisolated override init() { - super.init() - } - - nonisolated required init?(coder: NSCoder) { - super.init(coder: coder) - } - - nonisolated override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) { - guard let storage = textStorage, - let container = textContainers.first else { - super.drawBackground(forGlyphRange: glyphsToShow, at: origin) - return - } - - let fullRange = NSRange(location: 0, length: storage.length) - storage.enumerateAttribute(inlineCodeBackgroundAttribute, in: fullRange) { value, charRange, _ in - guard value != nil else { return } - let glyphRange = self.glyphRange(forCharacterRange: charRange, actualCharacterRange: nil) - let visibleRange = NSIntersectionRange(glyphRange, glyphsToShow) - guard visibleRange.length > 0 else { return } - - self.enumerateEnclosingRects( - forGlyphRange: glyphRange, - withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), - in: container - ) { rect, _ in - var roundedRect = rect.offsetBy(dx: origin.x, dy: origin.y) - roundedRect = roundedRect.insetBy(dx: -5, dy: -1.5) - let path = NSBezierPath(roundedRect: roundedRect, xRadius: 6, yRadius: 6) - MainActor.assumeIsolated { - NSColor(ClaudeTheme.codeBackground).setFill() - } - path.fill() - } - } - - super.drawBackground(forGlyphRange: glyphsToShow, at: origin) - } -} - -final class InlineCodeTextView: NSTextView { - init() { - let storage = NSTextStorage() - let layoutManager = InlineCodeLayoutManager() - let container = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) - container.lineFragmentPadding = 0 - container.widthTracksTextView = true - container.heightTracksTextView = false - storage.addLayoutManager(layoutManager) - layoutManager.addTextContainer(container) - super.init(frame: .zero, textContainer: container) - } - - required init?(coder: NSCoder) { - nil - } - - override var intrinsicContentSize: NSSize { - guard let layoutManager, let textContainer else { - return NSSize(width: NSView.noIntrinsicMetric, height: 0) - } - layoutManager.ensureLayout(for: textContainer) - let usedRect = layoutManager.usedRect(for: textContainer) - return NSSize(width: NSView.noIntrinsicMetric, height: ceil(usedRect.height)) - } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - textContainer?.containerSize = NSSize(width: newSize.width, height: CGFloat.greatestFiniteMagnitude) - invalidateIntrinsicContentSize() - } -} - -/// 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 } - let range = NSRange(text.startIndex..., in: text) - var result = text - for match in regex.matches(in: text, range: range).reversed() { - guard let fullRange = Range(match.range, in: result), - let labelRange = Range(match.range(at: 1), in: result), - let urlRange = Range(match.range(at: 2), in: result) else { continue } - let label = String(result[labelRange]) - let url = String(result[urlRange]).replacingOccurrences(of: "`", with: "") - result.replaceSubrange(fullRange, with: "[\(label)](\(url))") - } - 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 { - guard let swiftRange = Range(match.range, in: result) else { continue } - let url = String(result[swiftRange]) - result.replaceSubrange(swiftRange, with: "[\(url)](\(url))") - } - return result -} +// MARK: - Heading Style -private enum MarkdownBlock { - case heading(level: Int, content: String) - case text(String) - case codeBlock(language: String, code: String) - case unorderedListItem(content: String) - case orderedListItem(number: Int, content: String) - case blockquote(content: String) - case table(headers: [String], rows: [[String]]) - case horizontalRule - case spacer -} - -// MARK: - Blockquote View - -private struct BlockquoteView: View { - let content: AttributedString +/// 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] - var body: some View { - MarkdownAttributedTextView(content: content) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 13) - .overlay(alignment: .leading) { - RoundedRectangle(cornerRadius: 1.5) - .fill(ClaudeTheme.accent) - .frame(width: 3) - } + func makeBody(configuration: Configuration) -> some View { + let level = min(max(configuration.headingLevel, 1), 6) + configuration.label + .textual.fontScale(Self.fontScales[level - 1]) + .fontWeight(Self.fontWeights[level - 1]) + .textual.blockSpacing(.fontScaled(top: 1.2, bottom: 0.4)) } } -// MARK: - Table View - -private struct MarkdownTableView: View { - let headers: [String] - let rows: [[String]] - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { - // Header row - GridRow { - ForEach(Array(headers.enumerated()), id: \.offset) { colIndex, header in - cellView(text: header, isHeader: true, colIndex: colIndex) - } - } - .background(ClaudeTheme.surfaceTertiary) +// MARK: - Code Block Style - // Separator - GridRow { - Rectangle() - .fill(ClaudeTheme.border) - .frame(height: 1) - .gridCellColumns(headers.count) - } - - // Data rows - ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in - GridRow { - ForEach(Array(headers.indices), id: \.self) { colIndex in - let text = colIndex < row.count ? row[colIndex] : "" - cellView(text: text, isHeader: false, colIndex: colIndex) - } - } - .background(rowIndex % 2 == 0 ? Color.clear : ClaudeTheme.surfaceTertiary.opacity(0.4)) - } - } - .fixedSize(horizontal: true, vertical: false) - .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) - .overlay( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .strokeBorder(ClaudeTheme.border, lineWidth: 0.5) - ) - } - .textSelection(.enabled) - .padding(.vertical, 4) - } - - private func cellView(text: String, isHeader: Bool, colIndex: Int) -> some View { - Text(parseInlineMarkdown(text)) - .font(.system(size: ClaudeTheme.messageSize(14), weight: isHeader ? .semibold : .regular)) - .foregroundStyle(ClaudeTheme.textPrimary) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(minWidth: 80, maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .overlay(alignment: .leading) { - if colIndex > 0 { - Rectangle() - .fill(Color.primary.opacity(0.12)) - .frame(width: 0.5) - } - } +/// 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) } } -// MARK: - Code Block View - -struct CodeBlockView: View { - let language: String - let code: String +private struct RxCodeBlockBody: View { + let configuration: RxCodeBlockStyle.Configuration @State private var isCopied = false var body: some View { VStack(alignment: .leading, spacing: 0) { - // Header HStack { - if !language.isEmpty { + if let language = configuration.languageHint, !language.isEmpty { Text(language) .font(.system(size: ClaudeTheme.messageSize(11), weight: .medium, design: .monospaced)) .foregroundStyle(ClaudeTheme.textTertiary) } - Spacer() - copyButton } .padding(.horizontal, 12) @@ -1041,26 +118,33 @@ struct CodeBlockView: View { .fill(ClaudeTheme.border) .frame(height: 0.5) - // Code content ScrollView(.horizontal, showsIndicators: false) { - Text(SyntaxHighlighter.highlight(code, language: language, fontSize: 14)) - .textSelection(.enabled) + configuration.label + .monospaced() + .textual.fontScale(0.882) + .textual.lineSpacing(.fontScaled(0.39)) .fixedSize() .padding(12) } .frame(maxWidth: .infinity, alignment: .leading) + .background(ClaudeTheme.codeBackground) } - .background(ClaudeTheme.codeBackground) .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) .overlay( RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) .strokeBorder(ClaudeTheme.border, lineWidth: 0.5) ) + .textual.blockSpacing(.fontScaled(top: 0.88, bottom: 0.4)) } private var copyButton: some View { Button { - copyToClipboard(code, feedback: $isCopied) + configuration.codeBlock.copyToPasteboard() + withAnimation { isCopied = true } + Task { + try? await Task.sleep(for: .seconds(2)) + withAnimation { isCopied = false } + } } label: { HStack(spacing: 4) { Image(systemName: isCopied ? "checkmark" : "doc.on.doc") @@ -1074,18 +158,42 @@ struct CodeBlockView: View { } } -// MARK: - String Extension +// MARK: - Markdown Link Helpers -private extension String { - func trimmingTrailingNewlines() -> String { - var result = self - while result.hasSuffix("\n") { - result.removeLast() - } - return result +/// 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 } + let range = NSRange(text.startIndex..., in: text) + var result = text + for match in regex.matches(in: text, range: range).reversed() { + guard let fullRange = Range(match.range, in: result), + let labelRange = Range(match.range(at: 1), in: result), + let urlRange = Range(match.range(at: 2), in: result) else { continue } + let label = String(result[labelRange]) + let url = String(result[urlRange]).replacingOccurrences(of: "`", with: "") + result.replaceSubrange(fullRange, with: "[\(label)](\(url))") } + 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 { + guard let swiftRange = Range(match.range, in: result) else { continue } + let url = String(result[swiftRange]) + result.replaceSubrange(swiftRange, with: "[\(url)](\(url))") + } + return result +} // MARK: - Previews @@ -1130,4 +238,3 @@ private extension String { .frame(width: 500, height: 600) .background(ClaudeTheme.background) } -#endif diff --git a/Packages/Sources/RxCodeChatKit/MessageBubble.swift b/Packages/Sources/RxCodeChatKit/MessageBubble.swift index 08fadb03..2c8e9891 100644 --- a/Packages/Sources/RxCodeChatKit/MessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/MessageBubble.swift @@ -245,7 +245,6 @@ struct MessageBubble: View { previewImagePath = path return true } - .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) if isLong { Button { diff --git a/Packages/Sources/RxCodeChatKit/ToolResultView.swift b/Packages/Sources/RxCodeChatKit/ToolResultView.swift index cd579fa8..b832d6a5 100644 --- a/Packages/Sources/RxCodeChatKit/ToolResultView.swift +++ b/Packages/Sources/RxCodeChatKit/ToolResultView.swift @@ -227,6 +227,7 @@ struct ToolResultView: View { } .font(.system(size: ClaudeTheme.messageSize(12))) .foregroundStyle(toolCall.isError ? ClaudeTheme.statusError : ClaudeTheme.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/Packages/Sources/RxCodeCore/Models/AgentModel.swift b/Packages/Sources/RxCodeCore/Models/AgentModel.swift index 117b9d2d..93bced1f 100644 --- a/Packages/Sources/RxCodeCore/Models/AgentModel.swift +++ b/Packages/Sources/RxCodeCore/Models/AgentModel.swift @@ -40,3 +40,32 @@ public struct AgentModel: Identifiable, Codable, Sendable, Hashable { self.description = description } } + +/// A named group of agent models as presented in the model picker. Mirrors the +/// desktop's section layout — Claude Code, Codex, and one section per enabled +/// ACP client — so other surfaces (e.g. the mobile app) can reproduce the same +/// grouping instead of collapsing every ACP client into a single bucket. +public struct AgentModelSection: Identifiable, Codable, Sendable, Hashable { + /// Stable section identifier (`claudeCode`, `codex`, or `acp:`). + public let id: String + /// Human-readable section header (e.g. "Claude Code" or an ACP client name). + public let title: String + public let provider: AgentProvider + /// Remote icon URL for ACP client sections; `nil` for built-in providers. + public let iconURL: String? + public let models: [AgentModel] + + public init( + id: String, + title: String, + provider: AgentProvider, + iconURL: String? = nil, + models: [AgentModel] + ) { + self.id = id + self.title = title + self.provider = provider + self.iconURL = iconURL + self.models = models + } +} diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index c201172b..baf7df84 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -20,11 +20,16 @@ public enum Payload: Sendable { case cancelStream(CancelStreamPayload) case removeQueuedMessage(RemoveQueuedMessagePayload) case newSessionRequest(NewSessionRequestPayload) + case threadActionRequest(ThreadActionRequestPayload) + case loadMoreMessages(LoadMoreMessagesRequestPayload) + case moreMessages(MoreMessagesPayload) case searchRequest(SearchRequestPayload) case searchResults(SearchResultsPayload) case notification(NotificationPayload) case permissionRequest(PermissionRequestPayload) case permissionResponse(PermissionResponsePayload) + case questionQueue(QuestionQueuePayload) + case questionAnswer(QuestionAnswerPayload) case branchOpRequest(BranchOpRequestPayload) case branchOpResult(BranchOpResultPayload) case ping(PingPayload) @@ -89,6 +94,11 @@ public struct SnapshotPayload: Codable, Sendable { public let settings: MobileSettingsSnapshot? public let activeSessionID: String? public let activeSessionMessages: [ChatMessage]? + /// Whether the active thread has messages older than the window in + /// `activeSessionMessages`. Mobile uses this to decide whether to offer + /// "load more" as the user scrolls up. `nil`/`false` means the window is + /// the whole thread (or the desktop predates message paging). + public let activeSessionHasMore: Bool? /// Current git branch resolved per project on the desktop. Mobile uses this /// to surface "you're about to create a thread on branch X" when starting /// a new thread. Missing entries mean the project isn't a git repo or the @@ -102,6 +112,7 @@ public struct SnapshotPayload: Codable, Sendable { settings: MobileSettingsSnapshot? = nil, activeSessionID: String? = nil, activeSessionMessages: [ChatMessage]? = nil, + activeSessionHasMore: Bool? = nil, projectBranches: [ProjectBranchInfo]? = nil ) { self.projects = projects @@ -111,12 +122,13 @@ public struct SnapshotPayload: Codable, Sendable { self.settings = settings self.activeSessionID = activeSessionID self.activeSessionMessages = activeSessionMessages + self.activeSessionHasMore = activeSessionHasMore self.projectBranches = projectBranches } private enum CodingKeys: String, CodingKey { case projects, sessions, branchBriefings, threadSummaries, settings - case activeSessionID, activeSessionMessages, projectBranches + case activeSessionID, activeSessionMessages, activeSessionHasMore, projectBranches } public init(from decoder: Decoder) throws { @@ -128,6 +140,7 @@ public struct SnapshotPayload: Codable, Sendable { settings = try c.decodeIfPresent(MobileSettingsSnapshot.self, forKey: .settings) activeSessionID = try c.decodeIfPresent(String.self, forKey: .activeSessionID) activeSessionMessages = try c.decodeIfPresent([ChatMessage].self, forKey: .activeSessionMessages) + activeSessionHasMore = try c.decodeIfPresent(Bool.self, forKey: .activeSessionHasMore) projectBranches = try c.decodeIfPresent([ProjectBranchInfo].self, forKey: .projectBranches) } } @@ -269,6 +282,12 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { /// and ACP clients. Lets mobile show a model picker without re-deriving the /// list itself. Optional for backward compatibility with older desktops. public let availableModels: [AgentModel]? + /// Model picker sections, mirroring the desktop layout — Claude Code, + /// Codex, and one section per enabled ACP client. Lets mobile reproduce the + /// desktop's per-ACP-client grouping instead of collapsing every ACP client + /// into a single bucket. Optional for backward compatibility with older + /// desktops, which sent only the flattened `availableModels` list. + public let modelSections: [AgentModelSection]? public init( selectedAgentProvider: AgentProvider, @@ -286,7 +305,8 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { archiveRetentionDays: Int, autoPreviewSettings: AttachmentAutoPreviewSettings, availableEfforts: [String], - availableModels: [AgentModel]? = nil + availableModels: [AgentModel]? = nil, + modelSections: [AgentModelSection]? = nil ) { self.selectedAgentProvider = selectedAgentProvider self.selectedModel = selectedModel @@ -304,6 +324,7 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { self.autoPreviewSettings = autoPreviewSettings self.availableEfforts = availableEfforts self.availableModels = availableModels + self.modelSections = modelSections } private enum CodingKeys: String, CodingKey { @@ -311,7 +332,7 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { case permissionMode, summarizationProvider, summarizationProviderDisplayName case openAISummarizationEndpoint, openAISummarizationModel case notificationsEnabled, focusMode, autoArchiveEnabled, archiveRetentionDays - case autoPreviewSettings, availableEfforts, availableModels + case autoPreviewSettings, availableEfforts, availableModels, modelSections } public init(from decoder: Decoder) throws { @@ -332,6 +353,7 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { autoPreviewSettings = try c.decode(AttachmentAutoPreviewSettings.self, forKey: .autoPreviewSettings) availableEfforts = try c.decode([String].self, forKey: .availableEfforts) availableModels = try c.decodeIfPresent([AgentModel].self, forKey: .availableModels) + modelSections = try c.decodeIfPresent([AgentModelSection].self, forKey: .modelSections) } } @@ -403,6 +425,12 @@ public struct SessionSummary: Codable, Sendable, Identifiable { /// the desktop's per-session queue (threadStore). `nil` when the summary /// comes from an older desktop that doesn't know about queue sync. public let queuedMessages: [QueuedUserMessage]? + /// Whether the session's stream finished while the user wasn't viewing it + /// and it hasn't been opened since. Mirrors the desktop's + /// `hasUncheckedCompletion` so mobile can show the same green + /// "finished, unread" indicator. Defaults to `false` for summaries from + /// an older desktop that predates this field. + public let hasUncheckedCompletion: Bool public init( id: String, @@ -414,7 +442,8 @@ public struct SessionSummary: Codable, Sendable, Identifiable { isStreaming: Bool = false, attention: SessionAttentionKind? = nil, progress: SessionProgressSnapshot? = nil, - queuedMessages: [QueuedUserMessage]? = nil + queuedMessages: [QueuedUserMessage]? = nil, + hasUncheckedCompletion: Bool = false ) { self.id = id self.projectId = projectId @@ -426,10 +455,11 @@ public struct SessionSummary: Codable, Sendable, Identifiable { self.attention = attention self.progress = progress self.queuedMessages = queuedMessages + self.hasUncheckedCompletion = hasUncheckedCompletion } private enum CodingKeys: String, CodingKey { - case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress, queuedMessages + case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress, queuedMessages, hasUncheckedCompletion } public init(from decoder: Decoder) throws { @@ -444,6 +474,7 @@ public struct SessionSummary: Codable, Sendable, Identifiable { attention = try container.decodeIfPresent(SessionAttentionKind.self, forKey: .attention) progress = try container.decodeIfPresent(SessionProgressSnapshot.self, forKey: .progress) queuedMessages = try container.decodeIfPresent([QueuedUserMessage].self, forKey: .queuedMessages) + hasUncheckedCompletion = try container.decodeIfPresent(Bool.self, forKey: .hasUncheckedCompletion) ?? false } } @@ -459,6 +490,11 @@ public struct SessionUpdatePayload: Codable, Sendable { public let kind: Kind public let message: ChatMessage? public let isStreaming: Bool? + /// Whether the agent is currently producing reasoning/thinking tokens (as + /// opposed to output text). Drives the mobile streaming indicator's + /// "Thinking…" label, mirroring the desktop. Optional for backward + /// compatibility with desktops that predate thinking sync. + public let isThinking: Bool? public let summary: SessionSummary? public let previousSessionID: String? @@ -467,6 +503,7 @@ public struct SessionUpdatePayload: Codable, Sendable { kind: Kind, message: ChatMessage? = nil, isStreaming: Bool? = nil, + isThinking: Bool? = nil, summary: SessionSummary? = nil, previousSessionID: String? = nil ) { @@ -474,6 +511,7 @@ public struct SessionUpdatePayload: Codable, Sendable { self.kind = kind self.message = message self.isStreaming = isStreaming + self.isThinking = isThinking self.summary = summary self.previousSessionID = previousSessionID } @@ -536,6 +574,87 @@ public struct NewSessionRequestPayload: Codable, Sendable { } } +/// Mobile-initiated lifecycle action on an existing thread: rename, archive, +/// unarchive, or delete. The desktop applies the action against its +/// authoritative session store and broadcasts a fresh snapshot so every paired +/// device reconciles. Fire-and-forget — there is no dedicated result payload. +public struct ThreadActionRequestPayload: Codable, Sendable { + public enum Action: String, Codable, Sendable { + case rename + case archive + case unarchive + case delete + } + + public let clientRequestID: UUID + public let sessionID: String + public let action: Action + /// New title, required only when `action == .rename`. + public let newTitle: String? + + public init( + clientRequestID: UUID = UUID(), + sessionID: String, + action: Action, + newTitle: String? = nil + ) { + self.clientRequestID = clientRequestID + self.sessionID = sessionID + self.action = action + self.newTitle = newTitle + } +} + +/// Mobile-initiated request for an older page of a thread's messages. Mobile +/// holds only the most recent window (see `SnapshotPayload.activeSessionMessages`) +/// and pages backwards as the user scrolls up. The desktop replies with a +/// `MoreMessagesPayload` carrying the messages strictly older than +/// `beforeMessageID`. +public struct LoadMoreMessagesRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let sessionID: String + /// The oldest message the requester currently holds. The desktop returns + /// messages that come before this one in the thread. + public let beforeMessageID: UUID + /// How many older messages to return at most. + public let limit: Int + + public init( + clientRequestID: UUID = UUID(), + sessionID: String, + beforeMessageID: UUID, + limit: Int + ) { + self.clientRequestID = clientRequestID + self.sessionID = sessionID + self.beforeMessageID = beforeMessageID + self.limit = limit + } +} + +/// Desktop reply to a `LoadMoreMessagesRequestPayload`: one older page of a +/// thread's messages, to be prepended to the requester's local window. +public struct MoreMessagesPayload: Codable, Sendable { + public let clientRequestID: UUID + public let sessionID: String + /// Older messages in chronological order (oldest first). + public let messages: [ChatMessage] + /// Whether messages older than this page still remain. + public let hasMore: Bool + + public init( + clientRequestID: UUID, + sessionID: String, + messages: [ChatMessage], + hasMore: Bool + ) { + self.clientRequestID = clientRequestID + self.sessionID = sessionID + self.messages = messages + self.hasMore = hasMore + } +} + /// Mobile-initiated search across all threads and projects. The desktop is /// the authoritative source of session content, so the heavy lifting (semantic /// matching, title/project lookups) lives there; mobile just renders results. @@ -640,6 +759,61 @@ public struct PermissionResponsePayload: Codable, Sendable { } } +/// One `AskUserQuestion` tool call awaiting the user's answer, mirrored to +/// mobile so it can render the question sheet. `toolInputJSON` is the raw, +/// JSON-encoded `input` of the tool call — it decodes to `[String: JSONValue]`, +/// the same shape `AskUserQuestion(input:)` parses on either platform. +public struct PendingQuestionPayload: Codable, Sendable, Identifiable, Equatable { + public var id: String { toolUseID } + public let toolUseID: String + public let sessionID: String + public let toolInputJSON: String + public init(toolUseID: String, sessionID: String, toolInputJSON: String) { + self.toolUseID = toolUseID + self.sessionID = sessionID + self.toolInputJSON = toolInputJSON + } +} + +/// Desktop → mobile: the complete set of `AskUserQuestion` calls currently +/// awaiting an answer across every session. The desktop is authoritative and +/// re-broadcasts the full set whenever a question is queued or resolved, so +/// mobile mirrors the desktop's question queue exactly (additions and +/// retractions alike). +public struct QuestionQueuePayload: Codable, Sendable { + public let questions: [PendingQuestionPayload] + public init(questions: [PendingQuestionPayload]) { + self.questions = questions + } +} + +/// One answered question inside a `QuestionAnswerPayload`. `values` holds the +/// chosen option labels (or free-form "Other: …" text); a single-select answer +/// has exactly one value, a multi-select answer has zero or more. `multiSelect` +/// mirrors the original question so the desktop rebuilds `.single` vs `.multi`. +public struct QuestionAnswerEntry: Codable, Sendable { + public let questionIndex: Int + public let values: [String] + public let multiSelect: Bool + public init(questionIndex: Int, values: [String], multiSelect: Bool) { + self.questionIndex = questionIndex + self.values = values + self.multiSelect = multiSelect + } +} + +/// Mobile → desktop: the user's answers for one `AskUserQuestion` call. An +/// empty `answers` array means the user chose "Skip All Questions" — the +/// desktop then resolves the tool call as denied instead of injecting answers. +public struct QuestionAnswerPayload: Codable, Sendable { + public let toolUseID: String + public let answers: [QuestionAnswerEntry] + public init(toolUseID: String, answers: [QuestionAnswerEntry]) { + self.toolUseID = toolUseID + self.answers = answers + } +} + public struct PingPayload: Codable, Sendable { public let t: Date public init(t: Date = .now) { self.t = t } @@ -672,11 +846,16 @@ extension Payload: Codable { case cancelStream = "cancel_stream" case removeQueuedMessage = "remove_queued_message" case newSessionRequest = "new_session_request" + case threadActionRequest = "thread_action_request" + case loadMoreMessages = "load_more_messages" + case moreMessages = "more_messages" case searchRequest = "search_request" case searchResults = "search_results" case notification case permissionRequest = "permission_request" case permissionResponse = "permission_response" + case questionQueue = "question_queue" + case questionAnswer = "question_answer" case branchOpRequest = "branch_op_request" case branchOpResult = "branch_op_result" case ping @@ -704,11 +883,16 @@ extension Payload: Codable { case .cancelStream: self = .cancelStream(try container.decode(CancelStreamPayload.self, forKey: .data)) case .removeQueuedMessage: self = .removeQueuedMessage(try container.decode(RemoveQueuedMessagePayload.self, forKey: .data)) case .newSessionRequest: self = .newSessionRequest(try container.decode(NewSessionRequestPayload.self, forKey: .data)) + case .threadActionRequest: self = .threadActionRequest(try container.decode(ThreadActionRequestPayload.self, forKey: .data)) + case .loadMoreMessages: self = .loadMoreMessages(try container.decode(LoadMoreMessagesRequestPayload.self, forKey: .data)) + case .moreMessages: self = .moreMessages(try container.decode(MoreMessagesPayload.self, forKey: .data)) case .searchRequest: self = .searchRequest(try container.decode(SearchRequestPayload.self, forKey: .data)) case .searchResults: self = .searchResults(try container.decode(SearchResultsPayload.self, forKey: .data)) case .notification: self = .notification(try container.decode(NotificationPayload.self, forKey: .data)) case .permissionRequest: self = .permissionRequest(try container.decode(PermissionRequestPayload.self, forKey: .data)) case .permissionResponse: self = .permissionResponse(try container.decode(PermissionResponsePayload.self, forKey: .data)) + case .questionQueue: self = .questionQueue(try container.decode(QuestionQueuePayload.self, forKey: .data)) + case .questionAnswer: self = .questionAnswer(try container.decode(QuestionAnswerPayload.self, forKey: .data)) case .branchOpRequest: self = .branchOpRequest(try container.decode(BranchOpRequestPayload.self, forKey: .data)) case .branchOpResult: self = .branchOpResult(try container.decode(BranchOpResultPayload.self, forKey: .data)) case .ping: self = .ping(try container.decode(PingPayload.self, forKey: .data)) @@ -732,11 +916,16 @@ extension Payload: Codable { case .cancelStream(let p): try container.encode(TypeKey.cancelStream.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .removeQueuedMessage(let p): try container.encode(TypeKey.removeQueuedMessage.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .newSessionRequest(let p): try container.encode(TypeKey.newSessionRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .threadActionRequest(let p): try container.encode(TypeKey.threadActionRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .loadMoreMessages(let p): try container.encode(TypeKey.loadMoreMessages.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .moreMessages(let p): try container.encode(TypeKey.moreMessages.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .searchRequest(let p): try container.encode(TypeKey.searchRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .searchResults(let p): try container.encode(TypeKey.searchResults.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .notification(let p): try container.encode(TypeKey.notification.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .permissionRequest(let p): try container.encode(TypeKey.permissionRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .permissionResponse(let p): try container.encode(TypeKey.permissionResponse.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .questionQueue(let p): try container.encode(TypeKey.questionQueue.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .questionAnswer(let p): try container.encode(TypeKey.questionAnswer.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .branchOpRequest(let p): try container.encode(TypeKey.branchOpRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .branchOpResult(let p): try container.encode(TypeKey.branchOpResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .ping(let p): try container.encode(TypeKey.ping.rawValue, forKey: .type); try container.encode(p, forKey: .data) diff --git a/Packages/Sources/RxCodeSync/Transport/RelayClient.swift b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift index 26d99e78..8c03419b 100644 --- a/Packages/Sources/RxCodeSync/Transport/RelayClient.swift +++ b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift @@ -26,6 +26,11 @@ public actor RelayClient { case deliveryFailed(toHex: String) } + /// WebSocket frame ceiling. The 1 MiB `URLSessionWebSocketTask` default is + /// too small for sync payloads; 10 MiB is a comfortable safety margin now + /// that thread history is paged rather than sent in a single frame. + private static let maxWebSocketMessageSize = 10 * 1024 * 1024 + private let identity: DeviceIdentity private let relayURL: URL private let session: URLSession @@ -122,6 +127,9 @@ public actor RelayClient { } let newTask = session.webSocketTask(with: url) + // Raise the 1 MiB default frame limit so large sync payloads aren't + // silently dropped by `task.send`. Applies to both send and receive. + newTask.maximumMessageSize = Self.maxWebSocketMessageSize task = newTask newTask.resume() diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e91f9e7e..78fc9f58 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -19,6 +19,15 @@ "version" : "1.7.1" } }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, { "identity" : "swiftterm", "kind" : "remoteSourceControl", @@ -28,6 +37,24 @@ "version" : "1.13.0" } }, + { + "identity" : "swiftui-math", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swiftui-math", + "state" : { + "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "version" : "0.1.0" + } + }, + { + "identity" : "textual", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/textual", + "state" : { + "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", + "version" : "0.3.1" + } + }, { "identity" : "viewinspector", "kind" : "remoteSourceControl", diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index d8cd5233..39952678 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -1151,6 +1151,34 @@ final class AppState { } mobileSyncObservers.append(newSessionObserver) + let threadActionObserver = center.addObserver( + forName: .mobileSyncThreadActionRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? ThreadActionRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileThreadActionRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(threadActionObserver) + + let loadMoreObserver = center.addObserver( + forName: .mobileSyncLoadMoreMessagesRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? LoadMoreMessagesRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileLoadMoreMessages(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(loadMoreObserver) + let searchObserver = center.addObserver( forName: .mobileSyncSearchRequested, object: nil, @@ -1179,6 +1207,19 @@ final class AppState { } mobileSyncObservers.append(branchOpObserver) + let questionAnswerObserver = center.addObserver( + forName: .mobileSyncQuestionAnswerReceived, + object: nil, + queue: nil + ) { [weak self] notification in + guard let payload = notification.userInfo?["payload"] as? QuestionAnswerPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileQuestionAnswer(payload) + } + } + mobileSyncObservers.append(questionAnswerObserver) + observeMobileSnapshotInputs() } @@ -1230,6 +1271,44 @@ final class AppState { await sendMobileSnapshot(toHex: fromHex, activeSessionID: window.currentSessionId) } + /// Apply a mobile-initiated lifecycle action (rename / archive / unarchive / + /// delete) to an existing thread, then push a fresh snapshot back so the + /// requesting device reconciles immediately. The action mutators each + /// schedule their own broadcast for the remaining paired devices. + private func handleMobileThreadActionRequest(_ request: ThreadActionRequestPayload, fromHex: String) async { + // The mobile may hold a session id the CLI has since advanced + // (pending-→real swap, or a compaction boundary). Follow the redirect + // chain so the action lands on the live thread. + let sessionID = resolveCurrentSessionId(request.sessionID) + guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }) else { + logger.error("[MobileSync] thread action=\(request.action.rawValue, privacy: .public) for unknown thread=\(request.sessionID, privacy: .public)") + await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + return + } + + logger.info("[MobileSync] applying thread action=\(request.action.rawValue, privacy: .public) thread=\(sessionID, privacy: .public)") + + let session = summary.makeSession() + // Mobile actions aren't tied to a desktop window; a fresh WindowState + // keeps the data-layer mutators happy without disturbing open windows. + let window = WindowState() + + switch request.action { + case .rename: + let title = request.newTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !title.isEmpty else { break } + await renameSession(session, to: title) + case .archive: + await archiveSession(session, in: window) + case .unarchive: + await unarchiveSession(session, in: window) + case .delete: + await deleteSession(session, in: window) + } + + await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + } + private func handleMobileBranchOpRequest(_ request: BranchOpRequestPayload, fromHex: String) async { guard let project = projects.first(where: { $0.id == request.projectID }) else { await replyBranchOpResult( @@ -1564,6 +1643,13 @@ final class AppState { private func sendMobileSnapshot(toHex hex: String, activeSessionID: String?) async { let active = await mobileActiveSessionPayload(for: activeSessionID) + // Viewing a thread on any paired device counts as reading it: drop the + // "finished, unread" flag so the green indicator clears on desktop and + // mobile alike. Done before the snapshot is built so the payload below + // already reflects the cleared state. + if let activeID = active.id, sessionStates[activeID]?.hasUncheckedCompletion == true { + updateState(activeID) { $0.hasUncheckedCompletion = false } + } let branches = await mobileProjectBranches() let payload = SnapshotPayload( projects: projects, @@ -1573,9 +1659,16 @@ final class AppState { settings: mobileSettingsSnapshot(), activeSessionID: active.id, activeSessionMessages: active.messages, + activeSessionHasMore: active.hasMore, projectBranches: branches ) await MobileSyncService.shared.send(.snapshot(payload), toHex: hex) + // The snapshot doesn't carry the question queue; send it alongside so a + // freshly connected device renders any outstanding question banner. + await MobileSyncService.shared.send( + .questionQueue(QuestionQueuePayload(questions: mobilePendingQuestionPayloads())), + toHex: hex + ) logger.info( "[MobileSync] sent snapshot projects=\(self.projects.count, privacy: .public) sessions=\(payload.sessions.count, privacy: .public) active=\(active.id ?? "", privacy: .public)" ) @@ -1616,7 +1709,8 @@ final class AppState { isStreaming: sessionStates[summary.id]?.isStreaming ?? false, attention: mobileAttentionKind(forSessionId: summary.id), progress: progress, - queuedMessages: queued + queuedMessages: queued, + hasUncheckedCompletion: sessionStates[summary.id]?.hasUncheckedCompletion ?? false ) } @@ -1697,6 +1791,32 @@ final class AppState { ) } + /// Wire representation of every `AskUserQuestion` call currently awaiting an + /// answer, used to mirror the desktop's question queue to mobile. + private func mobilePendingQuestionPayloads() -> [PendingQuestionPayload] { + mobilePendingRequests.values + .filter { $0.toolName == "AskUserQuestion" } + .compactMap { request in + guard let sessionId = request.sessionId, + let data = try? JSONEncoder().encode(request.toolInput), + let json = String(data: data, encoding: .utf8) + else { return nil } + return PendingQuestionPayload( + toolUseID: request.id, + sessionID: sessionId, + toolInputJSON: json + ) + } + } + + /// Broadcast the current `AskUserQuestion` queue to paired mobile devices. + /// Called whenever the queue changes (a question is added or resolved) so + /// mobile mirrors it exactly — additions and retractions alike. + private func broadcastMobileQuestionQueue() { + guard !MobileSyncService.shared.pairedDevices.isEmpty else { return } + MobileSyncService.shared.broadcastQuestionQueue(mobilePendingQuestionPayloads()) + } + private func broadcastMobileSessionRedirect(from previousSessionID: String, to sessionID: String) { guard previousSessionID != sessionID, let summary = mobileSessionSummary(for: sessionID) @@ -1743,7 +1863,16 @@ final class AppState { } private func mobileSettingsSnapshot() -> MobileSettingsSnapshot { - let models = availableAgentModelSections().flatMap(\.models) + let sections = availableAgentModelSections().map { + AgentModelSection( + id: $0.id, + title: $0.title, + provider: $0.provider, + iconURL: $0.iconURL, + models: $0.models + ) + } + let models = sections.flatMap(\.models) return MobileSettingsSnapshot( selectedAgentProvider: selectedAgentProvider, selectedModel: selectedModel, @@ -1760,7 +1889,8 @@ final class AppState { archiveRetentionDays: archiveRetentionDays, autoPreviewSettings: autoPreviewSettings, availableEfforts: ["auto"] + Self.availableEfforts, - availableModels: models + availableModels: models, + modelSections: sections ) } @@ -1824,22 +1954,137 @@ final class AppState { } } - private func mobileActiveSessionPayload(for requestedID: String?) async -> (id: String?, messages: [ChatMessage]?) { - guard let requestedID else { return (nil, nil) } - let resolvedID = resolveCurrentSessionId(requestedID) + /// Number of messages in one mobile history page. Mobile loads the most + /// recent page on subscribe and requests older pages as the user scrolls up. + static let mobileMessagePageSize = 30 + + /// Encoded byte ceiling for the message slice carried in one sync frame. + /// The relay caps a WebSocket frame at 10 MiB and the encrypted envelope + /// inflates the plaintext by roughly a third (base64), so an oversized + /// snapshot would have `task.send` throw and be silently dropped — leaving + /// mobile with only the live stream and no history. Holding the message + /// slice to 3 MiB leaves ample room for the rest of the snapshot (projects, + /// session summaries, briefings) and the envelope overhead; older messages + /// beyond the budget are paged in on demand via `load_more_messages`. + static let mobileMessagePageByteBudget = 3 * 1024 * 1024 + + /// Largest suffix of `messages[.. (page: [ChatMessage], startIndex: Int) { + let clampedEnd = min(max(end, 0), messages.count) + guard clampedEnd > 0, countLimit > 0 else { return ([], clampedEnd) } + let encoder = JSONEncoder() + var startIndex = clampedEnd + var byteCount = 0 + var index = clampedEnd - 1 + while index >= 0 { + let size = (try? encoder.encode(messages[index]))?.count ?? 0 + let takenSoFar = clampedEnd - startIndex + // Always accept the newest message; afterwards stop once either + // budget would be exceeded so the frame stays under the relay cap. + if takenSoFar > 0, + takenSoFar >= countLimit + || byteCount + size > Self.mobileMessagePageByteBudget { + break + } + byteCount += size + startIndex = index + index -= 1 + } + return (Array(messages[startIndex ..< clampedEnd]), startIndex) + } + /// Single-entry cache of a disk-loaded session's full message list. Mobile + /// pages one thread at a time, so caching just the most recent one lets the + /// snapshot and every subsequent `load_more_messages` page reuse one parse + /// instead of re-reading the whole jsonl each time — without holding many + /// threads in memory. Live (streaming) sessions bypass this entirely. + private var mobileFullMessageCache: (sessionID: String, messages: [ChatMessage])? + + /// Resolve the full, cleaned message list for a session — from live stream + /// state when available, otherwise from disk (cached). `nil` only when the + /// session genuinely can't be located. + private func fullMobileMessages(for resolvedID: String) async -> [ChatMessage]? { if let state = sessionStates[resolvedID] { - return (resolvedID, cleanLoadedMessages(state.messages)) + return cleanLoadedMessages(state.messages) + } + if let cache = mobileFullMessageCache, cache.sessionID == resolvedID { + return cache.messages } - guard let summary = allSessionSummaries.first(where: { $0.id == resolvedID }), let project = projects.first(where: { $0.id == summary.projectId }), let full = await persistence.loadFullSession(summary: summary, cwd: project.path) else { - return (nil, nil) + return nil } + let cleaned = cleanLoadedMessages(full.messages) + mobileFullMessageCache = (resolvedID, cleaned) + return cleaned + } - return (resolvedID, cleanLoadedMessages(full.messages)) + /// Build the active-session payload for a snapshot: only the most recent + /// page of messages, plus whether older messages remain. Mobile pages the + /// rest in via `load_more_messages` so a snapshot never carries a whole + /// (potentially multi-MB) thread history in one frame. + private func mobileActiveSessionPayload( + for requestedID: String? + ) async -> (id: String?, messages: [ChatMessage]?, hasMore: Bool) { + guard let requestedID else { return (nil, nil, false) } + let resolvedID = resolveCurrentSessionId(requestedID) + guard let all = await fullMobileMessages(for: resolvedID) else { + return (nil, nil, false) + } + let (page, startIndex) = mobileMessagePage( + from: all, + endingAt: all.count, + countLimit: Self.mobileMessagePageSize + ) + return (resolvedID, page, startIndex > 0) + } + + /// Reply to a mobile `load_more_messages` request with the page of messages + /// immediately older than `beforeMessageID`. + private func handleMobileLoadMoreMessages( + _ request: LoadMoreMessagesRequestPayload, + fromHex: String + ) async { + let resolvedID = resolveCurrentSessionId(request.sessionID) + let all = await fullMobileMessages(for: resolvedID) ?? [] + guard let anchorIndex = all.firstIndex(where: { $0.id == request.beforeMessageID }) else { + // The anchor is gone (thread changed, or never loaded). Stop paging. + await MobileSyncService.shared.send( + .moreMessages(MoreMessagesPayload( + clientRequestID: request.clientRequestID, + sessionID: request.sessionID, + messages: [], + hasMore: false + )), + toHex: fromHex + ) + return + } + let limit = max(1, request.limit) + let (page, startIndex) = mobileMessagePage( + from: all, + endingAt: anchorIndex, + countLimit: limit + ) + await MobileSyncService.shared.send( + .moreMessages(MoreMessagesPayload( + clientRequestID: request.clientRequestID, + sessionID: request.sessionID, + messages: page, + hasMore: startIndex > 0 + )), + toHex: fromHex + ) } /// User-triggered full reindex of every thread. Wipes cached embeddings, @@ -2484,6 +2729,9 @@ final class AppState { if let requestSessionId = request.sessionId { broadcastMobileSessionStatus(sessionID: requestSessionId) } + if toolName == "AskUserQuestion" { + broadcastMobileQuestionQueue() + } // Auto-present the question sheet only when the user is actively viewing // the thread the question belongs to. Otherwise it stays in the queue // (yellow dot in sidebar + banner) so the user can decide when to answer. @@ -3051,16 +3299,34 @@ final class AppState { private func updateState(_ key: String, _ mutate: (inout SessionStreamState) -> Void) { let prevMessages = sessionStates[key]?.messages ?? [] + let prevThinking = sessionStates[key]?.isThinking ?? false guard var state = sessionStates[key] else { var fresh = SessionStreamState() mutate(&fresh) sessionStates[key] = fresh broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: fresh.messages, isStreaming: fresh.isStreaming) + broadcastMobileThinkingChange(sessionKey: key, prev: prevThinking, next: fresh.isThinking, isStreaming: fresh.isStreaming) return } mutate(&state) sessionStates[key] = state broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: state.messages, isStreaming: state.isStreaming) + broadcastMobileThinkingChange(sessionKey: key, prev: prevThinking, next: state.isThinking, isStreaming: state.isStreaming) + } + + /// Mirror `isThinking` transitions to paired mobile devices so the remote + /// streaming indicator can show a "Thinking…" label. Only fires on an + /// actual change — the flag flips repeatedly within a turn and we don't + /// want to flood the relay with redundant updates. + private func broadcastMobileThinkingChange(sessionKey: String, prev: Bool, next: Bool, isStreaming: Bool) { + guard prev != next, !MobileSyncService.shared.pairedDevices.isEmpty else { return } + MobileSyncService.shared.broadcastSessionUpdate( + sessionID: sessionKey, + kind: .statusChanged, + message: nil, + isStreaming: isStreaming, + isThinking: next + ) } private func finalizeStreamSession( @@ -3715,6 +3981,12 @@ final class AppState { sessionKey = resultEvent.sessionId } + // A background completion is "finished, unread". Setting the + // flag inside finalizeStreamSession means the trailing + // `.streamingFinished` broadcast already carries it to mobile. + let isFg = (window.currentSessionId ?? window.newSessionKey) == sessionKey + let markUnread = !isFg && !resultEvent.isError + finalizeStreamSession(for: sessionKey) { state in if let cost = resultEvent.totalCostUsd { state.costUsd = cost } if let duration = resultEvent.durationMs { state.durationMs += duration } @@ -3725,6 +3997,7 @@ final class AppState { state.cacheCreationTokens += usage.cacheCreationInputTokens state.cacheReadTokens += usage.cacheReadInputTokens } + if markUnread { state.hasUncheckedCompletion = true } } recordStreamCompletion( @@ -3734,10 +4007,6 @@ final class AppState { error: resultEvent.isError ? "Agent reported an error result." : nil ) - let isFg = (window.currentSessionId ?? window.newSessionKey) == sessionKey - if !isFg, !resultEvent.isError { - updateState(sessionKey) { $0.hasUncheckedCompletion = true } - } if isFg { window.currentSessionId = resultEvent.sessionId if resultEvent.isError { @@ -3855,9 +4124,9 @@ final class AppState { let stillStreaming = stateForSession(sessionKey).isStreaming if stillStreaming, isStillOwner { logger.warning("[Stream:UI] isStreaming was still true at stream end — forcing cleanup") - finalizeStreamSession(for: sessionKey) - if (window.currentSessionId ?? window.newSessionKey) != sessionKey { - updateState(sessionKey) { $0.hasUncheckedCompletion = true } + let markUnread = (window.currentSessionId ?? window.newSessionKey) != sessionKey + finalizeStreamSession(for: sessionKey) { state in + if markUnread { state.hasUncheckedCompletion = true } } // If the last assistant message is invisible after cleanup (blocks=[] because @@ -4299,6 +4568,7 @@ final class AppState { if let requestSessionId { broadcastMobileSessionStatus(sessionID: requestSessionId) } + broadcastMobileQuestionQueue() await permission.respondAskUserQuestion(toolUseId: toolUseId, updatedInput: updatedInput) } @@ -4314,9 +4584,69 @@ final class AppState { if let requestSessionId { broadcastMobileSessionStatus(sessionID: requestSessionId) } + broadcastMobileQuestionQueue() await permission.respond(toolUseId: toolUseId, decision: .deny) } + /// Apply a question answer that arrived from a paired mobile device. + /// Resolves the CLI hook, mirrors the answer into chat history, and clears + /// the request from every desktop window's queue. An empty `answers` array + /// means the user chose "Skip All Questions" on mobile. + private func handleMobileQuestionAnswer(_ payload: QuestionAnswerPayload) async { + let toolUseId = payload.toolUseID + guard let request = mobilePendingRequests[toolUseId] else { return } + + guard !payload.answers.isEmpty, let parsed = AskUserQuestion(input: request.toolInput) else { + // Skip — or a malformed payload we cannot answer: deny the hook. + clearPendingQuestion(toolUseId: toolUseId, sessionId: request.sessionId) + await permission.respond(toolUseId: toolUseId, decision: .deny) + return + } + + var answers: [Int: AskUserQuestion.Answer] = [:] + for entry in payload.answers { + answers[entry.questionIndex] = entry.multiSelect + ? .multi(entry.values) + : .single(entry.values.first ?? "") + } + + let updatedInput = AskUserQuestion.updatedInputJSON( + originalInput: request.toolInput, + questions: parsed.questions, + answers: answers + ) + let summary = AskUserQuestion.summary(questions: parsed.questions, answers: answers) + + if let sessionId = request.sessionId { + updateState(sessionId) { state in + for i in state.messages.indices.reversed() { + guard state.messages[i].toolCallIndex(id: toolUseId) != nil else { continue } + state.messages[i].setToolResult(id: toolUseId, result: summary, isError: false) + return + } + } + } + + clearPendingQuestion(toolUseId: toolUseId, sessionId: request.sessionId) + await permission.respondAskUserQuestion(toolUseId: toolUseId, updatedInput: updatedInput) + } + + /// Remove a resolved `AskUserQuestion` request from every window's queue and + /// re-broadcast the (now smaller) queue to mobile. + private func clearPendingQuestion(toolUseId: String, sessionId: String?) { + for window in registeredWindows() { + window.pendingPermissions.removeAll { $0.id == toolUseId } + if window.presentedPermissionId == toolUseId { + window.presentedPermissionId = nil + } + } + mobilePendingRequests.removeValue(forKey: toolUseId) + if let sessionId { + broadcastMobileSessionStatus(sessionID: sessionId) + } + broadcastMobileQuestionQueue() + } + // MARK: - Plan Decision Response /// Resolve a Claude `ExitPlanMode` tool call based on the user's choice in the plan card. diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 73740760..cdb0576d 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -219,13 +219,92 @@ final class MobileSyncService: ObservableObject { // MARK: - Notification fan-out /// Send a notification to every paired device. + /// + /// Two delivery paths run for each broadcast: + /// - the live WebSocket relay channel reaches devices that are currently + /// connected and foregrounded; + /// - a best-effort APNs push reaches devices that are backgrounded or + /// offline, so a finished thread still surfaces a banner. func broadcastNotification(_ payload: NotificationPayload) { Task { await client.broadcast(.notification(payload)) - // Best-effort APNs path is intentionally not yet wired here — the - // /push endpoint in the relay server provides it; a follow-up - // patch will submit encrypted alerts for each paired device that - // has an APNs token. See PLAN risk areas. + await fanoutAPNs(payload) + } + } + + /// Best-effort APNs fan-out: submit one encrypted alert per paired device + /// that has registered a push token. Per-device failures are logged and + /// swallowed — the live channel above remains the primary path. + private func fanoutAPNs(_ payload: NotificationPayload) async { + let devices = pairedDevices.filter { ($0.apnsToken?.isEmpty == false) } + guard !devices.isEmpty else { return } + guard let pushURL = Self.pushEndpointURL(from: relayURL) else { + logger.error("[APNs] cannot derive push endpoint from relay URL \(self.relayURL.absoluteString, privacy: .public)") + return + } + for device in devices { + do { + try await sendAPNsPush(payload, to: device, pushURL: pushURL) + } catch { + logger.error("[APNs] fan-out failed deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + } + + /// Encrypt `payload` for one device and POST it to the relay `/push` + /// endpoint. Throws on a missing token, unknown peer, or relay/APNs + /// rejection so callers can log the specific failure. + private func sendAPNsPush(_ payload: NotificationPayload, to device: PairedDevice, pushURL: URL) async throws { + guard let token = device.apnsToken, !token.isEmpty else { + throw MobilePushError.missingDeviceToken + } + guard let peer = await client.peer(forHex: device.pubkeyHex) else { + throw MobilePushError.unknownPeer + } + + let plaintext = AlertPlaintext( + title: payload.title, + body: payload.body, + sessionID: payload.sessionID, + projectID: payload.projectID, + kind: payload.kind.rawValue + ) + let encrypted = try APNsCrypto.seal( + plaintext: plaintext, + sender: identity.privateKey, + recipient: peer + ) + let encryptedAlertData = try JSONEncoder().encode(encrypted) + let body = APNsPushRequest( + deviceToken: token, + encryptedAlert: encryptedAlertData.base64EncodedString(), + category: payload.kind.rawValue, + collapseID: Self.notificationCollapseID(for: payload, device: device) + ) + + var request = URLRequest(url: pushURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + logger.info("[APNs] sending push kind=\(payload.kind.rawValue, privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public) tokenPrefix=\(String(token.prefix(12)), privacy: .public)") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw MobilePushError.relayRejected(status: -1, body: "No HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + throw MobilePushError.relayRejected( + status: http.statusCode, + body: Self.responseBodyString(data) + ) + } + let pushResponse = try JSONDecoder().decode(APNsPushResponse.self, from: data) + guard (200..<300).contains(pushResponse.statusCode) else { + throw MobilePushError.apnsRejected( + status: pushResponse.statusCode, + reason: pushResponse.reason + ) } } @@ -294,6 +373,7 @@ final class MobileSyncService: ObservableObject { kind: SessionUpdatePayload.Kind, message: ChatMessage?, isStreaming: Bool?, + isThinking: Bool? = nil, summary: RxCodeSync.SessionSummary? = nil, previousSessionID: String? = nil ) { @@ -303,6 +383,7 @@ final class MobileSyncService: ObservableObject { kind: kind, message: message, isStreaming: isStreaming, + isThinking: isThinking, summary: summary, previousSessionID: previousSessionID ) @@ -310,6 +391,15 @@ final class MobileSyncService: ObservableObject { } } + /// Mirror the desktop's current `AskUserQuestion` queue to every paired + /// device. Sent whenever a question is queued or resolved so mobile can + /// surface the same queue banner + question sheet. + func broadcastQuestionQueue(_ questions: [PendingQuestionPayload]) { + Task { + await client.broadcast(.questionQueue(QuestionQueuePayload(questions: questions))) + } + } + // MARK: - Event dispatch private func handle(event: RelayClient.Event) { @@ -394,6 +484,20 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) + case .threadActionRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "thread_action_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncThreadActionRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .loadMoreMessages(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "load_more_messages") else { return } + NotificationCenter.default.post( + name: .mobileSyncLoadMoreMessagesRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) case .searchRequest(let req): guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "search_request") else { return } NotificationCenter.default.post( @@ -418,6 +522,13 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["payload": resp] ) + case .questionAnswer(let answer): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "question_answer") else { return } + NotificationCenter.default.post( + name: .mobileSyncQuestionAnswerReceived, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": answer] + ) case .branchOpRequest(let req): guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "branch_op_request") else { return } NotificationCenter.default.post( @@ -533,6 +644,13 @@ final class MobileSyncService: ObservableObject { private static func testNotificationCollapseID(for device: PairedDevice) -> String { "rxcode-test-\(device.pubkeyHex.prefix(32))" } + + /// Collapse repeated alerts for the same session + kind so a device shows + /// the latest banner instead of stacking one per stream event. + private static func notificationCollapseID(for payload: NotificationPayload, device: PairedDevice) -> String { + let scope = payload.sessionID ?? "global" + return "rxcode-\(payload.kind.rawValue)-\(scope.prefix(48))" + } } private struct APNsPushRequest: Codable { @@ -567,8 +685,11 @@ extension Notification.Name { static let mobileSyncCancelStreamRequested = Notification.Name("mobileSync.cancelStreamRequested") static let mobileSyncRemoveQueuedRequested = Notification.Name("mobileSync.removeQueuedRequested") static let mobileSyncNewSessionRequested = Notification.Name("mobileSync.newSessionRequested") + static let mobileSyncThreadActionRequested = Notification.Name("mobileSync.threadActionRequested") + static let mobileSyncLoadMoreMessagesRequested = Notification.Name("mobileSync.loadMoreMessagesRequested") static let mobileSyncSearchRequested = Notification.Name("mobileSync.searchRequested") static let mobileSyncSettingsUpdateReceived = Notification.Name("mobileSync.settingsUpdateReceived") static let mobileSyncPermissionResponse = Notification.Name("mobileSync.permissionResponse") + static let mobileSyncQuestionAnswerReceived = Notification.Name("mobileSync.questionAnswerReceived") static let mobileSyncBranchOpRequested = Notification.Name("mobileSync.branchOpRequested") } diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index cb90591d..f6aaf2c4 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -39,8 +39,25 @@ final class MobileAppState: ObservableObject { /// Last branch op error message, surfaced once and cleared by the UI. @Published var lastBranchOpError: String? @Published var messagesBySession: [String: [ChatMessage]] = [:] + /// Sessions the desktop currently reports as producing reasoning/thinking + /// tokens. Drives the "Thinking…" label in the streaming indicator. + @Published var thinkingSessions: Set = [] + /// Sessions known to have messages older than the window currently held in + /// `messagesBySession`. Drives the scroll-up "load more" affordance. + @Published var sessionsWithMoreMessages: Set = [] + /// Sessions with an in-flight `load_more_messages` request. + @Published var loadingMoreSessions: Set = [] + /// Maps an outstanding load-more request ID to its session, so a late + /// `more_messages` reply lands on the right thread. + private var pendingLoadMoreRequests: [UUID: String] = [:] + /// Messages per history page — must match the desktop's expectation. + private static let messagePageSize = 30 @Published var activeSessionID: String? @Published var pendingPermission: PermissionRequestPayload? + /// Every `AskUserQuestion` call the desktop currently has awaiting an + /// answer, across all sessions. Mirrored from the desktop's queue; the chat + /// view filters it by session to drive the question banner and sheet. + @Published var pendingQuestions: [PendingQuestionPayload] = [] @Published var searchQuery: String = "" @Published var searchProjectIDs: [UUID] = [] @@ -267,6 +284,22 @@ final class MobileAppState: ObservableObject { sessions.first(where: { $0.id == sessionID })?.isStreaming ?? false } + /// True iff the desktop reports the given session as currently producing + /// reasoning/thinking tokens. + func isSessionThinking(_ sessionID: String) -> Bool { + thinkingSessions.contains(sessionID) + } + + /// Whether the given session has messages older than the loaded window. + func hasMoreMessages(sessionID: String) -> Bool { + sessionsWithMoreMessages.contains(sessionID) + } + + /// Whether an older page is currently being fetched for the given session. + func isLoadingMoreMessages(sessionID: String) -> Bool { + loadingMoreSessions.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 ?? [] @@ -278,6 +311,87 @@ final class MobileAppState: ObservableObject { try? await client.send(.newSessionRequest(payload), toHex: pairedDesktopPubkey) } + // MARK: - Thread lifecycle actions + + /// Rename a thread. The local title is updated optimistically; the desktop + /// confirms via the next snapshot / session update. + func renameThread(sessionID: String, title: String) async { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + replaceSession(sessionID: sessionID) { current in + SessionSummary( + id: current.id, + projectId: current.projectId, + title: trimmed, + updatedAt: current.updatedAt, + isPinned: current.isPinned, + isArchived: current.isArchived, + isStreaming: current.isStreaming, + attention: current.attention, + progress: current.progress, + queuedMessages: current.queuedMessages + ) + } + await sendThreadAction(sessionID: sessionID, action: .rename, newTitle: trimmed) + } + + /// Archive a thread. Optimistically flips `isArchived` so the row drops out + /// of the active list right away. + func archiveThread(sessionID: String) async { + replaceSession(sessionID: sessionID) { current in + SessionSummary( + id: current.id, + projectId: current.projectId, + title: current.title, + updatedAt: current.updatedAt, + isPinned: current.isPinned, + isArchived: true, + isStreaming: current.isStreaming, + attention: current.attention, + progress: current.progress, + queuedMessages: current.queuedMessages + ) + } + await sendThreadAction(sessionID: sessionID, action: .archive) + } + + /// Delete a thread. Optimistically drops it from local state. + func deleteThread(sessionID: String) async { + sessions.removeAll { $0.id == sessionID } + messagesBySession.removeValue(forKey: sessionID) + sessionsWithMoreMessages.remove(sessionID) + loadingMoreSessions.remove(sessionID) + if activeSessionID == sessionID { activeSessionID = nil } + await sendThreadAction(sessionID: sessionID, action: .delete) + } + + private func replaceSession(sessionID: String, _ transform: (SessionSummary) -> SessionSummary) { + guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else { return } + sessions[index] = transform(sessions[index]) + } + + private func sendThreadAction( + sessionID: String, + action: ThreadActionRequestPayload.Action, + newTitle: String? = nil + ) async { + guard isPaired else { + logger.error("[ThreadAction] not paired — dropping action=\(action.rawValue, privacy: .public)") + return + } + let payload = ThreadActionRequestPayload( + sessionID: sessionID, + action: action, + newTitle: newTitle + ) + do { + try await client.send(.threadActionRequest(payload), toHex: pairedDesktopPubkey) + logger.info("[ThreadAction] sent action=\(action.rawValue, privacy: .public) thread=\(sessionID, privacy: .public)") + } catch { + logger.error("[ThreadAction] send failed action=\(action.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + /// Tell the desktop to switch the project to an existing local branch. /// Returns immediately; the eventual snapshot broadcast carries the new /// `currentBranch` and any error surfaces via `lastBranchOpError`. @@ -317,6 +431,64 @@ final class MobileAppState: ObservableObject { try? await client.send(.subscribeSession(payload), toHex: pairedDesktopPubkey) } + // MARK: - Message paging + + /// Ask the desktop for the page of messages immediately older than the + /// oldest one currently loaded for `sessionID`. No-op when there's nothing + /// older, a request is already in flight, or the thread has no messages yet. + /// Returns `true` when a request was dispatched — callers can then expect + /// `isLoadingMoreMessages(sessionID:)` to flip back to `false` once it + /// settles. + @discardableResult + func loadMoreMessages(sessionID: String) async -> Bool { + guard isPaired, + sessionsWithMoreMessages.contains(sessionID), + !loadingMoreSessions.contains(sessionID), + let oldest = messagesBySession[sessionID]?.first + else { return false } + + let requestID = UUID() + loadingMoreSessions.insert(sessionID) + pendingLoadMoreRequests[requestID] = sessionID + + let payload = LoadMoreMessagesRequestPayload( + clientRequestID: requestID, + sessionID: sessionID, + beforeMessageID: oldest.id, + limit: Self.messagePageSize + ) + do { + try await client.send(.loadMoreMessages(payload), toHex: pairedDesktopPubkey) + } catch { + loadingMoreSessions.remove(sessionID) + pendingLoadMoreRequests.removeValue(forKey: requestID) + } + return true + } + + /// Fold an older page returned by the desktop into the local window. + private func applyMoreMessages(_ page: MoreMessagesPayload) { + guard let sessionID = pendingLoadMoreRequests.removeValue(forKey: page.clientRequestID) + else { return } + loadingMoreSessions.remove(sessionID) + + if !page.messages.isEmpty { + var current = messagesBySession[sessionID] ?? [] + let known = Set(current.map(\.id)) + let fresh = page.messages.filter { !known.contains($0.id) } + if !fresh.isEmpty { + current.insert(contentsOf: fresh, at: 0) + messagesBySession[sessionID] = current + } + } + + if page.hasMore { + sessionsWithMoreMessages.insert(sessionID) + } else { + sessionsWithMoreMessages.remove(sessionID) + } + } + func respondToPermission(allow: Bool, denyReason: String? = nil) async { guard let pending = pendingPermission else { return } let payload = PermissionResponsePayload( @@ -328,6 +500,41 @@ final class MobileAppState: ObservableObject { pendingPermission = nil } + // MARK: - AskUserQuestion + + /// Pending `AskUserQuestion` calls for one session, in the order the desktop + /// queued them. + func pendingQuestions(sessionID: String) -> [PendingQuestionPayload] { + pendingQuestions.filter { $0.sessionID == sessionID } + } + + /// Submit the user's answers for one `AskUserQuestion` call to the desktop. + /// The request is dropped from the local queue optimistically; the desktop + /// re-broadcasts the authoritative queue once it resolves the tool call. + func answerQuestion(toolUseID: String, answers: [Int: AskUserQuestion.Answer]) async { + guard isPaired else { return } + let entries: [QuestionAnswerEntry] = answers.map { index, answer in + switch answer { + case .single(let value): + return QuestionAnswerEntry(questionIndex: index, values: [value], multiSelect: false) + case .multi(let values): + return QuestionAnswerEntry(questionIndex: index, values: values, multiSelect: true) + } + } + let payload = QuestionAnswerPayload(toolUseID: toolUseID, answers: entries) + try? await client.send(.questionAnswer(payload), toHex: pairedDesktopPubkey) + pendingQuestions.removeAll { $0.toolUseID == toolUseID } + } + + /// Skip one `AskUserQuestion` call. An empty answer set tells the desktop to + /// resolve the tool call as denied. + func skipQuestion(toolUseID: String) async { + guard isPaired else { return } + let payload = QuestionAnswerPayload(toolUseID: toolUseID, answers: []) + try? await client.send(.questionAnswer(payload), toHex: pairedDesktopPubkey) + pendingQuestions.removeAll { $0.toolUseID == toolUseID } + } + func updateDesktopSettings(_ update: MobileSettingsUpdatePayload) async { guard isPaired else { return } applySettingsUpdateLocally(update) @@ -402,8 +609,13 @@ final class MobileAppState: ObservableObject { inFlightBranchOps = [] lastBranchOpError = nil messagesBySession = [:] + thinkingSessions = [] + sessionsWithMoreMessages = [] + loadingMoreSessions = [] + pendingLoadMoreRequests = [:] activeSessionID = nil pendingPermission = nil + pendingQuestions = [] savePairedDesktop() await resetIdentityAndClient() } @@ -468,17 +680,31 @@ final class MobileAppState: ObservableObject { } if let active = snap.activeSessionID { if let messages = snap.activeSessionMessages { + // The snapshot carries only the most recent page; replacing + // the window resets paging to that page. messagesBySession[active] = messages + if snap.activeSessionHasMore == true { + sessionsWithMoreMessages.insert(active) + } else { + sessionsWithMoreMessages.remove(active) + } + loadingMoreSessions.remove(active) } else if messagesBySession[active] == nil { messagesBySession[active] = [] } activeSessionID = active } + case .moreMessages(let page): + applyMoreMessages(page) case .sessionUpdate(let update): applySessionUpdate(update) case .permissionRequest(let req): pendingPermission = req MobileHaptics.attentionNeeded() + case .questionQueue(let queue): + let grew = queue.questions.count > pendingQuestions.count + pendingQuestions = queue.questions + if grew { MobileHaptics.attentionNeeded() } case .notification: // Foreground notifications arriving over WS — iOS won't show a // banner automatically; UI surfaces these in a toast/badge. @@ -502,9 +728,23 @@ final class MobileAppState: ObservableObject { private func applySessionUpdate(_ update: SessionUpdatePayload) { if let previous = update.previousSessionID, previous != update.sessionID { - if let messages = messagesBySession.removeValue(forKey: previous), - messagesBySession[update.sessionID] == nil { - messagesBySession[update.sessionID] = messages + if let carried = messagesBySession.removeValue(forKey: previous) { + if let existing = messagesBySession[update.sessionID], !existing.isEmpty { + // The new session id already accumulated live messages + // before the redirect landed. Prepend the carried history, + // deduped by id, so the older messages aren't dropped. + let existingIDs = Set(existing.map(\.id)) + messagesBySession[update.sessionID] = + carried.filter { !existingIDs.contains($0.id) } + existing + } else { + messagesBySession[update.sessionID] = carried + } + } + if sessionsWithMoreMessages.remove(previous) != nil { + sessionsWithMoreMessages.insert(update.sessionID) + } + if loadingMoreSessions.remove(previous) != nil { + loadingMoreSessions.insert(update.sessionID) } if activeSessionID == previous { activeSessionID = update.sessionID @@ -517,6 +757,14 @@ final class MobileAppState: ObservableObject { updateSessionStreamingFlag(sessionID: update.sessionID, isStreaming: isStreaming) } + if let isThinking = update.isThinking { + setSessionThinking(sessionID: update.sessionID, isThinking: isThinking) + } + // A session that is no longer streaming cannot still be thinking. + if update.isStreaming == false { + thinkingSessions.remove(update.sessionID) + } + switch update.kind { case .messageAppended: if let m = update.message { @@ -530,6 +778,7 @@ final class MobileAppState: ObservableObject { messagesBySession[update.sessionID] = list } case .streamingFinished: + thinkingSessions.remove(update.sessionID) // Soft success cue, but only when the user is actually looking at // (or last looked at) the session that just finished. Avoids // buzzing on background-session completions. @@ -571,6 +820,14 @@ final class MobileAppState: ObservableObject { ) } + private func setSessionThinking(sessionID: String, isThinking: Bool) { + if isThinking { + thinkingSessions.insert(sessionID) + } else { + thinkingSessions.remove(sessionID) + } + } + private func requestSnapshot() async { guard isPaired else { return } let payload = RequestSnapshotPayload(activeSessionID: activeSessionID) diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index 7ac095c6..490e8098 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -22,17 +22,19 @@ struct GroupedBriefing: Identifiable { struct MobileBriefingView: View { @EnvironmentObject private var state: MobileAppState + /// Selected project ids for filtering. Empty = show every project. + @State private var selectedProjectIds: Set = [] + + /// When false (default), only show briefings for each project's current branch. + @State private var showAllBranches: Bool = false + var body: some View { GeometryReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 12) { if groups.isEmpty { - ContentUnavailableView( - "No Briefings Yet", - systemImage: "doc.text.magnifyingglass", - description: Text("Briefings appear here after threads finish on your Mac.") - ) - .frame(maxWidth: .infinity, minHeight: 320) + emptyState + .frame(maxWidth: .infinity, minHeight: 320) } else { LazyVGrid( columns: gridColumns(for: proxy.size.width), @@ -54,6 +56,13 @@ struct MobileBriefingView: View { } } .navigationTitle("Briefing") + .toolbar { + if hasAnyData { + ToolbarItem(placement: .topBarTrailing) { + filterMenu + } + } + } .refreshable { await state.refreshSnapshot() } @@ -104,10 +113,124 @@ struct MobileBriefingView: View { Dictionary(uniqueKeysWithValues: state.projects.map { ($0.id, $0) }) } - private var groups: [GroupedBriefing] { + /// Every briefing group before the project/branch filters are applied. + private var allGroups: [GroupedBriefing] { groupBriefings(briefings: state.branchBriefings, threads: state.threadSummaries) } + /// Briefing groups after applying the active project and branch filters. + private var groups: [GroupedBriefing] { + let projectFiltered: [GroupedBriefing] + if selectedProjectIds.isEmpty { + projectFiltered = allGroups + } else { + projectFiltered = allGroups.filter { selectedProjectIds.contains($0.projectId) } + } + + if showAllBranches { + return projectFiltered + } + return projectFiltered.filter { group in + guard let branch = state.projectBranches[group.projectId] else { + // Branch not yet mirrored from the desktop — keep the group so + // the user isn't shown an empty state while it resolves. + return true + } + return group.branch == branch + } + } + + /// True when at least one briefing or summary exists, regardless of filters. + private var hasAnyData: Bool { + !state.branchBriefings.isEmpty || !state.threadSummaries.isEmpty + } + + /// Projects that actually have at least one briefing or summary recorded. + private var projectsWithData: [Project] { + let ids = Set(allGroups.map(\.projectId)) + return state.projects.filter { ids.contains($0.id) } + } + + private var isFilterActive: Bool { + showAllBranches || !selectedProjectIds.isEmpty + } + + private func toggleProject(_ id: UUID) { + if selectedProjectIds.contains(id) { + selectedProjectIds.remove(id) + } else { + selectedProjectIds.insert(id) + } + } + + // MARK: - Filter menu + + @ViewBuilder + private var filterMenu: some View { + Menu { + Section("Branches") { + Button { + showAllBranches = false + } label: { + Label("Current branch", systemImage: showAllBranches ? "" : "checkmark") + } + Button { + showAllBranches = true + } label: { + Label("All branches", systemImage: showAllBranches ? "checkmark" : "") + } + } + + let projects = projectsWithData + if projects.count > 1 { + Section("Projects") { + Button { + selectedProjectIds.removeAll() + } label: { + Label("All projects", systemImage: selectedProjectIds.isEmpty ? "checkmark" : "") + } + ForEach(projects) { project in + Button { + toggleProject(project.id) + } label: { + Label( + project.name, + systemImage: selectedProjectIds.contains(project.id) ? "checkmark" : "" + ) + } + } + } + } + } label: { + Image(systemName: isFilterActive + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + } + } + + // MARK: - Empty state + + @ViewBuilder + private var emptyState: some View { + if hasAnyData { + ContentUnavailableView( + "Nothing to Show", + systemImage: "line.3.horizontal.decrease.circle", + description: Text( + showAllBranches + ? "No briefings match the selected projects." + : "No briefings for the current branch. Choose All branches to see other branches." + ) + ) + } else { + ContentUnavailableView( + "No Briefings Yet", + systemImage: "doc.text.magnifyingglass", + description: Text("Briefings appear here after threads finish on your Mac.") + ) + } + } + private func briefingRow(_ group: GroupedBriefing) -> some View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 6) { diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 8c9bbedd..7da6580a 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -1,33 +1,203 @@ -import SwiftUI -import RxCodeCore +import Combine import RxCodeChatKit +import RxCodeCore import RxCodeSync +import SwiftUI /// Read-write chat view. User messages are forwarded to the desktop and the /// desktop agent's stream is mirrored back as `session_update` payloads. struct MobileChatView: View { @EnvironmentObject private var state: MobileAppState let sessionID: String + /// Invoked after the thread is archived or deleted so the parent can pop + /// this view — the thread is no longer reachable from the active list. + var onClose: () -> Void = {} @State private var composer: String = "" @State private var isNearBottom: Bool = true @State private var showingQueueSheet = false @State private var didEstablishInitialScroll = false + @State private var showingRenameSheet = false + @State private var showingArchiveConfirm = false + @State private var showingDeleteConfirm = false + @State private var showingTodoSheet = false + /// The question request whose sheet is currently presented, if any. + @State private var presentedQuestion: PendingQuestionPayload? + /// The message that sat at the top before an older page was requested. The + /// viewport is restored to it after the page is prepended so the content + /// the user was reading doesn't jump. + @State private var pendingTopAnchorID: UUID? + /// Gates the scroll-up "load more" trigger until the initial scroll-to- + /// bottom has settled, so opening a thread doesn't immediately page back. + @State private var didSettleInitialScroll = false private static let bottomAnchorID = "message-list-bottom" private static let bottomContentPadding: CGFloat = 200 private static let nearBottomThreshold: CGFloat = bottomContentPadding + 40 + /// Load older messages once the viewport scrolls within this many points + /// of the top. + private static let loadMoreThreshold: CGFloat = 150 var body: some View { activeThreadLayout .animation(.easeInOut(duration: 0.2), value: queuedMessages.count) + .animation(.easeInOut(duration: 0.2), value: sessionQuestions.count) .navigationBarTitleDisplayMode(.inline) .navigationTitle(title) + .toolbar { threadActionsToolbar } .sheet(isPresented: $showingQueueSheet) { QueuedMessagesSheet( messages: queuedMessages, onRemove: removeQueued ) } + .sheet(isPresented: $showingRenameSheet) { + RenameThreadSheet(currentTitle: title) { newTitle in + Task { await state.renameThread(sessionID: sessionID, title: newTitle) } + } + } + .sheet(isPresented: $showingTodoSheet) { + ThreadTodoSheet( + threadTitle: title, + todos: todos ?? [], + summary: threadSummary + ) + } + .sheet(item: $presentedQuestion) { question in + MobileQuestionSheet( + request: question, + remainingCount: max(0, sessionQuestions.count - 1), + onSubmit: { answers in + Task { await state.answerQuestion(toolUseID: question.toolUseID, answers: answers) } + }, + onSkipAll: { + Task { await state.skipQuestion(toolUseID: question.toolUseID) } + } + ) + } + .onChange(of: sessionQuestions) { _, questions in + // A question resolved elsewhere (desktop or another device) + // should close a now-stale sheet. + if let presented = presentedQuestion, + !questions.contains(where: { $0.toolUseID == presented.toolUseID }) + { + presentedQuestion = nil + } + } + .confirmationDialog( + "Archive this thread?", + isPresented: $showingArchiveConfirm, + titleVisibility: .visible + ) { + Button("Archive", role: .destructive) { performArchive() } + Button("Cancel", role: .cancel) {} + } message: { + Text("Archived threads are hidden from the list. You can still find them on your Mac.") + } + .confirmationDialog( + "Delete this thread?", + isPresented: $showingDeleteConfirm, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { performDelete() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This permanently deletes the thread and all of its messages. This cannot be undone.") + } + } + + // MARK: - Thread actions toolbar + + @ToolbarContentBuilder + private var threadActionsToolbar: some ToolbarContent { + if threadExists { + ToolbarItem(placement: .principal) { + navigationTitleView + } + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + showingRenameSheet = true + } label: { + Label("Rename", systemImage: "pencil") + } + Button { + showingArchiveConfirm = true + } label: { + Label("Archive", systemImage: "archivebox") + } + Divider() + Button(role: .destructive) { + showingDeleteConfirm = true + } label: { + Label("Delete", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis") + } + .accessibilityLabel("Thread actions") + } + } + } + + /// Tappable navigation-title view. Tapping always opens the todo + summary + /// sheet so the thread summary stays reachable. When the thread has todos + /// it also shows a progress indicator; otherwise a subtle chevron hints + /// that the title is interactive. Mirrors the desktop's todo progress pill. + @ViewBuilder + private var navigationTitleView: some View { + let todos = self.todos + Button { + showingTodoSheet = true + } label: { + HStack(spacing: 8) { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + if let todos, !todos.isEmpty { + let done = todos.filter { $0.status == .completed }.count + let inProgress = todos.contains { $0.status == .inProgress } + MobileTodoProgressIndicator( + done: done, + total: todos.count, + inProgress: inProgress + ) + } else { + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(navigationTitleAccessibilityLabel) + .accessibilityHint("Opens todos and thread summary") + } + + private var navigationTitleAccessibilityLabel: String { + guard let todos, !todos.isEmpty else { + return "\(title). Tap to view thread summary." + } + let done = todos.filter { $0.status == .completed }.count + return "\(title). Todos, \(done) of \(todos.count) complete. Tap to view todos and thread summary." + } + + /// A real, persisted thread the desktop can act on — excludes drafts. + private var threadExists: Bool { + !MobileDraftSessionID.isDraft(sessionID) + && state.sessions.contains(where: { $0.id == sessionID }) + } + + private func performArchive() { + Task { await state.archiveThread(sessionID: sessionID) } + onClose() + } + + private func performDelete() { + Task { await state.deleteThread(sessionID: sessionID) } + onClose() } // MARK: - Active Thread Layout @@ -37,13 +207,22 @@ struct MobileChatView: View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 12) { + if isLoadingMore { + loadMoreSpinner + } ChatMessageListView(messages: messages) + if isStreaming { + MobileStreamingIndicator(isThinking: isThinking) + .transition(.opacity) + } Color.clear .frame(height: Self.bottomContentPadding) .id(Self.bottomAnchorID) } .padding(.horizontal, 16) .padding(.top, 8) + .animation(.easeInOut(duration: 0.2), value: isStreaming) + .animation(.easeInOut(duration: 0.2), value: isLoadingMore) } .scrollDismissesKeyboard(.interactively) .onScrollGeometryChange(for: Bool.self) { geo in @@ -52,12 +231,26 @@ struct MobileChatView: View { } action: { _, newValue in if isNearBottom != newValue { isNearBottom = newValue } } + .onScrollGeometryChange(for: CGFloat.self) { geo in + geo.contentOffset.y + } action: { _, offsetY in + if offsetY < Self.loadMoreThreshold { triggerLoadMoreIfNeeded() } + } .onAppear { if !didEstablishInitialScroll { didEstablishInitialScroll = true } } - .onChange(of: messages.count) { _, _ in + .task { + // Let the initial scroll-to-bottom settle before arming the + // scroll-up "load more" trigger, so opening a thread doesn't + // immediately page in older history. + try? await Task.sleep(for: .seconds(0.8)) + didSettleInitialScroll = true + } + .onChange(of: messages.last?.id) { _, _ in + // Keyed on the last message id so a prepended older page + // (which leaves the tail unchanged) doesn't yank to bottom. guard didEstablishInitialScroll else { didEstablishInitialScroll = true return @@ -68,6 +261,18 @@ struct MobileChatView: View { guard didEstablishInitialScroll, isNearBottom else { return } withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } + .onChange(of: isStreaming) { _, streaming in + // Keep the newly appeared loading indicator in view. + guard streaming, didEstablishInitialScroll, isNearBottom else { return } + withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } + } + .onChange(of: isLoadingMore) { _, loading in + // An older page finished loading: restore the viewport to + // the message that was on top so the prepend isn't jarring. + guard !loading, let anchor = pendingTopAnchorID else { return } + pendingTopAnchorID = nil + proxy.scrollTo(anchor, anchor: .top) + } .overlay(alignment: .bottomTrailing) { if !isNearBottom { scrollToBottomButton(proxy: proxy) @@ -87,6 +292,12 @@ struct MobileChatView: View { .padding(.bottom, 4) .transition(.move(edge: .bottom).combined(with: .opacity)) } + if !sessionQuestions.isEmpty { + questionQueueBanner + .padding(.horizontal, 12) + .padding(.bottom, 4) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } MobileInputBar( text: $composer, isStreaming: isStreaming, @@ -107,14 +318,74 @@ struct MobileChatView: View { state.sessions.first(where: { $0.id == sessionID })?.title ?? "Thread" } + /// Live todos extracted from the most recent `TodoWrite` tool call in the + /// thread's messages — the same source the desktop toolbar pill uses. + /// `nil` when the thread has never emitted a todo list. + private var todos: [TodoItem]? { + TodoExtractor.latest(in: messages) + } + + /// The desktop-generated summary for this thread, if one exists. Summaries + /// are produced once a thread finishes a turn, so live threads may not have + /// one yet. + private var threadSummary: MobileThreadSummary? { + state.threadSummaries.first { $0.sessionId == sessionID } + } + private var isStreaming: Bool { state.isSessionStreaming(sessionID) } + private var isThinking: Bool { + state.isSessionThinking(sessionID) + } + + private var isLoadingMore: Bool { + state.isLoadingMoreMessages(sessionID: sessionID) + } + private var queuedMessages: [QueuedUserMessage] { state.queuedMessages(sessionID: sessionID) } + /// `AskUserQuestion` calls awaiting an answer for this thread. + private var sessionQuestions: [PendingQuestionPayload] { + state.pendingQuestions(sessionID: sessionID) + } + + // MARK: - Message paging + + /// Spinner shown at the top of the list while an older page is loading. + private var loadMoreSpinner: some View { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .transition(.opacity) + } + + /// Request the next older page when the user scrolls near the top. Captures + /// the current top message so the viewport can be restored after the page + /// is prepended. `pendingTopAnchorID` doubles as an in-flight guard. + private func triggerLoadMoreIfNeeded() { + guard didSettleInitialScroll, + pendingTopAnchorID == nil, + state.hasMoreMessages(sessionID: sessionID), + !state.isLoadingMoreMessages(sessionID: sessionID), + let anchor = messages.first?.id + else { return } + pendingTopAnchorID = anchor + Task { + let dispatched = await state.loadMoreMessages(sessionID: sessionID) + // Nothing was sent (e.g. not paired) — clear the guard so a later + // scroll can retry. + if !dispatched { pendingTopAnchorID = nil } + } + } + // MARK: - Send / Stop private func handleSend(_ trimmed: String) { @@ -200,6 +471,166 @@ struct MobileChatView: View { .buttonStyle(.plain) .accessibilityLabel("\(queuedMessages.count) queued messages. Tap to view all.") } + + // MARK: - Question queue banner + + /// Compact pill shown directly above the input bar whenever the agent has + /// `AskUserQuestion` calls awaiting an answer. Tapping it opens the question + /// sheet for the first queued request — mirrors the desktop banner. + private var questionQueueBanner: some View { + Button { + presentedQuestion = sessionQuestions.first + } label: { + HStack(spacing: 10) { + Image(systemName: "questionmark.circle.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + + Text(questionBannerText) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + + Spacer(minLength: 8) + + Text("Answer") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(ClaudeTheme.accent, in: Capsule()) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(ClaudeTheme.accentSubtle) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(ClaudeTheme.accent.opacity(0.35), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("\(questionBannerText). Tap to answer.") + } + + private var questionBannerText: String { + let count = sessionQuestions.count + return count == 1 ? "1 question pending" : "\(count) questions pending" + } +} + +// MARK: - Streaming Indicator + +/// Loading indicator shown at the end of the message list while the agent is +/// generating a response — mirrors the desktop `StreamingIndicatorView`. Shows +/// a "Thinking…" label while the agent is producing reasoning tokens, and three +/// bouncing dots throughout. +private struct MobileStreamingIndicator: View { + var isThinking: Bool = false + @State private var phase: Int = 0 + private let timer = Timer.publish(every: 0.18, on: .main, in: .common).autoconnect() + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if isThinking { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + Text("Thinking") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textSecondary) + } + .transition(.opacity) + } + dots + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .animation(.easeInOut(duration: 0.2), value: isThinking) + .onReceive(timer) { _ in + phase = (phase + 1) % 3 + } + .accessibilityLabel(isThinking ? "Thinking" : "Response in progress") + } + + private var dots: some View { + HStack(spacing: 5) { + ForEach(0 ..< 3, id: \.self) { i in + Circle() + .fill(ClaudeTheme.textTertiary) + .frame(width: 7, height: 7) + .opacity(phase == i ? 1.0 : 0.3) + .scaleEffect(phase == i ? 1.0 : 0.85) + .animation(.easeInOut(duration: 0.25), value: phase) + } + } + } +} + +// MARK: - Rename Thread Sheet + +/// Compact modal that captures a new thread title. Commits the trimmed value +/// via `onSubmit` and dismisses; an empty title is treated as a no-op. +private struct RenameThreadSheet: View { + let currentTitle: String + let onSubmit: (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var text: String + @FocusState private var isFocused: Bool + + init(currentTitle: String, onSubmit: @escaping (String) -> Void) { + self.currentTitle = currentTitle + self.onSubmit = onSubmit + _text = State(initialValue: currentTitle) + } + + private var trimmed: String { + text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSave: Bool { + !trimmed.isEmpty && trimmed != currentTitle + } + + var body: some View { + NavigationStack { + Form { + Section("Thread name") { + TextField("Thread name", text: $text) + .focused($isFocused) + .submitLabel(.done) + .onSubmit(commit) + } + } + .navigationTitle("Rename Thread") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Save") { commit() } + .fontWeight(.semibold) + .disabled(!canSave) + } + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + isFocused = true + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + + private func commit() { + guard canSave else { return } + onSubmit(trimmed) + dismiss() + } } // MARK: - Queued Messages Sheet @@ -252,3 +683,209 @@ private struct QueuedMessagesSheet: View { .presentationDragIndicator(.visible) } } + +// MARK: - Todo Progress Indicator + +/// Compact progress ring shown beside the navigation title while a thread has +/// todos. Mirrors the desktop `TodoProgressPill`: a determinate ring filling as +/// todos complete, accented while work is in progress and green once done. +private struct MobileTodoProgressIndicator: View { + let done: Int + let total: Int + let inProgress: Bool + + private var isComplete: Bool { total > 0 && done == total } + + private var fraction: Double { + guard total > 0 else { return 0 } + return min(1, max(0, Double(done) / Double(total))) + } + + private var accent: Color { + if isComplete { return ClaudeTheme.statusSuccess } + if inProgress { return ClaudeTheme.accent } + return .secondary + } + + var body: some View { + HStack(spacing: 5) { + ZStack { + Circle() + .stroke(accent.opacity(0.22), lineWidth: 2) + Circle() + .trim(from: 0, to: fraction) + .stroke(accent, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.25), value: fraction) + } + .frame(width: 15, height: 15) + + Text("\(done)/\(total)") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.primary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Color.secondary.opacity(0.14))) + .contentShape(Capsule()) + } +} + +// MARK: - Thread Todo Sheet + +/// Bottom sheet listing the thread's todo items and its generated summary, +/// mirroring the desktop's todo popover. Opened from the navigation-title +/// progress indicator. +private struct ThreadTodoSheet: View { + let threadTitle: String + let todos: [TodoItem] + let summary: MobileThreadSummary? + @Environment(\.dismiss) private var dismiss + + private var doneCount: Int { + todos.filter { $0.status == .completed }.count + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + titleHeader + if !todos.isEmpty { + todoSection + } + summarySection + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle(todos.isEmpty ? "Thread Summary" : "Todos & Summary") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + // MARK: Title header + + private var titleHeader: some View { + Text(threadTitle) + .font(.title3.weight(.semibold)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: Todo section + + @ViewBuilder + private var todoSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Image(systemName: "checklist") + .font(.caption) + .foregroundStyle(.secondary) + Text("Todos") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Text("\(doneCount)/\(todos.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + } + + if todos.isEmpty { + Text("No todos for this thread.") + .font(.body) + .foregroundStyle(.tertiary) + .italic() + } else { + VStack(alignment: .leading, spacing: 10) { + ForEach(todos) { todo in + todoRow(todo) + } + } + } + } + } + + private func todoRow(_ todo: TodoItem) -> some View { + HStack(alignment: .top, spacing: 10) { + statusIcon(todo.status) + .font(.system(size: 17)) + .frame(width: 22) + Text(label(for: todo)) + .font(.callout) + .foregroundStyle(textColor(todo.status)) + .strikethrough(todo.status == .completed, color: .secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder + private func statusIcon(_ status: TodoItem.Status) -> some View { + switch status { + case .pending: + Image(systemName: "circle") + .foregroundStyle(.tertiary) + case .inProgress: + Image(systemName: "arrow.right.circle.fill") + .foregroundStyle(ClaudeTheme.accent) + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(ClaudeTheme.statusSuccess) + } + } + + private func label(for todo: TodoItem) -> String { + todo.status == .inProgress && !todo.activeForm.isEmpty ? todo.activeForm : todo.content + } + + private func textColor(_ status: TodoItem.Status) -> Color { + switch status { + case .pending: return .secondary + case .inProgress: return .primary + case .completed: return .secondary + } + } + + // MARK: Summary section + + @ViewBuilder + private var summarySection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "text.alignleft") + .font(.caption) + .foregroundStyle(.secondary) + Text("Summary") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + + if let summary, !summary.summary.isEmpty { + ChatTextContentView( + markdown: summary.summary, + size: 15, + color: .primary, + lineSpacing: 3 + ) + } else { + Text("No summary yet. A summary is generated once the thread finishes a turn on your Mac.") + .font(.body) + .foregroundStyle(.tertiary) + .italic() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/RxCodeMobile/Views/MobileQuestionSheet.swift b/RxCodeMobile/Views/MobileQuestionSheet.swift new file mode 100644 index 00000000..37303920 --- /dev/null +++ b/RxCodeMobile/Views/MobileQuestionSheet.swift @@ -0,0 +1,611 @@ +import SwiftUI +import UIKit +import RxCodeCore +import RxCodeSync + +/// Sheet UI for an `AskUserQuestion` tool call mirrored from the desktop. +/// Displays every question in the payload behind a navigator of pills, lets the +/// user answer each (single-select, multi-select, or free-form "Other" text), +/// then submits all answers at once. Mirrors the desktop `QuestionSheetView`. +struct MobileQuestionSheet: View { + let request: PendingQuestionPayload + /// Other question requests still queued for the same session, shown as a + /// "+N more pending" hint in the header. + let remainingCount: Int + let onSubmit: (_ answers: [Int: AskUserQuestion.Answer]) -> Void + /// Resolves the tool call as denied — "Skip All Questions". + let onSkipAll: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var answers: [Int: LocalAnswer] = [:] + @State private var currentQuestionIndex: Int = 0 + @State private var isSubmitting: Bool = false + + private var parsed: AskUserQuestion? { + guard let data = request.toolInputJSON.data(using: .utf8), + let input = try? JSONDecoder().decode([String: JSONValue].self, from: data) + else { return nil } + return AskUserQuestion(input: input) + } + + private var questions: [AskUserQuestion.Question] { + parsed?.questions ?? [] + } + + private var totalQuestions: Int { questions.count } + + private var isLastQuestion: Bool { + currentQuestionIndex >= totalQuestions - 1 + } + + private var currentQuestion: AskUserQuestion.Question? { + guard currentQuestionIndex < questions.count else { return nil } + return questions[currentQuestionIndex] + } + + private var currentAnswerProvided: Bool { + guard let question = currentQuestion else { return false } + return isAnswered(answers[currentQuestionIndex], for: question) + } + + private var allAnswered: Bool { + questions.enumerated().allSatisfy { index, question in + isAnswered(answers[index], for: question) + } + } + + var body: some View { + VStack(spacing: 0) { + topBar + if totalQuestions > 1 { + questionNavigator + .padding(.top, 12) + .padding(.bottom, 4) + } + ScrollView { + VStack(spacing: 0) { + if let question = currentQuestion { + questionContent(index: currentQuestionIndex, question: question) + .id(currentQuestionIndex) + } else { + fallbackEmpty + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 24) + } + bottomActionBar + } + .background(ClaudeTheme.background) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } +} + +// MARK: - Local Answer Storage + +private enum LocalAnswer { + case singleChoice(String) + case multipleChoice(Set) + case otherText(String) + /// Multi-select where the user toggled "Other" — `selectedLabels` are real + /// options, `otherText` is the free-form value (may be empty until typed). + case multipleChoiceWithOther(selectedLabels: Set, otherText: String) +} + +private extension MobileQuestionSheet { + func isAnswered(_ answer: LocalAnswer?, for question: AskUserQuestion.Question) -> Bool { + guard let answer else { return false } + switch answer { + case .singleChoice(let label): + return !label.isEmpty + case .multipleChoice(let labels): + return !labels.isEmpty + case .otherText(let text): + return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .multipleChoiceWithOther(let labels, let text): + return !labels.isEmpty || !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } +} + +// MARK: - Top Bar + +private extension MobileQuestionSheet { + var topBar: some View { + HStack(alignment: .top, spacing: 12) { + ZStack { + Circle() + .fill(ClaudeTheme.accentSubtle) + .frame(width: 34, height: 34) + Image(systemName: "questionmark.bubble.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + } + VStack(alignment: .leading, spacing: 2) { + Text("Assistant has a question") + .font(.headline) + .foregroundStyle(ClaudeTheme.textPrimary) + if remainingCount > 0 { + Text("+\(remainingCount) more pending") + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + Spacer(minLength: 8) + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ClaudeTheme.textSecondary) + .frame(width: 30, height: 30) + .background(Circle().fill(ClaudeTheme.surfaceSecondary)) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + .accessibilityLabel("Close — answer later from the queue") + } + .padding(.horizontal, 16) + .padding(.top, 18) + .padding(.bottom, 10) + } +} + +// MARK: - Navigator + +private extension MobileQuestionSheet { + var questionNavigator: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(questions.enumerated()), id: \.offset) { index, question in + questionPill(index: index, question: question) + .id(index) + } + } + .padding(.horizontal, 16) + } + .onChange(of: currentQuestionIndex) { _, newValue in + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(newValue, anchor: .center) + } + } + } + } + + func questionPill(index: Int, question: AskUserQuestion.Question) -> some View { + let isActive = index == currentQuestionIndex + let answered = isAnswered(answers[index], for: question) + return Button { + withAnimation(.easeInOut(duration: 0.2)) { + currentQuestionIndex = index + } + } label: { + HStack(spacing: 6) { + if answered { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + } + Text(pillLabel(index: index, question: question)) + .font(.system(size: 12, weight: isActive ? .semibold : .medium)) + .foregroundStyle(isActive || answered ? Color.white : ClaudeTheme.textSecondary) + .lineLimit(1) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(pillColor(isActive: isActive, answered: answered))) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + } + + func pillLabel(index: Int, question: AskUserQuestion.Question) -> String { + if totalQuestions <= 3 { + let title = question.header ?? question.question + return title.count > 22 ? String(title.prefix(20)) + "…" : title + } + return "Q\(index + 1)" + } + + func pillColor(isActive: Bool, answered: Bool) -> Color { + if isActive { return ClaudeTheme.accent } + if answered { return ClaudeTheme.accent.opacity(0.6) } + return ClaudeTheme.surfaceSecondary + } +} + +// MARK: - Question Content + +private extension MobileQuestionSheet { + @ViewBuilder + func questionContent(index: Int, question: AskUserQuestion.Question) -> some View { + VStack(alignment: .leading, spacing: 0) { + questionHeader(question: question) + .padding(.bottom, 14) + Divider() + .padding(.bottom, 14) + if question.multiSelect { + multipleChoiceInput(index: index, question: question) + } else { + singleChoiceInput(index: index, question: question) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .fill(ClaudeTheme.surfacePrimary) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .strokeBorder(ClaudeTheme.border, lineWidth: 0.5) + ) + } + + func questionHeader(question: AskUserQuestion.Question) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: question.multiSelect ? "checklist" : "list.bullet") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + .frame(width: 22, height: 22) + .background(Circle().fill(ClaudeTheme.accentSubtle)) + Text(question.multiSelect ? "Select all that apply" : "Choose one") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textSecondary) + .textCase(.uppercase) + Spacer() + if totalQuestions > 1 { + Text("\(currentQuestionIndex + 1)/\(totalQuestions)") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + .monospacedDigit() + } + } + if let header = question.header, !header.isEmpty { + Text(header) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + .padding(.top, 4) + } + Text(question.question) + .font(.system(size: 14)) + .foregroundStyle(ClaudeTheme.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } + } + + // MARK: Single-choice + + func singleChoiceInput(index: Int, question: AskUserQuestion.Question) -> some View { + let currentSelection: String? = { + if case .singleChoice(let label) = answers[index] { return label } + return nil + }() + let isOtherActive: Bool = { + if case .otherText = answers[index] { return true } + return false + }() + return VStack(spacing: 8) { + ForEach(question.options) { option in + ChoiceRow( + title: option.label, + description: option.description, + isSelected: currentSelection == option.label, + style: .radio + ) { + answers[index] = .singleChoice(option.label) + } + } + ChoiceRow( + title: "Other", + description: nil, + isSelected: isOtherActive, + style: .radio + ) { + answers[index] = .otherText("") + } + if isOtherActive { + otherTextField( + text: Binding( + get: { + if case .otherText(let v) = answers[index] { return v } + return "" + }, + set: { answers[index] = .otherText($0) } + ) + ) + } + } + .disabled(isSubmitting) + } + + // MARK: Multi-choice + + func multipleChoiceInput(index: Int, question: AskUserQuestion.Question) -> some View { + let selected: Set = { + switch answers[index] { + case .multipleChoice(let s): return s + case .multipleChoiceWithOther(let s, _): return s + default: return [] + } + }() + let otherActive: Bool = { + if case .multipleChoiceWithOther = answers[index] { return true } + return false + }() + let otherText: String = { + if case .multipleChoiceWithOther(_, let t) = answers[index] { return t } + return "" + }() + + return VStack(spacing: 8) { + ForEach(question.options) { option in + ChoiceRow( + title: option.label, + description: option.description, + isSelected: selected.contains(option.label), + style: .checkbox + ) { + var newSet = selected + if newSet.contains(option.label) { + newSet.remove(option.label) + } else { + newSet.insert(option.label) + } + if otherActive { + answers[index] = .multipleChoiceWithOther(selectedLabels: newSet, otherText: otherText) + } else { + answers[index] = .multipleChoice(newSet) + } + } + } + ChoiceRow( + title: "Other", + description: nil, + isSelected: otherActive, + style: .checkbox + ) { + if otherActive { + answers[index] = .multipleChoice(selected) + } else { + answers[index] = .multipleChoiceWithOther(selectedLabels: selected, otherText: "") + } + } + if otherActive { + otherTextField( + text: Binding( + get: { otherText }, + set: { answers[index] = .multipleChoiceWithOther(selectedLabels: selected, otherText: $0) } + ) + ) + } + } + .disabled(isSubmitting) + } + + func otherTextField(text: Binding) -> some View { + TextField("Type your answer…", text: text, axis: .vertical) + .lineLimit(2 ... 6) + .textFieldStyle(.plain) + .font(.system(size: 14)) + .padding(12) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(ClaudeTheme.inputBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .strokeBorder(ClaudeTheme.inputBorder, lineWidth: 1) + ) + } + + var fallbackEmpty: some View { + VStack(spacing: 8) { + Text("Malformed question payload") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(ClaudeTheme.statusError) + Text("The desktop sent an AskUserQuestion call this app could not parse.") + .font(.system(size: 13)) + .foregroundStyle(ClaudeTheme.textSecondary) + .multilineTextAlignment(.center) + } + .padding(20) + } +} + +// MARK: - Bottom Action Bar + +private extension MobileQuestionSheet { + var bottomActionBar: some View { + VStack(spacing: 10) { + HStack(spacing: 10) { + if currentQuestionIndex > 0 { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + currentQuestionIndex -= 1 + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + .frame(width: 48, height: 44) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(ClaudeTheme.surfaceSecondary) + ) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + } + Button { + if isLastQuestion || allAnswered { + submit() + } else { + withAnimation(.easeInOut(duration: 0.2)) { + currentQuestionIndex += 1 + } + } + } label: { + HStack(spacing: 8) { + if isSubmitting { + ProgressView() + .controlSize(.small) + .tint(.white) + } + Text(mainButtonLabel) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + if !isSubmitting { + Image(systemName: mainButtonIcon) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + } + } + .frame(maxWidth: .infinity, minHeight: 44) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(submitEnabled ? ClaudeTheme.accent : ClaudeTheme.textTertiary) + ) + } + .buttonStyle(.plain) + .disabled(!submitEnabled) + } + Button(role: .destructive) { + isSubmitting = true + onSkipAll() + dismiss() + } label: { + Text("Skip All Questions") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + .frame(maxWidth: .infinity, minHeight: 32) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 16) + .background( + Rectangle().fill(ClaudeTheme.surfacePrimary) + .overlay(alignment: .top) { + Rectangle().fill(ClaudeTheme.border).frame(height: 0.5) + } + .ignoresSafeArea(edges: .bottom) + ) + } + + var submitEnabled: Bool { + guard !isSubmitting else { return false } + if isLastQuestion || allAnswered { return allAnswered } + return currentAnswerProvided + } + + var mainButtonLabel: String { + (isLastQuestion || allAnswered) ? "Submit" : "Next" + } + + var mainButtonIcon: String { + (isLastQuestion || allAnswered) ? "paperplane.fill" : "arrow.right" + } + + func submit() { + guard allAnswered, !isSubmitting else { return } + isSubmitting = true + UIImpactFeedbackGenerator(style: .light).impactOccurred() + var resolved: [Int: AskUserQuestion.Answer] = [:] + for (index, question) in questions.enumerated() { + guard let local = answers[index] else { continue } + resolved[index] = convert(local: local, multiSelect: question.multiSelect) + } + onSubmit(resolved) + dismiss() + } + + func convert(local: LocalAnswer, multiSelect: Bool) -> AskUserQuestion.Answer { + switch local { + case .singleChoice(let label): + return .single(label) + case .otherText(let text): + return .single("Other: \(text.trimmingCharacters(in: .whitespacesAndNewlines))") + case .multipleChoice(let labels): + return .multi(labels.sorted()) + case .multipleChoiceWithOther(let labels, let text): + var values = labels.sorted() + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + values.append("Other: \(trimmed)") + } + return multiSelect ? .multi(values) : .single(values.joined(separator: ", ")) + } + } +} + +// MARK: - Choice Row + +private struct ChoiceRow: View { + enum Style { + case radio, checkbox + + var selectedIcon: String { + switch self { + case .radio: return "largecircle.fill.circle" + case .checkbox: return "checkmark.square.fill" + } + } + + var deselectedIcon: String { + switch self { + case .radio: return "circle" + case .checkbox: return "square" + } + } + } + + let title: String + let description: String? + let isSelected: Bool + let style: Style + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: isSelected ? style.selectedIcon : style.deselectedIcon) + .font(.system(size: 16)) + .foregroundStyle(isSelected ? ClaudeTheme.accent : ClaudeTheme.textSecondary) + .frame(width: 22, height: 22) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + .multilineTextAlignment(.leading) + if let description, !description.isEmpty { + Text(description) + .font(.system(size: 12)) + .foregroundStyle(ClaudeTheme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(isSelected ? ClaudeTheme.accentSubtle : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .strokeBorder(isSelected ? ClaudeTheme.accent.opacity(0.45) : ClaudeTheme.border, + lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} diff --git a/RxCodeMobile/Views/NewThreadSheet.swift b/RxCodeMobile/Views/NewThreadSheet.swift index f1eca4bb..8ca73030 100644 --- a/RxCodeMobile/Views/NewThreadSheet.swift +++ b/RxCodeMobile/Views/NewThreadSheet.swift @@ -292,23 +292,41 @@ struct NewThreadConfigStrip: View { // MARK: Model - private var availableModels: [AgentModel] { + /// Flat model list synced from the desktop. Used only as a fallback when the + /// paired desktop is too old to send structured `modelSections`. + private var flatModels: [AgentModel] { state.desktopSettings?.availableModels ?? [] } - private var groupedModels: [(provider: AgentProvider, models: [AgentModel])] { + /// Model picker sections mirroring the desktop layout: Claude Code, Codex, + /// and one section per enabled ACP client. Falls back to grouping the flat + /// model list by provider when paired with a desktop that predates + /// `modelSections` in the sync protocol. + private var modelSections: [AgentModelSection] { + if let sections = state.desktopSettings?.modelSections, !sections.isEmpty { + return sections + } var seen: [AgentProvider] = [] - for model in availableModels where !seen.contains(model.provider) { + for model in flatModels where !seen.contains(model.provider) { seen.append(model.provider) } return seen.map { provider in - (provider, availableModels.filter { $0.provider == provider }) + AgentModelSection( + id: provider.rawValue, + title: provider.displayName, + provider: provider, + models: flatModels.filter { $0.provider == provider } + ) } } + private var allModels: [AgentModel] { + modelSections.flatMap(\.models) + } + private var selectedModelLabel: String { guard let settings = state.desktopSettings else { return "Model" } - if let match = availableModels.first(where: { + if let match = allModels.first(where: { $0.provider == settings.selectedAgentProvider && $0.id == settings.selectedModel }) { return match.displayName @@ -318,9 +336,9 @@ struct NewThreadConfigStrip: View { private var modelMenu: some View { Menu { - ForEach(groupedModels, id: \.provider) { group in - Section(group.provider.displayName) { - ForEach(group.models, id: \.key) { model in + ForEach(modelSections) { section in + Section(section.title) { + ForEach(section.models, id: \.key) { model in Button { applyModel(model) } label: { @@ -337,13 +355,13 @@ struct NewThreadConfigStrip: View { } } } - if availableModels.isEmpty { + if allModels.isEmpty { Text("No models available") } } label: { chipLabel(icon: "cpu", title: selectedModelLabel) } - .disabled(availableModels.isEmpty) + .disabled(allModels.isEmpty) } private func applyModel(_ model: AgentModel) { diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift index 993811c6..2f87b503 100644 --- a/RxCodeMobile/Views/RootView.swift +++ b/RxCodeMobile/Views/RootView.swift @@ -48,10 +48,6 @@ struct RootView: View { .onChange(of: state.activeSessionID) { _, newValue in openActiveSession(newValue) } - .onChange(of: selectedSession) { _, newValue in - guard compactClass == .compact, let newValue else { return } - projectsPath.append(newValue) - } } private var phoneTabs: some View { @@ -158,7 +154,7 @@ struct RootView: View { } private func chatDestination(_ sessionID: String) -> some View { - MobileChatView(sessionID: sessionID) + MobileChatView(sessionID: sessionID, onClose: { closeChat() }) .id(sessionID) .toolbar(.hidden, for: .tabBar) .task(id: sessionID) { @@ -168,21 +164,38 @@ struct RootView: View { } } + /// Pop the chat view after its thread is archived or deleted. Compact mode + /// is driven by `projectsPath`; the split view by `selectedSession`. + private func closeChat() { + if compactClass == .compact { + if !projectsPath.isEmpty { projectsPath.removeLast() } + } else { + selectedSession = nil + } + } + + /// Navigate to a session surfaced by the desktop (deep links, notifications, + /// freshly created threads). Compact mode is driven solely by `projectsPath` + /// while the regular split view is driven by `selectedSession`. Keeping the + /// two mechanisms separate avoids pushing the same chat page twice. private func openActiveSession(_ sessionID: String?) { guard let sessionID, let session = state.sessions.first(where: { $0.id == sessionID }) else { return } selectedTab = .projects - selectedProject = session.projectId - selectedSession = sessionID showingBriefing = false if compactClass == .compact { var path = NavigationPath() path.append(session.projectId) path.append(sessionID) - projectsPath = path + if projectsPath != path { + projectsPath = path + } + } else { + selectedProject = session.projectId + selectedSession = sessionID } } } diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index da2ed7e7..539d17a7 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -28,6 +28,11 @@ struct SessionsList: View { @State private var searchText = "" @State private var showingNewThread = false + /// Number of rows currently materialized. The list grows in `pageSize` + /// increments as the user scrolls so we never render every thread at once. + @State private var displayLimit = SessionsList.pageSize + private static let pageSize = 20 + var body: some View { list .navigationTitle("Threads") @@ -53,6 +58,10 @@ struct SessionsList: View { ) .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) @@ -63,22 +72,44 @@ struct SessionsList: View { @ViewBuilder private var list: some View { if usesSelection { - List(filtered, selection: $selected) { session in - sessionLink(session) - } + List(selection: $selected) { sessionRows } } else { - List(filtered) { session in - sessionLink(session) + List { sessionRows } + } + } + + @ViewBuilder + private var sessionRows: some View { + ForEach(visible) { session in + sessionLink(session) + .onAppear { + if session.id == visible.last?.id { loadMore() } + } + } + if displayLimit < filtered.count { + HStack { + Spacer() + ProgressView() + Spacer() } + .listRowSeparator(.hidden) } } + /// The slice of `filtered` currently rendered. + private var visible: [SessionSummary] { + Array(filtered.prefix(displayLimit)) + } + + private func loadMore() { + guard displayLimit < filtered.count else { return } + displayLimit = min(displayLimit + Self.pageSize, filtered.count) + } + private func sessionLink(_ session: SessionSummary) -> some View { NavigationLink(value: session.id) { HStack(spacing: 6) { - if let attention = session.attention { - statusDot(for: attention) - } + leadingDot(for: session) SessionSidebarRow( title: rowTitle(for: session), @@ -90,6 +121,21 @@ struct SessionsList: View { } } + /// 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 diff --git a/icon.icon/Assets/image 2.png b/icon.icon/Assets/image 2.png new file mode 100644 index 00000000..b2f2228c Binary files /dev/null and b/icon.icon/Assets/image 2.png differ diff --git a/icon.icon/Assets/image.png b/icon.icon/Assets/image.png deleted file mode 100644 index 0af94506..00000000 Binary files a/icon.icon/Assets/image.png and /dev/null differ diff --git a/icon.icon/icon.json b/icon.icon/icon.json index 4290945a..a1b5fb6c 100644 --- a/icon.icon/icon.json +++ b/icon.icon/icon.json @@ -1,13 +1,14 @@ { "fill" : { - "solid" : "display-p3:0.00057,0.16509,1.00000,1.00000" + "solid" : "extended-srgb:1.00000,0.55294,0.15686,1.00000" }, "groups" : [ { "layers" : [ { - "image-name" : "image.png", - "name" : "image" + "glass" : true, + "image-name" : "image 2.png", + "name" : "image 2" } ], "shadow" : {