Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public enum AutopilotDomain: String, Codable, Sendable {
case docs
case release
case project
case search
}

/// Every autopilot operation, globally unique so the desktop can switch on it
Expand Down Expand Up @@ -111,6 +112,11 @@ public enum AutopilotOp: String, Codable, Sendable {
case projectSecretsDownload
case projectSecretsWrite
case projectCreatePullRequest

// Global search — one call returns on-device thread matches AND published
// docs matches for the same query, so mobile gets a single combined result
// instead of stitching two responses together.
case searchThreadsAndDocs
}

/// Mobile → desktop: a single autopilot operation. `body` is a JSON-encoded
Expand Down Expand Up @@ -557,3 +563,43 @@ public struct AutopilotProjectSecretsDownloadResult: Codable, Sendable {
self.conflicts = conflicts
}
}

// MARK: - Global search

/// Mobile → desktop: a single query to run across both on-device threads and
/// published docs. The desktop owns both corpora (its local thread index and
/// the rxlab docs service), so one round trip returns everything.
public struct AutopilotSearchBody: Codable, Sendable {
public let query: String
/// Max hits per source (threads and docs each). Defaults to a sane page on
/// the desktop when nil.
public let limit: Int?

public init(query: String, limit: Int? = nil) {
self.query = query
self.limit = limit
}
}

/// Desktop → mobile: combined search results for one query. Thread hits come
/// from the desktop's on-device index; doc hits from the rxlab docs service.
/// `docHits` is best-effort — a signed-out desktop or a docs-service error
/// yields an empty array rather than failing the whole search.
public struct AutopilotSearchResult: Codable, Sendable {
public let query: String
public let projectIDs: [UUID]
public let threadHits: [SearchHit]
public let docHits: [DocsSearchHit]

public init(
query: String,
projectIDs: [UUID],
threadHits: [SearchHit],
docHits: [DocsSearchHit]
) {
self.query = query
self.projectIDs = projectIDs
self.threadHits = threadHits
self.docHits = docHits
}
}
13 changes: 12 additions & 1 deletion Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -344,11 +344,22 @@ public struct SearchResultsPayload: Codable, Sendable {
public let query: String
public let projectIDs: [UUID]
public let threadHits: [SearchHit]
public init(clientRequestID: UUID, query: String, projectIDs: [UUID], threadHits: [SearchHit]) {
/// Published-docs matches for the same query. Optional for wire-compatibility
/// with desktops that predate mobile docs search — older builds omit the key
/// and mobile decodes it as `nil` (rendered as no docs results).
public let docHits: [DocsSearchHit]?
public init(
clientRequestID: UUID,
query: String,
projectIDs: [UUID],
threadHits: [SearchHit],
docHits: [DocsSearchHit]? = nil
) {
self.clientRequestID = clientRequestID
self.query = query
self.projectIDs = projectIDs
self.threadHits = threadHits
self.docHits = docHits
}
}

Expand Down
14 changes: 13 additions & 1 deletion Packages/Sources/RxCodeSync/Protocol/Payload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -496,12 +496,24 @@ public struct BranchOpRequestPayload: Codable, Sendable {
public let projectID: UUID
public let operation: Operation
public let branch: String
/// For `.createNew`: when true (or absent, for back-compat with older
/// clients), create the branch in an isolated Git worktree. When false,
/// create the branch and check it out in the project root instead.
/// Ignored for other operations.
public let useWorktree: Bool?

public init(clientRequestID: UUID = UUID(), projectID: UUID, operation: Operation, branch: String) {
public init(
clientRequestID: UUID = UUID(),
projectID: UUID,
operation: Operation,
branch: String,
useWorktree: Bool? = nil
) {
self.clientRequestID = clientRequestID
self.projectID = projectID
self.operation = operation
self.branch = branch
self.useWorktree = useWorktree
}
}

Expand Down
11 changes: 11 additions & 0 deletions RxCode/App/AppState+MobileAutopilot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,17 @@ extension AppState {
.filter { FileManager.default.fileExists(atPath: directory.appendingPathComponent($0).path) }
let written = try writeDecryptedSecrets(files, to: directory, overwrite: body.overwrite)
return try encoder.encode(AutopilotProjectSecretsDownloadResult(written: written, conflicts: conflicts))

// MARK: Global search
case .searchThreadsAndDocs:
let body = try decodeAutopilotBody(request, as: AutopilotSearchBody.self)
let result = await combinedThreadAndDocsSearch(query: body.query, limit: body.limit ?? 25)
return try encoder.encode(AutopilotSearchResult(
query: body.query,
projectIDs: result.projectIDs,
threadHits: result.threadHits,
docHits: result.docHits
))
}
}

Expand Down
50 changes: 30 additions & 20 deletions RxCode/App/AppState+MobileSnapshots.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,37 @@
}

