diff --git a/.gitignore b/.gitignore index 42bd2b0a..6cd13332 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ relay-server/.env relay-server/.env.* relay-server/*.p8 relay-server/k8s/secrets.yaml +.playwright-mcp diff --git a/Packages/Sources/RxCodeChatKit/PlanSheetView.swift b/Packages/Sources/RxCodeChatKit/PlanSheetView.swift index ad4e7c02..347fd5f1 100644 --- a/Packages/Sources/RxCodeChatKit/PlanSheetView.swift +++ b/Packages/Sources/RxCodeChatKit/PlanSheetView.swift @@ -1,8 +1,11 @@ import SwiftUI import RxCodeCore +import Combine #if os(macOS) import AppKit -import Combine +#else +import UIKit +#endif /// Test-only inspection emissary used by `PlanSheetView` so ViewInspector tests /// can observe @State transitions (composer reveal, feedback text). At runtime @@ -97,7 +100,9 @@ public struct PlanSheetView: View { Divider() footer } + #if os(macOS) .frame(minWidth: 640, idealWidth: 760, minHeight: 480, idealHeight: 640) + #endif .background(ClaudeTheme.background) .onReceive(inspection.notice) { inspection.visit(self, $0) } } @@ -307,9 +312,13 @@ public struct PlanSheetView: View { } private func copyMarkdown() { + #if os(macOS) let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(plan.markdown, forType: .string) + #else + UIPasteboard.general.string = plan.markdown + #endif } // MARK: - Button helper @@ -366,4 +375,3 @@ public struct PlanSheetView: View { .joined(separator: "-") } } -#endif diff --git a/Packages/Sources/RxCodeCore/Models/RateLimitUsage.swift b/Packages/Sources/RxCodeCore/Models/RateLimitUsage.swift index 5a90acce..4c8ff8f1 100644 --- a/Packages/Sources/RxCodeCore/Models/RateLimitUsage.swift +++ b/Packages/Sources/RxCodeCore/Models/RateLimitUsage.swift @@ -1,7 +1,9 @@ import Foundation /// Rate limit usage data passed through ChatBridge to avoid direct RateLimitService dependency in RxCodeChatKit. -public struct RateLimitUsage: Sendable { +/// +/// `Codable` so it can travel over the mobile sync protocol inside a snapshot. +public struct RateLimitUsage: Sendable, Codable, Equatable { public let fiveHourPercent: Double public let sevenDayPercent: Double public let twentyFourHourPercent: Double? diff --git a/Packages/Sources/RxCodeCore/Utilities/MarkdownStripping.swift b/Packages/Sources/RxCodeCore/Utilities/MarkdownStripping.swift new file mode 100644 index 00000000..87ee9ab5 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Utilities/MarkdownStripping.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Convert lightweight Markdown to a single line of plain text suitable for +/// notification banners. APNs alerts and the macOS local banner render raw +/// Markdown syntax literally (`**bold**`, `` `code` ``, `[text](url)`, +/// `# heading`) rather than formatting it, so the syntax must be removed before +/// the text is shown. +/// +/// This is a best-effort, heuristic stripper for short summary text — not a full +/// CommonMark parser. It drops the syntax characters and collapses the result +/// into a single trimmed line. Inline lookarounds keep `snake_case` +/// identifiers and arithmetic like `2 * 3` intact. +public func stripMarkdown(_ text: String) -> String { + var s = text + + func replace(_ pattern: String, _ template: String) { + s = s.replacingOccurrences( + of: pattern, + with: template, + options: [.regularExpression] + ) + } + + // Fenced code block fences (``` or ```lang) — drop the fence line, keep code. + replace("(?m)^[ \\t]*```.*$", "") + // Images: ![alt](url) -> alt (before links — the syntax overlaps). + replace("!\\[([^\\]]*)\\]\\([^)]*\\)", "$1") + // Links: [text](url) -> text + replace("\\[([^\\]]*)\\]\\([^)]*\\)", "$1") + // ATX headings: leading #'s + replace("(?m)^[ \\t]*#{1,6}[ \\t]+", "") + // Blockquote markers + replace("(?m)^[ \\t]*>[ \\t]?", "") + // Horizontal rules + replace("(?m)^[ \\t]*[-*_]{3,}[ \\t]*$", "") + // Unordered list markers + replace("(?m)^[ \\t]*[-*+][ \\t]+", "") + // Ordered list markers + replace("(?m)^[ \\t]*\\d+\\.[ \\t]+", "") + // Strikethrough ~~text~~ + replace("~~([\\s\\S]+?)~~", "$1") + // Bold: **text** and __text__ + replace("\\*\\*([\\s\\S]+?)\\*\\*", "$1") + replace("(? 0 else { return 0 } + return Double(memoryUsedBytes) / Double(memoryTotalBytes) * 100 + } + + private enum CodingKeys: String, CodingKey { + case cpuUsagePercent, memoryUsedBytes, memoryTotalBytes, thermalState, sampledAt + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + cpuUsagePercent = try c.decode(Double.self, forKey: .cpuUsagePercent) + memoryUsedBytes = try c.decode(UInt64.self, forKey: .memoryUsedBytes) + memoryTotalBytes = try c.decode(UInt64.self, forKey: .memoryTotalBytes) + // Tolerate a thermal state added by a newer desktop build. + let rawThermal = try c.decode(String.self, forKey: .thermalState) + thermalState = ThermalState(rawValue: rawThermal) ?? .unknown + sampledAt = try c.decode(Date.self, forKey: .sampledAt) + } +} + public struct SnapshotPayload: Codable, Sendable { public let projects: [Project] public let sessions: [SessionSummary] @@ -104,6 +183,12 @@ public struct SnapshotPayload: Codable, Sendable { /// a new thread. Missing entries mean the project isn't a git repo or the /// branch couldn't be resolved. public let projectBranches: [ProjectBranchInfo]? + /// Agent rate-limit usage mirrored from the desktop. `nil` when the desktop + /// predates usage sync. + public let usage: MobileUsageSnapshot? + /// Desktop CPU/memory/thermal load. `nil` when the desktop predates + /// computer-status sync. + public let hostMetrics: HostMetricsSnapshot? public init( projects: [Project], sessions: [SessionSummary], @@ -113,7 +198,9 @@ public struct SnapshotPayload: Codable, Sendable { activeSessionID: String? = nil, activeSessionMessages: [ChatMessage]? = nil, activeSessionHasMore: Bool? = nil, - projectBranches: [ProjectBranchInfo]? = nil + projectBranches: [ProjectBranchInfo]? = nil, + usage: MobileUsageSnapshot? = nil, + hostMetrics: HostMetricsSnapshot? = nil ) { self.projects = projects self.sessions = sessions @@ -124,11 +211,14 @@ public struct SnapshotPayload: Codable, Sendable { self.activeSessionMessages = activeSessionMessages self.activeSessionHasMore = activeSessionHasMore self.projectBranches = projectBranches + self.usage = usage + self.hostMetrics = hostMetrics } private enum CodingKeys: String, CodingKey { case projects, sessions, branchBriefings, threadSummaries, settings case activeSessionID, activeSessionMessages, activeSessionHasMore, projectBranches + case usage, hostMetrics } public init(from decoder: Decoder) throws { @@ -142,6 +232,8 @@ public struct SnapshotPayload: Codable, Sendable { activeSessionMessages = try c.decodeIfPresent([ChatMessage].self, forKey: .activeSessionMessages) activeSessionHasMore = try c.decodeIfPresent(Bool.self, forKey: .activeSessionHasMore) projectBranches = try c.decodeIfPresent([ProjectBranchInfo].self, forKey: .projectBranches) + usage = try c.decodeIfPresent(MobileUsageSnapshot.self, forKey: .usage) + hostMetrics = try c.decodeIfPresent(HostMetricsSnapshot.self, forKey: .hostMetrics) } } @@ -262,6 +354,21 @@ public struct MobileThreadSummary: Codable, Sendable, Identifiable, Equatable { } } +/// One selectable summarization provider, mirrored from the desktop's +/// `SummarizationProvider` enum. Sent as data rather than the enum itself +/// because the enum — and its hardware-dependent availability (Apple +/// Foundation Model) — lives in the desktop target. +public struct SummarizationProviderOption: Codable, Sendable, Equatable, Identifiable { + /// Raw value of the desktop's `SummarizationProvider` case. + public let id: String + public let displayName: String + + public init(id: String, displayName: String) { + self.id = id + self.displayName = displayName + } +} + public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { public let selectedAgentProvider: AgentProvider public let selectedModel: String @@ -288,6 +395,14 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { /// into a single bucket. Optional for backward compatibility with older /// desktops, which sent only the flattened `availableModels` list. public let modelSections: [AgentModelSection]? + /// Summarization providers the desktop currently offers, so mobile can + /// render a provider picker without re-deriving hardware availability. + /// Optional for backward compatibility with older desktops. + public let availableSummarizationProviders: [SummarizationProviderOption]? + /// Model identifiers fetched from the OpenAI-compatible summarization + /// endpoint, so mobile can render a model picker. Empty/`nil` when the + /// desktop hasn't fetched them yet (e.g. no API key configured). + public let openAISummarizationModels: [String]? public init( selectedAgentProvider: AgentProvider, @@ -306,7 +421,9 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { autoPreviewSettings: AttachmentAutoPreviewSettings, availableEfforts: [String], availableModels: [AgentModel]? = nil, - modelSections: [AgentModelSection]? = nil + modelSections: [AgentModelSection]? = nil, + availableSummarizationProviders: [SummarizationProviderOption]? = nil, + openAISummarizationModels: [String]? = nil ) { self.selectedAgentProvider = selectedAgentProvider self.selectedModel = selectedModel @@ -325,6 +442,8 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { self.availableEfforts = availableEfforts self.availableModels = availableModels self.modelSections = modelSections + self.availableSummarizationProviders = availableSummarizationProviders + self.openAISummarizationModels = openAISummarizationModels } private enum CodingKeys: String, CodingKey { @@ -333,6 +452,7 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { case openAISummarizationEndpoint, openAISummarizationModel case notificationsEnabled, focusMode, autoArchiveEnabled, archiveRetentionDays case autoPreviewSettings, availableEfforts, availableModels, modelSections + case availableSummarizationProviders, openAISummarizationModels } public init(from decoder: Decoder) throws { @@ -354,6 +474,12 @@ public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { availableEfforts = try c.decode([String].self, forKey: .availableEfforts) availableModels = try c.decodeIfPresent([AgentModel].self, forKey: .availableModels) modelSections = try c.decodeIfPresent([AgentModelSection].self, forKey: .modelSections) + availableSummarizationProviders = try c.decodeIfPresent( + [SummarizationProviderOption].self, forKey: .availableSummarizationProviders + ) + openAISummarizationModels = try c.decodeIfPresent( + [String].self, forKey: .openAISummarizationModels + ) } } @@ -363,6 +489,10 @@ public struct MobileSettingsUpdatePayload: Codable, Sendable { public let selectedACPClientId: String? public let selectedEffort: String? public let permissionMode: PermissionMode? + /// Raw value of a `SummarizationProvider` case the user picked on mobile. + public let summarizationProvider: String? + /// Model identifier picked on mobile for the OpenAI-compatible endpoint. + public let openAISummarizationModel: String? public let notificationsEnabled: Bool? public let focusMode: Bool? public let autoArchiveEnabled: Bool? @@ -375,6 +505,8 @@ public struct MobileSettingsUpdatePayload: Codable, Sendable { selectedACPClientId: String? = nil, selectedEffort: String? = nil, permissionMode: PermissionMode? = nil, + summarizationProvider: String? = nil, + openAISummarizationModel: String? = nil, notificationsEnabled: Bool? = nil, focusMode: Bool? = nil, autoArchiveEnabled: Bool? = nil, @@ -386,6 +518,8 @@ public struct MobileSettingsUpdatePayload: Codable, Sendable { self.selectedACPClientId = selectedACPClientId self.selectedEffort = selectedEffort self.permissionMode = permissionMode + self.summarizationProvider = summarizationProvider + self.openAISummarizationModel = openAISummarizationModel self.notificationsEnabled = notificationsEnabled self.focusMode = focusMode self.autoArchiveEnabled = autoArchiveEnabled @@ -567,10 +701,38 @@ public struct NewSessionRequestPayload: Codable, Sendable { public let clientRequestID: UUID public let projectID: UUID public let initialText: String? - public init(clientRequestID: UUID = UUID(), projectID: UUID, initialText: String? = nil) { + /// Per-thread agent configuration captured in the mobile new-thread sheet. + /// All optional for wire-compatibility with older builds — synthesized + /// `Codable` decodes missing keys as `nil`, in which case the desktop falls + /// back to its global defaults. + public let selectedAgentProvider: AgentProvider? + public let selectedModel: String? + public let selectedACPClientId: String? + public let selectedEffort: String? + public let permissionMode: PermissionMode? + /// When `true`, the desktop starts the thread in plan mode + /// (CLI `--permission-mode plan`). + public let planMode: Bool? + public init( + clientRequestID: UUID = UUID(), + projectID: UUID, + initialText: String? = nil, + selectedAgentProvider: AgentProvider? = nil, + selectedModel: String? = nil, + selectedACPClientId: String? = nil, + selectedEffort: String? = nil, + permissionMode: PermissionMode? = nil, + planMode: Bool? = nil + ) { self.clientRequestID = clientRequestID self.projectID = projectID self.initialText = initialText + self.selectedAgentProvider = selectedAgentProvider + self.selectedModel = selectedModel + self.selectedACPClientId = selectedACPClientId + self.selectedEffort = selectedEffort + self.permissionMode = permissionMode + self.planMode = planMode } } @@ -814,6 +976,66 @@ public struct QuestionAnswerPayload: Codable, Sendable { } } +/// Mobile → desktop: the user's decision on a Claude `ExitPlanMode` plan card. +/// Uses a flat wire shape (string action + optional reason) because +/// `PlanDecisionAction` carries an associated value (`rejectWithFeedback`). +public struct PlanDecisionPayload: Codable, Sendable { + public enum Action: String, Codable, Sendable { + case acceptAsk + case acceptWithEdits + case acceptAutoApprove + case reject + case rejectWithFeedback + } + public let toolUseID: String + public let sessionID: String + public let action: Action + /// Free-form revision feedback; only meaningful for `.rejectWithFeedback`. + public let reason: String? + + public init(toolUseID: String, sessionID: String, action: Action, reason: String? = nil) { + self.toolUseID = toolUseID + self.sessionID = sessionID + self.action = action + self.reason = reason + } + + /// Build the wire payload from the shared `PlanDecisionAction` enum. + public init(toolUseID: String, sessionID: String, decision: PlanDecisionAction) { + self.toolUseID = toolUseID + self.sessionID = sessionID + switch decision { + case .acceptAsk: + action = .acceptAsk + reason = nil + case .acceptWithEdits: + action = .acceptWithEdits + reason = nil + case .acceptAutoApprove: + action = .acceptAutoApprove + reason = nil + case .reject: + action = .reject + reason = nil + case .rejectWithFeedback(let feedback): + action = .rejectWithFeedback + reason = feedback + } + } + + /// Map back to the shared `PlanDecisionAction` the desktop's + /// `respondToPlanDecision` consumes. An empty reason is normalized there. + public func toDecisionAction() -> PlanDecisionAction { + switch action { + case .acceptAsk: return .acceptAsk + case .acceptWithEdits: return .acceptWithEdits + case .acceptAutoApprove: return .acceptAutoApprove + case .reject: return .reject + case .rejectWithFeedback: return .rejectWithFeedback(reason: reason ?? "") + } + } +} + public struct PingPayload: Codable, Sendable { public let t: Date public init(t: Date = .now) { self.t = t } @@ -856,6 +1078,7 @@ extension Payload: Codable { case permissionResponse = "permission_response" case questionQueue = "question_queue" case questionAnswer = "question_answer" + case planDecision = "plan_decision" case branchOpRequest = "branch_op_request" case branchOpResult = "branch_op_result" case ping @@ -893,6 +1116,7 @@ extension Payload: Codable { case .permissionResponse: self = .permissionResponse(try container.decode(PermissionResponsePayload.self, forKey: .data)) case .questionQueue: self = .questionQueue(try container.decode(QuestionQueuePayload.self, forKey: .data)) case .questionAnswer: self = .questionAnswer(try container.decode(QuestionAnswerPayload.self, forKey: .data)) + case .planDecision: self = .planDecision(try container.decode(PlanDecisionPayload.self, forKey: .data)) case .branchOpRequest: self = .branchOpRequest(try container.decode(BranchOpRequestPayload.self, forKey: .data)) case .branchOpResult: self = .branchOpResult(try container.decode(BranchOpResultPayload.self, forKey: .data)) case .ping: self = .ping(try container.decode(PingPayload.self, forKey: .data)) @@ -926,6 +1150,7 @@ extension Payload: Codable { case .permissionResponse(let p): try container.encode(TypeKey.permissionResponse.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .questionQueue(let p): try container.encode(TypeKey.questionQueue.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .questionAnswer(let p): try container.encode(TypeKey.questionAnswer.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .planDecision(let p): try container.encode(TypeKey.planDecision.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .branchOpRequest(let p): try container.encode(TypeKey.branchOpRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .branchOpResult(let p): try container.encode(TypeKey.branchOpResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .ping(let p): try container.encode(TypeKey.ping.rawValue, forKey: .type); try container.encode(p, forKey: .data) diff --git a/Packages/Sources/RxCodeSync/Transport/RelayClient.swift b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift index 8c03419b..02fecf2e 100644 --- a/Packages/Sources/RxCodeSync/Transport/RelayClient.swift +++ b/Packages/Sources/RxCodeSync/Transport/RelayClient.swift @@ -113,16 +113,25 @@ public actor RelayClient { } private func openSocket() { + // A live socket already exists, or a competing reconnect already won + // the race. Opening another would register a second connection for the + // same pubkey on the relay — the cause of duplicate registrations when + // resuming from background. Never stack sockets. + guard task == nil else { return } + // A disconnect() may have landed while a scheduled reconnect was still + // sleeping; honour it instead of reopening. + guard shouldReconnect else { return } + updateState(.connecting) guard var components = URLComponents(url: relayURL, resolvingAgainstBaseURL: false) else { - handleSocketFailure(error: RelayError.invalidURL) + handleSocketFailure(of: nil, error: RelayError.invalidURL) return } components.path = Self.webSocketPath(from: components.path) components.queryItems = [URLQueryItem(name: "pubkey", value: identity.publicKeyHex)] guard let url = components.url else { - handleSocketFailure(error: RelayError.invalidURL) + handleSocketFailure(of: nil, error: RelayError.invalidURL) return } @@ -137,7 +146,7 @@ public actor RelayClient { // in .connecting and let the first successful ping flip us to // .connected, so the UI reflects the real handshake outcome. startReceiveLoop() - sendPing() + sendPing(on: newTask) startPingLoop() } @@ -148,7 +157,10 @@ public actor RelayClient { return "/" + trimmed + "/ws" } - private func markConnected() { + private func markConnected(_ socket: URLSessionWebSocketTask) { + // A ping that completes after its socket was already replaced must not + // resurrect a stale connection's state. + guard socket === task else { return } if state != .connected { reconnectAttempt = 0 updateState(.connected) @@ -161,17 +173,22 @@ public actor RelayClient { while !Task.isCancelled { try? await Task.sleep(nanoseconds: 25 * 1_000_000_000) guard let self else { return } - await self.sendPing() + await self.pingCurrentSocket() } } } - private func sendPing() { - task?.sendPing { [weak self] error in + private func pingCurrentSocket() { + guard let task else { return } + sendPing(on: task) + } + + private func sendPing(on socket: URLSessionWebSocketTask) { + socket.sendPing { [weak self] error in if let error { - Task { await self?.handleSocketFailure(error: error) } + Task { await self?.handleSocketFailure(of: socket, error: error) } } else { - Task { await self?.markConnected() } + Task { await self?.markConnected(socket) } } } } @@ -196,7 +213,7 @@ public actor RelayClient { @unknown default: break } } catch { - handleSocketFailure(error: error) + handleSocketFailure(of: task, error: error) } } @@ -229,7 +246,15 @@ public actor RelayClient { } } - private func handleSocketFailure(error: Error) { + private func handleSocketFailure(of failedTask: URLSessionWebSocketTask?, error: Error) { + // The receive loop and the ping handler both observe the same dead + // socket. Only the first report — the one still matching the current + // task — may drive a reconnect; later reports (including stale pings + // that resolve after a new socket is already up) are ignored. Without + // this guard each report would schedule its own reconnect and the + // client would open, and register, multiple sockets on the relay. + if let failedTask, failedTask !== task { return } + closeSocketLocally() guard shouldReconnect else { return } reconnectAttempt += 1 diff --git a/Packages/Tests/RxCodeCoreTests/MarkdownStrippingTests.swift b/Packages/Tests/RxCodeCoreTests/MarkdownStrippingTests.swift new file mode 100644 index 00000000..1ae48f66 --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/MarkdownStrippingTests.swift @@ -0,0 +1,74 @@ +import Testing +@testable import RxCodeCore + +@Suite("Markdown stripping for notification banners") +struct MarkdownStrippingTests { + @Test("Strips bold and italic markers") + func stripsEmphasis() { + #expect(stripMarkdown("Added a **new feature** today") == "Added a new feature today") + #expect(stripMarkdown("This is *important* work") == "This is important work") + #expect(stripMarkdown("Use __strong__ and _emphasis_") == "Use strong and emphasis") + } + + @Test("Preserves snake_case and arithmetic") + func preservesNonMarkdownUnderscoresAndStars() { + #expect(stripMarkdown("Renamed user_session_id") == "Renamed user_session_id") + #expect(stripMarkdown("Computed 2 * 3 inline") == "Computed 2 * 3 inline") + } + + @Test("Strips ATX headings") + func stripsHeadings() { + #expect(stripMarkdown("## Done") == "Done") + #expect(stripMarkdown("### Summary of changes") == "Summary of changes") + } + + @Test("Strips inline and fenced code") + func stripsCode() { + #expect(stripMarkdown("Fixed the `edge case` handler") == "Fixed the edge case handler") + let fenced = """ + Here is code: + ```swift + let x = 1 + ``` + """ + #expect(stripMarkdown(fenced) == "Here is code: let x = 1") + } + + @Test("Strips links and images to their text") + func stripsLinksAndImages() { + #expect(stripMarkdown("See [the docs](https://example.com)") == "See the docs") + #expect(stripMarkdown("![diagram](img.png) attached") == "diagram attached") + } + + @Test("Strips blockquotes, list markers and rules") + func stripsBlockStructure() { + #expect(stripMarkdown("> quoted note") == "quoted note") + #expect(stripMarkdown("- first\n- second") == "first second") + #expect(stripMarkdown("1. step one\n2. step two") == "step one step two") + #expect(stripMarkdown("Above\n---\nBelow") == "Above Below") + } + + @Test("Strips strikethrough") + func stripsStrikethrough() { + #expect(stripMarkdown("This was ~~removed~~ cleanly") == "This was removed cleanly") + } + + @Test("Collapses multi-line text into a single line") + func collapsesToSingleLine() { + #expect(stripMarkdown("Line one\n\nLine two") == "Line one Line two") + } + + @Test("Leaves an unbalanced bold marker from a sentence slice intact-ish") + func handlesUnbalancedMarkers() { + // A first-sentence slice can cut a **bold** span; the result must not crash + // and should still be readable. + let result = stripMarkdown("Refactored the **auth") + #expect(result.contains("Refactored the")) + #expect(result.contains("auth")) + } + + @Test("Plain text passes through unchanged") + func plainTextUnchanged() { + #expect(stripMarkdown("Just a normal sentence.") == "Just a normal sentence.") + } +} diff --git a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift index 43d8b214..ec652853 100644 --- a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift +++ b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift @@ -64,6 +64,97 @@ struct PayloadTests { #expect(snapshot.settings?.permissionMode == .acceptEdits) } + @Test("snapshot carries agent usage") + func snapshotCarriesAgentUsage() throws { + let payload = Payload.snapshot( + SnapshotPayload( + projects: [], + sessions: [], + usage: MobileUsageSnapshot( + claudeCode: RateLimitUsage( + fiveHourPercent: 42, + sevenDayPercent: 13, + fiveHourResetsAt: Date(timeIntervalSince1970: 100), + sevenDayResetsAt: Date(timeIntervalSince1970: 200) + ), + codex: RateLimitUsage( + fiveHourPercent: 7, + sevenDayPercent: 0, + twentyFourHourPercent: 55, + fiveHourResetsAt: nil, + sevenDayResetsAt: nil, + twentyFourHourResetsAt: Date(timeIntervalSince1970: 300) + ) + ) + ) + ) + + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(Payload.self, from: data) + guard case .snapshot(let snapshot) = decoded else { + Issue.record("Expected snapshot payload") + return + } + + #expect(snapshot.usage?.hasAnyUsage == true) + #expect(snapshot.usage?.claudeCode?.fiveHourPercent == 42) + #expect(snapshot.usage?.claudeCode?.sevenDayResetsAt == Date(timeIntervalSince1970: 200)) + #expect(snapshot.usage?.codex?.twentyFourHourPercent == 55) + } + + @Test("snapshot without usage decodes to nil") + func snapshotWithoutUsageDecodesToNil() throws { + let payload = Payload.snapshot(SnapshotPayload(projects: [], sessions: [])) + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(Payload.self, from: data) + guard case .snapshot(let snapshot) = decoded else { + Issue.record("Expected snapshot payload") + return + } + #expect(snapshot.usage == nil) + #expect(snapshot.hostMetrics == nil) + } + + @Test("snapshot carries host metrics") + func snapshotCarriesHostMetrics() throws { + let payload = Payload.snapshot( + SnapshotPayload( + projects: [], + sessions: [], + hostMetrics: HostMetricsSnapshot( + cpuUsagePercent: 37.5, + memoryUsedBytes: 8_000_000_000, + memoryTotalBytes: 16_000_000_000, + thermalState: .fair, + sampledAt: Date(timeIntervalSince1970: 500) + ) + ) + ) + + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(Payload.self, from: data) + guard case .snapshot(let snapshot) = decoded else { + Issue.record("Expected snapshot payload") + return + } + + #expect(snapshot.hostMetrics?.cpuUsagePercent == 37.5) + #expect(snapshot.hostMetrics?.memoryUsedBytes == 8_000_000_000) + #expect(snapshot.hostMetrics?.thermalState == .fair) + #expect(snapshot.hostMetrics?.memoryUsedPercent == 50) + } + + @Test("host metrics tolerates an unknown thermal state") + func hostMetricsToleratesUnknownThermalState() throws { + // Simulate a newer desktop sending a thermal state this build predates. + let json = """ + {"cpuUsagePercent":10,"memoryUsedBytes":1,"memoryTotalBytes":2,\ + "thermalState":"meltdown","sampledAt":0} + """ + let decoded = try JSONDecoder().decode(HostMetricsSnapshot.self, from: Data(json.utf8)) + #expect(decoded.thermalState == .unknown) + } + @Test("settings update round trips") func settingsUpdateRoundTrips() throws { let payload = Payload.settingsUpdate( diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 30b8007a..cb726dbf 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -1220,6 +1220,19 @@ final class AppState { } mobileSyncObservers.append(questionAnswerObserver) + let planDecisionObserver = center.addObserver( + forName: .mobileSyncPlanDecisionReceived, + object: nil, + queue: nil + ) { [weak self] notification in + guard let payload = notification.userInfo?["payload"] as? PlanDecisionPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobilePlanDecision(payload) + } + } + mobileSyncObservers.append(planDecisionObserver) + observeMobileSnapshotInputs() } @@ -1239,6 +1252,8 @@ final class AppState { _ = threadSummaryRevision _ = projects.count _ = allSessionSummaries.count + _ = latestRateLimitUsage + _ = latestCodexRateLimitUsage } onChange: { Task { @MainActor [weak self] in self?.scheduleMobileSnapshotBroadcast() @@ -1263,6 +1278,25 @@ final class AppState { let window = WindowState() window.selectedProject = project + // Per-thread agent config captured in the mobile new-thread sheet. These + // session-scoped fields win over the project/global defaults in + // `effectiveModelSelection`, so the thread runs on exactly the chosen + // model — and starts in plan mode when requested. Carrying the config in + // the request also removes the `settings_update`/`newSessionRequest` + // ordering race. + if let provider = request.selectedAgentProvider { + window.sessionAgentProvider = provider + } + if let model = request.selectedModel, !model.isEmpty { + window.sessionModel = model + } + if let effort = request.selectedEffort, !effort.isEmpty, effort != "auto" { + window.sessionEffort = effort + } + if let mode = request.permissionMode { + window.sessionPermissionMode = mode + } + window.sessionPlanMode = request.planMode ?? false if let pending = mobilePendingWorktrees.removeValue(forKey: project.id) { window.pendingWorktreePath = pending.path window.pendingWorktreeBranch = pending.branch @@ -1385,11 +1419,27 @@ final class AppState { } private func handleMobileCancelStream(_ cancel: CancelStreamPayload) async { - let sessionID = cancel.sessionID - guard sessionStates[sessionID]?.isStreaming == true else { return } + // The mobile may hold a session id the CLI has since advanced + // (pending-→real swap, or a compaction boundary). Follow the redirect + // chain so the cancel lands on the live, streaming thread — otherwise + // `sessionStates[sessionID]` is nil and the stop button is a no-op. + let sessionID = resolveCurrentSessionId(cancel.sessionID) + guard sessionStates[sessionID]?.isStreaming == true else { + logger.info("[MobileSync] cancel ignored — thread=\(sessionID, privacy: .public) (from \(cancel.sessionID, privacy: .public)) is not streaming") + return + } let window = WindowState() window.currentSessionId = sessionID + // Resolve the project so cancelStreaming can persist the partial + // messages accumulated up to the cancellation point. + if let summary = allSessionSummaries.first(where: { $0.id == sessionID }) { + window.selectedProject = projects.first(where: { $0.id == summary.projectId }) + } await cancelStreaming(in: window) + // cancelStreaming intentionally skips finalizeStreamSession, so no + // status update is emitted — broadcast it so the mobile flips its + // stop button back to send. + broadcastMobileSessionStatus(sessionID: sessionID) } private func handleMobileUserMessage(_ message: UserMessagePayload, fromHex: String) async { @@ -1642,6 +1692,16 @@ final class AppState { } private func sendMobileSnapshot(toHex hex: String, activeSessionID: String?) async { + // Populate the OpenAI summarization model list so mobile's model + // picker has options. Fetched at most once — a prior failure (no API + // key, bad endpoint) is recorded in `openAISummarizationModelsError` + // and not retried on every snapshot. + if summarizationProvider == .openAI, + openAISummarizationModels.isEmpty, + openAISummarizationModelsError == nil, + !isLoadingOpenAISummarizationModels { + await refreshOpenAISummarizationModels() + } let active = await mobileActiveSessionPayload(for: activeSessionID) // Viewing a thread on any paired device counts as reading it: drop the // "finished, unread" flag so the green indicator clears on desktop and @@ -1651,6 +1711,16 @@ final class AppState { updateState(activeID) { $0.hasUncheckedCompletion = false } } let branches = await mobileProjectBranches() + // Keep mobile usage reasonably fresh. Non-forced — RateLimitService's + // 5-minute cache turns this into a no-op when usage was fetched + // recently, so frequent snapshots don't translate into API calls. A + // genuine refresh updates `latestRateLimitUsage`, which + // `observeMobileSnapshotInputs` tracks, re-broadcasting the snapshot. + Task { [weak self] in + await self?.refreshRateLimitUsage() + await self?.refreshCodexRateLimitUsage() + } + let hostMetrics = await SystemMetricsService.shared.sample() let payload = SnapshotPayload( projects: projects, sessions: mobileSessionSummaries(), @@ -1660,7 +1730,12 @@ final class AppState { activeSessionID: active.id, activeSessionMessages: active.messages, activeSessionHasMore: active.hasMore, - projectBranches: branches + projectBranches: branches, + usage: MobileUsageSnapshot( + claudeCode: latestRateLimitUsage, + codex: latestCodexRateLimitUsage + ), + hostMetrics: hostMetrics ) await MobileSyncService.shared.send(.snapshot(payload), toHex: hex) // The snapshot doesn't carry the question queue; send it alongside so a @@ -1759,6 +1834,10 @@ final class AppState { ) { guard !MobileSyncService.shared.pairedDevices.isEmpty else { return } if prev.count == next.count, prev == next { return } + // Diff against the raw messages (so a CLI result overwrite still counts + // as a change), but bake the user-decision summary into the broadcast + // copy so the mobile plan banner clears once a plan is resolved. + let summaries = sessionStates[sessionKey]?.planDecisionSummaries ?? [:] let prevById = Dictionary(uniqueKeysWithValues: prev.map { ($0.id, $0) }) for message in next { if let old = prevById[message.id] { @@ -1766,14 +1845,14 @@ final class AppState { MobileSyncService.shared.broadcastSessionUpdate( sessionID: sessionKey, kind: .messageUpdated, - message: message, + message: messageWithPlanDecisions(message, summaries: summaries), isStreaming: isStreaming ) } else { MobileSyncService.shared.broadcastSessionUpdate( sessionID: sessionKey, kind: .messageAppended, - message: message, + message: messageWithPlanDecisions(message, summaries: summaries), isStreaming: isStreaming ) } @@ -1890,7 +1969,11 @@ final class AppState { autoPreviewSettings: autoPreviewSettings, availableEfforts: ["auto"] + Self.availableEfforts, availableModels: models, - modelSections: sections + modelSections: sections, + availableSummarizationProviders: SummarizationProvider.availableCases.map { + SummarizationProviderOption(id: $0.rawValue, displayName: $0.displayName) + }, + openAISummarizationModels: openAISummarizationModels ) } @@ -1937,6 +2020,14 @@ final class AppState { if let mode = update.permissionMode { permissionMode = mode } + if let rawProvider = update.summarizationProvider, + let provider = SummarizationProvider(rawValue: rawProvider), + SummarizationProvider.availableCases.contains(provider) { + summarizationProvider = provider + } + if let model = update.openAISummarizationModel { + openAISummarizationModel = model + } if let enabled = update.notificationsEnabled { notificationsEnabled = enabled } @@ -2013,10 +2104,16 @@ final class AppState { /// session genuinely can't be located. private func fullMobileMessages(for resolvedID: String) async -> [ChatMessage]? { if let state = sessionStates[resolvedID] { - return cleanLoadedMessages(state.messages) + return messagesWithPlanDecisions( + cleanLoadedMessages(state.messages), + summaries: state.planDecisionSummaries + ) } + // Non-active session: the in-memory sidecar is absent, so pull the + // persisted decisions from the thread store. + let summaries = threadStore.loadPlanDecisions(sessionId: resolvedID) if let cache = mobileFullMessageCache, cache.sessionID == resolvedID { - return cache.messages + return messagesWithPlanDecisions(cache.messages, summaries: summaries) } guard let summary = allSessionSummaries.first(where: { $0.id == resolvedID }), let project = projects.first(where: { $0.id == summary.projectId }), @@ -2026,7 +2123,40 @@ final class AppState { } let cleaned = cleanLoadedMessages(full.messages) mobileFullMessageCache = (resolvedID, cleaned) - return cleaned + return messagesWithPlanDecisions(cleaned, summaries: summaries) + } + + /// Bake persisted plan decisions into `ExitPlanMode` tool results before + /// messages are sent to mobile. Once a plan resolves, the CLI overwrites the + /// tool result with its own follow-up text ("User has approved your plan…"), + /// which the desktop UI sidesteps with the `planDecisionSummaries` sidecar. + /// Mobile has no such sidecar — it reads `toolCall.result` directly — so the + /// user-decision summary must be written onto the wire result, otherwise the + /// mobile plan banner reappears after a decision. + private func messagesWithPlanDecisions( + _ messages: [ChatMessage], + summaries: [String: String] + ) -> [ChatMessage] { + guard !summaries.isEmpty else { return messages } + return messages.map { messageWithPlanDecisions($0, summaries: summaries) } + } + + private func messageWithPlanDecisions( + _ message: ChatMessage, + summaries: [String: String] + ) -> ChatMessage { + guard !summaries.isEmpty else { return message } + var result = message + for block in message.blocks { + guard let toolCall = block.toolCall, + PlanLogic.isExitPlanMode(toolCall), + let summary = summaries[toolCall.id] else { continue } + // Skip when the result is already the user-decision string. + if toolCall.result.map(PlanDecisionAction.isUserDecisionResult) != true { + result.setToolResult(id: toolCall.id, result: summary, isError: false) + } + } + return result } /// Build the active-session payload for a snapshot: only the most recent @@ -3040,11 +3170,15 @@ final class AppState { } func openTerminal(in window: WindowState) async { - if window.showInspector, window.inspectorTab == .terminal { - window.showInspector = false + // Right sidebar visibility lives in UserDefaults (read via @AppStorage + // in views). Writing here triggers those views to update. + let defaults = UserDefaults.standard + let key = AppStorageKeys.showRightSidebar + if defaults.bool(forKey: key), window.inspectorTab == .terminal { + defaults.set(false, forKey: key) } else { window.inspectorTab = .terminal - window.showInspector = true + defaults.set(true, forKey: key) } } @@ -4640,6 +4774,36 @@ final class AppState { await permission.respondAskUserQuestion(toolUseId: toolUseId, updatedInput: updatedInput) } + /// Apply a plan decision that arrived from a paired mobile device. Builds a + /// transient `WindowState` bound to the target session — mirroring + /// `handleMobileUserMessage` — and delegates to `respondToPlanDecision`, + /// which owns all the desktop plan logic (CLI hook release, decision summary + /// into `toolCall.result` + `planDecisionSummaries`, `threadStore` persist, + /// one-shot plan-mode clear, follow-up permission mode, continuation prompt). + /// The updated messages broadcast back through the normal session-update sync. + private func handleMobilePlanDecision(_ payload: PlanDecisionPayload) async { + let sessionID = resolveCurrentSessionId(payload.sessionID) + guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }) else { + logger.error("[MobileSync] plan decision for unknown thread=\(payload.sessionID, privacy: .public)") + return + } + let window = WindowState() + window.selectedProject = projects.first(where: { $0.id == summary.projectId }) + window.currentSessionId = sessionID + // `respondToPlanDecision` reads `window.sessionPlanMode` to clear plan + // mode one-shot and `window.sessionPermissionMode` for re-registration — + // mirror the live session state into the fresh window. + if let state = sessionStates[sessionID] { + window.sessionPlanMode = state.planMode + window.sessionPermissionMode = state.permissionMode + } + await respondToPlanDecision( + toolUseId: payload.toolUseID, + action: payload.toDecisionAction(), + in: window + ) + } + /// Remove a resolved `AskUserQuestion` request from every window's queue and /// re-broadcast the (now smaller) queue to mobile. private func clearPendingQuestion(toolUseId: String, sessionId: String?) { diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index cdb0576d..91f0e683 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -529,6 +529,13 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["from": inbound.fromHex, "payload": answer] ) + case .planDecision(let decision): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "plan_decision") else { return } + NotificationCenter.default.post( + name: .mobileSyncPlanDecisionReceived, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": decision] + ) case .branchOpRequest(let req): guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "branch_op_request") else { return } NotificationCenter.default.post( @@ -691,5 +698,6 @@ extension Notification.Name { static let mobileSyncSettingsUpdateReceived = Notification.Name("mobileSync.settingsUpdateReceived") static let mobileSyncPermissionResponse = Notification.Name("mobileSync.permissionResponse") static let mobileSyncQuestionAnswerReceived = Notification.Name("mobileSync.questionAnswerReceived") + static let mobileSyncPlanDecisionReceived = Notification.Name("mobileSync.planDecisionReceived") static let mobileSyncBranchOpRequested = Notification.Name("mobileSync.branchOpRequested") } diff --git a/RxCode/Services/NotificationService.swift b/RxCode/Services/NotificationService.swift index 21eb73c0..851359ca 100644 --- a/RxCode/Services/NotificationService.swift +++ b/RxCode/Services/NotificationService.swift @@ -1,5 +1,6 @@ import AppKit import UserNotifications +import RxCodeCore import RxCodeSync import os.log @@ -186,10 +187,13 @@ final class NotificationService: NSObject { /// Mobile fan-out always runs; the local macOS banner is skipped when /// `postLocalBanner` is false (e.g. the desktop app is foregrounded). func postResponseComplete(title: String, body: String, projectId: UUID, sessionId: String, postLocalBanner: Bool = true) async { + // Notification banners (the APNs alert and the macOS local banner) render + // Markdown syntax literally, so strip it from the assistant-summary body. + let cleanBody = stripMarkdown(body) await fanoutToMobile(.init( kind: .responseComplete, title: title, - body: body.isEmpty ? "Response complete" : body, + body: cleanBody.isEmpty ? "Response complete" : cleanBody, sessionID: sessionId, projectID: projectId )) @@ -204,9 +208,9 @@ final class NotificationService: NSObject { let content = UNMutableNotificationContent() content.title = title - content.body = body.isEmpty + content.body = cleanBody.isEmpty ? NSLocalizedString("Response complete", comment: "Notification body when Claude finishes a response") - : body + : cleanBody content.sound = .default content.userInfo = ["projectId": projectId.uuidString, "sessionId": sessionId] diff --git a/RxCode/Services/SystemMetricsService.swift b/RxCode/Services/SystemMetricsService.swift new file mode 100644 index 00000000..0c13fa86 --- /dev/null +++ b/RxCode/Services/SystemMetricsService.swift @@ -0,0 +1,140 @@ +import Darwin +import Foundation +import RxCodeSync + +/// Samples the host machine's CPU, memory, and thermal load so the desktop can +/// mirror a "Computer Status" panel to paired mobile devices. +/// +/// CPU utilization is a delta between two tick samples; the service keeps the +/// previous sample plus a short result cache so a burst of snapshot broadcasts +/// (one per paired device) share a single, meaningful reading instead of each +/// computing a near-zero delta over a sub-millisecond window. +actor SystemMetricsService { + + static let shared = SystemMetricsService() + + /// Aggregate CPU tick counters read from `host_statistics`. + private struct CPUTicks { + let user: UInt64 + let system: UInt64 + let idle: UInt64 + let nice: UInt64 + var total: UInt64 { user &+ system &+ idle &+ nice } + var busy: UInt64 { user &+ system &+ nice } + } + + private var previousTicks: CPUTicks? + private var cached: HostMetricsSnapshot? + private var cachedAt: Date? + /// Reuse a reading for this long — longer than a multi-device broadcast + /// burst, shorter than the mobile pull-to-refresh interval. + private let cacheTTL: TimeInterval = 2 + + /// Current host metrics, served from a short-lived cache when fresh. + func sample() async -> HostMetricsSnapshot { + if let cached, let cachedAt, Date().timeIntervalSince(cachedAt) < cacheTTL { + return cached + } + let snapshot = await computeSnapshot() + cached = snapshot + cachedAt = Date() + return snapshot + } + + private func computeSnapshot() async -> HostMetricsSnapshot { + let cpu = await currentCPUUsage() + let memory = currentMemory() + return HostMetricsSnapshot( + cpuUsagePercent: cpu, + memoryUsedBytes: memory.used, + memoryTotalBytes: memory.total, + thermalState: Self.thermalState(), + sampledAt: Date() + ) + } + + // MARK: - CPU + + private func currentCPUUsage() async -> Double { + guard let current = Self.readCPUTicks() else { return 0 } + guard let previous = previousTicks else { + // Cold start: no baseline yet. Take a second sample after a brief + // pause so the first reading isn't a meaningless 0%. + try? await Task.sleep(nanoseconds: 250_000_000) + guard let second = Self.readCPUTicks() else { + previousTicks = current + return 0 + } + previousTicks = second + return Self.percentage(from: current, to: second) + } + previousTicks = current + return Self.percentage(from: previous, to: current) + } + + private static func readCPUTicks() -> CPUTicks? { + var info = host_cpu_load_info() + var count = mach_msg_type_number_t( + MemoryLayout.stride / MemoryLayout.stride + ) + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count) + } + } + guard result == KERN_SUCCESS else { return nil } + // cpu_ticks is indexed by CPU_STATE_{USER,SYSTEM,IDLE,NICE} = 0,1,2,3. + return CPUTicks( + user: UInt64(info.cpu_ticks.0), + system: UInt64(info.cpu_ticks.1), + idle: UInt64(info.cpu_ticks.2), + nice: UInt64(info.cpu_ticks.3) + ) + } + + private static func percentage(from start: CPUTicks, to end: CPUTicks) -> Double { + let totalDelta = Double(end.total &- start.total) + guard totalDelta > 0 else { return 0 } + let busyDelta = Double(end.busy &- start.busy) + return min(100, max(0, busyDelta / totalDelta * 100)) + } + + // MARK: - Memory + + private func currentMemory() -> (used: UInt64, total: UInt64) { + let total = ProcessInfo.processInfo.physicalMemory + var stats = vm_statistics64() + var count = mach_msg_type_number_t( + MemoryLayout.stride / MemoryLayout.stride + ) + let result = withUnsafeMutablePointer(to: &stats) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count) + } + } + guard result == KERN_SUCCESS else { return (0, total) } + + var pageSize: vm_size_t = 0 + guard host_page_size(mach_host_self(), &pageSize) == KERN_SUCCESS else { + return (0, total) + } + // Activity Monitor's "Memory Used" footprint: resident pages that + // aren't trivially reclaimable — active, wired, and compressed. + let usedPages = UInt64(stats.active_count) + &+ UInt64(stats.wire_count) + &+ UInt64(stats.compressor_page_count) + return (usedPages &* UInt64(pageSize), total) + } + + // MARK: - Thermal + + private static func thermalState() -> HostMetricsSnapshot.ThermalState { + switch ProcessInfo.processInfo.thermalState { + case .nominal: return .nominal + case .fair: return .fair + case .serious: return .serious + case .critical: return .critical + @unknown default: return .unknown + } + } +} diff --git a/RxCode/Views/Inspector/RightInspectorPanel.swift b/RxCode/Views/Inspector/RightInspectorPanel.swift index 65751386..c334645d 100644 --- a/RxCode/Views/Inspector/RightInspectorPanel.swift +++ b/RxCode/Views/Inspector/RightInspectorPanel.swift @@ -18,6 +18,7 @@ struct InspectorTerminal: Identifiable { struct RightInspectorPanel: View { @Environment(WindowState.self) private var windowState + @AppStorage(AppStorageKeys.showRightSidebar) private var showRightSidebar = false // Per-thread terminal storage. Each session/thread can have multiple // terminals; all stay alive across thread switches. @@ -153,28 +154,27 @@ struct RightInspectorPanel: View { .frame(width: 0.5) } .frame( - minWidth: windowState.showInspector ? 420 : 0, - maxWidth: windowState.showInspector ? .infinity : 0 + minWidth: showRightSidebar ? 420 : 0, + maxWidth: showRightSidebar ? .infinity : 0 ) - .opacity(windowState.showInspector ? 1 : 0) + .opacity(showRightSidebar ? 1 : 0) .clipped() .background(terminalShortcuts) .task(id: currentSessionKey) { + // Ensure the terminal process exists for this session. The panel's + // visibility is owned by `showRightSidebar` (@AppStorage) — do not + // force it open here, or the user could never close it. ensureTerminal(for: currentSessionKey) - // Keep the user's last-chosen inspector mode/tab (persisted in - // WindowState). Just make sure the panel is visible and the - // terminal process exists for this session. - windowState.showInspector = true } .onChange(of: windowState.inspectorTab) { _, newTab in if windowState.inspectorMode == .inspector { bumpFocus(for: newTab) } } .onChange(of: windowState.inspectorMode) { _, newMode in - if newMode == .inspector, windowState.showInspector { + if newMode == .inspector, showRightSidebar { bumpFocus(for: windowState.inspectorTab) } } - .onChange(of: windowState.showInspector) { _, isShowing in + .onChange(of: showRightSidebar) { _, isShowing in if isShowing, windowState.inspectorMode == .inspector { bumpFocus(for: windowState.inspectorTab) } @@ -193,7 +193,7 @@ struct RightInspectorPanel: View { /// from the single global Cmd+K handler in MainView. @ViewBuilder private var terminalShortcuts: some View { - if windowState.showInspector, + if showRightSidebar, windowState.inspectorMode == .inspector, windowState.inspectorTab == .terminal { Button("New Terminal", action: addTerminalToCurrent) @@ -242,7 +242,7 @@ struct RightInspectorPanel: View { } HeaderIconButton(systemImage: "xmark", help: "Close") { - windowState.showInspector = false + showRightSidebar = false } .keyboardShortcut("w", modifiers: .command) } diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index 1ebe56b0..b3d0c8f7 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -13,6 +13,7 @@ struct MainView: View { @State private var sidebarTab: SidebarTab = .history @State private var fileSearchTrigger = false @State private var inspectorStarted = false + @AppStorage(AppStorageKeys.showRightSidebar) private var showRightSidebar = false @State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var projectToDelete: Project? = nil @State private var projectToRename: Project? = nil @@ -57,7 +58,7 @@ struct MainView: View { HSplitView { navigationContent .id(appState.themeRevision) - .onChange(of: windowState.showInspector) { _, isShowing in + .onChange(of: showRightSidebar) { _, isShowing in if isShowing, !inspectorStarted { inspectorStarted = true } } .onChange(of: appState.focusMode) { _, newValue in @@ -65,6 +66,9 @@ struct MainView: View { } .onAppear { windowState.focusMode = appState.focusMode + // The panel is built lazily; if it was left open in a + // previous launch, start it now so it reappears. + if showRightSidebar { inspectorStarted = true } } .toolbar(removing: .title) .toolbarBackground(.hidden, for: .windowToolbar) @@ -104,7 +108,7 @@ struct MainView: View { Button("") { // Cmd+K is context-sensitive: when the inspector terminal tab // is active, clear the terminal; otherwise open global search. - if windowState.showInspector, + if showRightSidebar, windowState.inspectorMode == .inspector, windowState.inspectorTab == .terminal { windowState.clearTerminalRequest = UUID() diff --git a/RxCode/Views/ProjectWindowView.swift b/RxCode/Views/ProjectWindowView.swift index eb4b773e..4ad868cd 100644 --- a/RxCode/Views/ProjectWindowView.swift +++ b/RxCode/Views/ProjectWindowView.swift @@ -8,6 +8,7 @@ struct ProjectWindowView: View { @Environment(WindowState.self) private var windowState @State private var inspectorStarted = false @State private var columnVisibility: NavigationSplitViewVisibility = .all + @AppStorage(AppStorageKeys.showRightSidebar) private var showRightSidebar = false private var navigationTitleText: String { if windowState.showingBriefing { @@ -39,7 +40,7 @@ struct ProjectWindowView: View { } .id(appState.themeRevision) .navigationTitle(navigationTitleText) - .onChange(of: windowState.showInspector) { _, isShowing in + .onChange(of: showRightSidebar) { _, isShowing in if isShowing, !inspectorStarted { inspectorStarted = true } } .onChange(of: appState.focusMode) { _, newValue in @@ -47,6 +48,7 @@ struct ProjectWindowView: View { } .onAppear { windowState.focusMode = appState.focusMode + if showRightSidebar { inspectorStarted = true } } if inspectorStarted { diff --git a/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift b/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift index 003fe8ac..69727571 100644 --- a/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift +++ b/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift @@ -8,6 +8,7 @@ import SwiftUI struct RunProfileToolbarGroup: View { @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState + @AppStorage(AppStorageKeys.showRightSidebar) private var showRightSidebar = false /// Destinations cached per (container, scheme). Loaded on demand when /// the picker first appears or the refresh button is tapped. @@ -207,7 +208,7 @@ struct RunProfileToolbarGroup: View { private func openRunInspector() { windowState.inspectorMode = .inspector windowState.inspectorTab = .run - windowState.showInspector = true + showRightSidebar = true windowState.selectedRunTaskId = appState.runService.activeTasks.first?.id } diff --git a/RxCode/Views/Toolbar/RxCodeToolbar.swift b/RxCode/Views/Toolbar/RxCodeToolbar.swift index e7c51f92..c7855543 100644 --- a/RxCode/Views/Toolbar/RxCodeToolbar.swift +++ b/RxCode/Views/Toolbar/RxCodeToolbar.swift @@ -14,6 +14,7 @@ struct RxCodeToolbarContent: ToolbarContent { @Environment(WindowState.self) private var windowState @Environment(\.openSettings) private var openSettings @Environment(\.openWindow) private var openWindow + @AppStorage(AppStorageKeys.showRightSidebar) private var showRightSidebar = false private var fileEditCount: Int { _ = appState.threadFileEditsRevision @@ -47,7 +48,7 @@ struct RxCodeToolbarContent: ToolbarContent { } else { windowState.inspectorMode = .inspector windowState.inspectorTab = .terminal - windowState.showInspector = true + showRightSidebar = true } } label: { Image(systemName: "apple.terminal") @@ -57,14 +58,14 @@ struct RxCodeToolbarContent: ToolbarContent { Button { windowState.inspectorMode = .inspector windowState.inspectorTab = .memo - windowState.showInspector = true + showRightSidebar = true } label: { Image(systemName: "note.text") } .help("Memo") Button { - windowState.showInspector.toggle() + showRightSidebar.toggle() } label: { Image(systemName: "sidebar.trailing") .overlay(alignment: .topTrailing) { diff --git a/RxCodeMobile/AppDelegate.swift b/RxCodeMobile/AppDelegate.swift index 8369c267..6bb85230 100644 --- a/RxCodeMobile/AppDelegate.swift +++ b/RxCodeMobile/AppDelegate.swift @@ -7,7 +7,17 @@ import os.log /// Bridges UIKit's APNs registration into `MobileAppState`. The state object /// forwards the device token to the paired desktop over the encrypted relay. final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { - weak var mobileState: MobileAppState? + weak var mobileState: MobileAppState? { + didSet { + guard let target = pendingNotificationDeepLink, let state = mobileState else { return } + pendingNotificationDeepLink = nil + logger.info("[APNs] flushing buffered notification deep link sessionID=\(target.sessionID, privacy: .public)") + state.openThreadFromNotification(sessionID: target.sessionID, projectID: target.projectID) + } + } + /// Buffers a notification tap that arrives before `mobileState` is wired up + /// (cold launch). Flushed by the `mobileState` `didSet` above. + private var pendingNotificationDeepLink: (sessionID: String, projectID: UUID?)? private let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "AppDelegate") func application(_ application: UIApplication, @@ -68,21 +78,83 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent return [.banner, .sound, .list] } + /// Handles a notification tap — banner, lock screen, or cold launch from a + /// killed app. Routes the UI to the notification's thread. + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse) async { + let requestID = response.notification.request.identifier + guard response.actionIdentifier == UNNotificationDefaultActionIdentifier else { + logger.info("[APNs] notification action ignored request=\(requestID, privacy: .public) action=\(response.actionIdentifier, privacy: .public)") + return + } + let userInfo = response.notification.request.content.userInfo + guard let target = notificationThreadTarget(from: userInfo, requestID: requestID) else { + logger.info("[APNs] notification tap has no thread target request=\(requestID, privacy: .public)") + return + } + if let state = mobileState { + state.openThreadFromNotification(sessionID: target.sessionID, projectID: target.projectID) + } else { + logger.info("[APNs] notification tap buffered (state not ready) request=\(requestID, privacy: .public)") + pendingNotificationDeepLink = target + } + } + + /// Resolves the `(sessionID, projectID)` a tapped notification points to. + /// The NSE / foreground handler usually decrypts these into `userInfo` + /// already; if only the encrypted `enc` blob is present (NSE failed), this + /// decrypts it as a fallback. + private func notificationThreadTarget(from userInfo: [AnyHashable: Any], + requestID: String) -> (sessionID: String, projectID: UUID?)? { + if let sessionID = userInfo["sessionId"] as? String, !sessionID.isEmpty { + let projectID = (userInfo["projectId"] as? String).flatMap(UUID.init) + return (sessionID, projectID) + } + guard let plaintext = decryptAlertPlaintext(from: userInfo, context: requestID), + let sessionID = plaintext.sessionID, !sessionID.isEmpty + else { return nil } + return (sessionID, plaintext.projectID) + } + private func decryptedForegroundNotification(from content: UNNotificationContent, requestID: String) -> UNMutableNotificationContent? { - guard let encB64 = content.userInfo["enc"] as? String else { - logger.info("[APNs] foreground notification has no enc payload request=\(requestID, privacy: .public)") + guard let plaintext = decryptAlertPlaintext(from: content.userInfo, context: requestID) else { + return nil + } + guard let replacement = content.mutableCopy() as? UNMutableNotificationContent else { + logger.error("[APNs] foreground unable to copy notification content request=\(requestID, privacy: .public)") + return nil + } + replacement.title = plaintext.title + replacement.body = plaintext.body + var userInfo = replacement.userInfo + userInfo["rxcodeForegroundDecrypted"] = true + if let sessionID = plaintext.sessionID { userInfo["sessionId"] = sessionID } + if let projectID = plaintext.projectID { userInfo["projectId"] = projectID.uuidString } + if let kind = plaintext.kind { userInfo["kind"] = kind } + replacement.userInfo = userInfo + logger.info("[APNs] foreground decrypted notification request=\(requestID, privacy: .public) kind=\(plaintext.kind ?? "", privacy: .public) sessionID=\(plaintext.sessionID ?? "", privacy: .public)") + return replacement + } + + /// Decrypts the `enc` blob nested in an APNs payload into its + /// `AlertPlaintext`. Shared by the foreground replacement path and the + /// notification-tap fallback. `context` is a request identifier for logging. + private func decryptAlertPlaintext(from userInfo: [AnyHashable: Any], + context: String) -> AlertPlaintext? { + guard let encB64 = userInfo["enc"] as? String else { + logger.info("[APNs] notification has no enc payload context=\(context, privacy: .public)") return nil } guard let raw = Data(base64Encoded: encB64) else { - logger.error("[APNs] foreground enc payload is not valid base64 request=\(requestID, privacy: .public) length=\(encB64.count, privacy: .public)") + logger.error("[APNs] enc payload is not valid base64 context=\(context, privacy: .public) length=\(encB64.count, privacy: .public)") return nil } let envelope: EncryptedAlert do { envelope = try JSONDecoder().decode(EncryptedAlert.self, from: raw) } catch { - logger.error("[APNs] foreground encrypted alert decode failed request=\(requestID, privacy: .public): \(error.localizedDescription, privacy: .public)") + logger.error("[APNs] encrypted alert decode failed context=\(context, privacy: .public): \(error.localizedDescription, privacy: .public)") return nil } @@ -90,46 +162,29 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent let identity: DeviceIdentity do { identity = try DeviceIdentity.loadOrCreate(accessGroup: accessGroup) - logger.info("[APNs] foreground identity loaded accessGroup=\(accessGroup, privacy: .public) publicKey=\(String(identity.publicKeyHex.prefix(12)), privacy: .public) sender=\(String(envelope.from.prefix(12)), privacy: .public)") } catch { - logger.error("[APNs] foreground identity load failed accessGroup=\(accessGroup, privacy: .public): \(error.localizedDescription, privacy: .public)") + logger.error("[APNs] identity load failed accessGroup=\(accessGroup, privacy: .public): \(error.localizedDescription, privacy: .public)") return nil } guard let senderRaw = Data(rxcodeHexString: envelope.from) else { - logger.error("[APNs] foreground sender public key is not hex sender=\(String(envelope.from.prefix(12)), privacy: .public)") + logger.error("[APNs] sender public key is not hex sender=\(String(envelope.from.prefix(12)), privacy: .public)") return nil } let senderKey: Curve25519.KeyAgreement.PublicKey do { senderKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: senderRaw) } catch { - logger.error("[APNs] foreground sender public key parse failed sender=\(String(envelope.from.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + logger.error("[APNs] sender public key parse failed sender=\(String(envelope.from.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") return nil } - let plaintext: AlertPlaintext do { - plaintext = try APNsCrypto.open(envelope: envelope, recipient: identity.privateKey, sender: senderKey) + return try APNsCrypto.open(envelope: envelope, recipient: identity.privateKey, sender: senderKey) } catch { - logger.error("[APNs] foreground decrypt failed request=\(requestID, privacy: .public) sender=\(String(envelope.from.prefix(12)), privacy: .public) identity=\(String(identity.publicKeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") - return nil - } - - guard let replacement = content.mutableCopy() as? UNMutableNotificationContent else { - logger.error("[APNs] foreground unable to copy notification content request=\(requestID, privacy: .public)") + logger.error("[APNs] decrypt failed context=\(context, privacy: .public) sender=\(String(envelope.from.prefix(12)), privacy: .public) identity=\(String(identity.publicKeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") return nil } - replacement.title = plaintext.title - replacement.body = plaintext.body - var userInfo = replacement.userInfo - userInfo["rxcodeForegroundDecrypted"] = true - if let sessionID = plaintext.sessionID { userInfo["sessionId"] = sessionID } - if let projectID = plaintext.projectID { userInfo["projectId"] = projectID.uuidString } - if let kind = plaintext.kind { userInfo["kind"] = kind } - replacement.userInfo = userInfo - logger.info("[APNs] foreground decrypted notification request=\(requestID, privacy: .public) kind=\(plaintext.kind ?? "", privacy: .public) sessionID=\(plaintext.sessionID ?? "", privacy: .public)") - return replacement } private static func apnsSummary(_ userInfo: [AnyHashable: Any]) -> String { diff --git a/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeMobile/RxCodeMobileApp.swift index 8d524b34..d6900f09 100644 --- a/RxCodeMobile/RxCodeMobileApp.swift +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -6,6 +6,7 @@ struct RxCodeMobileApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var state = MobileAppState() @State private var windowState = WindowState() + @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { @@ -24,6 +25,9 @@ struct RxCodeMobileApp: App { handlePairingURL(url) } } + .onChange(of: scenePhase) { _, newPhase in + state.handleScenePhase(newPhase) + } } private func handlePairingURL(_ url: URL) { diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index f6aaf2c4..0e6c447a 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -2,10 +2,31 @@ import Foundation import Combine import CryptoKit import RxCodeCore +import RxCodeChatKit import RxCodeSync +import SwiftUI import UIKit import os.log +/// A pending request to open a specific thread, set when the user taps an APNs +/// notification. `RootView` observes this and pushes the chat detail page. +/// `requestID` makes every tap a distinct value, so `onChange` still fires when +/// the user re-taps a notification for a thread they already navigated away from. +struct MobileDeepLink: Equatable { + let sessionID: String + let projectID: UUID? + let requestID = UUID() +} + +struct PairedDesktop: Codable, Identifiable, Equatable, Hashable { + var pubkeyHex: String + var displayName: String + var pairedAt: Date + var lastSeen: Date? + + var id: String { pubkeyHex } +} + /// Single source of truth for the mobile app. Owns the `SyncClient`, the /// decoded projects/sessions/messages mirrored from the paired desktop, and /// the pairing flow. @@ -20,6 +41,7 @@ final class MobileAppState: ObservableObject { @Published var isPaired: Bool = false @Published var pairedDesktopName: String = "" @Published var pairedDesktopPubkey: String = "" + @Published var pairedDesktops: [PairedDesktop] = [] @Published var connectionState: RelayClient.ConnectionState = .disconnected @Published var relayURL: URL @Published var pairingStatus: PairingStatus = .idle @@ -29,6 +51,14 @@ final class MobileAppState: ObservableObject { @Published var branchBriefings: [MobileBranchBriefing] = [] @Published var threadSummaries: [MobileThreadSummary] = [] @Published var desktopSettings: MobileSettingsSnapshot? + /// Agent rate-limit usage mirrored from the paired desktop. `nil` until the + /// first snapshot arrives, or when paired with a desktop that predates + /// usage sync. + @Published var desktopUsage: MobileUsageSnapshot? + /// Desktop CPU/memory/thermal load mirrored from the paired desktop. `nil` + /// until the first snapshot arrives, or when paired with a desktop that + /// predates computer-status sync. + @Published var desktopHostMetrics: HostMetricsSnapshot? /// Current git branch per project, mirrored from the desktop's snapshot. @Published var projectBranches: [UUID: String] = [:] /// Local branch list per project, mirrored from the desktop's snapshot. @@ -53,6 +83,9 @@ final class MobileAppState: ObservableObject { /// Messages per history page — must match the desktop's expectation. private static let messagePageSize = 30 @Published var activeSessionID: String? + /// Set when the user taps an APNs notification; `RootView` observes this and + /// navigates to the thread's chat detail page, then clears it. + @Published var pendingDeepLink: MobileDeepLink? @Published var pendingPermission: PermissionRequestPayload? /// Every `AskUserQuestion` call the desktop currently has awaiting an /// answer, across all sessions. Mirrored from the desktop's queue; the chat @@ -76,6 +109,11 @@ final class MobileAppState: ObservableObject { private var clientStarted = false static let pairingTimeoutSeconds: UInt64 = 25 + private static let pairedDesktopsKey = "mobileSync.pairedDesktops" + private static let activeDesktopPubkeyKey = "mobileSync.activeDesktopPubkey" + private static let legacyDesktopPubkeyKey = "mobileSync.desktopPubkey" + private static let legacyDesktopNameKey = "mobileSync.desktopName" + private static let mobilePubkeyKey = "mobileSync.mobilePubkey" init() { let stored = UserDefaults.standard.string(forKey: "mobileSync.relayURL") @@ -98,7 +136,7 @@ final class MobileAppState: ObservableObject { } self.client = SyncClient(identity: identity, relayURL: initial) logger.info("[MobileIdentity] loaded publicKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public) accessGroup=\(Self.keychainAccessGroup, privacy: .public)") - loadPairedDesktop() + loadPairedDesktops() } /// Bare suffix as declared (post-`$(AppIdentifierPrefix)`) in @@ -122,16 +160,41 @@ final class MobileAppState: ObservableObject { var localPublicKeyHex: String { identity.publicKeyHex } + var activePairedDesktop: PairedDesktop? { + pairedDesktops.first { $0.pubkeyHex == pairedDesktopPubkey } + } + func start() { Task { @MainActor in await startClient() } } + /// Drive the relay connection from the app lifecycle. iOS suspends the + /// process shortly after backgrounding, which kills the WebSocket on an + /// unpredictable schedule; disconnecting deterministically keeps the + /// relay's registration table in sync with reality and gives a clean, + /// single re-register on the next foreground. + func handleScenePhase(_ phase: ScenePhase) { + guard clientStarted else { return } + switch phase { + case .background: + logger.info("[Lifecycle] entering background — disconnecting relay") + Task { await client.stop() } + case .active: + logger.info("[Lifecycle] entering foreground — reconnecting relay") + Task { await client.start() } + case .inactive: + break + @unknown default: + break + } + } + private func startClient() async { clientStarted = true - if !pairedDesktopPubkey.isEmpty { - try? await client.addPeer(pairedDesktopPubkey) + for desktop in pairedDesktops { + try? await client.addPeer(desktop.pubkeyHex) } let events = await client.events() eventTask?.cancel() @@ -236,7 +299,7 @@ final class MobileAppState: ObservableObject { func cancelPairing() { pairingTimeoutTask?.cancel() pairingTimeoutTask = nil - if !isPaired { pairingStatus = .idle } + pairingStatus = .idle } func dismissPairingError() { @@ -251,7 +314,7 @@ final class MobileAppState: ObservableObject { guard !Task.isCancelled else { return } await MainActor.run { guard let self else { return } - guard !self.isPaired else { return } + guard self.pairingStatus == .inProgress else { return } self.failPairing( "Your Mac didn't respond. Make sure RxCode is open and connected, then try again." ) @@ -305,12 +368,82 @@ final class MobileAppState: ObservableObject { sessions.first(where: { $0.id == sessionID })?.queuedMessages ?? [] } - func requestNewSession(projectID: UUID, initialText: String? = nil) async { + /// Ask the desktop to create a new thread. The per-thread agent config + /// (model, permission mode, plan mode) travels in the request so the thread + /// is created with exactly the chosen config — fixing the bug where a + /// non-default model was dropped in favor of the project default. ACP client + /// and effort have no mobile picker, so they ride along from the last synced + /// desktop settings. + func requestNewSession( + projectID: UUID, + initialText: String? = nil, + agentProvider: AgentProvider? = nil, + model: String? = nil, + permissionMode: PermissionMode? = nil, + planMode: Bool = false + ) async { guard isPaired else { return } - let payload = NewSessionRequestPayload(projectID: projectID, initialText: initialText) + let payload = NewSessionRequestPayload( + projectID: projectID, + initialText: initialText, + selectedAgentProvider: agentProvider, + selectedModel: model, + selectedACPClientId: desktopSettings?.selectedACPClientId, + selectedEffort: desktopSettings?.selectedEffort, + permissionMode: permissionMode, + planMode: planMode + ) try? await client.send(.newSessionRequest(payload), toHex: pairedDesktopPubkey) } + // MARK: - Plan mode + + /// Plan cards for a session, derived live from the synced messages using the + /// shared `PlanLogic` — the same `ExitPlanMode` detection the desktop chat + /// uses. Superseded plans (re-emitted within the same turn) are dropped so + /// only actionable cards surface. + func pendingPlans(sessionID: String) -> [PendingPlan] { + let messages = messagesBySession[sessionID] ?? [] + var plans: [PendingPlan] = [] + for message in messages { + for block in message.blocks { + guard let toolCall = block.toolCall, + PlanLogic.isExitPlanMode(toolCall) else { continue } + if PlanLogic.isSupersededExitPlanMode( + toolCall: toolCall, in: message, allMessages: messages + ) { continue } + let markdown = PlanLogic.renderMarkdown(for: toolCall, in: message) ?? "" + let decided = PlanLogic.isPlanDecided(toolCall) + plans.append(PendingPlan( + toolCallId: toolCall.id, + markdown: markdown, + isStreaming: !decided && markdown.isEmpty && message.isStreaming, + isDecided: decided, + decisionSummary: decided ? toolCall.result : nil + )) + } + } + return plans + } + + /// Send the user's plan decision to the desktop, which resolves the CLI + /// `ExitPlanMode` hook via `respondToPlanDecision`. No optimistic mutation — + /// the desktop broadcasts the updated `toolCall.result` back through the + /// normal session-update sync, so the banner and chat reconcile from that. + func respondToPlanDecision( + toolUseID: String, + sessionID: String, + action: PlanDecisionAction + ) async { + guard isPaired else { return } + let payload = PlanDecisionPayload( + toolUseID: toolUseID, + sessionID: sessionID, + decision: action + ) + try? await client.send(.planDecision(payload), toHex: pairedDesktopPubkey) + } + // MARK: - Thread lifecycle actions /// Rename a thread. The local title is updated optimistically; the desktop @@ -584,26 +717,57 @@ final class MobileAppState: ObservableObject { Task { await reportAPNsTokenIfPending() } } - func unpair() async { - let desktopHex = pairedDesktopPubkey - if !desktopHex.isEmpty { - try? await client.send(.unpair(UnpairPayload(reason: "mobile")), toHex: desktopHex) - } - await clearPairing(removePeerHex: desktopHex) + /// Routes a tapped APNs notification to its thread. Called by `AppDelegate`'s + /// `didReceive` handler; `RootView` observes `pendingDeepLink` and navigates. + func openThreadFromNotification(sessionID: String, projectID: UUID?) { + logger.info("[APNs] notification tap -> open thread sessionID=\(sessionID, privacy: .public) projectID=\(projectID?.uuidString ?? "", privacy: .public)") + pendingDeepLink = MobileDeepLink(sessionID: sessionID, projectID: projectID) } - private func clearPairing(removePeerHex hex: String?) async { - if let hex, !hex.isEmpty { - await client.removePeer(hex) + func switchPairedDesktop(_ desktop: PairedDesktop) async { + guard pairedDesktops.contains(where: { $0.pubkeyHex == desktop.pubkeyHex }) else { return } + guard desktop.pubkeyHex != pairedDesktopPubkey else { return } + clearDesktopMirror() + setActiveDesktop(pubkeyHex: desktop.pubkeyHex) + savePairedDesktops() + try? await client.addPeer(desktop.pubkeyHex) + await requestSnapshot() + await reportAPNsTokenIfPending() + } + + func removePairedDesktop(_ desktop: PairedDesktop) async { + let wasActive = desktop.pubkeyHex == pairedDesktopPubkey + try? await client.send(.unpair(UnpairPayload(reason: "mobile")), toHex: desktop.pubkeyHex) + pairedDesktops.removeAll { $0.pubkeyHex == desktop.pubkeyHex } + await client.removePeer(desktop.pubkeyHex) + + if wasActive { + clearDesktopMirror() + setActiveDesktop(pubkeyHex: pairedDesktops.first?.pubkeyHex) + } else { + setActiveDesktop(pubkeyHex: pairedDesktopPubkey) } - pairedDesktopPubkey = "" - pairedDesktopName = "" - isPaired = false + savePairedDesktops() + + if wasActive, isPaired { + await requestSnapshot() + await reportAPNsTokenIfPending() + } + } + + func unpair() async { + guard let desktop = activePairedDesktop else { return } + await removePairedDesktop(desktop) + } + + private func clearDesktopMirror() { projects = [] sessions = [] branchBriefings = [] threadSummaries = [] desktopSettings = nil + desktopUsage = nil + desktopHostMetrics = nil projectBranches = [:] availableBranchesByProject = [:] inFlightBranchOps = [] @@ -616,8 +780,6 @@ final class MobileAppState: ObservableObject { activeSessionID = nil pendingPermission = nil pendingQuestions = [] - savePairedDesktop() - await resetIdentityAndClient() } // MARK: - Inbound events @@ -645,30 +807,38 @@ final class MobileAppState: ObservableObject { pairingTimeoutTask?.cancel() pairingTimeoutTask = nil if ack.accepted { - pairedDesktopPubkey = inbound.fromHex - pairedDesktopName = ack.desktopName - isPaired = true + upsertPairedDesktop( + PairedDesktop( + pubkeyHex: inbound.fromHex, + displayName: ack.desktopName, + pairedAt: .now, + lastSeen: .now + ) + ) + setActiveDesktop(pubkeyHex: inbound.fromHex) pairingStatus = .idle logger.info("[Pairing] accepted desktop=\(ack.desktopName, privacy: .public) desktopKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") MobileHaptics.connected() - savePairedDesktop() + savePairedDesktops() Task { await self.requestSnapshot() await self.reportAPNsTokenIfPending() } } else { - isPaired = false failPairing("Your Mac declined the pairing request.") } case .unpair: - guard inbound.fromHex == pairedDesktopPubkey else { return } - Task { await self.clearPairing(removePeerHex: inbound.fromHex) } + guard let desktop = pairedDesktops.first(where: { $0.pubkeyHex == inbound.fromHex }) else { return } + Task { await self.removePairedDesktopAfterRemoteUnpair(desktop) } case .snapshot(let snap): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "snapshot") else { return } projects = snap.projects sessions = snap.sessions branchBriefings = snap.branchBriefings ?? [] threadSummaries = snap.threadSummaries ?? [] desktopSettings = snap.settings + desktopUsage = snap.usage + desktopHostMetrics = snap.hostMetrics if let branches = snap.projectBranches { projectBranches = Dictionary(uniqueKeysWithValues: branches.map { ($0.projectId, $0.currentBranch) }) availableBranchesByProject = Dictionary( @@ -695,31 +865,39 @@ final class MobileAppState: ObservableObject { activeSessionID = active } case .moreMessages(let page): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "more_messages") else { return } applyMoreMessages(page) case .sessionUpdate(let update): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "session_update") else { return } applySessionUpdate(update) case .permissionRequest(let req): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "permission_request") else { return } pendingPermission = req MobileHaptics.attentionNeeded() case .questionQueue(let queue): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "question_queue") else { return } let grew = queue.questions.count > pendingQuestions.count pendingQuestions = queue.questions if grew { MobileHaptics.attentionNeeded() } case .notification: + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "notification") else { return } // Foreground notifications arriving over WS — iOS won't show a // banner automatically; UI surfaces these in a toast/badge. break case .searchResults(let results): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "search_results") else { return } guard let pending = pendingSearchID, results.clientRequestID == pending else { return } searchProjectIDs = results.projectIDs searchThreadHits = results.threadHits isSearching = false case .branchOpResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "branch_op_result") else { return } inFlightBranchOps.remove(result.clientRequestID) if !result.ok { lastBranchOpError = result.errorMessage ?? "Branch operation failed." } case .ping: + guard pairedDesktops.contains(where: { $0.pubkeyHex == inbound.fromHex }) else { return } Task { try? await self.client.send(.pong(PongPayload()), toHex: inbound.fromHex) } default: break @@ -865,7 +1043,7 @@ final class MobileAppState: ObservableObject { } private func reportAPNsTokenIfPending() async { - guard isPaired else { + guard !pairedDesktops.isEmpty else { logger.info("[APNs] token report pending: mobile is not paired") return } @@ -878,86 +1056,166 @@ final class MobileAppState: ObservableObject { return } let payload = APNsTokenPayload(tokenHex: tokenHex, environment: env) - do { - try await client.send(.apnsToken(payload), toHex: pairedDesktopPubkey) - logger.info("[APNs] token reported to desktop tokenPrefix=\(String(tokenHex.prefix(12)), privacy: .public) environment=\(env, privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") - } catch { - logger.error("[APNs] token report failed desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + for desktop in pairedDesktops { + do { + try await client.send(.apnsToken(payload), toHex: desktop.pubkeyHex) + logger.info("[APNs] token reported to desktop tokenPrefix=\(String(tokenHex.prefix(12)), privacy: .public) environment=\(env, privacy: .public) desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") + } catch { + logger.error("[APNs] token report failed desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } } } private func applySettingsUpdateLocally(_ update: MobileSettingsUpdatePayload) { guard let current = desktopSettings else { return } + // Optimistically reflect a provider change, resolving its display name + // from the synced options so the picker label updates immediately. + let summarizationProvider = update.summarizationProvider ?? current.summarizationProvider + let summarizationProviderDisplayName = update.summarizationProvider.flatMap { raw in + current.availableSummarizationProviders?.first(where: { $0.id == raw })?.displayName + } ?? current.summarizationProviderDisplayName desktopSettings = MobileSettingsSnapshot( selectedAgentProvider: update.selectedAgentProvider ?? current.selectedAgentProvider, selectedModel: update.selectedModel ?? current.selectedModel, selectedACPClientId: update.selectedACPClientId ?? current.selectedACPClientId, selectedEffort: update.selectedEffort ?? current.selectedEffort, permissionMode: update.permissionMode ?? current.permissionMode, - summarizationProvider: current.summarizationProvider, - summarizationProviderDisplayName: current.summarizationProviderDisplayName, + summarizationProvider: summarizationProvider, + summarizationProviderDisplayName: summarizationProviderDisplayName, openAISummarizationEndpoint: current.openAISummarizationEndpoint, - openAISummarizationModel: current.openAISummarizationModel, + openAISummarizationModel: update.openAISummarizationModel ?? current.openAISummarizationModel, notificationsEnabled: update.notificationsEnabled ?? current.notificationsEnabled, focusMode: update.focusMode ?? current.focusMode, autoArchiveEnabled: update.autoArchiveEnabled ?? current.autoArchiveEnabled, archiveRetentionDays: update.archiveRetentionDays ?? current.archiveRetentionDays, autoPreviewSettings: update.autoPreviewSettings ?? current.autoPreviewSettings, availableEfforts: current.availableEfforts, - availableModels: current.availableModels + availableModels: current.availableModels, + modelSections: current.modelSections, + availableSummarizationProviders: current.availableSummarizationProviders, + openAISummarizationModels: current.openAISummarizationModels ) } // MARK: - Persistence - private func loadPairedDesktop() { - pairedDesktopPubkey = UserDefaults.standard.string(forKey: "mobileSync.desktopPubkey") ?? "" - pairedDesktopName = UserDefaults.standard.string(forKey: "mobileSync.desktopName") ?? "" - let savedMobilePubkey = UserDefaults.standard.string(forKey: "mobileSync.mobilePubkey") + private func loadPairedDesktops() { + let defaults = UserDefaults.standard + let savedMobilePubkey = defaults.string(forKey: Self.mobilePubkeyKey) if let savedMobilePubkey, !savedMobilePubkey.isEmpty, savedMobilePubkey != identity.publicKeyHex { logger.warning("[Pairing] clearing stale saved desktop pairing savedMobileKey=\(String(savedMobilePubkey.prefix(12)), privacy: .public) currentMobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") - pairedDesktopPubkey = "" - pairedDesktopName = "" - savePairedDesktop() + pairedDesktops = [] + setActiveDesktop(pubkeyHex: nil) + savePairedDesktops() + return + } + + if let data = defaults.data(forKey: Self.pairedDesktopsKey), + let decoded = try? JSONDecoder().decode([PairedDesktop].self, from: data) { + pairedDesktops = Self.deduplicate(decoded) + } else { + let legacyPubkey = defaults.string(forKey: Self.legacyDesktopPubkeyKey) ?? "" + let legacyName = defaults.string(forKey: Self.legacyDesktopNameKey) ?? "" + if !legacyPubkey.isEmpty { + pairedDesktops = [ + PairedDesktop( + pubkeyHex: legacyPubkey, + displayName: legacyName, + pairedAt: .now, + lastSeen: nil + ) + ] + } + } + + let preferred = defaults.string(forKey: Self.activeDesktopPubkeyKey) + ?? defaults.string(forKey: Self.legacyDesktopPubkeyKey) + setActiveDesktop(pubkeyHex: preferred) + if !pairedDesktops.isEmpty { + savePairedDesktops() } - isPaired = !pairedDesktopPubkey.isEmpty } - private func savePairedDesktop() { - if pairedDesktopPubkey.isEmpty { - UserDefaults.standard.removeObject(forKey: "mobileSync.desktopPubkey") - UserDefaults.standard.removeObject(forKey: "mobileSync.desktopName") - UserDefaults.standard.removeObject(forKey: "mobileSync.mobilePubkey") + private func savePairedDesktops() { + let defaults = UserDefaults.standard + if pairedDesktops.isEmpty { + defaults.removeObject(forKey: Self.pairedDesktopsKey) + defaults.removeObject(forKey: Self.activeDesktopPubkeyKey) + defaults.removeObject(forKey: Self.legacyDesktopPubkeyKey) + defaults.removeObject(forKey: Self.legacyDesktopNameKey) + defaults.removeObject(forKey: Self.mobilePubkeyKey) } else { - UserDefaults.standard.set(pairedDesktopPubkey, forKey: "mobileSync.desktopPubkey") - UserDefaults.standard.set(pairedDesktopName, forKey: "mobileSync.desktopName") - UserDefaults.standard.set(identity.publicKeyHex, forKey: "mobileSync.mobilePubkey") + let encoder = JSONEncoder() + if let data = try? encoder.encode(pairedDesktops) { + defaults.set(data, forKey: Self.pairedDesktopsKey) + } + defaults.set(pairedDesktopPubkey, forKey: Self.activeDesktopPubkeyKey) + defaults.set(pairedDesktopPubkey, forKey: Self.legacyDesktopPubkeyKey) + defaults.set(pairedDesktopName, forKey: Self.legacyDesktopNameKey) + defaults.set(identity.publicKeyHex, forKey: Self.mobilePubkeyKey) } } - private func resetIdentityAndClient() async { - let oldClient = client - eventTask?.cancel() - eventTask = nil - await oldClient.stop() + private func setActiveDesktop(pubkeyHex: String?) { + let resolved = pubkeyHex.flatMap { requested in + pairedDesktops.first { $0.pubkeyHex == requested } + } ?? pairedDesktops.first - do { - try DeviceIdentity.reset(accessGroup: Self.keychainAccessGroup) - identity = try DeviceIdentity.loadOrCreate(accessGroup: Self.keychainAccessGroup) - client = SyncClient(identity: identity, relayURL: relayURL) - connectionState = .disconnected - logger.info("[MobileIdentity] regenerated publicKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public) accessGroup=\(Self.keychainAccessGroup, privacy: .public)") - } catch { - logger.error("[MobileIdentity] reset failed accessGroup=\(Self.keychainAccessGroup, privacy: .public): \(error.localizedDescription, privacy: .public)") - client = SyncClient(identity: identity, relayURL: relayURL) - connectionState = .disconnected + pairedDesktopPubkey = resolved?.pubkeyHex ?? "" + pairedDesktopName = resolved?.displayName ?? "" + isPaired = resolved != nil + } + + private func upsertPairedDesktop(_ desktop: PairedDesktop) { + if let index = pairedDesktops.firstIndex(where: { $0.pubkeyHex == desktop.pubkeyHex }) { + let existing = pairedDesktops[index] + pairedDesktops[index] = PairedDesktop( + pubkeyHex: desktop.pubkeyHex, + displayName: desktop.displayName, + pairedAt: existing.pairedAt, + lastSeen: desktop.lastSeen ?? existing.lastSeen + ) + } else { + pairedDesktops.append(desktop) } + } - if clientStarted { - await startClient() + private func acceptsActiveDesktopPayload(from pubkeyHex: String, type: String) -> Bool { + guard pubkeyHex == pairedDesktopPubkey else { + logger.info("[Pairing] ignoring \(type, privacy: .public) from inactive desktopKey=\(String(pubkeyHex.prefix(12)), privacy: .public)") + return false + } + return true + } + + private func removePairedDesktopAfterRemoteUnpair(_ desktop: PairedDesktop) async { + let wasActive = desktop.pubkeyHex == pairedDesktopPubkey + pairedDesktops.removeAll { $0.pubkeyHex == desktop.pubkeyHex } + await client.removePeer(desktop.pubkeyHex) + if wasActive { + clearDesktopMirror() + setActiveDesktop(pubkeyHex: pairedDesktops.first?.pubkeyHex) + } else { + setActiveDesktop(pubkeyHex: pairedDesktopPubkey) + } + savePairedDesktops() + if wasActive, isPaired { + await requestSnapshot() + await reportAPNsTokenIfPending() + } + } + + private static func deduplicate(_ desktops: [PairedDesktop]) -> [PairedDesktop] { + var seen: Set = [] + var result: [PairedDesktop] = [] + for desktop in desktops { + guard !desktop.pubkeyHex.isEmpty, !seen.contains(desktop.pubkeyHex) else { continue } + seen.insert(desktop.pubkeyHex) + result.append(desktop) } + return result } } diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index f3222d08..b57188ea 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -5,19 +5,21 @@ import RxCodeSync struct MobileBriefingDetailView: View { @EnvironmentObject private var state: MobileAppState + @Namespace private var glassNamespace let groupKey: BriefingGroupKey var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 20) { - header - summarySection + VStack(alignment: .leading, spacing: 24) { + headerCard + summaryCard threadsSection } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) - .padding(.vertical, 16) + .padding(.vertical, 20) } + .scrollContentBackground(.hidden) .navigationTitle(projectName) .navigationBarTitleDisplayMode(.inline) .refreshable { @@ -40,97 +42,219 @@ struct MobileBriefingDetailView: View { state.projects.first(where: { $0.id == groupKey.projectId })?.name ?? "Unknown Project" } - private var header: some View { - VStack(alignment: .leading, spacing: 6) { - Text(projectName) - .font(.title2.weight(.semibold)) - HStack(spacing: 8) { - Label(groupKey.branch, systemImage: "arrow.triangle.branch") - if let updatedAt = group?.updatedAt { - Text(updatedAt.formatted(.relative(presentation: .named))) + // MARK: - Header Card + + private var headerCard: some View { + HStack(spacing: 14) { + // Project icon + ZStack { + Circle() + .fill(accentGradient.opacity(0.15)) + .frame(width: 52, height: 52) + + Image(systemName: "folder.fill") + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(accentGradient) + } + + VStack(alignment: .leading, spacing: 4) { + Text(projectName) + .font(.title3.weight(.semibold)) + .foregroundStyle(.primary) + + HStack(spacing: 12) { + // Branch chip + HStack(spacing: 5) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 10, weight: .medium)) + Text(groupKey.branch) + .font(.caption.weight(.medium)) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.ultraThinMaterial, in: Capsule()) + + // Updated time + if let updatedAt = group?.updatedAt { + Text(updatedAt.formatted(.relative(presentation: .named))) + .font(.caption) + .foregroundStyle(.tertiary) + } } } - .font(.caption) - .foregroundStyle(.secondary) + + Spacer(minLength: 0) } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .glassEffect(.regular, in: .rect(cornerRadius: 20)) } + // MARK: - Summary Card + @ViewBuilder - private var summarySection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Summary") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) + private var summaryCard: some View { + VStack(alignment: .leading, spacing: 12) { + // Section header + HStack(spacing: 8) { + Image(systemName: "doc.text") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(accentGradient) + + Text("Summary") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + } if let summary = group?.briefing?.briefing, !summary.isEmpty { ChatTextContentView( markdown: summary, - size: 16, + size: 15, color: .primary, - lineSpacing: 3 + lineSpacing: 4 ) } else { - Text("No summary available yet.") - .font(.body) - .foregroundStyle(.tertiary) - .italic() + HStack(spacing: 10) { + Image(systemName: "text.justify.leading") + .font(.system(size: 24)) + .foregroundStyle(.tertiary) + + VStack(alignment: .leading, spacing: 2) { + Text("No summary yet") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + Text("A summary will appear after threads complete") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) } } + .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(.background) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.secondary.opacity(0.2), lineWidth: 1) - ) + .glassEffect(.regular, in: .rect(cornerRadius: 20)) } + // MARK: - Threads Section + @ViewBuilder private var threadsSection: some View { let threads = group?.threads ?? [] - VStack(alignment: .leading, spacing: 8) { - Text("Threads") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) + + VStack(alignment: .leading, spacing: 12) { + // Section header + HStack(spacing: 8) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(accentGradient) + + Text("Threads") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + + if !threads.isEmpty { + Text("\(threads.count)") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.ultraThinMaterial, in: Capsule()) + } + } + .padding(.leading, 4) if threads.isEmpty { - Text("No threads on this branch yet.") - .font(.subheadline) - .foregroundStyle(.tertiary) - .italic() - .padding(.vertical, 8) + emptyThreadsCard } else { - VStack(spacing: 8) { - ForEach(threads) { thread in - NavigationLink(value: thread.sessionId) { - threadRow(thread) + GlassEffectContainer(spacing: 12) { + VStack(spacing: 12) { + ForEach(threads) { thread in + NavigationLink(value: thread.sessionId) { + ThreadCard(thread: thread, namespace: glassNamespace) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } } } } - private func threadRow(_ thread: MobileThreadSummary) -> some View { + private var emptyThreadsCard: some View { + HStack(spacing: 12) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 28)) + .foregroundStyle(.tertiary) + + VStack(alignment: .leading, spacing: 2) { + Text("No threads yet") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + Text("Start a conversation from your Mac") + .font(.caption) + .foregroundStyle(.tertiary) + } + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .glassEffect(.regular, in: .rect(cornerRadius: 16)) + } + + // MARK: - Accent Gradient + + private var accentGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.95, green: 0.6, blue: 0.4), + Color(red: 0.85, green: 0.5, blue: 0.55) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } +} + +// MARK: - Thread Card + +private struct ThreadCard: View { + let thread: MobileThreadSummary + let namespace: Namespace.ID + + var body: some View { HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { + // Thread icon + ZStack { + Circle() + .fill(Color.secondary.opacity(0.1)) + .frame(width: 36, height: 36) + + Image(systemName: "text.bubble") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 6) { Text(thread.title.isEmpty ? "Untitled" : thread.title) .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) .lineLimit(2) + .multilineTextAlignment(.leading) + if !thread.summary.isEmpty { ChatTextContentView( markdown: thread.summary, - size: 12, + size: 13, color: .secondary, - lineSpacing: 1, + lineSpacing: 2, maximumNumberOfLines: 3 ) } + Text(thread.updatedAt.formatted(.relative(presentation: .named))) .font(.caption2) .foregroundStyle(.tertiary) @@ -139,18 +263,14 @@ struct MobileBriefingDetailView: View { Spacer(minLength: 0) Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) + .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.tertiary) - .padding(.top, 4) + .padding(.top, 2) } - .padding() + .padding(14) .frame(maxWidth: .infinity, alignment: .leading) - .background(.background) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.secondary.opacity(0.2), lineWidth: 1) - ) - .contentShape(Rectangle()) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) + .glassEffectID(thread.id, in: namespace) + .contentShape(.rect(cornerRadius: 16)) } } diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index 490e8098..17cab5c1 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -21,6 +21,7 @@ struct GroupedBriefing: Identifiable { struct MobileBriefingView: View { @EnvironmentObject private var state: MobileAppState + @Namespace private var glassNamespace /// Selected project ids for filtering. Empty = show every project. @State private var selectedProjectIds: Set = [] @@ -31,29 +32,36 @@ struct MobileBriefingView: View { var body: some View { GeometryReader { proxy in ScrollView { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 16) { if groups.isEmpty { emptyState .frame(maxWidth: .infinity, minHeight: 320) } else { - LazyVGrid( - columns: gridColumns(for: proxy.size.width), - alignment: .leading, - spacing: 12 - ) { - ForEach(groups) { group in - NavigationLink(value: group.key) { - briefingRow(group) + GlassEffectContainer(spacing: 16) { + LazyVGrid( + columns: gridColumns(for: proxy.size.width), + alignment: .leading, + spacing: 16 + ) { + ForEach(groups) { group in + NavigationLink(value: group.key) { + BriefingCard( + group: group, + projectName: projectsById[group.projectId]?.name ?? "Unknown Project", + namespace: glassNamespace + ) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } } } .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.horizontal, horizontalPadding(for: proxy.size.width)) - .padding(.vertical, 16) + .padding(.vertical, 20) } + .scrollContentBackground(.hidden) } .navigationTitle("Briefing") .toolbar { @@ -87,13 +95,13 @@ struct MobileBriefingView: View { } else if width >= 700 { minimumWidth = 320 } else { - minimumWidth = max(260, width - (horizontalPadding(for: width) * 2)) + minimumWidth = max(280, width - (horizontalPadding(for: width) * 2)) } return [ GridItem( .adaptive(minimum: minimumWidth, maximum: 560), - spacing: 12, + spacing: 16, alignment: .top ) ] @@ -230,53 +238,123 @@ struct MobileBriefingView: View { ) } } +} + +// MARK: - Briefing Card + +private struct BriefingCard: View { + let group: GroupedBriefing + let projectName: String + let namespace: Namespace.ID - private func briefingRow(_ group: GroupedBriefing) -> some View { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 6) { - Text(projectsById[group.projectId]?.name ?? "Unknown Project") - .font(.headline) - .foregroundStyle(.primary) + private var threadCount: Int { group.threads.count } + private var hasBriefing: Bool { !(group.briefing?.briefing.isEmpty ?? true) } + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header with project icon and name + HStack(spacing: 12) { + projectIcon + + VStack(alignment: .leading, spacing: 2) { + Text(projectName) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + + HStack(spacing: 6) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 10, weight: .medium)) + Text(group.branch) + .font(.caption) + .lineLimit(1) + } + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + + // Summary content + VStack(alignment: .leading, spacing: 12) { if let summary = group.briefing?.briefing, !summary.isEmpty { ChatTextContentView( markdown: summary, size: 14, color: .secondary, - lineSpacing: 2, - maximumNumberOfLines: 3 + lineSpacing: 3, + maximumNumberOfLines: 4 ) } else { - Text("No summary yet") + Text("No summary available yet") .font(.subheadline) .foregroundStyle(.tertiary) .italic() } - - HStack(spacing: 8) { - Label(group.branch, systemImage: "arrow.triangle.branch") - Text(group.updatedAt.formatted(.relative(presentation: .named))) + } + .padding(.horizontal, 16) + .padding(.bottom, 14) + + // Footer with metadata + HStack(spacing: 12) { + // Thread count chip + if threadCount > 0 { + HStack(spacing: 4) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 10, weight: .medium)) + Text("\(threadCount)") + .font(.caption.weight(.medium)) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.ultraThinMaterial, in: Capsule()) } - .font(.caption) - .foregroundStyle(.secondary) + + Spacer(minLength: 0) + + // Time ago + Text(group.updatedAt.formatted(.relative(presentation: .named))) + .font(.caption) + .foregroundStyle(.tertiary) } - - Spacer(minLength: 0) - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - .padding(.top, 4) + .padding(.horizontal, 16) + .padding(.bottom, 14) } - .padding() .frame(maxWidth: .infinity, alignment: .leading) - .background(.background) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 20)) + .glassEffectID(group.id, in: namespace) + .contentShape(.rect(cornerRadius: 20)) + } + + private var projectIcon: some View { + ZStack { + Circle() + .fill(accentGradient.opacity(0.15)) + .frame(width: 40, height: 40) + + Image(systemName: "folder.fill") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(accentGradient) + } + } + + private var accentGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.95, green: 0.6, blue: 0.4), + Color(red: 0.85, green: 0.5, blue: 0.55) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing ) - .contentShape(Rectangle()) } } diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 7da6580a..030c0949 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -22,6 +22,8 @@ struct MobileChatView: View { @State private var showingTodoSheet = false /// The question request whose sheet is currently presented, if any. @State private var presentedQuestion: PendingQuestionPayload? + /// The plan whose review sheet is currently presented, if any. + @State private var presentedPlan: PendingPlan? /// 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. @@ -29,10 +31,26 @@ struct MobileChatView: View { /// 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 + /// 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 + /// 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 private static let bottomAnchorID = "message-list-bottom" private static let bottomContentPadding: CGFloat = 200 - private static let nearBottomThreshold: CGFloat = bottomContentPadding + 40 + /// Distance from the bottom past which the "scroll to bottom" button shows. + private static let nearBottomThreshold: CGFloat = 120 + /// Distance from the true bottom within which auto-follow re-arms. Kept + /// small on purpose: the bottom is always reachable regardless of thread + /// length, so this works even for threads barely taller than the viewport. + private static let atBottomThreshold: CGFloat = 16 + /// Minimum upward scroll delta (points) that counts as the user + /// deliberately leaving the bottom. + private static let userScrollUpDelta: CGFloat = 4 /// Load older messages once the viewport scrolls within this many points /// of the top. private static let loadMoreThreshold: CGFloat = 150 @@ -41,6 +59,7 @@ struct MobileChatView: View { activeThreadLayout .animation(.easeInOut(duration: 0.2), value: queuedMessages.count) .animation(.easeInOut(duration: 0.2), value: sessionQuestions.count) + .animation(.easeInOut(duration: 0.2), value: pendingPlans.count) .navigationBarTitleDisplayMode(.inline) .navigationTitle(title) .toolbar { threadActionsToolbar } @@ -83,6 +102,34 @@ struct MobileChatView: View { presentedQuestion = nil } } + .sheet(item: $presentedPlan) { plan in + PlanSheetView( + plan: plan, + remainingCount: max(0, pendingPlans.count - 1), + onSubmit: { toolCallID, action in + Task { + await state.respondToPlanDecision( + toolUseID: toolCallID, + sessionID: sessionID, + action: action + ) + } + presentedPlan = nil + }, + onClose: { presentedPlan = nil } + ) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .onChange(of: sessionPlans) { _, plans in + // The plan resolved (decided here or on another device) or + // vanished — close the now-stale sheet. + guard let presented = presentedPlan else { return } + let stillPending = plans.contains { + $0.toolCallId == presented.toolCallId && !$0.isDecided + } + if !stillPending { presentedPlan = nil } + } .confirmationDialog( "Archive this thread?", isPresented: $showingArchiveConfirm, @@ -225,15 +272,31 @@ struct MobileChatView: View { .animation(.easeInOut(duration: 0.2), value: isLoadingMore) } .scrollDismissesKeyboard(.interactively) - .onScrollGeometryChange(for: Bool.self) { geo in - let distanceFromBottom = geo.contentSize.height - geo.visibleRect.maxY - return distanceFromBottom <= Self.nearBottomThreshold - } action: { _, newValue in - if isNearBottom != newValue { isNearBottom = newValue } + .onScrollPhaseChange { _, newPhase, _ in + isUserDragging = newPhase == .tracking + || newPhase == .interacting + || newPhase == .decelerating + } + .onScrollGeometryChange(for: CGFloat.self) { geo in + geo.contentSize.height - geo.visibleRect.maxY + } action: { _, distanceFromBottom in + let near = distanceFromBottom <= Self.nearBottomThreshold + if isNearBottom != near { isNearBottom = near } + // Returning to the true bottom re-arms auto-follow. + if distanceFromBottom <= Self.atBottomThreshold { + autoScrollEnabled = true + } } .onScrollGeometryChange(for: CGFloat.self) { geo in geo.contentOffset.y - } action: { _, offsetY in + } action: { oldOffsetY, offsetY in + // A deliberate upward drag means the user wants to read + // history — stop the stream from pulling them back down. + // Gated on `isUserDragging` so keyboard/layout-induced + // offset shifts don't disable auto-follow. + if isUserDragging, offsetY < oldOffsetY - Self.userScrollUpDelta { + autoScrollEnabled = false + } if offsetY < Self.loadMoreThreshold { triggerLoadMoreIfNeeded() } } .onAppear { @@ -255,15 +318,16 @@ struct MobileChatView: View { didEstablishInitialScroll = true return } + guard autoScrollEnabled else { return } withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } .onChange(of: messages.last?.content) { _, _ in - guard didEstablishInitialScroll, isNearBottom else { return } + guard didEstablishInitialScroll, autoScrollEnabled else { return } withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } .onChange(of: isStreaming) { _, streaming in // Keep the newly appeared loading indicator in view. - guard streaming, didEstablishInitialScroll, isNearBottom else { return } + guard streaming, didEstablishInitialScroll, autoScrollEnabled else { return } withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } .onChange(of: isLoadingMore) { _, loading in @@ -298,6 +362,12 @@ struct MobileChatView: View { .padding(.bottom, 4) .transition(.move(edge: .bottom).combined(with: .opacity)) } + if !pendingPlans.isEmpty { + planBanner + .padding(.horizontal, 12) + .padding(.bottom, 4) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } MobileInputBar( text: $composer, isStreaming: isStreaming, @@ -353,6 +423,17 @@ struct MobileChatView: View { 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] { + state.pendingPlans(sessionID: sessionID) + } + + /// Plans still awaiting a decision — what the plan banner surfaces. + private var pendingPlans: [PendingPlan] { + sessionPlans.filter { !$0.isDecided && !$0.isStreaming } + } + // MARK: - Message paging /// Spinner shown at the top of the list while an older page is loading. @@ -389,6 +470,8 @@ struct MobileChatView: View { // MARK: - Send / Stop private func handleSend(_ trimmed: String) { + // Sending always returns the user's focus to the latest messages. + autoScrollEnabled = true Task { await state.sendUserMessage(trimmed, sessionID: sessionID) composer = "" @@ -408,6 +491,7 @@ struct MobileChatView: View { private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View { Button { + autoScrollEnabled = true withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } label: { Image(systemName: "arrow.down") @@ -518,6 +602,53 @@ struct MobileChatView: View { 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)) + .foregroundStyle(ClaudeTheme.textPrimary) + + 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) + .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("\(planBannerText). Tap to review.") + } + + private var planBannerText: String { + let count = pendingPlans.count + return count == 1 ? "Plan ready to review" : "\(count) plans ready to review" + } } // MARK: - Streaming Indicator diff --git a/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift index fc0955f0..3dfc2405 100644 --- a/RxCodeMobile/Views/MobileSettingsView.swift +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -1,12 +1,13 @@ -import SwiftUI import RxCodeCore import RxCodeSync +import SwiftUI struct MobileSettingsView: View { @EnvironmentObject private var state: MobileAppState @Environment(\.dismiss) private var dismiss let showsDoneButton: Bool - @State private var showUnpairConfirm = false + @State private var showPairingSheet = false + @State private var desktopPendingRemoval: PairedDesktop? @State private var modelDraft = "" @State private var acpClientDraft = "" @@ -17,20 +18,13 @@ struct MobileSettingsView: View { var body: some View { NavigationStack { Form { - Section("Paired Mac") { - HStack { - Image(systemName: "desktopcomputer").frame(width: 22) - Text(state.pairedDesktopName.isEmpty ? "Unknown Mac" : state.pairedDesktopName) - } - HStack { - Text("Connection") - Spacer() - connectionLabel - } - } + pairedMacsSection + + computerStatusSection + + desktopUsageSection if let settings = state.desktopSettings { - desktopRuntimeSection(settings) desktopBehaviorSection(settings) desktopAutoPreviewSection(settings) desktopSummarizationSection(settings) @@ -40,21 +34,19 @@ struct MobileSettingsView: View { } } - Section { - Button(role: .destructive) { - showUnpairConfirm = true - } label: { - Label("Unpair", systemImage: "minus.circle") - } - } footer: { - Text("Unpairing regenerates this device's identity. You'll need to scan a fresh QR to re-pair.") - } + pairNewSection } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .refreshable { await state.refreshSnapshot() } + .task { + // Pull a fresh snapshot when Settings opens so the computer + // status and usage reflect the desktop's current state rather + // than whatever the last broadcast happened to carry. + await state.refreshSnapshot() + } .toolbar { if showsDoneButton { ToolbarItem(placement: .topBarTrailing) { @@ -62,11 +54,33 @@ struct MobileSettingsView: View { } } } - .alert("Unpair this device?", isPresented: $showUnpairConfirm) { + .sheet(isPresented: $showPairingSheet) { + NavigationStack { + OnboardingView(showsCancelButton: true) { + showPairingSheet = false + } + .environmentObject(state) + .navigationTitle("Pair New Mac") + .navigationBarTitleDisplayMode(.inline) + } + } + .alert( + "Remove pairing?", + isPresented: Binding( + get: { desktopPendingRemoval != nil }, + set: { if !$0 { desktopPendingRemoval = nil } } + ) + ) { Button("Cancel", role: .cancel) {} - Button("Unpair", role: .destructive) { - Task { await state.unpair() } - dismiss() + Button("Remove", role: .destructive) { + if let desktop = desktopPendingRemoval { + Task { await state.removePairedDesktop(desktop) } + } + desktopPendingRemoval = nil + } + } message: { + if let desktop = desktopPendingRemoval { + Text("This removes \(desktop.displayName.isEmpty ? "this Mac" : desktop.displayName) from this device. Other paired Macs stay available.") } } .onAppear { @@ -82,64 +96,192 @@ struct MobileSettingsView: View { } } - private func desktopRuntimeSection(_ settings: MobileSettingsSnapshot) -> some View { - Section("Desktop Runtime") { - Picker("Agent", selection: settingBinding(settings.selectedAgentProvider) { value in - MobileSettingsUpdatePayload(selectedAgentProvider: value) - }) { - ForEach(AgentProvider.allCases, id: \.self) { provider in - Text(provider.displayName).tag(provider) - } + private var pairedMacsSection: some View { + Section { + ForEach(state.pairedDesktops) { desktop in + pairedMacRow(desktop) } - HStack { - Text("Model") - TextField("Model", text: $modelDraft) - .multilineTextAlignment(.trailing) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .onSubmit { applyModelDraft() } + Text("Connection") + Spacer() + connectionLabel } + } header: { + Text("Paired Macs") + } footer: { + Text("Select which Mac this device controls. Removing one pairing does not reset this device's identity.") + } + } - Button("Apply Model") { - applyModelDraft() + private func pairedMacRow(_ desktop: PairedDesktop) -> some View { + HStack(spacing: 12) { + Image(systemName: "desktopcomputer") + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(desktop.displayName.isEmpty ? "Unknown Mac" : desktop.displayName) + .foregroundStyle(.primary) + Text("Paired \(desktop.pairedAt, format: .relative(presentation: .named))") + .font(.caption) + .foregroundStyle(.secondary) } - .disabled(modelDraft.trimmingCharacters(in: .whitespacesAndNewlines) == settings.selectedModel) - - if settings.selectedAgentProvider == .acp { - HStack { - Text("ACP Client") - TextField("Client ID", text: $acpClientDraft) - .multilineTextAlignment(.trailing) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .onSubmit { applyACPClientDraft() } + Spacer() + if desktop.pubkeyHex == state.pairedDesktopPubkey { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .accessibilityLabel("Active") + } else { + Button { + Task { await state.switchPairedDesktop(desktop) } + } label: { + Label("Switch", systemImage: "arrow.triangle.2.circlepath") + .labelStyle(.iconOnly) } + .buttonStyle(.borderless) + .accessibilityLabel("Switch to \(desktop.displayName.isEmpty ? "Mac" : desktop.displayName)") + } + Button(role: .destructive) { + desktopPendingRemoval = desktop + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .accessibilityLabel("Remove \(desktop.displayName.isEmpty ? "Mac" : desktop.displayName)") + } + } - Button("Apply ACP Client") { - applyACPClientDraft() - } - .disabled(acpClientDraft.trimmingCharacters(in: .whitespacesAndNewlines) == settings.selectedACPClientId) + private var pairNewSection: some View { + Section { + Button { + showPairingSheet = true + } label: { + Label("Pair New Mac", systemImage: "plus.circle") } + } + } - Picker("Effort", selection: settingBinding(settings.selectedEffort) { value in - MobileSettingsUpdatePayload(selectedEffort: value) - }) { - ForEach(settings.availableEfforts, id: \.self) { effort in - Text(effort == "auto" ? "Auto" : effortDisplayName(effort)).tag(effort) + /// Agent rate-limit usage mirrored from the paired desktop. Renders one + /// section per provider that reported usage; falls back to a hint when the + /// desktop synced a usage payload but neither agent is signed in. Renders + /// nothing at all when paired with a desktop that predates usage sync. + @ViewBuilder + private var desktopUsageSection: some View { + if let usage = state.desktopUsage { + if usage.hasAnyUsage { + if let claude = usage.claudeCode { + Section("Claude Code Usage") { + MetricBar( + label: "5-hour limit", + percent: claude.fiveHourPercent, + valueText: Self.percentText(claude.fiveHourPercent), + caption: Self.resetCaption(claude.fiveHourResetsAt) + ) + MetricBar( + label: "7-day limit", + percent: claude.sevenDayPercent, + valueText: Self.percentText(claude.sevenDayPercent), + caption: Self.resetCaption(claude.sevenDayResetsAt) + ) + } + } + if let codex = usage.codex { + Section("Codex Usage") { + MetricBar( + label: "5-hour limit", + percent: codex.fiveHourPercent, + valueText: Self.percentText(codex.fiveHourPercent), + caption: Self.resetCaption(codex.fiveHourResetsAt) + ) + if let twentyFour = codex.twentyFourHourPercent { + MetricBar( + label: "24-hour limit", + percent: twentyFour, + valueText: Self.percentText(twentyFour), + caption: Self.resetCaption(codex.twentyFourHourResetsAt) + ) + } + } + } + } else { + Section("Usage") { + Text("Sign in to Claude Code or Codex on your Mac to see usage.") + .font(.footnote) + .foregroundStyle(.secondary) } } + } + } - Picker("Permission", selection: settingBinding(settings.permissionMode) { value in - MobileSettingsUpdatePayload(permissionMode: value) - }) { - ForEach(PermissionMode.allCases, id: \.self) { mode in - Label(mode.displayName, systemImage: mode.systemImage).tag(mode) + /// Live CPU / memory / thermal load mirrored from the active paired desktop. + /// Renders nothing when paired with a desktop that predates this sync. + @ViewBuilder + private var computerStatusSection: some View { + if let metrics = state.desktopHostMetrics { + Section { + MetricBar( + label: "CPU", + percent: metrics.cpuUsagePercent, + valueText: Self.percentText(metrics.cpuUsagePercent) + ) + MetricBar( + label: "Memory", + percent: metrics.memoryUsedPercent, + valueText: Self.formatBytes(metrics.memoryUsedBytes), + caption: "of \(Self.formatBytes(metrics.memoryTotalBytes))" + ) + HStack { + Text("Thermal") + Spacer() + Text(Self.thermalLabel(metrics.thermalState)) + .foregroundStyle(Self.thermalColor(metrics.thermalState)) } + } header: { + Text("Computer Status") + } footer: { + Text("Updated \(metrics.sampledAt, format: .relative(presentation: .named)). Pull to refresh.") } } } + /// Format a 0–100 utilization value as a percentage string, keeping one + /// decimal for sub-1% values so light usage doesn't read as a flat "0%". + private static func percentText(_ value: Double) -> String { + if value > 0 && value < 1 { + return String(format: "%.1f%%", value) + } + return "\(Int(value.rounded()))%" + } + + /// Relative "Resets …" caption for a rate-limit window, or `nil` when the + /// desktop didn't report a reset time. + private static func resetCaption(_ date: Date?) -> String? { + guard let date else { return nil } + return "Resets \(date.formatted(.relative(presentation: .named)))" + } + + private static func formatBytes(_ bytes: UInt64) -> String { + Int64(bytes).formatted(.byteCount(style: .memory)) + } + + private static func thermalLabel(_ state: HostMetricsSnapshot.ThermalState) -> String { + switch state { + case .nominal: return "Normal" + case .fair: return "Fair" + case .serious: return "Serious" + case .critical: return "Critical" + case .unknown: return "Unknown" + } + } + + private static func thermalColor(_ state: HostMetricsSnapshot.ThermalState) -> Color { + switch state { + case .nominal: return .green + case .fair: return .yellow + case .serious: return .orange + case .critical: return .red + case .unknown: return .secondary + } + } + private func desktopBehaviorSection(_ settings: MobileSettingsSnapshot) -> some View { Section("Desktop Behavior") { Toggle("Response notifications", isOn: settingBinding(settings.notificationsEnabled) { value in @@ -156,7 +298,7 @@ struct MobileSettingsView: View { value: settingBinding(settings.archiveRetentionDays) { value in MobileSettingsUpdatePayload(archiveRetentionDays: value) }, - in: 1...365 + in: 1 ... 365 ) .disabled(!settings.autoArchiveEnabled) } @@ -173,12 +315,30 @@ struct MobileSettingsView: View { private func desktopSummarizationSection(_ settings: MobileSettingsSnapshot) -> some View { Section { - LabeledContent("Provider", value: settings.summarizationProviderDisplayName) - if !settings.openAISummarizationEndpoint.isEmpty { - LabeledContent("Endpoint", value: settings.openAISummarizationEndpoint) + Picker( + "Provider", + selection: settingBinding(settings.summarizationProvider) { value in + MobileSettingsUpdatePayload(summarizationProvider: value) + } + ) { + ForEach(settings.availableSummarizationProviders ?? []) { option in + Text(option.displayName).tag(option.id) + } + // Keep the current value selectable when the desktop is too + // old to send the options list, or sent one without it. + if settings.availableSummarizationProviders? + .contains(where: { $0.id == settings.summarizationProvider }) != true { + Text(settings.summarizationProviderDisplayName) + .tag(settings.summarizationProvider) + } } - if !settings.openAISummarizationModel.isEmpty { - LabeledContent("Model", value: settings.openAISummarizationModel) + .pickerStyle(.menu) + + if settings.summarizationProvider == "openAI" { + if !settings.openAISummarizationEndpoint.isEmpty { + LabeledContent("Endpoint", value: settings.openAISummarizationEndpoint) + } + summarizationModelPicker(settings) } } header: { Text("Summarization") @@ -187,6 +347,37 @@ struct MobileSettingsView: View { } } + /// Model control for the OpenAI-compatible summarization endpoint. Shows a + /// picker once the desktop has synced its fetched model list; falls back to + /// a read-only row while that list is still empty. + @ViewBuilder + private func summarizationModelPicker(_ settings: MobileSettingsSnapshot) -> some View { + let models = settings.openAISummarizationModels ?? [] + if models.isEmpty { + if !settings.openAISummarizationModel.isEmpty { + LabeledContent("Model", value: settings.openAISummarizationModel) + } + } else { + Picker( + "Model", + selection: settingBinding(settings.openAISummarizationModel) { value in + MobileSettingsUpdatePayload(openAISummarizationModel: value) + } + ) { + // Preserve a current value the desktop's list doesn't contain. + if !settings.openAISummarizationModel.isEmpty, + !models.contains(settings.openAISummarizationModel) { + Text(settings.openAISummarizationModel) + .tag(settings.openAISummarizationModel) + } + ForEach(models, id: \.self) { model in + Text(model).tag(model) + } + } + .pickerStyle(.menu) + } + } + private func settingBinding( _ value: Value, update: @escaping (Value) -> MobileSettingsUpdatePayload @@ -253,3 +444,47 @@ struct MobileSettingsView: View { } } } + +/// A labeled progress bar with a trailing value and optional caption. Shared by +/// the agent usage sections and the computer-status section, mirroring the +/// desktop menu-bar usage bar. +private struct MetricBar: View { + let label: String + /// 0–100, drives the bar's fill width and color. + let percent: Double + /// Trailing value text on the label row, e.g. "42%" or "8.1 GB". + let valueText: String + /// Optional small line below the bar, e.g. "Resets in 2h" or "of 16 GB". + var caption: String? + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(label) + Spacer() + Text(valueText) + .monospacedDigit() + .foregroundStyle(.secondary) + } + ProgressView(value: min(max(percent / 100, 0), 1)) + .tint(Self.barColor(for: percent)) + + if let caption { + Text(caption) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 2) + } + + /// Green up to 60%, amber to 85%, red past it — same thresholds the desktop + /// usage bar uses. + static func barColor(for percent: Double) -> Color { + switch percent { + case ..<60: return ClaudeTheme.accent + case ..<85: return .orange + default: return .red + } + } +} diff --git a/RxCodeMobile/Views/NewThreadSheet.swift b/RxCodeMobile/Views/NewThreadSheet.swift index 8ca73030..5d46edb9 100644 --- a/RxCodeMobile/Views/NewThreadSheet.swift +++ b/RxCodeMobile/Views/NewThreadSheet.swift @@ -19,6 +19,15 @@ struct NewThreadSheet: View { @State private var awaitingFromSessionIDs: Set? @FocusState private var isFocused: Bool + // Per-thread agent config. Seeded once from the desktop's current settings, + // then applied only to the thread this sheet creates (no longer mutates the + // desktop's global defaults). + @State private var planModeEnabled = false + @State private var selectedProvider: AgentProvider? + @State private var selectedModelID: String? + @State private var selectedPermissionMode: PermissionMode = .default + @State private var didSeedConfig = false + private var trimmed: String { text.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -37,8 +46,14 @@ struct NewThreadSheet: View { VStack(spacing: 28) { header composer - NewThreadConfigStrip(projectID: projectID) - .environmentObject(state) + NewThreadConfigStrip( + projectID: projectID, + selectedProvider: $selectedProvider, + selectedModelID: $selectedModelID, + selectedPermissionMode: $selectedPermissionMode, + planModeEnabled: $planModeEnabled + ) + .environmentObject(state) } .padding(.horizontal, 16) .padding(.top, 12) @@ -59,10 +74,14 @@ struct NewThreadSheet: View { .presentationDragIndicator(.visible) .interactiveDismissDisabled(isSubmitting) .onAppear { + seedConfigIfNeeded() DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { isFocused = true } } + .onChange(of: state.desktopSettings) { _, _ in + seedConfigIfNeeded() + } .onChange(of: state.sessions.map(\.id)) { _, _ in attemptJumpToNewSession() } @@ -167,6 +186,16 @@ struct NewThreadSheet: View { // MARK: - Actions + /// Copy the desktop's current agent config into local state once it is + /// available. Runs on appear and again if the snapshot arrives afterwards. + private func seedConfigIfNeeded() { + guard !didSeedConfig, let settings = state.desktopSettings else { return } + didSeedConfig = true + selectedProvider = settings.selectedAgentProvider + selectedModelID = settings.selectedModel + selectedPermissionMode = settings.permissionMode + } + private func submit() { guard canSend else { return } let body = trimmed @@ -179,7 +208,14 @@ struct NewThreadSheet: View { ) UIImpactFeedbackGenerator(style: .light).impactOccurred() Task { - await state.requestNewSession(projectID: projectID, initialText: body) + await state.requestNewSession( + projectID: projectID, + initialText: body, + agentProvider: selectedProvider, + model: selectedModelID, + permissionMode: selectedPermissionMode, + planMode: planModeEnabled + ) } } @@ -197,18 +233,27 @@ struct NewThreadSheet: View { // MARK: - New Thread Config Strip -/// Compact configuration row showing the same three knobs the desktop exposes -/// for a new session: branch (with create/switch menu), permission mode, and -/// model. Picker changes mutate the desktop's default settings via the -/// existing `settings_update` payload — the new thread inherits whatever is -/// selected when the user sends the first message. +/// Compact configuration row for a new thread: branch (create/switch menu), +/// permission mode, model, and a plan-mode toggle. Model/permission/plan picks +/// are bound to the parent sheet's local state and travel in the +/// `new_session_request` payload — they apply only to the created thread and no +/// longer mutate the desktop's global defaults. struct NewThreadConfigStrip: View { @EnvironmentObject private var state: MobileAppState let projectID: UUID + @Binding var selectedProvider: AgentProvider? + @Binding var selectedModelID: String? + @Binding var selectedPermissionMode: PermissionMode + @Binding var planModeEnabled: Bool @State private var showingCreateBranch = false + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + var body: some View { - VStack(spacing: 10) { + VStack(spacing: 12) { HStack { Text("Settings") .font(.footnote.weight(.semibold)) @@ -217,12 +262,14 @@ struct NewThreadConfigStrip: View { Spacer() } - HStack(spacing: 8) { - branchMenu - permissionMenu - modelMenu + GlassEffectContainer(spacing: 12) { + LazyVGrid(columns: columns, spacing: 12) { + branchMenu + modelMenu + permissionMenu + planToggleChip + } } - .frame(maxWidth: .infinity, alignment: .leading) } .sheet(isPresented: $showingCreateBranch) { CreateBranchSheet( @@ -325,13 +372,13 @@ struct NewThreadConfigStrip: View { } private var selectedModelLabel: String { - guard let settings = state.desktopSettings else { return "Model" } if let match = allModels.first(where: { - $0.provider == settings.selectedAgentProvider && $0.id == settings.selectedModel + $0.provider == selectedProvider && $0.id == selectedModelID }) { return match.displayName } - return settings.selectedModel.isEmpty ? settings.selectedAgentProvider.displayName : settings.selectedModel + if let id = selectedModelID, !id.isEmpty { return id } + return selectedProvider?.displayName ?? "Model" } private var modelMenu: some View { @@ -342,8 +389,8 @@ struct NewThreadConfigStrip: View { Button { applyModel(model) } label: { - let isSelected = state.desktopSettings?.selectedAgentProvider == model.provider - && state.desktopSettings?.selectedModel == model.id + let isSelected = selectedProvider == model.provider + && selectedModelID == model.id HStack { Text(model.displayName) if isSelected { @@ -365,27 +412,23 @@ struct NewThreadConfigStrip: View { } private func applyModel(_ model: AgentModel) { - Task { - await state.updateDesktopSettings( - MobileSettingsUpdatePayload( - selectedAgentProvider: model.provider, - selectedModel: model.id - ) - ) - } + selectedProvider = model.provider + selectedModelID = model.id } // MARK: Permission Mode private var permissionMenu: some View { Menu { - ForEach(PermissionMode.allCases, id: \.self) { mode in + // `.plan` is intentionally excluded — plan mode is a dedicated + // toggle, matching the desktop's permission dropdown. + ForEach(PermissionMode.allCases.filter { $0 != .plan }, id: \.self) { mode in Button { - applyPermissionMode(mode) + selectedPermissionMode = mode } label: { HStack { Label(mode.displayName, systemImage: mode.systemImage) - if state.desktopSettings?.permissionMode == mode { + if selectedPermissionMode == mode { Spacer() Image(systemName: "checkmark") } @@ -393,22 +436,40 @@ struct NewThreadConfigStrip: View { } } } label: { - let mode = state.desktopSettings?.permissionMode ?? .default - chipLabel(icon: mode.systemImage, title: mode.displayName) + chipLabel(icon: selectedPermissionMode.systemImage, title: selectedPermissionMode.displayName) } } - private func applyPermissionMode(_ mode: PermissionMode) { - Task { - await state.updateDesktopSettings( - MobileSettingsUpdatePayload(permissionMode: mode) + // MARK: Plan Mode + + /// Dedicated plan-mode toggle, orthogonal to the permission menu. Mirrors the + /// desktop's `sessionPlanMode` boolean — when on, the thread starts with the + /// CLI in `--permission-mode plan`. + private var planToggleChip: some View { + Button { + planModeEnabled.toggle() + UISelectionFeedbackGenerator().selectionChanged() + } label: { + chipLabel( + icon: PermissionMode.plan.systemImage, + title: "Plan", + showsChevron: false, + active: planModeEnabled ) } + .buttonStyle(.plain) + .accessibilityLabel("Plan mode") + .accessibilityValue(planModeEnabled ? "On" : "Off") } // MARK: Chip - private func chipLabel(icon: String, title: String, showsChevron: Bool = true) -> some View { + private func chipLabel( + icon: String, + title: String, + showsChevron: Bool = true, + active: Bool = false + ) -> some View { HStack(spacing: 6) { Image(systemName: icon) .font(.system(size: 12, weight: .medium)) @@ -422,16 +483,13 @@ struct NewThreadConfigStrip: View { .foregroundStyle(.tertiary) } } - .foregroundStyle(.primary) + .foregroundStyle(active ? Color.white : Color.primary) + .frame(maxWidth: .infinity) .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(.tertiarySystemGroupedBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.secondary.opacity(0.15), lineWidth: 0.5) + .padding(.vertical, 12) + .glassEffect( + active ? .regular.tint(ClaudeTheme.accent).interactive() : .regular.interactive(), + in: .rect(cornerRadius: 12) ) } } diff --git a/RxCodeMobile/Views/OnboardingView.swift b/RxCodeMobile/Views/OnboardingView.swift index d28346ef..03415cae 100644 --- a/RxCodeMobile/Views/OnboardingView.swift +++ b/RxCodeMobile/Views/OnboardingView.swift @@ -7,6 +7,9 @@ private let onboardingLogger = Logger(subsystem: "com.claudework", category: "On struct OnboardingView: View { @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let showsCancelButton: Bool + let onPairingCompleted: (() -> Void)? @State private var showScanner = false @State private var showScanOptions = false @State private var photoPickerShown = false @@ -15,6 +18,14 @@ struct OnboardingView: View { @State private var displayName: String = UIDevice.current.name @FocusState private var nameFocused: Bool + init( + showsCancelButton: Bool = false, + onPairingCompleted: (() -> Void)? = nil + ) { + self.showsCancelButton = showsCancelButton + self.onPairingCompleted = onPairingCompleted + } + var body: some View { ZStack { BackdropOrbs() @@ -109,6 +120,17 @@ struct OnboardingView: View { guard let newItem else { return } Task { await loadAndDecode(item: newItem) } } + .toolbar { + if showsCancelButton { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + MobileHaptics.buttonTap() + state.cancelPairing() + dismiss() + } + } + } + } } private func presentPhotoPicker() { photoPickerShown = true } @@ -303,6 +325,9 @@ struct OnboardingView: View { onboardingLogger.info("starting pair task displayName=\(displayName, privacy: .public)") await state.pair(with: token, displayName: displayName) onboardingLogger.info("pair task finished isPaired=\(state.isPaired, privacy: .public)") + if state.isPaired, state.pairedDesktopPubkey == token.desktopPubkeyHex { + onPairingCompleted?() + } } } catch { onboardingLogger.error("token parse failed: \(error.localizedDescription, privacy: .public)") diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift index 2f87b503..b003c831 100644 --- a/RxCodeMobile/Views/RootView.swift +++ b/RxCodeMobile/Views/RootView.swift @@ -43,11 +43,15 @@ struct RootView: View { } } .task { + consumePendingDeepLink() await state.refreshSnapshot() } .onChange(of: state.activeSessionID) { _, newValue in openActiveSession(newValue) } + .onChange(of: state.pendingDeepLink) { _, _ in + consumePendingDeepLink() + } } private var phoneTabs: some View { @@ -174,13 +178,33 @@ struct RootView: View { } } - /// Navigate to a session surfaced by the desktop (deep links, notifications, - /// freshly created threads). Compact mode is driven solely by `projectsPath` - /// while the regular split view is driven by `selectedSession`. Keeping the - /// two mechanisms separate avoids pushing the same chat page twice. + /// Navigate to a session surfaced by the desktop (freshly created threads, + /// desktop-driven focus changes). private func openActiveSession(_ sessionID: String?) { - guard let sessionID, - let session = state.sessions.first(where: { $0.id == sessionID }) + guard let sessionID else { return } + navigate(toSession: sessionID, projectID: nil) + } + + /// Consume a pending APNs deep link (set by a notification tap) and navigate + /// to its thread. Called both when the link changes and when the paired view + /// first appears, since a link can already be set at cold launch. + private func consumePendingDeepLink() { + guard let link = state.pendingDeepLink else { return } + state.pendingDeepLink = nil + navigate(toSession: link.sessionID, projectID: link.projectID) + } + + /// Push the chat detail page for `sessionID`. Shared by desktop-driven + /// navigation and APNs deep links. When `projectID` is supplied (notification + /// payloads carry it) navigation works even before the session has synced + /// into `state.sessions`; otherwise the project is looked up there. + /// + /// Compact mode is driven solely by `projectsPath` while the regular split + /// view is driven by `selectedSession`. Keeping the two mechanisms separate + /// avoids pushing the same chat page twice. + private func navigate(toSession sessionID: String, projectID: UUID?) { + guard let projectID = projectID + ?? state.sessions.first(where: { $0.id == sessionID })?.projectId else { return } selectedTab = .projects @@ -188,13 +212,13 @@ struct RootView: View { if compactClass == .compact { var path = NavigationPath() - path.append(session.projectId) + path.append(projectID) path.append(sessionID) if projectsPath != path { projectsPath = path } } else { - selectedProject = session.projectId + selectedProject = projectID selectedSession = sessionID } } diff --git a/website/app/agent-talk-feature.tsx b/website/app/agent-talk-feature.tsx new file mode 100644 index 00000000..d1d6ae00 --- /dev/null +++ b/website/app/agent-talk-feature.tsx @@ -0,0 +1,280 @@ +import type { ReactNode } from "react"; + +/** + * "Agents that talk to each other" feature card with an animated diagram. + * + * The animation is pure CSS (keyframes live in globals.css), so this stays a + * server component and degrades gracefully under `prefers-reduced-motion`. + */ + +const NODE_W = 188; +const NODE_H = 86; + +const ACCENTS = { + frontend: { stroke: "#ff8c00", soft: "rgba(255, 140, 0, 0.14)" }, + backend: { stroke: "#85cfff", soft: "rgba(133, 207, 255, 0.14)" }, + mobile: { stroke: "#bdf4ff", soft: "rgba(189, 244, 255, 0.14)" }, +} as const; + +// Each edge is a thread connecting two project agents. Messages stream both +// ways: an orange packet trail in one direction, a cyan trail in the other. +const EDGES = [ + { d: "M216 83 L504 83", fwdDelay: "0s", revDelay: "0.8s" }, + { d: "M122 126 L313 250", fwdDelay: "0.5s", revDelay: "1.2s" }, + { d: "M598 126 L407 250", fwdDelay: "0.9s", revDelay: "0.3s" }, +]; + +// Where the threads dock onto each node. +const PORTS: Array<[number, number, string]> = [ + [216, 83, ACCENTS.frontend.stroke], + [504, 83, ACCENTS.backend.stroke], + [122, 126, ACCENTS.frontend.stroke], + [313, 250, ACCENTS.mobile.stroke], + [598, 126, ACCENTS.backend.stroke], + [407, 250, ACCENTS.mobile.stroke], +]; + +export function AgentTalkFeature() { + return ( +
+
+
+
+ + Agent Collaboration +
+

