From d1c33ae481760cfb6d0831a62e0cf7eb946d7827 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 02:09:22 +0800 Subject: [PATCH] fix: parallelize mobile-sync fan-out and skip known-offline peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync broadcasts were O(peers × relay-RTT) because every paired device was serviced serially. With 5 paired peers on a remote relay (~150ms RTT) a single change event took close to a second wall-clock, and offline peers still burned a full round-trip per broadcast before the relay returned `delivery_failed`. - broadcastToAllClients: fan out concurrently via TaskGroup. - fanoutPush / fanoutAPNs: same — each push is an independent HTTPS POST so per-device RTT no longer compounds. - broadcastMobileSnapshots: skip peers already known offline; presence flips back to online on the next inbound, so a peer that returns still gets snapshots immediately. Co-Authored-By: Claude Opus 4.7 (1M context) --- .swiftlint.yml | 4 +- .../Sources/MessageList/MessageList.swift | 1 + .../CLISession/CLISessionStore.swift | 60 +- RxCode/App/AppState+CrossProject.swift | 70 --- RxCode/App/AppState+Helpers.swift | 5 +- RxCode/App/AppState+MobileDiff.swift | 200 ++++++ RxCode/App/AppState+MobileSnapshots.swift | 206 +----- RxCode/App/AppState+MobileSync.swift | 199 ------ .../App/AppState+MobileSyncRunProfiles.swift | 209 +++++++ RxCode/App/AppState+StreamHelpers.swift | 76 +++ RxCode/Services/MobileSyncService+Push.swift | 280 +++++++++ RxCode/Services/MobileSyncService.swift | 259 +------- .../Views/MobileChatView+Toolbar.swift | 247 ++++++++ RxCodeMobile/Views/MobileChatView.swift | 589 ++---------------- 14 files changed, 1153 insertions(+), 1252 deletions(-) create mode 100644 RxCode/App/AppState+MobileDiff.swift create mode 100644 RxCode/App/AppState+MobileSyncRunProfiles.swift create mode 100644 RxCode/App/AppState+StreamHelpers.swift create mode 100644 RxCode/Services/MobileSyncService+Push.swift create mode 100644 RxCodeMobile/Views/MobileChatView+Toolbar.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index e223b026..caaba5c1 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -24,6 +24,6 @@ check_for_updates: false reporter: xcode file_length: - warning: 800 - error: 1200 + warning: 600 + error: 800 ignore_comment_only_lines: true diff --git a/Packages/Sources/MessageList/MessageList.swift b/Packages/Sources/MessageList/MessageList.swift index 3c4ede91..1c955b97 100644 --- a/Packages/Sources/MessageList/MessageList.swift +++ b/Packages/Sources/MessageList/MessageList.swift @@ -94,6 +94,7 @@ public struct MessageList: View { } .coordinateSpace(.named(MessageListConstants.coordinateSpaceName)) } + .scrollIndicators(.hidden) .onGeometryChange(for: CGFloat.self) { geometry in geometry.size.height } action: { height in diff --git a/Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift b/Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift index 1ff704f6..fbda9a44 100644 --- a/Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift +++ b/Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift @@ -20,6 +20,11 @@ public actor CLISessionStore { private var cwdIndex: [String: URL] = [:] private var cwdIndexBuiltAt: Date? + /// Per-sid resolved URL cache. Populated lazily when `jsonlURL` falls back + /// to a cross-directory scan (worktree sessions, moved projects, etc.) so + /// the scan only happens once per session per process. + private var sidURLCache: [String: URL] = [:] + /// Per-sid cache of jsonl sniff results, keyed by sid and invalidated by /// file mtime. Avoids re-reading the first ~400 lines of every jsonl every /// time the FS watcher fires (which happens on every assistant turn since @@ -459,6 +464,15 @@ public actor CLISessionStore { } } + /// Resolve a session's on-disk jsonl URL, including the worktree fallback. + /// Returns nil only when no matching `{sid}.jsonl` exists anywhere under + /// `~/.claude/projects/`. Public so callers outside this actor (e.g. + /// disk-reconcile size checks) can route to the same physical file. + public func resolveExistingJsonlURL(sid: String, cwd: String) async -> URL? { + let url = await jsonlURL(sid: sid, cwd: cwd) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } + private func jsonlURL(sid: String, cwd: String) async -> URL { // Fast path: most cwds round-trip cleanly through forward encoding, so // the jsonl is sitting at the predictable path. Probe that first and @@ -471,7 +485,51 @@ public actor CLISessionStore { if FileManager.default.fileExists(atPath: forwardURL.path) { return forwardURL } - return await directory(forCwd: cwd).appendingPathComponent("\(sid).jsonl") + let indexedURL = await directory(forCwd: cwd).appendingPathComponent("\(sid).jsonl") + if FileManager.default.fileExists(atPath: indexedURL.path) { + return indexedURL + } + // Worktree fallback: the session may live under a sibling project + // directory (e.g. the CLI ran inside a git worktree whose cwd doesn't + // match the RxCode project's `path`). Sids are UUIDs, so scanning every + // CLI project subdirectory for `{sid}.jsonl` is unambiguous. Result is + // memoized per-sid so repeat clicks are O(1). + if let resolved = await resolveByScan(sid: sid) { + return resolved + } + // Nothing found — return the forward path so the existing "not found" + // diagnostic logging continues to show the expected location. + return forwardURL + } + + /// Locate a session's jsonl by sid, regardless of which project directory + /// the CLI wrote it under. Used as a worktree-aware fallback when the + /// caller's cwd doesn't match the session's recorded cwd. + private func resolveByScan(sid: String) async -> URL? { + if let cached = sidURLCache[sid], + FileManager.default.fileExists(atPath: cached.path) { + return cached + } + let projectsRoot = CLIProjectsDirectory.url + guard let dirs = try? FileManager.default.contentsOfDirectory( + at: projectsRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: .skipsHiddenFiles + ) else { + return nil + } + let filename = "\(sid).jsonl" + for dir in dirs { + let isDir = (try? dir.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + guard isDir else { continue } + let candidate = dir.appendingPathComponent(filename) + if FileManager.default.fileExists(atPath: candidate.path) { + sidURLCache[sid] = candidate + logger.info("[JsonlURL] resolved-by-scan sid=\(sid, privacy: .public) dir=\(dir.lastPathComponent, privacy: .public)") + return candidate + } + } + return nil } // MARK: - External activity detection (S2) diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index 9c18a538..3655b628 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -960,74 +960,4 @@ extension AppState { } } - func finalizeAgentStream(agentProvider: AgentProvider, streamId: UUID) async { - // Claude needs an explicit stdin close before finalize so the CLI - // sees EOF; other backends manage stdin internally. - if agentProvider == .claudeCode { - await claude.closeStdin(streamId: streamId) - } - await backend(for: agentProvider).finalize(streamId: streamId) - } - - /// Release the per-session IDE-MCP listener allocated for ACP turns. - /// Safe to call for non-ACP providers (no-op). - func releaseIDESession(sessionKey: String) async { - await ideMCPServer.release(sessionKey: sessionKey) - } - - func consumeAgentStderr(agentProvider: AgentProvider, streamId: UUID) async -> String? { - switch agentProvider { - case .claudeCode: - return await claude.consumeStderr(for: streamId) - case .codex: - return await codex.consumeStderr(for: streamId) - case .acp: - return await acp.consumeStderr(for: streamId) - } - } - - // MARK: - Editing File Snapshot Capture - - /// Detects Edit/MultiEdit/Write tool_use events and reads the target - /// file's current contents **synchronously** so the pre-edit snapshot is - /// captured before the CLI starts executing the tool. The result is cached - /// on `state.editingFileSnapshots` keyed by absolute path; only the FIRST - /// tool_use per (session, path) reads — later edits in the same thread - /// keep diffing against the original snapshot. A previous implementation - /// did this read on a detached task, which lost the race for large files - /// and produced an `originalContent` identical to `modifiedContent`, - /// collapsing the diff. Reading synchronously here is safe because we run - /// at content_block_stop / assistant tool_use arrival — the CLI has only - /// just finished emitting the tool_use and has not yet executed it. - /// - /// Handles both the Claude/Anthropic shape (`file_path` in input) and the - /// Codex `changes: [{ path, diff }]` shape. Tools that don't touch files - /// (Bash, Read, etc.) are skipped. - nonisolated static func captureEditingFileSnapshot( - toolName: String, - input: [String: JSONValue], - state: inout SessionStreamState - ) { - let nameLower = toolName.lowercased() - let editToolNames: Set = ["edit", "multiedit", "multi_edit", "write"] - guard editToolNames.contains(nameLower) else { return } - - var paths: [String] = [] - if let path = input["file_path"]?.stringValue { - paths.append(path) - } - if nameLower == "edit", let changes = input["changes"]?.arrayValue { - for change in changes { - if let obj = change.objectValue, - let path = obj["path"]?.stringValue { - paths.append(path) - } - } - } - - for path in paths where state.editingFileSnapshots[path] == nil { - state.editingFileSnapshots[path] = try? String(contentsOfFile: path, encoding: .utf8) - } - } - } diff --git a/RxCode/App/AppState+Helpers.swift b/RxCode/App/AppState+Helpers.swift index 966a2b74..f619ec08 100644 --- a/RxCode/App/AppState+Helpers.swift +++ b/RxCode/App/AppState+Helpers.swift @@ -144,8 +144,9 @@ extension AppState { Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } - let url = await self.cliStore.directory(forCwd: cwd) - .appendingPathComponent("\(sessionId).jsonl") + let resolvedURL = await self.cliStore.resolveExistingJsonlURL(sid: sessionId, cwd: cwd) + let fallbackURL = await self.cliStore.directory(forCwd: cwd).appendingPathComponent("\(sessionId).jsonl") + let url = resolvedURL ?? fallbackURL let currentSize: UInt64? = ((try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int) .flatMap(UInt64.init(exactly:)) if let lastSize, let currentSize, currentSize <= lastSize { return } diff --git a/RxCode/App/AppState+MobileDiff.swift b/RxCode/App/AppState+MobileDiff.swift new file mode 100644 index 00000000..78c3cd10 --- /dev/null +++ b/RxCode/App/AppState+MobileDiff.swift @@ -0,0 +1,200 @@ +import Foundation +import RxCodeChatKit +import RxCodeCore +import RxCodeSync + +// MARK: - Mobile File Diff & Snapshot Budget Helpers + +extension AppState { + /// Strips per-file `originalContent` / `modifiedContent` once the running + /// total of attached snapshots would push the encoded reply past the + /// relay's 10 MiB envelope cap. Order is preserved so earlier files in the + /// thread keep their snapshot-pair diff; later files fall back to the + /// `fullFileDiff` already attached on each `SyncFileEdit`. + nonisolated static func applySnapshotBudget(to edits: [SyncFileEdit]) -> [SyncFileEdit] { + // Conservative budget — leaves headroom for hunks, full-file diffs, + // uncommitted git changes, JSON escaping, and base64 envelope overhead. + let totalBudget = 4 * 1024 * 1024 + let perFileCap = 512 * 1024 + var remaining = totalBudget + return edits.map { edit in + let pairSize = (edit.originalContent?.utf8.count ?? 0) + + (edit.modifiedContent?.utf8.count ?? 0) + guard pairSize > 0, pairSize <= perFileCap, pairSize <= remaining else { + return SyncFileEdit( + path: edit.path, + name: edit.name, + containsWrite: edit.containsWrite, + hunks: edit.hunks, + fullFileDiff: edit.fullFileDiff, + originalContent: nil, + modifiedContent: nil + ) + } + remaining -= pairSize + return edit + } + } + + nonisolated static func mobileFullFileDiff( + path: String, + hunks: [PreviewFile.EditHunk], + originalContent: String? + ) async -> String? { + await Task.detached(priority: .utility) { () -> String? in + guard let currentContent = try? String(contentsOfFile: path, encoding: .utf8) else { + return nil + } + if let originalContent { + return snapshotDiff(original: originalContent, current: currentContent) + } + return fullFileDiff(currentContent: currentContent, hunks: hunks) + }.value + } + + nonisolated static func fullFileDiff(currentContent: String, hunks: [PreviewFile.EditHunk]) -> String { + currentContentDiff(currentContent: currentContent, hunks: hunks) + } + + /// Build a unified diff string from a pre-edit snapshot and the file's + /// current content. Mirrors `FileDiffView.buildSnapshotDiffLines` on + /// macOS but emits the wire-format string the mobile renderer parses. + nonisolated static func snapshotDiff(original: String, current: String) -> String { + let oldLines = contentLines(original) + let newLines = contentLines(current) + let diff = newLines.difference(from: oldLines) + var removalIndices = Set() + var insertionElements: [Int: String] = [:] + for change in diff { + switch change { + case .remove(let offset, _, _): + removalIndices.insert(offset) + case .insert(let offset, let element, _): + insertionElements[offset] = element + } + } + + var lines: [String] = [] + var oldIdx = 0 + var newIdx = 0 + while oldIdx < oldLines.count || newIdx < newLines.count { + if oldIdx < oldLines.count, removalIndices.contains(oldIdx) { + lines.append("-\(oldLines[oldIdx])") + oldIdx += 1 + } else if newIdx < newLines.count, let added = insertionElements[newIdx] { + lines.append("+\(added)") + newIdx += 1 + } else if oldIdx < oldLines.count, newIdx < newLines.count { + lines.append(" \(newLines[newIdx])") + oldIdx += 1 + newIdx += 1 + } else if oldIdx < oldLines.count { + lines.append(" \(oldLines[oldIdx])") + oldIdx += 1 + } else if newIdx < newLines.count { + lines.append(" \(newLines[newIdx])") + newIdx += 1 + } + } + return lines.joined(separator: "\n") + } + + nonisolated static func currentContentDiff( + currentContent: String, + hunks: [PreviewFile.EditHunk] + ) -> String { + var lines = contentLines(currentContent).map { " \($0)" } + guard !lines.isEmpty else { + return hunks.flatMap { hunk in + contentLines(hunk.oldString).map { "-\($0)" } + contentLines(hunk.newString).map { "+\($0)" } + }.joined(separator: "\n") + } + + for hunk in hunks { + let addedLines = contentLines(hunk.newString) + guard !addedLines.isEmpty else { continue } + let matchStart = firstRange(of: addedLines, in: lines.map(contentWithoutDiffMarker)) + guard let matchStart else { continue } + + let removedLines = contentLines(hunk.oldString) + if !removedLines.isEmpty { + lines.insert(contentsOf: removedLines.map { "-\($0)" }, at: matchStart) + } + + let addedStart = matchStart + removedLines.count + for offset in addedLines.indices where addedStart + offset < lines.count { + lines[addedStart + offset] = "+\(addedLines[offset])" + } + } + + return lines.joined(separator: "\n") + } + + nonisolated static func firstRange(of needle: [String], in haystack: [String]) -> Int? { + guard !needle.isEmpty, needle.count <= haystack.count else { return nil } + for start in 0...(haystack.count - needle.count) { + if Array(haystack[start..<(start + needle.count)]) == needle { + return start + } + } + return nil + } + + nonisolated static func contentWithoutDiffMarker(_ text: String) -> String { + guard let first = text.first, first == " " || first == "+" || first == "-" else { + return text + } + return String(text.dropFirst()) + } + + nonisolated static func contentLines(_ content: String) -> [String] { + guard !content.isEmpty else { return [] } + var lines = content.components(separatedBy: "\n") + if lines.last == "" { lines.removeLast() } + return lines + } +} + +// MARK: - Remote File Preview + +enum MobileRemoteFileReadError: LocalizedError { + case unreadable + case binary + + var errorDescription: String? { + switch self { + case .unreadable: + return "File could not be opened." + case .binary: + return "Binary file — preview not available." + } + } +} + +extension AppState { + nonisolated static func isPath(_ path: String, underAnyProject projectPaths: [String]) -> Bool { + let standardizedPath = URL(fileURLWithPath: path).standardizedFileURL.path + return projectPaths.contains { projectPath in + let root = URL(fileURLWithPath: projectPath).standardizedFileURL.path + return standardizedPath == root || standardizedPath.hasPrefix(root + "/") + } + } + + nonisolated static func readMobilePreviewFile(path: String) throws -> (content: String, truncated: Bool) { + let attributes = try FileManager.default.attributesOfItem(atPath: path) + let size = (attributes[.size] as? Int) ?? 0 + let byteLimit = 1_500_000 + let truncated = size > byteLimit + + guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { + throw MobileRemoteFileReadError.unreadable + } + defer { try? handle.close() } + + let data = try handle.read(upToCount: min(size, byteLimit)) ?? Data() + guard let content = String(data: data, encoding: .utf8) else { + throw MobileRemoteFileReadError.binary + } + return (content, truncated) + } +} diff --git a/RxCode/App/AppState+MobileSnapshots.swift b/RxCode/App/AppState+MobileSnapshots.swift index b1f9c12f..283d8404 100644 --- a/RxCode/App/AppState+MobileSnapshots.swift +++ b/RxCode/App/AppState+MobileSnapshots.swift @@ -18,8 +18,18 @@ extension AppState { } func broadcastMobileSnapshots() async { - for device in MobileSyncService.shared.pairedDevices { - await sendMobileSnapshot(toHex: device.pubkeyHex, activeSessionID: nil) + // Skip peers the relay has already told us are offline — sending to + // them costs a round-trip just to get back `delivery_failed`, and on + // remote relays that RTT (200ms+) adds up fast across 5+ peers. + // Presence flips back to `.online` the moment any inbound payload + // arrives, so a peer that comes back gets snapshots again + // immediately. + let targets = MobileSyncService.shared.pairedDevices + .filter { $0.onlineState != .offline } + .map(\.pubkeyHex) + guard !targets.isEmpty else { return } + for hex in targets { + await sendMobileSnapshot(toHex: hex, activeSessionID: nil) } } @@ -838,185 +848,6 @@ extension AppState { } } - nonisolated private static func isPath(_ path: String, underAnyProject projectPaths: [String]) -> Bool { - let standardizedPath = URL(fileURLWithPath: path).standardizedFileURL.path - return projectPaths.contains { projectPath in - let root = URL(fileURLWithPath: projectPath).standardizedFileURL.path - return standardizedPath == root || standardizedPath.hasPrefix(root + "/") - } - } - - nonisolated private static func readMobilePreviewFile(path: String) throws -> (content: String, truncated: Bool) { - let attributes = try FileManager.default.attributesOfItem(atPath: path) - let size = (attributes[.size] as? Int) ?? 0 - let byteLimit = 1_500_000 - let truncated = size > byteLimit - - guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { - throw MobileRemoteFileReadError.unreadable - } - defer { try? handle.close() } - - let data = try handle.read(upToCount: min(size, byteLimit)) ?? Data() - guard let content = String(data: data, encoding: .utf8) else { - throw MobileRemoteFileReadError.binary - } - return (content, truncated) - } - - /// Strips per-file `originalContent` / `modifiedContent` once the running - /// total of attached snapshots would push the encoded reply past the - /// relay's 10 MiB envelope cap. Order is preserved so earlier files in the - /// thread keep their snapshot-pair diff; later files fall back to the - /// `fullFileDiff` already attached on each `SyncFileEdit`. - nonisolated private static func applySnapshotBudget(to edits: [SyncFileEdit]) -> [SyncFileEdit] { - // Conservative budget — leaves headroom for hunks, full-file diffs, - // uncommitted git changes, JSON escaping, and base64 envelope overhead. - let totalBudget = 4 * 1024 * 1024 - let perFileCap = 512 * 1024 - var remaining = totalBudget - return edits.map { edit in - let pairSize = (edit.originalContent?.utf8.count ?? 0) - + (edit.modifiedContent?.utf8.count ?? 0) - guard pairSize > 0, pairSize <= perFileCap, pairSize <= remaining else { - return SyncFileEdit( - path: edit.path, - name: edit.name, - containsWrite: edit.containsWrite, - hunks: edit.hunks, - fullFileDiff: edit.fullFileDiff, - originalContent: nil, - modifiedContent: nil - ) - } - remaining -= pairSize - return edit - } - } - - nonisolated private static func mobileFullFileDiff( - path: String, - hunks: [PreviewFile.EditHunk], - originalContent: String? - ) async -> String? { - await Task.detached(priority: .utility) { () -> String? in - guard let currentContent = try? String(contentsOfFile: path, encoding: .utf8) else { - return nil - } - // When the thread captured a pre-edit snapshot, diff that against - // the current file directly. This produces a correct diff for - // sequences that hunk reverse-apply can't handle — e.g. - // edit-then-revert where the inserted lines no longer appear on - // disk, which otherwise rendered as a plain context-only dump. - if let originalContent { - return snapshotDiff(original: originalContent, current: currentContent) - } - return fullFileDiff(currentContent: currentContent, hunks: hunks) - }.value - } - - nonisolated private static func fullFileDiff(currentContent: String, hunks: [PreviewFile.EditHunk]) -> String { - return currentContentDiff(currentContent: currentContent, hunks: hunks) - } - - /// Build a unified diff string from a pre-edit snapshot and the file's - /// current content. Mirrors `FileDiffView.buildSnapshotDiffLines` on - /// macOS but emits the wire-format string the mobile renderer parses. - nonisolated private static func snapshotDiff(original: String, current: String) -> String { - let oldLines = contentLines(original) - let newLines = contentLines(current) - let diff = newLines.difference(from: oldLines) - var removalIndices = Set() - var insertionElements: [Int: String] = [:] - for change in diff { - switch change { - case .remove(let offset, _, _): - removalIndices.insert(offset) - case .insert(let offset, let element, _): - insertionElements[offset] = element - } - } - - var lines: [String] = [] - var oldIdx = 0 - var newIdx = 0 - while oldIdx < oldLines.count || newIdx < newLines.count { - if oldIdx < oldLines.count, removalIndices.contains(oldIdx) { - lines.append("-\(oldLines[oldIdx])") - oldIdx += 1 - } else if newIdx < newLines.count, let added = insertionElements[newIdx] { - lines.append("+\(added)") - newIdx += 1 - } else if oldIdx < oldLines.count, newIdx < newLines.count { - lines.append(" \(newLines[newIdx])") - oldIdx += 1 - newIdx += 1 - } else if oldIdx < oldLines.count { - lines.append(" \(oldLines[oldIdx])") - oldIdx += 1 - } else if newIdx < newLines.count { - lines.append(" \(newLines[newIdx])") - newIdx += 1 - } - } - return lines.joined(separator: "\n") - } - - nonisolated private static func currentContentDiff( - currentContent: String, - hunks: [PreviewFile.EditHunk] - ) -> String { - var lines = contentLines(currentContent).map { " \($0)" } - guard !lines.isEmpty else { - return hunks.flatMap { hunk in - contentLines(hunk.oldString).map { "-\($0)" } + contentLines(hunk.newString).map { "+\($0)" } - }.joined(separator: "\n") - } - - for hunk in hunks { - let addedLines = contentLines(hunk.newString) - guard !addedLines.isEmpty else { continue } - let matchStart = firstRange(of: addedLines, in: lines.map(contentWithoutDiffMarker)) - guard let matchStart else { continue } - - let removedLines = contentLines(hunk.oldString) - if !removedLines.isEmpty { - lines.insert(contentsOf: removedLines.map { "-\($0)" }, at: matchStart) - } - - let addedStart = matchStart + removedLines.count - for offset in addedLines.indices where addedStart + offset < lines.count { - lines[addedStart + offset] = "+\(addedLines[offset])" - } - } - - return lines.joined(separator: "\n") - } - - nonisolated private static func firstRange(of needle: [String], in haystack: [String]) -> Int? { - guard !needle.isEmpty, needle.count <= haystack.count else { return nil } - for start in 0...(haystack.count - needle.count) { - if Array(haystack[start..<(start + needle.count)]) == needle { - return start - } - } - return nil - } - - nonisolated private static func contentWithoutDiffMarker(_ text: String) -> String { - guard let first = text.first, first == " " || first == "+" || first == "-" else { - return text - } - return String(text.dropFirst()) - } - - nonisolated private static func contentLines(_ content: String) -> [String] { - guard !content.isEmpty else { return [] } - var lines = content.components(separatedBy: "\n") - if lines.last == "" { lines.removeLast() } - return lines - } - /// User-triggered full reindex of every thread. Wipes cached embeddings, /// then re-embeds every thread. Updates `reindexProgress` so the UI can /// render a counter. @@ -1041,16 +872,3 @@ extension AppState { } -private enum MobileRemoteFileReadError: LocalizedError { - case unreadable - case binary - - var errorDescription: String? { - switch self { - case .unreadable: - return "File could not be opened." - case .binary: - return "Binary file — preview not available." - } - } -} diff --git a/RxCode/App/AppState+MobileSync.swift b/RxCode/App/AppState+MobileSync.swift index bc0aa946..1852ca8e 100644 --- a/RxCode/App/AppState+MobileSync.swift +++ b/RxCode/App/AppState+MobileSync.swift @@ -691,203 +691,4 @@ extension AppState { await MobileSyncService.shared.send(.createProjectResult(result), toHex: hex) } - func handleMobileRunProfileMutation( - _ request: RunProfileMutationRequestPayload, - fromHex: String - ) async { - logger.info("[MobileSync] handling run profile mutation operation=\(request.operation.rawValue, privacy: .public) project=\(request.projectID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") - guard projects.contains(where: { $0.id == request.projectID }) else { - logger.error("[MobileSync] run profile mutation rejected unknown project=\(request.projectID.uuidString, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: false, - errorMessage: "Project not found on desktop.", - task: nil, - toHex: fromHex - ) - return - } - - await ensureRunProfilesLoaded(for: request.projectID) - var profiles = runProfiles(for: request.projectID) - let now = Date() - - switch request.operation { - case .upsert: - guard var profile = request.profile else { - logger.error("[MobileSync] run profile upsert rejected missing payload project=\(request.projectID.uuidString, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: false, - errorMessage: "Profile payload is missing.", - task: nil, - toHex: fromHex - ) - return - } - profile.projectId = request.projectID - profile.updatedAt = now - if let idx = profiles.firstIndex(where: { $0.id == profile.id }) { - profiles[idx] = profile - } else { - profile.createdAt = now - profiles.append(profile) - } - case .delete: - guard let profileID = request.profileID else { - logger.error("[MobileSync] run profile delete rejected missing profile id project=\(request.projectID.uuidString, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: false, - errorMessage: "Profile id is missing.", - task: nil, - toHex: fromHex - ) - return - } - profiles.removeAll { $0.id == profileID } - } - - setRunProfiles(profiles, for: request.projectID) - logger.info("[MobileSync] run profile mutation applied project=\(request.projectID.uuidString, privacy: .public) count=\(profiles.count, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: true, - errorMessage: nil, - task: nil, - toHex: fromHex - ) - } - - func handleMobileRunProfileRun( - _ request: RunProfileRunRequestPayload, - fromHex: String - ) async { - logger.info("[MobileSync] handling run profile run project=\(request.projectID.uuidString, privacy: .public) profile=\(request.profileID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") - guard let project = projects.first(where: { $0.id == request.projectID }) else { - logger.error("[MobileSync] run profile run rejected unknown project=\(request.projectID.uuidString, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: false, - errorMessage: "Project not found on desktop.", - task: nil, - toHex: fromHex - ) - return - } - - await ensureRunProfilesLoaded(for: request.projectID) - guard let profile = runProfiles(for: request.projectID).first(where: { $0.id == request.profileID }) else { - logger.error("[MobileSync] run profile run rejected missing profile=\(request.profileID.uuidString, privacy: .public) project=\(request.projectID.uuidString, privacy: .public) knownProfiles=\(self.runProfiles(for: request.projectID).count, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: false, - errorMessage: "Run profile not found on desktop.", - task: nil, - toHex: fromHex - ) - return - } - - let task = runService.start(profile: profile, project: project) - logger.info("[MobileSync] run profile started task=\(task.id.uuidString, privacy: .public) profile=\(profile.name, privacy: .public) project=\(project.id.uuidString, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: true, - errorMessage: nil, - task: mobileRunTaskSnapshot(task), - toHex: fromHex - ) - } - - func handleMobileRunProfileStop( - _ request: RunProfileStopRequestPayload, - fromHex: String - ) async { - logger.info("[MobileSync] handling run profile stop task=\(request.taskID?.uuidString ?? "", privacy: .public) project=\(request.projectID?.uuidString ?? "", privacy: .public) profile=\(request.profileID?.uuidString ?? "", privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") - let stoppedTask: RunTask? - if let taskID = request.taskID { - stoppedTask = runService.task(id: taskID) - runService.stop(taskId: taskID) - } else if let projectID = request.projectID, let profileID = request.profileID { - stoppedTask = runService.activeTasks.first { - $0.project.id == projectID && $0.profile.id == profileID - } - if let stoppedTask { - runService.stop(taskId: stoppedTask.id) - } - } else { - stoppedTask = nil - } - - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID ?? stoppedTask?.project.id ?? UUID(), - ok: stoppedTask != nil, - errorMessage: stoppedTask == nil ? "No matching running task was found." : nil, - task: stoppedTask.map(mobileRunTaskSnapshot), - toHex: fromHex - ) - } - - func replyRunProfileResult( - requestID: UUID, - projectID: UUID, - ok: Bool, - errorMessage: String?, - task: MobileRunTaskSnapshot?, - toHex hex: String - ) async { - await ensureRunProfilesLoaded(for: projectID) - logger.info("[MobileSync] replying run profile result ok=\(ok, privacy: .public) project=\(projectID.uuidString, privacy: .public) profiles=\(self.runProfiles(for: projectID).count, privacy: .public) task=\(task?.taskId.uuidString ?? "", privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public) error=\(errorMessage ?? "", privacy: .public)") - let result = RunProfileResultPayload( - clientRequestID: requestID, - projectID: projectID, - ok: ok, - errorMessage: errorMessage, - profiles: runProfiles(for: projectID), - task: task - ) - await MobileSyncService.shared.send(.runProfileResult(result), toHex: hex) - if ok { scheduleMobileSnapshotBroadcast() } - } - - /// Scan a project for runnable Xcode schemes, npm scripts, and Make targets - /// on behalf of a paired mobile device. Detection logic lives entirely on - /// the desktop — mobile only displays the result. - func handleMobileRunnableDetectRequest( - _ request: RunnableDetectRequestPayload, - fromHex: String - ) async { - logger.info("[MobileSync] handling runnable detection project=\(request.projectID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") - guard let project = projects.first(where: { $0.id == request.projectID }) else { - logger.error("[MobileSync] runnable detection rejected unknown project=\(request.projectID.uuidString, privacy: .public)") - let result = RunnableDetectResultPayload( - clientRequestID: request.clientRequestID, - projectID: request.projectID, - ok: false, - errorMessage: "Project not found on desktop." - ) - await MobileSyncService.shared.send(.runnableDetectResult(result), toHex: fromHex) - return - } - - let detected = await RunProfileDetector().detect(in: project.path) - logger.info("[MobileSync] runnable detection complete project=\(request.projectID.uuidString, privacy: .public) xcode=\(detected.xcode.count, privacy: .public) npm=\(detected.npm.count, privacy: .public) make=\(detected.make.count, privacy: .public)") - let result = RunnableDetectResultPayload( - clientRequestID: request.clientRequestID, - projectID: request.projectID, - ok: true, - detected: detected - ) - await MobileSyncService.shared.send(.runnableDetectResult(result), toHex: fromHex) - } - } diff --git a/RxCode/App/AppState+MobileSyncRunProfiles.swift b/RxCode/App/AppState+MobileSyncRunProfiles.swift new file mode 100644 index 00000000..375c4d9b --- /dev/null +++ b/RxCode/App/AppState+MobileSyncRunProfiles.swift @@ -0,0 +1,209 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +// MARK: - Mobile Sync — Run Profile & Runnable Detection Handlers + +extension AppState { + func handleMobileRunProfileMutation( + _ request: RunProfileMutationRequestPayload, + fromHex: String + ) async { + logger.info("[MobileSync] handling run profile mutation operation=\(request.operation.rawValue, privacy: .public) project=\(request.projectID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") + guard projects.contains(where: { $0.id == request.projectID }) else { + logger.error("[MobileSync] run profile mutation rejected unknown project=\(request.projectID.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Project not found on desktop.", + task: nil, + toHex: fromHex + ) + return + } + + await ensureRunProfilesLoaded(for: request.projectID) + var profiles = runProfiles(for: request.projectID) + let now = Date() + + switch request.operation { + case .upsert: + guard var profile = request.profile else { + logger.error("[MobileSync] run profile upsert rejected missing payload project=\(request.projectID.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Profile payload is missing.", + task: nil, + toHex: fromHex + ) + return + } + profile.projectId = request.projectID + profile.updatedAt = now + if let idx = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[idx] = profile + } else { + profile.createdAt = now + profiles.append(profile) + } + case .delete: + guard let profileID = request.profileID else { + logger.error("[MobileSync] run profile delete rejected missing profile id project=\(request.projectID.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Profile id is missing.", + task: nil, + toHex: fromHex + ) + return + } + profiles.removeAll { $0.id == profileID } + } + + setRunProfiles(profiles, for: request.projectID) + logger.info("[MobileSync] run profile mutation applied project=\(request.projectID.uuidString, privacy: .public) count=\(profiles.count, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: true, + errorMessage: nil, + task: nil, + toHex: fromHex + ) + } + + func handleMobileRunProfileRun( + _ request: RunProfileRunRequestPayload, + fromHex: String + ) async { + logger.info("[MobileSync] handling run profile run project=\(request.projectID.uuidString, privacy: .public) profile=\(request.profileID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") + guard let project = projects.first(where: { $0.id == request.projectID }) else { + logger.error("[MobileSync] run profile run rejected unknown project=\(request.projectID.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Project not found on desktop.", + task: nil, + toHex: fromHex + ) + return + } + + await ensureRunProfilesLoaded(for: request.projectID) + guard let profile = runProfiles(for: request.projectID).first(where: { $0.id == request.profileID }) else { + logger.error("[MobileSync] run profile run rejected missing profile=\(request.profileID.uuidString, privacy: .public) project=\(request.projectID.uuidString, privacy: .public) knownProfiles=\(self.runProfiles(for: request.projectID).count, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Run profile not found on desktop.", + task: nil, + toHex: fromHex + ) + return + } + + let task = runService.start(profile: profile, project: project) + logger.info("[MobileSync] run profile started task=\(task.id.uuidString, privacy: .public) profile=\(profile.name, privacy: .public) project=\(project.id.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: true, + errorMessage: nil, + task: mobileRunTaskSnapshot(task), + toHex: fromHex + ) + } + + func handleMobileRunProfileStop( + _ request: RunProfileStopRequestPayload, + fromHex: String + ) async { + logger.info("[MobileSync] handling run profile stop task=\(request.taskID?.uuidString ?? "", privacy: .public) project=\(request.projectID?.uuidString ?? "", privacy: .public) profile=\(request.profileID?.uuidString ?? "", privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") + let stoppedTask: RunTask? + if let taskID = request.taskID { + stoppedTask = runService.task(id: taskID) + runService.stop(taskId: taskID) + } else if let projectID = request.projectID, let profileID = request.profileID { + stoppedTask = runService.activeTasks.first { + $0.project.id == projectID && $0.profile.id == profileID + } + if let stoppedTask { + runService.stop(taskId: stoppedTask.id) + } + } else { + stoppedTask = nil + } + + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID ?? stoppedTask?.project.id ?? UUID(), + ok: stoppedTask != nil, + errorMessage: stoppedTask == nil ? "No matching running task was found." : nil, + task: stoppedTask.map(mobileRunTaskSnapshot), + toHex: fromHex + ) + } + + func replyRunProfileResult( + requestID: UUID, + projectID: UUID, + ok: Bool, + errorMessage: String?, + task: MobileRunTaskSnapshot?, + toHex hex: String + ) async { + await ensureRunProfilesLoaded(for: projectID) + logger.info("[MobileSync] replying run profile result ok=\(ok, privacy: .public) project=\(projectID.uuidString, privacy: .public) profiles=\(self.runProfiles(for: projectID).count, privacy: .public) task=\(task?.taskId.uuidString ?? "", privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public) error=\(errorMessage ?? "", privacy: .public)") + let result = RunProfileResultPayload( + clientRequestID: requestID, + projectID: projectID, + ok: ok, + errorMessage: errorMessage, + profiles: runProfiles(for: projectID), + task: task + ) + await MobileSyncService.shared.send(.runProfileResult(result), toHex: hex) + if ok { scheduleMobileSnapshotBroadcast() } + } + + /// Scan a project for runnable Xcode schemes, npm scripts, and Make targets + /// on behalf of a paired mobile device. Detection logic lives entirely on + /// the desktop — mobile only displays the result. + func handleMobileRunnableDetectRequest( + _ request: RunnableDetectRequestPayload, + fromHex: String + ) async { + logger.info("[MobileSync] handling runnable detection project=\(request.projectID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") + guard let project = projects.first(where: { $0.id == request.projectID }) else { + logger.error("[MobileSync] runnable detection rejected unknown project=\(request.projectID.uuidString, privacy: .public)") + let result = RunnableDetectResultPayload( + clientRequestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Project not found on desktop." + ) + await MobileSyncService.shared.send(.runnableDetectResult(result), toHex: fromHex) + return + } + + let detected = await RunProfileDetector().detect(in: project.path) + logger.info("[MobileSync] runnable detection complete project=\(request.projectID.uuidString, privacy: .public) xcode=\(detected.xcode.count, privacy: .public) npm=\(detected.npm.count, privacy: .public) make=\(detected.make.count, privacy: .public)") + let result = RunnableDetectResultPayload( + clientRequestID: request.clientRequestID, + projectID: request.projectID, + ok: true, + detected: detected + ) + await MobileSyncService.shared.send(.runnableDetectResult(result), toHex: fromHex) + } +} diff --git a/RxCode/App/AppState+StreamHelpers.swift b/RxCode/App/AppState+StreamHelpers.swift new file mode 100644 index 00000000..c5b00a55 --- /dev/null +++ b/RxCode/App/AppState+StreamHelpers.swift @@ -0,0 +1,76 @@ +import Foundation +import RxCodeChatKit +import RxCodeCore +import RxCodeSync + +extension AppState { + func finalizeAgentStream(agentProvider: AgentProvider, streamId: UUID) async { + // Claude needs an explicit stdin close before finalize so the CLI + // sees EOF; other backends manage stdin internally. + if agentProvider == .claudeCode { + await claude.closeStdin(streamId: streamId) + } + await backend(for: agentProvider).finalize(streamId: streamId) + } + + /// Release the per-session IDE-MCP listener allocated for ACP turns. + /// Safe to call for non-ACP providers (no-op). + func releaseIDESession(sessionKey: String) async { + await ideMCPServer.release(sessionKey: sessionKey) + } + + func consumeAgentStderr(agentProvider: AgentProvider, streamId: UUID) async -> String? { + switch agentProvider { + case .claudeCode: + return await claude.consumeStderr(for: streamId) + case .codex: + return await codex.consumeStderr(for: streamId) + case .acp: + return await acp.consumeStderr(for: streamId) + } + } + + // MARK: - Editing File Snapshot Capture + + /// Detects Edit/MultiEdit/Write tool_use events and reads the target + /// file's current contents **synchronously** so the pre-edit snapshot is + /// captured before the CLI starts executing the tool. The result is cached + /// on `state.editingFileSnapshots` keyed by absolute path; only the FIRST + /// tool_use per (session, path) reads — later edits in the same thread + /// keep diffing against the original snapshot. A previous implementation + /// did this read on a detached task, which lost the race for large files + /// and produced an `originalContent` identical to `modifiedContent`, + /// collapsing the diff. Reading synchronously here is safe because we run + /// at content_block_stop / assistant tool_use arrival — the CLI has only + /// just finished emitting the tool_use and has not yet executed it. + /// + /// Handles both the Claude/Anthropic shape (`file_path` in input) and the + /// Codex `changes: [{ path, diff }]` shape. Tools that don't touch files + /// (Bash, Read, etc.) are skipped. + nonisolated static func captureEditingFileSnapshot( + toolName: String, + input: [String: JSONValue], + state: inout SessionStreamState + ) { + let nameLower = toolName.lowercased() + let editToolNames: Set = ["edit", "multiedit", "multi_edit", "write"] + guard editToolNames.contains(nameLower) else { return } + + var paths: [String] = [] + if let path = input["file_path"]?.stringValue { + paths.append(path) + } + if nameLower == "edit", let changes = input["changes"]?.arrayValue { + for change in changes { + if let obj = change.objectValue, + let path = obj["path"]?.stringValue { + paths.append(path) + } + } + } + + for path in paths where state.editingFileSnapshots[path] == nil { + state.editingFileSnapshots[path] = try? String(contentsOfFile: path, encoding: .utf8) + } + } +} diff --git a/RxCode/Services/MobileSyncService+Push.swift b/RxCode/Services/MobileSyncService+Push.swift new file mode 100644 index 00000000..7969346c --- /dev/null +++ b/RxCode/Services/MobileSyncService+Push.swift @@ -0,0 +1,280 @@ +import Foundation +import os.log +import RxCodeCore +import RxCodeSync + +// MARK: - APNs / FCM Push Fan-out + +extension MobileSyncService { + func fanoutPush(_ payload: NotificationPayload) async { + let devices = pairedDevices.filter { Self.pushToken(for: $0)?.isEmpty == false } + guard !devices.isEmpty else { return } + + // Each push is an independent HTTPS POST to the relay's `/push` + // endpoint. On a remote relay, RTT dominates (100-300ms per device), + // so serial fan-out scales linearly with paired-device count. Run + // them concurrently — the per-device error path already swallows + // failures, so one slow/failing device cannot poison the rest. + struct Target { + let device: PairedDevice + let pushURL: URL + let provider: String + } + var targets: [Target] = [] + for device in devices { + guard let relayURLString = device.relayURL, + let relayURL = URL(string: relayURLString), + let pushURL = Self.pushEndpointURL(from: relayURL) else { + logger.error("[Push] cannot derive push endpoint for device=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + continue + } + targets.append(Target(device: device, pushURL: pushURL, provider: Self.pushProvider(for: device))) + } + + let logger = self.logger + await withTaskGroup(of: Void.self) { group in + for target in targets { + group.addTask { [weak self] in + guard let self else { return } + do { + switch target.provider { + case "fcm": + try await self.sendFCMPush(payload, to: target.device, pushURL: target.pushURL) + default: + try await self.sendAPNsPush(payload, to: target.device, pushURL: target.pushURL) + } + } catch { + logger.error("[Push] fan-out failed provider=\(target.provider, privacy: .public) deviceKey=\(String(target.device.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + } + } + } + + /// 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. + /// + /// Each device's push is an independent HTTPS POST; run them concurrently + /// so a slow remote-relay RTT for one device does not stall the others. + func fanoutAPNs(_ payload: NotificationPayload) async { + let devices = pairedDevices.filter { ($0.apnsToken?.isEmpty == false) } + guard !devices.isEmpty else { return } + + var targets: [(device: PairedDevice, pushURL: URL)] = [] + for device in devices { + guard let relayURLString = device.relayURL, + let relayURL = URL(string: relayURLString), + let pushURL = Self.pushEndpointURL(from: relayURL) else { + logger.error("[APNs] cannot derive push endpoint for device=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + continue + } + targets.append((device, pushURL)) + } + + let logger = self.logger + await withTaskGroup(of: Void.self) { group in + for target in targets { + group.addTask { [weak self] in + guard let self else { return } + do { + try await self.sendAPNsPush(payload, to: target.device, pushURL: target.pushURL) + } catch { + logger.error("[APNs] fan-out failed deviceKey=\(String(target.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. + func sendAPNsPush(_ payload: NotificationPayload, to device: PairedDevice, pushURL: URL) async throws { + guard let token = Self.apnsToken(for: device), !token.isEmpty else { + throw MobilePushError.missingDeviceToken + } + let deviceClient = clientForDevice(device) + guard let peer = await deviceClient?.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( + provider: nil, + deviceToken: token, + encryptedAlert: encryptedAlertData.base64EncodedString(), + category: payload.kind.rawValue, + collapseID: Self.notificationCollapseID(for: payload, device: device), + apnsEnvironment: Self.apnsEnvironmentForPush(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 ?? "Unknown error" + ) + } + } + + func sendFCMPush(_ payload: NotificationPayload, to device: PairedDevice, pushURL: URL) async throws { + guard let token = Self.pushToken(for: device), !token.isEmpty else { + throw MobilePushError.missingDeviceToken + } + let deviceClient = clientForDevice(device) + guard let peer = await deviceClient?.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 = PushRequest( + provider: "fcm", + deviceToken: token, + encryptedAlert: encryptedAlertData.base64EncodedString(), + category: payload.kind.rawValue, + collapseID: Self.notificationCollapseID(for: payload, device: device), + apnsEnvironment: nil + ) + + var request = URLRequest(url: pushURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + logger.info("[FCM] 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(PushResponse.self, from: data) + guard (200..<300).contains(pushResponse.statusCode) else { + throw MobilePushError.fcmRejected( + status: pushResponse.statusCode, + reason: pushResponse.reason ?? "Unknown error" + ) + } + } + + /// Send one APNs-backed test notification to a paired device. + func sendTestNotification(to device: PairedDevice) async throws { + if Self.pushProvider(for: device) == "fcm" { + guard let deviceRelayURL = pushEndpointURL(for: device) else { + throw MobilePushError.invalidRelayURL + } + let payload = NotificationPayload( + kind: .generic, + title: "RxCode test notification", + body: "Notifications are working for \(device.displayName)." + ) + try await sendFCMPush(payload, to: device, pushURL: deviceRelayURL) + return + } + + guard let token = Self.apnsToken(for: device), !token.isEmpty else { + throw MobilePushError.missingDeviceToken + } + let deviceClient = clientForDevice(device) + guard let peer = await deviceClient?.peer(forHex: device.pubkeyHex) else { + throw MobilePushError.unknownPeer + } + guard let relayURLString = device.relayURL, + let deviceRelayURL = URL(string: relayURLString), + let pushURL = Self.pushEndpointURL(from: deviceRelayURL) else { + throw MobilePushError.invalidRelayURL + } + + logger.info("[APNs] sending test push deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public) tokenPrefix=\(String(token.prefix(12)), privacy: .public) environment=\(device.apnsEnvironment ?? "", privacy: .public) sender=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") + let plaintext = AlertPlaintext( + title: "RxCode test notification", + body: "Notifications are working for \(device.displayName).", + kind: NotificationPayload.Kind.generic.rawValue + ) + let encrypted = try APNsCrypto.seal( + plaintext: plaintext, + sender: identity.privateKey, + recipient: peer + ) + let encryptedAlertData = try JSONEncoder().encode(encrypted) + let body = APNsPushRequest( + provider: nil, + deviceToken: token, + encryptedAlert: encryptedAlertData.base64EncodedString(), + category: "test_notification", + collapseID: Self.testNotificationCollapseID(for: device), + apnsEnvironment: Self.apnsEnvironmentForPush(device) + ) + + var request = URLRequest(url: pushURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + 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 ?? "Unknown error" + ) + } + } +} diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index feaeb2c1..44ba2250 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -523,10 +523,22 @@ final class MobileSyncService: ObservableObject { /// the very first send after launch isn't suppressed. Peers in `.offline` /// are skipped on the relay socket; APNs fan-out runs separately and is /// not gated here. + /// + /// Fan-out is parallel: each peer's socket write is awaited concurrently + /// so a slow remote-relay RTT for one peer does not stall delivery to the + /// others. The previous serial loop made a 5-peer broadcast cost roughly + /// 5× the slowest relay RTT. private func broadcastToAllClients(_ payload: RxCodeSync.Payload) async { - for device in pairedDevices where device.onlineState != .offline { - let target = clientForPeer(device.pubkeyHex) - try? await target.send(payload, toHex: device.pubkeyHex) + let targets: [(pubkeyHex: String, client: SyncClient)] = pairedDevices + .filter { $0.onlineState != .offline } + .map { ($0.pubkeyHex, clientForPeer($0.pubkeyHex)) } + guard !targets.isEmpty else { return } + await withTaskGroup(of: Void.self) { group in + for target in targets { + group.addTask { + try? await target.client.send(payload, toHex: target.pubkeyHex) + } + } } } @@ -544,247 +556,6 @@ final class MobileSyncService: ObservableObject { } } - func fanoutPush(_ payload: NotificationPayload) async { - let devices = pairedDevices.filter { Self.pushToken(for: $0)?.isEmpty == false } - guard !devices.isEmpty else { return } - - for device in devices { - guard let relayURLString = device.relayURL, - let relayURL = URL(string: relayURLString), - let pushURL = Self.pushEndpointURL(from: relayURL) else { - logger.error("[Push] cannot derive push endpoint for device=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") - continue - } - - do { - switch Self.pushProvider(for: device) { - case "fcm": - try await sendFCMPush(payload, to: device, pushURL: pushURL) - default: - try await sendAPNsPush(payload, to: device, pushURL: pushURL) - } - } catch { - logger.error("[Push] fan-out failed provider=\(Self.pushProvider(for: device), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - } - - /// 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. - func fanoutAPNs(_ payload: NotificationPayload) async { - let devices = pairedDevices.filter { ($0.apnsToken?.isEmpty == false) } - guard !devices.isEmpty else { return } - - for device in devices { - // Find the relay URL for this device - guard let relayURLString = device.relayURL, - let relayURL = URL(string: relayURLString), - let pushURL = Self.pushEndpointURL(from: relayURL) else { - logger.error("[APNs] cannot derive push endpoint for device=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") - continue - } - 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. - func sendAPNsPush(_ payload: NotificationPayload, to device: PairedDevice, pushURL: URL) async throws { - guard let token = Self.apnsToken(for: device), !token.isEmpty else { - throw MobilePushError.missingDeviceToken - } - // Find the client for this device's relay - let deviceClient = clientForDevice(device) - guard let peer = await deviceClient?.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( - provider: nil, - deviceToken: token, - encryptedAlert: encryptedAlertData.base64EncodedString(), - category: payload.kind.rawValue, - collapseID: Self.notificationCollapseID(for: payload, device: device), - apnsEnvironment: Self.apnsEnvironmentForPush(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 ?? "Unknown error" - ) - } - } - - func sendFCMPush(_ payload: NotificationPayload, to device: PairedDevice, pushURL: URL) async throws { - guard let token = Self.pushToken(for: device), !token.isEmpty else { - throw MobilePushError.missingDeviceToken - } - let deviceClient = clientForDevice(device) - guard let peer = await deviceClient?.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 = PushRequest( - provider: "fcm", - deviceToken: token, - encryptedAlert: encryptedAlertData.base64EncodedString(), - category: payload.kind.rawValue, - collapseID: Self.notificationCollapseID(for: payload, device: device), - apnsEnvironment: nil - ) - - var request = URLRequest(url: pushURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONEncoder().encode(body) - - logger.info("[FCM] 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(PushResponse.self, from: data) - guard (200..<300).contains(pushResponse.statusCode) else { - throw MobilePushError.fcmRejected( - status: pushResponse.statusCode, - reason: pushResponse.reason ?? "Unknown error" - ) - } - } - - /// Send one APNs-backed test notification to a paired device. - func sendTestNotification(to device: PairedDevice) async throws { - if Self.pushProvider(for: device) == "fcm" { - guard let deviceRelayURL = pushEndpointURL(for: device) else { - throw MobilePushError.invalidRelayURL - } - let payload = NotificationPayload( - kind: .generic, - title: "RxCode test notification", - body: "Notifications are working for \(device.displayName)." - ) - try await sendFCMPush(payload, to: device, pushURL: deviceRelayURL) - return - } - - guard let token = Self.apnsToken(for: device), !token.isEmpty else { - throw MobilePushError.missingDeviceToken - } - // Find the client for this device's relay - let deviceClient = clientForDevice(device) - guard let peer = await deviceClient?.peer(forHex: device.pubkeyHex) else { - throw MobilePushError.unknownPeer - } - guard let relayURLString = device.relayURL, - let deviceRelayURL = URL(string: relayURLString), - let pushURL = Self.pushEndpointURL(from: deviceRelayURL) else { - throw MobilePushError.invalidRelayURL - } - - logger.info("[APNs] sending test push deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public) tokenPrefix=\(String(token.prefix(12)), privacy: .public) environment=\(device.apnsEnvironment ?? "", privacy: .public) sender=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") - let plaintext = AlertPlaintext( - title: "RxCode test notification", - body: "Notifications are working for \(device.displayName).", - kind: NotificationPayload.Kind.generic.rawValue - ) - let encrypted = try APNsCrypto.seal( - plaintext: plaintext, - sender: identity.privateKey, - recipient: peer - ) - let encryptedAlertData = try JSONEncoder().encode(encrypted) - let body = APNsPushRequest( - provider: nil, - deviceToken: token, - encryptedAlert: encryptedAlertData.base64EncodedString(), - category: "test_notification", - collapseID: Self.testNotificationCollapseID(for: device), - apnsEnvironment: Self.apnsEnvironmentForPush(device) - ) - - var request = URLRequest(url: pushURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONEncoder().encode(body) - - 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 ?? "Unknown error" - ) - } - } - // MARK: - Streaming hooks /// Called by AppState's streaming loop after each StreamEvent is folded diff --git a/RxCodeMobile/Views/MobileChatView+Toolbar.swift b/RxCodeMobile/Views/MobileChatView+Toolbar.swift new file mode 100644 index 00000000..31515f1a --- /dev/null +++ b/RxCodeMobile/Views/MobileChatView+Toolbar.swift @@ -0,0 +1,247 @@ +import RxCodeCore +import RxCodeSync +import SwiftUI + +extension MobileChatView { + // MARK: - Thread actions toolbar + + @ToolbarContentBuilder + var threadActionsToolbar: some ToolbarContent { + if threadExists { + ToolbarItem(placement: .principal) { + navigationTitleView + } + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + showingBrowser = true + } label: { + Label("Open in Browser", systemImage: "globe") + } + Button { + showingRunProfiles = true + } label: { + Label("Run Profiles", systemImage: "play.rectangle") + } + .disabled(currentProjectID == nil) + Button { + showingChanges = true + } label: { + Label("View Changes", systemImage: "plus.forwardslash.minus") + } + Divider() + 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 + 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") + } + + 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. + var threadExists: Bool { + !MobileDraftSessionID.isDraft(sessionID) + && state.sessions.contains(where: { $0.id == sessionID }) + } + + func performArchive() { + Task { await state.archiveThread(sessionID: sessionID) } + onClose() + } + + func performDelete() { + Task { await state.deleteThread(sessionID: sessionID) } + onClose() + } + + // MARK: - Queued preview pill + + var queuedPreviewPill: some View { + Button { + showingQueueSheet = true + } label: { + HStack(spacing: 10) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 1) { + Text("\(queuedMessages.count) queued") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + if let first = queuedMessages.first { + Text(first.text) + .font(.callout) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.up") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.secondary.opacity(0.15), lineWidth: 0.5) + ) + } + .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. + 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.") + } + + var questionBannerText: String { + let count = sessionQuestions.count + return count == 1 ? "1 question pending" : "\(count) questions pending" + } + + // MARK: - Plan review banner + + /// Compact pill above the input bar shown whenever the agent has produced a + /// plan awaiting a decision. Tapping it opens the shared `PlanSheetView` — + /// the same review/accept/modify component the desktop uses. + var planBanner: some View { + Button { + presentedPlan = pendingPlans.first + } label: { + HStack(spacing: 10) { + Image(systemName: "list.bullet.clipboard") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + + Text(planBannerText) + .font(.system(size: 13, weight: .medium)) + + Spacer(minLength: 8) + + Text("Review") + .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) + .glassEffect(.regular.tint(ClaudeTheme.accent).interactive(), in: .rect(cornerRadius: 14)) + } + .buttonStyle(.plain) + .accessibilityLabel("\(planBannerText). Tap to review.") + } + + var planBannerText: String { + let count = pendingPlans.count + return count == 1 ? "Plan ready to review" : "\(count) plans ready to review" + } +} diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 7103b1bb..cf0020af 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -13,98 +13,98 @@ private let mobileChatLogger = Logger( /// 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 + @EnvironmentObject 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 var composer: String = "" + @State var isNearBottom: Bool = true + @State var showingQueueSheet = false /// Set once the thread has been scrolled to its newest message on open. /// Until then, an arriving message jumps to the bottom without animation; /// after, message updates animate as the streaming follow. - @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 - @State private var showingRunProfiles = false - @State private var showingBrowser = false - @State private var showingChanges = false + @State var didEstablishInitialScroll = false + @State var showingRenameSheet = false + @State var showingArchiveConfirm = false + @State var showingDeleteConfirm = false + @State var showingTodoSheet = false + @State var showingRunProfiles = false + @State var showingBrowser = false + @State var showingChanges = false /// The question request whose sheet is currently presented, if any. - @State private var presentedQuestion: PendingQuestionPayload? + @State var presentedQuestion: PendingQuestionPayload? /// The plan whose review sheet is currently presented, if any. - @State private var presentedPlan: PendingPlan? + @State var presentedPlan: PendingPlan? /// Local file link tapped in rendered markdown. Mobile fetches the content /// from the paired desktop before presenting it. - @State private var presentedFileLink: LocalFileLink? + @State var presentedFileLink: LocalFileLink? /// 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? + @State var pendingTopAnchorID: UUID? /// Re-asserts the preserved position after SwiftUI has laid out a prepended /// page. A single `scrollTo` can fire before the lazy stack has realized the /// old anchor row. - @State private var loadMoreRestoreTask: Task? + @State var loadMoreRestoreTask: Task? /// Re-asserts the just-sent message pin while lazy row geometry, dynamic /// spacer height, and keyboard-driven composer movement settle. - @State private var pinToTopTask: Task? + @State var pinToTopTask: Task? /// Re-asserts the first scroll-to-bottom while the lazy stack and composer /// geometry settle on thread entry. - @State private var initialScrollTask: Task? + @State var initialScrollTask: Task? /// Owns coalesced automatic bottom-follow while streamed content grows. - @State private var autoBottomScrollTask: Task? + @State var autoBottomScrollTask: Task? /// Prevents repeated scheduling while the delayed initial scroll is /// waiting for the first loaded page to finish laying out. - @State private var isEstablishingInitialScroll = false + @State var isEstablishingInitialScroll = false /// Hides the thread while the initial delayed scroll is pending, avoiding /// a visible flash at the top before the view lands on the latest message. - @State private var isInitialMessageListHidden = true + @State var isInitialMessageListHidden = true /// 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 + @State var didSettleInitialScroll = false /// True while the user is actively driving the scroll view (dragging or /// momentum). Layout- and keyboard-induced offset shifts happen outside /// these phases and must not be mistaken for a deliberate scroll-up. - @State private var isUserDragging = false + @State var isUserDragging = false /// While `false`, the stream no longer pulls the viewport to the bottom — /// set when the user scrolls up so they can read history without being /// yanked back down. Re-armed once they return to the bottom. - @State private var autoScrollEnabled = true + @State var autoScrollEnabled = true /// Full height of the scroll view (independent of the keyboard). - @State private var scrollViewHeight: CGFloat = 0 + @State var scrollViewHeight: CGFloat = 0 /// Global `minY` of the scroll view. - @State private var scrollViewMinY: CGFloat = 0 + @State var scrollViewMinY: CGFloat = 0 /// Global `minY` of the floating composer stack — its top is the boundary /// between the visible chat area and the area covered by composer + keyboard. - @State private var composerMinY: CGFloat = 0 + @State var composerMinY: CGFloat = 0 /// `minY` of the latest user message in `chatContentCoordinateSpace`. - @State private var latestUserMinY: CGFloat = 0 + @State var latestUserMinY: CGFloat = 0 /// `minY` of the tail spacer in `chatContentCoordinateSpace`. - @State private var tailSpacerMinY: CGFloat = 0 + @State var tailSpacerMinY: CGFloat = 0 /// Set by `handleSend`; the next appended user message is the one we sent /// and should be pinned to the top of the viewport. - @State private var awaitingSentUserMessage = false + @State var awaitingSentUserMessage = false /// User message for the active turn. Its trailing spacer persists even /// after manual scroll and shrinks as the assistant response fills in. - @State private var activeTurnUserMessageID: UUID? + @State var activeTurnUserMessageID: UUID? /// Immediate spacer reduction used while SwiftUI has not yet reported the /// streaming indicator's new tail geometry. - @State private var pendingIndicatorSpacerReduction: CGFloat = 0 + @State var pendingIndicatorSpacerReduction: CGFloat = 0 /// True only for the active send flow where the just-sent user message is /// actively kept at the top while layout and keyboard geometry settle. - @State private var isPinningLatestTurnToTop = false + @State var isPinningLatestTurnToTop = false /// Prevents stale/user scroll phase callbacks from immediately canceling a /// new programmatic top-pin before it has settled. - @State private var canReleasePinnedTurnByScroll = false - @State private var distanceFromBottom: CGFloat = 0 - @State private var lastAutoBottomScrollDate = Date.distantPast - @State private var packageShouldScrollToBottom = false - @State private var packageScrollRequestTask: Task? - @State private var minimumThreadLoadElapsed = false - @State private var isThreadLoadingOverlayVisible = true - @State private var threadLoadingHideTask: Task? + @State var canReleasePinnedTurnByScroll = false + @State var distanceFromBottom: CGFloat = 0 + @State var lastAutoBottomScrollDate = Date.distantPast + @State var packageShouldScrollToBottom = false + @State var packageScrollRequestTask: Task? + @State var minimumThreadLoadElapsed = false + @State var isThreadLoadingOverlayVisible = true + @State var threadLoadingHideTask: Task? private static let bottomAnchorID = "message-list-bottom" private static let endOfScreenAnchorID = "message-list-end-of-screen" @@ -269,118 +269,6 @@ struct MobileChatView: View { } } - // MARK: - Thread actions toolbar - - @ToolbarContentBuilder - private var threadActionsToolbar: some ToolbarContent { - if threadExists { - ToolbarItem(placement: .principal) { - navigationTitleView - } - ToolbarItem(placement: .topBarTrailing) { - Menu { - Button { - showingBrowser = true - } label: { - Label("Open in Browser", systemImage: "globe") - } - Button { - showingRunProfiles = true - } label: { - Label("Run Profiles", systemImage: "play.rectangle") - } - .disabled(currentProjectID == nil) - Button { - showingChanges = true - } label: { - Label("View Changes", systemImage: "plus.forwardslash.minus") - } - Divider() - 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 private var activeThreadLayout: some View { @@ -578,7 +466,7 @@ struct MobileChatView: View { state.messages(sessionID: sessionID) } - private var currentProjectID: UUID? { + var currentProjectID: UUID? { state.sessionSummary(sessionID: sessionID)?.projectId } @@ -592,14 +480,14 @@ struct MobileChatView: View { return MobileBrowserURLDetector.detect(in: taskText) } - private var title: String { + var title: String { state.sessionSummary(sessionID: sessionID)?.title ?? "Thread" } /// Live todos from synced messages when available, otherwise from the /// desktop session summary. Codex plan updates arrive as desktop-owned /// snapshots rather than `TodoWrite` message tool calls. - private var todos: [TodoItem]? { + var todos: [TodoItem]? { TodoExtractor.latest(in: messages) ?? state.sessionSummary(sessionID: sessionID)?.todos } @@ -637,23 +525,23 @@ struct MobileChatView: View { && isThreadLoadingOverlayVisible } - private var queuedMessages: [QueuedUserMessage] { + var queuedMessages: [QueuedUserMessage] { state.queuedMessages(sessionID: sessionID) } /// `AskUserQuestion` calls awaiting an answer for this thread. - private var sessionQuestions: [PendingQuestionPayload] { + var sessionQuestions: [PendingQuestionPayload] { state.pendingQuestions(sessionID: sessionID) } /// Every plan card for this thread (pending + decided), derived from the /// synced messages via the shared `PlanLogic`. - private var sessionPlans: [PendingPlan] { + var sessionPlans: [PendingPlan] { state.pendingPlans(sessionID: sessionID) } /// Plans still awaiting a decision — what the plan banner surfaces. - private var pendingPlans: [PendingPlan] { + var pendingPlans: [PendingPlan] { sessionPlans.filter { !$0.isDecided && !$0.isStreaming } } @@ -726,56 +614,10 @@ struct MobileChatView: View { .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 } - } - } - private func loadMorePreviousMessages() async throws { _ = await state.loadMoreMessages(sessionID: sessionID) } - /// Scroll restoration after prepending older messages needs to survive lazy - /// layout and row grouping changes. Repeating the same non-animated scroll - /// across a handful of frames keeps the original anchor visible without - /// fighting later user gestures. - private func settleScroll(proxy: ScrollViewProxy, to id: ID, anchor: UnitPoint) { - loadMoreRestoreTask?.cancel() - loadMoreRestoreTask = Task { @MainActor in - var transaction = Transaction() - transaction.animation = nil - for _ in 0 ..< 8 { - guard !Task.isCancelled else { return } - withTransaction(transaction) { - proxy.scrollTo(id, anchor: anchor) - } - try? await Task.sleep(for: .milliseconds(16)) - } - } - } - - private func scrollToBottomAfterLayout(proxy: ScrollViewProxy) { - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(16)) - guard didEstablishInitialScroll, autoScrollEnabled else { return } - scrollToBottomDebounced(proxy: proxy, reason: "layout") - } - } - private func establishInitialPackageScroll(reason: String) { guard !didEstablishInitialScroll else { isInitialMessageListHidden = false @@ -806,79 +648,6 @@ struct MobileChatView: View { } } - /// Jump straight to the latest message the first time the thread's content - /// is available, so an opened thread starts at the bottom instead of the - /// top. The deferred scroll covers the lazy stack not having measured its - /// full height on the first layout pass. - private func establishInitialScroll(proxy: ScrollViewProxy, reason: String) { - guard !didEstablishInitialScroll else { - isInitialMessageListHidden = false - mobileChatLogger.debug( - "[InitialScroll] skip already-established reason=\(reason, privacy: .public) session=\(sessionID, privacy: .public)" - ) - return - } - guard !isEstablishingInitialScroll else { - mobileChatLogger.debug( - "[InitialScroll] skip already-scheduled reason=\(reason, privacy: .public) session=\(sessionID, privacy: .public)" - ) - return - } - guard !messages.isEmpty else { - mobileChatLogger.debug( - "[InitialScroll] waiting-empty reason=\(reason, privacy: .public) session=\(sessionID, privacy: .public)" - ) - return - } - isEstablishingInitialScroll = true - isInitialMessageListHidden = true - isPinningLatestTurnToTop = false - mobileChatLogger.info( - "[InitialScroll] scheduled reason=\(reason, privacy: .public) session=\(sessionID, privacy: .public) messages=\(messages.count, privacy: .public) delay=0.5 scrollHeight=\(Double(scrollViewHeight), privacy: .public) available=\(Double(availableHeight), privacy: .public) minTail=\(Double(minTailSpacer), privacy: .public)" - ) - initialScrollTask?.cancel() - initialScrollTask = Task { @MainActor in - do { - try await Task.sleep(for: .milliseconds(800)) - } catch { - isEstablishingInitialScroll = false - return - } - guard !Task.isCancelled else { - isEstablishingInitialScroll = false - mobileChatLogger.debug( - "[InitialScroll] cancelled session=\(sessionID, privacy: .public)" - ) - return - } - let target = initialScrollTarget() - withAnimation(.easeInOut(duration: 0.28)) { - proxy.scrollTo(target.id, anchor: target.anchor) - } - didEstablishInitialScroll = true - isEstablishingInitialScroll = false - withAnimation(.easeInOut(duration: 0.18)) { - isInitialMessageListHidden = false - } - mobileChatLogger.info( - "[InitialScroll] scrolled target=\(target.name, privacy: .public) session=\(sessionID, privacy: .public) messages=\(messages.count, privacy: .public) distance=\(Double(distanceFromBottom), privacy: .public) tailY=\(Double(tailSpacerMinY), privacy: .public) available=\(Double(availableHeight), privacy: .public) minTail=\(Double(minTailSpacer), privacy: .public) userY=\(Double(latestUserMinY), privacy: .public)" - ) - } - } - - private func initialScrollTarget() -> (id: String, anchor: UnitPoint, name: String) { - guard availableHeight > 0, tailSpacerMinY > 0 else { - return (Self.bottomAnchorID, .bottom, "bottom-unmeasured") - } - // For real scrollable content, land on the true bottom so the newest - // message is fully reached. For short threads, avoid aligning the - // composer-clearance spacer as the primary visible content. - if tailSpacerMinY > availableHeight { - return (Self.bottomAnchorID, .bottom, "bottom") - } - return (Self.endOfScreenAnchorID, .bottom, "end-of-screen") - } - // MARK: - Send / Stop private func handleSend(_ trimmed: String) { @@ -940,90 +709,6 @@ struct MobileChatView: View { return max(0, scrollViewHeight - latestTurnHeight - minTailSpacer) } - /// Pin a freshly sent user message to the top of the viewport. - private func pinSentMessageToTop(_ id: UUID, proxy: ScrollViewProxy, animated: Bool) { - autoBottomScrollTask?.cancel() - autoBottomScrollTask = nil - pinToTopTask?.cancel() - canReleasePinnedTurnByScroll = false - pinToTopTask = Task { @MainActor in - // Give SwiftUI one frame to realize the new row, then keep - // asserting the pin while the tracked-row geometry, tail spacer, - // streaming indicator, and keyboard layout settle. - try? await Task.sleep(for: .milliseconds(16)) - if animated { - guard !Task.isCancelled else { return } - withAnimation(.easeInOut(duration: Self.pinToTopAnimationSeconds)) { - proxy.scrollTo(id, anchor: .top) - } - try? await Task.sleep(for: Self.pinToTopAnimationDuration) - } - for _ in 0 ..< 8 { - guard !Task.isCancelled else { return } - var transaction = Transaction() - transaction.animation = nil - withTransaction(transaction) { - proxy.scrollTo(id, anchor: .top) - } - try? await Task.sleep(for: .milliseconds(16)) - } - guard !Task.isCancelled, isPinningLatestTurnToTop else { return } - canReleasePinnedTurnByScroll = true - } - } - - private func handleComposerBoundaryChange( - oldValue: CGFloat, - newValue: CGFloat, - proxy: ScrollViewProxy - ) { - guard didEstablishInitialScroll, abs(newValue - oldValue) > 0.5 else { return } - if isPinningLatestTurnToTop, let id = activeTurnUserMessageID ?? trackedUserMessageID { - pinSentMessageToTop(id, proxy: proxy, animated: false) - } else if autoScrollEnabled { - scrollToBottomAfterLayout(proxy: proxy) - } - } - - private func repinActiveTurnIfNeeded(proxy: ScrollViewProxy) -> Bool { - guard isPinningLatestTurnToTop, let id = activeTurnUserMessageID else { - return false - } - pinSentMessageToTop(id, proxy: proxy, animated: false) - return true - } - - private func releasePinnedTurnToBottom(proxy: ScrollViewProxy) { - pinToTopTask?.cancel() - canReleasePinnedTurnByScroll = false - isPinningLatestTurnToTop = false - autoScrollEnabled = true - scrollToBottomAfterLayout(proxy: proxy) - } - - /// `minY` of the latest user message — fed back from `ChatMessageListView`. - private func updateLatestUserMinY(_ value: CGFloat) { - guard abs(value - latestUserMinY) > 0.5 else { return } - var t = Transaction() - t.animation = nil - withTransaction(t) { latestUserMinY = value } - } - - /// `minY` of the tail spacer — its distance from the user message is the - /// height of the latest turn. - private func updateTailSpacerMinY(_ value: CGFloat) { - guard abs(value - tailSpacerMinY) > 0.5 else { return } - let oldValue = tailSpacerMinY - var t = Transaction() - t.animation = nil - withTransaction(t) { - tailSpacerMinY = value - if pendingIndicatorSpacerReduction > 0, value > oldValue { - pendingIndicatorSpacerReduction = 0 - } - } - } - private func handleStop() { guard !sessionID.isEmpty else { return } Task { await state.cancelStream(sessionID: sessionID) } @@ -1067,180 +752,4 @@ struct MobileChatView: View { .accessibilityLabel("Scroll to bottom") } - private func scrollToBottomFromButton(_ proxy: ScrollViewProxy) { - mobileChatLogger.debug("[ScrollButton] scroll immediate") - pinToTopTask?.cancel() - canReleasePinnedTurnByScroll = false - isPinningLatestTurnToTop = false - lastAutoBottomScrollDate = Date() - withAnimation(.easeInOut(duration: 0.2)) { - proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) - } - DispatchQueue.main.async { - mobileChatLogger.debug("[ScrollButton] scroll deferred") - withAnimation(.easeInOut(duration: 0.2)) { - proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) - } - } - } - - /// Coalesces automatic bottom-follow while streaming content grows. Explicit - /// user actions and initial thread positioning still scroll immediately. - private func scrollToBottomDebounced(proxy: ScrollViewProxy, reason: String) { - guard autoBottomScrollTask == nil else { return } - let elapsed = Date().timeIntervalSince(lastAutoBottomScrollDate) - let delay = max(0, Self.autoBottomScrollInterval - elapsed) - mobileChatLogger.debug( - "[AutoScroll] scheduled reason=\(reason, privacy: .public) delay=\(delay, privacy: .public) session=\(sessionID, privacy: .public)" - ) - autoBottomScrollTask = Task { @MainActor in - if delay > 0 { - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - } - guard !Task.isCancelled else { return } - autoBottomScrollTask = nil - guard didEstablishInitialScroll, - autoScrollEnabled, - !isUserDragging, - !isPinningLatestTurnToTop - else { return } - lastAutoBottomScrollDate = Date() - mobileChatLogger.debug( - "[AutoScroll] fired reason=\(reason, privacy: .public) session=\(sessionID, privacy: .public)" - ) - withAnimation(.easeInOut(duration: 0.2)) { - proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) - } - } - } - - // MARK: - Queued preview pill - - private var queuedPreviewPill: some View { - Button { - showingQueueSheet = true - } label: { - HStack(spacing: 10) { - Image(systemName: "clock.arrow.circlepath") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - - VStack(alignment: .leading, spacing: 1) { - Text("\(queuedMessages.count) queued") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - if let first = queuedMessages.first { - Text(first.text) - .font(.callout) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - Image(systemName: "chevron.up") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.regularMaterial) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color.secondary.opacity(0.15), lineWidth: 0.5) - ) - } - .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: - Plan review banner - - /// Compact pill above the input bar shown whenever the agent has produced a - /// plan awaiting a decision. Tapping it opens the shared `PlanSheetView` — - /// the same review/accept/modify component the desktop uses. - private var planBanner: some View { - Button { - presentedPlan = pendingPlans.first - } label: { - HStack(spacing: 10) { - Image(systemName: "list.bullet.clipboard") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(ClaudeTheme.accent) - - Text(planBannerText) - .font(.system(size: 13, weight: .medium)) - - Spacer(minLength: 8) - - Text("Review") - .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) - .glassEffect(.regular.tint(ClaudeTheme.accent).interactive(), in: .rect(cornerRadius: 14)) - } - .buttonStyle(.plain) - .accessibilityLabel("\(planBannerText). Tap to review.") - } - - private var planBannerText: String { - let count = pendingPlans.count - return count == 1 ? "Plan ready to review" : "\(count) plans ready to review" - } }