func handleMobileSearchRequest(_ request: SearchRequestPayload, fromHex hex: String) async {
let trimmed = request.query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
let empty = SearchResultsPayload(
clientRequestID: request.clientRequestID,
query: request.query,
projectIDs: [],
threadHits: []
)
await MobileSyncService.shared.send(.searchResults(empty), toHex: hex)
return
}
let result = await combinedThreadAndDocsSearch(query: request.query, limit: request.limit)
let payload = SearchResultsPayload(
clientRequestID: request.clientRequestID,
query: request.query,
projectIDs: result.projectIDs,
threadHits: result.threadHits,
docHits: result.docHits
)
await MobileSyncService.shared.send(.searchResults(payload), toHex: hex)
}

/// Runs one query across both corpora the desktop owns: its on-device thread
/// index (semantic + title match) and the rxlab docs service. Shared by the
/// legacy `searchResults` relay and the `searchThreadsAndDocs` autopilot RPC
/// so both stay in lockstep. Docs are best-effort — a signed-out desktop or a
/// docs-service error collapses to an empty docs list and never fails the
/// thread search. Mirrors the desktop overlay's global docs search
/// (GlobalSearchOverlay).
func combinedThreadAndDocsSearch(
query: String,
limit: Int
) async -> (projectIDs: [UUID], threadHits: [SearchHit], docHits: [DocsSearchHit]) {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return ([], [], []) }

let cappedLimit = max(limit, 1)
let knownProjectIDs = Set(projects.map(\.id))
let summaries = allSessionSummaries.filter { knownProjectIDs.contains($0.projectId) }
let summaryByID = Dictionary(uniqueKeysWithValues: summaries.map { ($0.id, $0) })

let semantic = await searchService.search(trimmed, limit: max(request.limit, 1))
let semantic = await searchService.search(trimmed, limit: cappedLimit)

var threadHitByID: [String: SearchHit] = [:]
for group in semantic {
Expand Down Expand Up @@ -88,7 +102,7 @@
if lhs.score != rhs.score { return lhs.score > rhs.score }
return lhs.updatedAt > rhs.updatedAt
}
.prefix(max(request.limit, 1))
.prefix(cappedLimit)

let projectIDs = projects
.filter { project in
Expand All @@ -97,13 +111,9 @@
}
.map(\.id)

let payload = SearchResultsPayload(
clientRequestID: request.clientRequestID,
query: request.query,
projectIDs: projectIDs,
threadHits: Array(threadHits)
)
await MobileSyncService.shared.send(.searchResults(payload), toHex: hex)
let docHits = (try? await docs.search(query: trimmed, repo: nil, limit: cappedLimit)) ?? []

return (projectIDs, Array(threadHits), docHits)
}

func sendMobileSnapshot(toHex hex: String, activeSessionID: String?) async {
Expand Down Expand Up @@ -883,4 +893,4 @@
reindexProgress = nil
}

}

Check warning on line 896 in RxCode/App/AppState+MobileSnapshots.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 736 (file_length)
10 changes: 9 additions & 1 deletion RxCode/App/AppState+MobileSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,15 @@
try await attachWorktreeForExistingBranch(trimmed, in: window)
updateMobilePendingWorktree(from: window, projectID: project.id)
case .createNew:
try await attachWorktree(branch: trimmed, in: window)
// Honor the mobile picker: worktree (default) keeps the main
// checkout untouched; checkout creates the branch in the
// project root and clears any parked worktree so the next
// thread spawns there.
if request.useWorktree ?? true {
try await attachWorktree(branch: trimmed, in: window)
} else {
try await createBranchInPlace(branch: trimmed, in: window)
}
updateMobilePendingWorktree(from: window, projectID: project.id)
case .initGit:
break // handled above
Expand Down Expand Up @@ -766,4 +774,4 @@
await MobileSyncService.shared.send(.deleteProjectResult(result), toHex: hex)
}

}