+ Agents that talk to each other +

+

+ When your work spans several projects — a frontend, a backend, and a + mobile app — each project's agent can message the others + through their threads. Agents exchange context across the whole + codebase, so every change is made with a full understanding of the + system instead of a single repository. +

+
+ +
+
+ ); +} + +function AgentTalkDiagram() { + return ( +
+
+ + {EDGES.map((edge) => ( + + + + + + ))} + + {PORTS.map(([cx, cy, color]) => ( + + ))} + + } + /> + } + /> + } + /> + +

+ Messages relayed agent-to-agent through project threads +

+
+ ); +} + +function ProjectNode({ + x, + y, + name, + role, + accent, + delay, + icon, +}: { + x: number; + y: number; + name: string; + role: string; + accent: { stroke: string; soft: string }; + delay: string; + icon: ReactNode; +}) { + return ( + + + + + + {icon} + + + {name} + + + {role.toUpperCase()} + + + + ); +} + +function FrontendGlyph({ color }: { color: string }) { + return ( + + + + + + + ); +} + +function BackendGlyph({ color }: { color: string }) { + return ( + + + + + + + ); +} + +function MobileGlyph({ color }: { color: string }) { + return ( + + + + + ); +} + +function SparkIcon({ className = "" }: { className?: string }) { + return ( + + ); +} diff --git a/website/app/globals.css b/website/app/globals.css index 5c44070f..00f1b84c 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -240,3 +240,65 @@ body { background: var(--color-surface-container); font-weight: 600; } + +/* --- Agent collaboration diagram --- */ +@keyframes agent-stream { + to { + stroke-dashoffset: -32; + } +} + +@keyframes agent-glow { + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 1; + } +} + +@keyframes agent-ring { + 0%, + 100% { + stroke-opacity: 0; + } + 50% { + stroke-opacity: 0.7; + } +} + +/* Round packets streaming along a thread between two agents. */ +.agent-stream { + stroke-dasharray: 1 15; + stroke-linecap: round; + animation: agent-stream 1.6s linear infinite; +} + +/* Same thread, packets travelling the opposite direction. */ +.agent-stream-rev { + animation-direction: reverse; +} + +.agent-pulse { + animation: agent-glow 2.4s ease-in-out infinite; +} + +.agent-ring { + stroke-opacity: 0; + animation: agent-ring 3.4s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .agent-stream, + .agent-pulse, + .agent-ring { + animation: none; + } + .agent-ring { + stroke-opacity: 0.4; + } + .agent-pulse { + opacity: 0.85; + } +} diff --git a/website/app/page.tsx b/website/app/page.tsx index faf71f20..c8fe3cdc 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -1,5 +1,6 @@ import Image from "next/image"; import Link from "next/link"; +import { AgentTalkFeature } from "./agent-talk-feature"; import { formatSize, getLatestRelease } from "./lib/release"; const GITHUB_REPO_URL = "https://github.com/rxtech-lab/rxcode"; @@ -95,6 +96,35 @@ const FEATURE_CARDS = [ }, ]; +const MOBILE_SHOTS = [ + { + image: "/screenshot/mobile-screenshot-1.PNG", + alt: "RxCode Mobile projects list synced from the desktop app", + caption: "Browse synced projects", + }, + { + image: "/screenshot/mobile-screenshot-2.PNG", + alt: "RxCode Mobile searching across synced agent threads", + caption: "Search every thread", + }, + { + image: "/screenshot/mobile-screenshot-3.PNG", + alt: "RxCode Mobile starting a new agent thread with a model picker", + caption: "Start new threads", + }, + { + image: "/screenshot/mobile-screenshot-4.PNG", + alt: "RxCode Mobile following a live agent conversation", + caption: "Follow live sessions", + }, +]; + +const MOBILE_POINTS = [ + "End-to-end encrypted — the relay forwards only ciphertext it can never read.", + "Stateless relay with APNs push, so notifications land even when the app is closed.", + "Start a session on your Mac, pick it up on your phone, and hand it back.", +]; + export default async function Home() { const release = await getLatestRelease(); const sizeLabel = formatSize(release.sizeBytes); @@ -107,6 +137,7 @@ export default async function Home() { +
@@ -139,6 +170,12 @@ function TopNav({ > Supported Agents + + Mobile +
+ {FEATURE_CARDS.map((feature) => ( ))} @@ -376,6 +414,88 @@ function FeatureCard({ ); } +function MobileCompanion() { + const appStoreUrl = process.env.NEXT_PUBLIC_APP_STORE_URL?.trim(); + + return ( +
+
+ + Mobile Companion + + +
+
+
+
+ + End-to-end encrypted sync +
+

+ Your desktop sessions, synced to your pocket +

+

+ The RxCode companion app mirrors your Mac. Browse projects, search + every thread, kick off new agent runs, and follow live sessions from + your phone — all relayed over an end-to-end encrypted channel so + nothing in the middle can read your code or conversations. +

+
    + {MOBILE_POINTS.map((point) => ( +
  • + + {point} +
  • + ))} +
+ {appStoreUrl ? ( + + Download on the App Store + + ) : null} +
+
+ {MOBILE_SHOTS.map((shot) => ( +
+
+ {shot.alt} +
+
+ {shot.caption} +
+
+ ))} +
+
+
+ ); +} + function CTA({ release, }: { @@ -505,3 +625,38 @@ function DotIcon({ className = "" }: { className?: string }) { ); } + +function LockIcon({ className = "" }: { className?: string }) { + return ( + + ); +} + +function CheckIcon({ className = "" }: { className?: string }) { + return ( + + ); +} diff --git a/website/public/download-appstore.svg b/website/public/download-appstore.svg new file mode 100644 index 00000000..354bc184 --- /dev/null +++ b/website/public/download-appstore.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/website/public/screenshot/mobile-screenshot-1.PNG b/website/public/screenshot/mobile-screenshot-1.PNG new file mode 100644 index 00000000..1a03df99 Binary files /dev/null and b/website/public/screenshot/mobile-screenshot-1.PNG differ diff --git a/website/public/screenshot/mobile-screenshot-2.PNG b/website/public/screenshot/mobile-screenshot-2.PNG new file mode 100644 index 00000000..691c9f0f Binary files /dev/null and b/website/public/screenshot/mobile-screenshot-2.PNG differ diff --git a/website/public/screenshot/mobile-screenshot-3.PNG b/website/public/screenshot/mobile-screenshot-3.PNG new file mode 100644 index 00000000..6677cfa0 Binary files /dev/null and b/website/public/screenshot/mobile-screenshot-3.PNG differ diff --git a/website/public/screenshot/mobile-screenshot-4.PNG b/website/public/screenshot/mobile-screenshot-4.PNG new file mode 100644 index 00000000..16fcb301 Binary files /dev/null and b/website/public/screenshot/mobile-screenshot-4.PNG differ