Check warning on line 777 in RxCode/App/AppState+MobileSync.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 689 (file_length)
40 changes: 38 additions & 2 deletions RxCode/Services/RateLimitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ actor RateLimitService {
private let cacheTTL: TimeInterval = 300 // 5 minutes
private var authFailed = false

/// In-memory copy of the Claude Code OAuth tokens. The
/// `Claude Code-credentials` Keychain item is owned by the *Claude Code*
/// app, not RxCode, so every read of it pops the macOS "wants to use
/// confidential information" prompt once RxCode is re-signed (its signature
/// is never on that item's ACL). Reading it once per launch and serving the
/// rest from memory keeps that prompt to a single appearance. The network
/// refresh updates this copy in place (we never write back to the Keychain),
/// and it's cleared only when auth fails — so a credential the user freshly
/// logged into Claude Code with is still picked up on the next poll.
private var cachedTokens: OAuthTokens?

func fetchUsage(forceRefresh: Bool = false) async -> RateLimitUsage? {
if !forceRefresh, let c = cached, let at = cachedAt, Date().timeIntervalSince(at) < cacheTTL {
return c
Expand Down Expand Up @@ -55,6 +66,7 @@ actor RateLimitService {
} else {
logger.debug("[RateLimit] Token refresh failed, cannot fetch usage")
authFailed = true
cachedTokens = nil
return cached
}
} else {
Expand All @@ -78,6 +90,11 @@ actor RateLimitService {
// MARK: - Keychain

private func readOAuthTokens() async -> OAuthTokens? {
// Serve from memory whenever possible — see `cachedTokens`. Only the
// first miss (or the first poll after an auth failure cleared it)
// touches the foreign Keychain item that triggers the prompt.
if let cachedTokens { return cachedTokens }

guard let raw = await MainActor.run(body: { KeychainHelper.readString(service: "Claude Code-credentials") }) else {
return nil
}
Expand All @@ -87,7 +104,9 @@ actor RateLimitService {
else { return nil }

let refreshToken = oauth["refreshToken"] as? String
return OAuthTokens(accessToken: accessToken, refreshToken: refreshToken, rawOauth: oauth)
let tokens = OAuthTokens(accessToken: accessToken, refreshToken: refreshToken, rawOauth: oauth)
cachedTokens = tokens
return tokens
}

private func isExpired(_ oauth: [String: Any]) -> Bool {
Expand Down Expand Up @@ -142,7 +161,23 @@ actor RateLimitService {
}

logger.info("[RateLimit] Token refreshed successfully")
// Skip Keychain write since account is unknown — use in-memory cache only
// Skip Keychain write since account is unknown — update the
// in-memory copy instead. Refresh the stored `expiresAt` from the
// response's `expires_in` (seconds) so the next poll doesn't treat
// the just-refreshed token as expired and refresh again; drop it if
// the server didn't say, which makes `isExpired` return false.
var updatedOauth = tokens.rawOauth
if let expiresIn = json["expires_in"] as? Double {
updatedOauth["expiresAt"] = (Date().timeIntervalSince1970 + expiresIn) * 1000
} else {
updatedOauth.removeValue(forKey: "expiresAt")
}
let newRefreshToken = (json["refresh_token"] as? String) ?? tokens.refreshToken
cachedTokens = OAuthTokens(
accessToken: newAccessToken,
refreshToken: newRefreshToken,
rawOauth: updatedOauth
)
return newAccessToken
} catch {
logger.debug("[RateLimit] Token refresh error: \(error.localizedDescription)")
Expand All @@ -167,6 +202,7 @@ actor RateLimitService {
if code == 401 {
logger.debug("[RateLimit] API returned 401 — token invalid")
authFailed = true
cachedTokens = nil
} else {
logger.warning("[RateLimit] API returned status \(code)")
}
Expand Down
24 changes: 24 additions & 0 deletions RxCodeMobile/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,9 @@
}
}
}
},
"Branch + checkout" : {

},
"Branch briefings collect recent thread summaries from your Mac into a quick mobile status view." : {
"localizations" : {
Expand Down Expand Up @@ -1662,6 +1665,12 @@
}
}
}
},
"Creates an isolated worktree so this thread works without touching the main checkout." : {

},
"Creates the branch and checks it out in the project root, changing the main checkout." : {

},
"Creating Pull Request…" : {

Expand Down Expand Up @@ -3540,6 +3549,9 @@
}
}
}
},
"Mode" : {

},
"Model" : {
"localizations" : {
Expand Down Expand Up @@ -3634,6 +3646,9 @@
}
}
}
},
"New worktree" : {

},
"Next change" : {
"localizations" : {
Expand Down Expand Up @@ -5258,6 +5273,9 @@
}
}
}
},
"Scope" : {

},
"Script" : {
"localizations" : {
Expand All @@ -5284,6 +5302,9 @@
}
}
}
},
"Search" : {

},
"Search or enter website name" : {
"localizations" : {
Expand Down Expand Up @@ -5326,6 +5347,9 @@
}
}
}
},
"Search threads and docs" : {

},
"Searching…" : {
"localizations" : {
Expand Down
12 changes: 12 additions & 0 deletions RxCodeMobile/State/MobileAppState+Autopilot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,18 @@ extension MobileAppState {
try await autopilotSendVoid(.docs, .docsDeleteDocument, body: AutopilotDocsDocBody(repoId: repoId, docId: docId))
}

// MARK: - Global search

/// One round trip that returns both on-device thread matches and published
/// docs matches for `query`. The desktop owns both corpora, so this single
/// request/response RPC replaces stitching two separate relay messages
/// together.
func searchThreadsAndDocs(query: String, limit: Int = 25) async throws -> AutopilotSearchResult {
try await autopilotSend(.search, .searchThreadsAndDocs,
body: AutopilotSearchBody(query: query, limit: limit),
as: AutopilotSearchResult.self)
}

@discardableResult
func createDocsUploadToken(repoId: String, name: String?) async throws -> DocsUploadToken {
try await autopilotSend(.docs, .docsCreateUploadToken, body: AutopilotDocsCreateTokenBody(repoId: repoId, name: name), as: DocsUploadToken.self)
Expand Down
Loading
Loading