From 9b0b7fd6552fea32d53d856d29bc7a46208e47c2 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Mon, 1 Jun 2026 02:23:16 +0800 Subject: [PATCH] feat: add docs search support --- .github/workflows/upload-docs.yaml | 26 + .../Backend/BackendCapability.swift | 5 + .../RxCodeCore/Backend/IDEToolRegistry.swift | 37 ++ .../Sources/RxCodeCore/Docs/DocsModels.swift | 293 ++++++++++ .../RxCodeCore/Hooks/HookController.swift | 14 + RxCode/App/AppState+Docs.swift | 35 ++ RxCode/App/AppState.swift | 17 + RxCode/App/RxCodeApp.swift | 12 +- RxCode/Resources/Localizable.xcstrings | 553 +++++++++++++++++- RxCode/Services/Docs/DocsService.swift | 298 ++++++++++ RxCode/Services/Docs/DocsSkill.swift | 120 ++++ .../Hooks/AppStateHookController.swift | 21 + RxCode/Services/Hooks/hooks/DocsHook.swift | 73 +++ .../IDEServer/AppState+IDEToolHandling.swift | 105 ++++ RxCode/Views/Docs/AddDocsDocumentSheet.swift | 124 ++++ RxCode/Views/Docs/AddDocsRepoSheet.swift | 156 +++++ RxCode/Views/Docs/DocsDeepLink.swift | 62 ++ .../Views/Docs/DocsDocumentDetailView.swift | 168 ++++++ RxCode/Views/Docs/DocsManageSheet.swift | 166 ++++++ RxCode/Views/Docs/DocsRepoDetailView.swift | 225 +++++++ RxCode/Views/Hooks/DocsSetupBanner.swift | 82 +++ RxCode/Views/MainView.swift | 16 + .../OnboardingAutopilotPreview.swift | 150 +++++ RxCode/Views/Onboarding/OnboardingView.swift | 10 + RxCode/Views/Search/GlobalSearchOverlay.swift | 273 ++++++++- .../Views/Settings/AutopilotSettingsTab.swift | 42 ++ RxCode/Views/Sidebar/ProjectTreeView.swift | 87 ++- docs/api/relay-server.md | 123 ++++ docs/architecture/data-flow.md | 47 ++ docs/architecture/overview.md | 60 ++ docs/architecture/packages.md | 49 ++ docs/architecture/services.md | 52 ++ scripts/upload_docs.py | 162 +++++ 33 files changed, 3591 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/upload-docs.yaml create mode 100644 Packages/Sources/RxCodeCore/Docs/DocsModels.swift create mode 100644 RxCode/App/AppState+Docs.swift create mode 100644 RxCode/Services/Docs/DocsService.swift create mode 100644 RxCode/Services/Docs/DocsSkill.swift create mode 100644 RxCode/Services/Hooks/hooks/DocsHook.swift create mode 100644 RxCode/Views/Docs/AddDocsDocumentSheet.swift create mode 100644 RxCode/Views/Docs/AddDocsRepoSheet.swift create mode 100644 RxCode/Views/Docs/DocsDeepLink.swift create mode 100644 RxCode/Views/Docs/DocsDocumentDetailView.swift create mode 100644 RxCode/Views/Docs/DocsManageSheet.swift create mode 100644 RxCode/Views/Docs/DocsRepoDetailView.swift create mode 100644 RxCode/Views/Hooks/DocsSetupBanner.swift create mode 100644 RxCode/Views/Onboarding/OnboardingAutopilotPreview.swift create mode 100644 docs/api/relay-server.md create mode 100644 docs/architecture/data-flow.md create mode 100644 docs/architecture/overview.md create mode 100644 docs/architecture/packages.md create mode 100644 docs/architecture/services.md create mode 100644 scripts/upload_docs.py diff --git a/.github/workflows/upload-docs.yaml b/.github/workflows/upload-docs.yaml new file mode 100644 index 0000000..0e4c79b --- /dev/null +++ b/.github/workflows/upload-docs.yaml @@ -0,0 +1,26 @@ +name: Upload docs to autopilot +on: + push: + branches: ["main"] + paths: + - "docs/**" + - "scripts/upload_docs.py" + - ".github/workflows/upload-docs.yaml" + workflow_dispatch: +concurrency: + group: upload-docs + cancel-in-progress: false +jobs: + upload: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Upload docs + env: + DOCS_ENDPOINT: https://autopilot.rxlab.app + DOCS_REPOSITORY_ID: rxtech-lab/rxcode + DOCS_UPLOAD_TOKEN: ${{ secrets.DOCS_UPLOAD_TOKEN }} + run: python scripts/upload_docs.py diff --git a/Packages/Sources/RxCodeCore/Backend/BackendCapability.swift b/Packages/Sources/RxCodeCore/Backend/BackendCapability.swift index b308c90..54ba078 100644 --- a/Packages/Sources/RxCodeCore/Backend/BackendCapability.swift +++ b/Packages/Sources/RxCodeCore/Backend/BackendCapability.swift @@ -14,6 +14,11 @@ public enum BackendCapability: String, Sendable, Hashable, CaseIterable, Codable case hooks case mcpServers case skills + /// Native documentation search. No backend declares this yet, so the + /// `ide__search_docs` polyfill is exposed to every agent (it calls + /// github-pm's docs API). A backend that ships its own docs search can + /// declare this to suppress the polyfill. + case docsSearch } public typealias CapabilitySet = Set diff --git a/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift b/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift index ffc3187..e88710a 100644 --- a/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift +++ b/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift @@ -313,6 +313,43 @@ public enum IDEToolRegistry { "properties": .object([:]), ]) ), + IDETool( + name: "ide__search_docs", + description: "Semantic search over the project's published documentation (design docs, API docs, code docs) indexed by the docs service. Use this to look up how something works before reading source. Returns ranked snippets with their document ids.", + visibility: .polyfill(.docsSearch), + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "query": .object([ + "type": .string("string"), + "description": .string("Natural-language search query."), + ]), + "repository": .object([ + "type": .string("string"), + "description": .string("Optional `owner/repo` full name to restrict the search to one repository. Omit to search every docs repository you can read."), + ]), + "limit": .object([ + "type": .string("integer"), + "description": .string("Maximum number of hits to return. Default 10, capped at 50."), + ]), + ]), + "required": .array([.string("query")]), + ]) + ), + IDETool( + name: "ide__setup_docs_secret", + description: "Mint a DOCS_UPLOAD_TOKEN and install it as the repository's GitHub Actions secret in one step, so the repo's docs-publishing CI can authenticate. Use this when setting up documentation publishing instead of asking the user to run `gh secret set` manually. If the repository isn't registered with the docs service yet, it's registered automatically (the RxLab GitHub App must be installed on it). If the call fails with a permission error, tell the user to re-authorize the RxLab GitHub App (Actions secrets: read & write) and retry.", + visibility: .alwaysIDEOnly, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "repository": .object([ + "type": .string("string"), + "description": .string("Optional `owner/repo` full name. Omit to use the current project's repository."), + ]), + ]), + ]) + ), ] /// Returns the tools that should be exposed to an agent whose declared diff --git a/Packages/Sources/RxCodeCore/Docs/DocsModels.swift b/Packages/Sources/RxCodeCore/Docs/DocsModels.swift new file mode 100644 index 0000000..7fd8370 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Docs/DocsModels.swift @@ -0,0 +1,293 @@ +import Foundation + +// Codable DTOs mirroring github-pm's `/api/v1/docs/*` JSON shapes. Field names +// match the server responses; extra fields decode harmlessly and uncertain +// fields are optional so a contract drift degrades gracefully rather than +// failing the whole decode. + +// MARK: - Search + +/// One hit from `GET /api/v1/docs/search?q=&repository=&repo=&limit=`. Field +/// names match the server's contract verbatim. +public struct DocsSearchHit: Codable, Sendable, Identifiable, Hashable { + /// Internal document UUID. + public let documentId: String? + /// The logical document id (the frontmatter `slug` the doc was uploaded as). + public let docId: String + /// Internal docs-repository UUID. + public let docsRepositoryId: String? + /// The repo this doc belongs to, as `owner/repo`. + public let repositoryFullName: String? + public let version: Int? + /// Cosine similarity in 0...1. + public let similarity: Double? + /// Short highlighted excerpt around the match (server returns ~240 chars). + public let snippet: String? + /// Canonical URL for the source document, when known. + public let originalLink: String? + + public var id: String { documentId ?? "\(repositoryFullName ?? ""):\(docId)" } + + // Display aliases used across the UI / tools. + public var repository: String? { repositoryFullName } + public var score: Double? { similarity } + /// No dedicated title field in the contract — fall back to the doc id. + public var title: String? { nil } + + public init( + documentId: String? = nil, + docId: String, + docsRepositoryId: String? = nil, + repositoryFullName: String? = nil, + version: Int? = nil, + similarity: Double? = nil, + snippet: String? = nil, + originalLink: String? = nil + ) { + self.documentId = documentId + self.docId = docId + self.docsRepositoryId = docsRepositoryId + self.repositoryFullName = repositoryFullName + self.version = version + self.similarity = similarity + self.snippet = snippet + self.originalLink = originalLink + } +} + +public struct DocsSearchResult: Codable, Sendable { + public let items: [DocsSearchHit] + + public init(items: [DocsSearchHit]) { self.items = items } +} + +// MARK: - Repositories + +/// A docs-managed repository from `GET /api/v1/docs/repositories`. +public struct DocsRepo: Codable, Sendable, Identifiable, Hashable { + /// Internal docs-repository UUID. + public let id: String + public let installationId: Int? + public let repositoryId: Int? + public let repositoryFullName: String + public let owner: String? + public let repo: String? + public let lastIndexedAt: String? + public let documentsCount: Int? + public let createdAt: String? + public let updatedAt: String? + + public var fullName: String { repositoryFullName } + + public init( + id: String, + installationId: Int? = nil, + repositoryId: Int? = nil, + repositoryFullName: String, + owner: String? = nil, + repo: String? = nil, + lastIndexedAt: String? = nil, + documentsCount: Int? = nil, + createdAt: String? = nil, + updatedAt: String? = nil + ) { + self.id = id + self.installationId = installationId + self.repositoryId = repositoryId + self.repositoryFullName = repositoryFullName + self.owner = owner + self.repo = repo + self.lastIndexedAt = lastIndexedAt + self.documentsCount = documentsCount + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public struct DocsRepoListResponse: Codable, Sendable { + public let items: [DocsRepo] + public let pagination: Pagination? + + public struct Pagination: Codable, Sendable { + public let nextCursor: String? + public let hasMore: Bool + } +} + +/// Body for `POST /api/v1/docs/repositories` (register a repo for docs). +public struct AddDocsRepoBody: Codable, Sendable { + public let installationId: Int + public let repositoryId: Int + public let repositoryFullName: String + + public init(installationId: Int, repositoryId: Int, repositoryFullName: String) { + self.installationId = installationId + self.repositoryId = repositoryId + self.repositoryFullName = repositoryFullName + } +} + +// MARK: - "contains docs" status + +/// Whether a repo has docs set up. There is no batch-status endpoint server +/// side; `DocsService.statuses(forRepos:)` derives this from the managed +/// repositories listing (`GET /api/v1/docs/repositories`). +public struct DocsRepoStatus: Codable, Sendable, Hashable { + /// `owner/repo` the status is for. + public let repository: String + public let hasDocs: Bool + public let documentsCount: Int? + public let readyCount: Int? + public let docsRepositoryId: String? + + public init( + repository: String, + hasDocs: Bool, + documentsCount: Int? = nil, + readyCount: Int? = nil, + docsRepositoryId: String? = nil + ) { + self.repository = repository + self.hasDocs = hasDocs + self.documentsCount = documentsCount + self.readyCount = readyCount + self.docsRepositoryId = docsRepositoryId + } +} + +// MARK: - Documents + +/// One document in a repo from `GET /api/v1/docs/repositories/{id}/documents`. +public struct DocsDocument: Codable, Sendable, Identifiable, Hashable { + public let docId: String + public let title: String? + /// `pending | indexing | ready | failed` per the server's embedding pipeline. + public let embeddingStatus: String? + public let updatedAt: String? + + public var id: String { docId } +} + +public struct DocsDocumentList: Codable, Sendable { + public let items: [DocsDocument] + public let pagination: Pagination? + + public struct Pagination: Codable, Sendable { + public let nextCursor: String? + public let hasMore: Bool + } +} + +/// One document in the batch body for +/// `POST /api/v1/docs/repositories/{id}/documents`. `docId` is the logical +/// slug (must be non-empty and unique within the repo); `originalLink`, when +/// present, must be a valid http(s) URL. +public struct DocsDocumentUpload: Codable, Sendable { + public let docId: String + public let content: String + public let originalLink: String? + + public init(docId: String, content: String, originalLink: String? = nil) { + self.docId = docId + self.content = content + self.originalLink = originalLink + } +} + +public struct UploadDocumentsBody: Codable, Sendable { + public let documents: [DocsDocumentUpload] + + public init(documents: [DocsDocumentUpload]) { self.documents = documents } +} + +/// Result of a batch upload. Uploads are async: `jobId` identifies the +/// embedding job and the doc(s) become searchable once it finishes. +public struct DocsUploadResult: Codable, Sendable { + public let jobId: String? + public let accepted: Int? + public let skipped: Int? + public let total: Int? +} + +// MARK: - Single document + versions + +/// One stored version of a document. Used both in the version-list response +/// (where `content` is omitted) and as the single-version response (where +/// `content` carries that version's full markdown). Versions are integers, +/// 1-based, returned newest-first. +public struct DocsDocumentVersion: Codable, Sendable, Identifiable, Hashable { + public let id: String? + public let documentId: String? + public let version: Int + public let content: String? + public let contentHash: String? + public let embeddingStatus: String? + public let embeddingError: String? + public let createdAt: String? + public let embeddedAt: String? +} + +public struct DocsDocumentVersionList: Codable, Sendable { + public let items: [DocsDocumentVersion] +} + +/// `GET /api/v1/docs/repositories/{id}/documents/{docId}` wraps the document +/// row and its current version. The markdown lives on `currentVersion.content`, +/// not on `document`. +public struct DocsDocumentDetail: Codable, Sendable { + public let document: Document + public let currentVersion: DocsDocumentVersion? + + public struct Document: Codable, Sendable { + public let id: String? + public let docsRepositoryId: String? + public let docId: String + public let originalLink: String? + public let currentVersionId: String? + public let currentVersion: Int? + public let createdAt: String? + public let updatedAt: String? + } +} + +// MARK: - Upload tokens (CI / machine auth) + +/// Result of `POST /api/v1/docs/repositories/{id}/upload-tokens`. The plaintext +/// token is returned exactly once at creation time. +public struct DocsUploadToken: Codable, Sendable { + public let id: String? + /// Plaintext upload token (e.g. `dput_…`). Present only on creation. + public let plaintext: String? + /// 12-char display prefix for later identification. + public let tokenPrefix: String? + + /// Convenience alias for the one-time plaintext token. + public var token: String? { plaintext } +} + +public struct CreateDocsUploadTokenBody: Codable, Sendable { + /// Required, non-empty display name for the token. + public let name: String + + public init(name: String) { self.name = name } +} + +/// Result of `POST /api/v1/docs/repositories/{id}/github-secret`, which mints a +/// `DOCS_UPLOAD_TOKEN` and installs it as the repo's GitHub Actions secret in +/// one step. The plaintext token never leaves the server, so only the secret +/// name and target repo come back. +public struct DocsGithubSecretResult: Codable, Sendable { + public let secretName: String + public let repositoryFullName: String + + public init(secretName: String, repositoryFullName: String) { + self.secretName = secretName + self.repositoryFullName = repositoryFullName + } +} + +// MARK: - Misc + +public struct DocsIDResponse: Codable, Sendable { + public let id: String +} diff --git a/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift index 96751c8..547579e 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookController.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookController.swift @@ -114,6 +114,20 @@ public protocol HookController: AnyObject { /// Write decrypted files into a project folder, skipping existing files /// unless `overwrite`. Returns the filenames actually written. func writeSecrets(_ files: [HookSecretFile], toPath path: String, overwrite: Bool) throws -> [String] + + // MARK: Docs + + /// Whether the repo has documentation indexed in the docs service. Returns + /// `nil` when the check can't be completed (signed out, offline, request + /// failed) so callers can tell that apart from a genuine "no docs" and avoid + /// surfacing a misleading banner. + func docsIndexed(repoFullName: String) async -> Bool? + + /// One-shot: if a docs-setup chat was kicked off for `projectId` (via the + /// docs banner), returns the docs-publishing skill text to inject as the + /// session's system prompt and clears the pending flag. Returns `nil` + /// otherwise. + func consumePendingDocsSetupSkill(projectId: UUID) -> String? } // MARK: - Banner surfaces diff --git a/RxCode/App/AppState+Docs.swift b/RxCode/App/AppState+Docs.swift new file mode 100644 index 0000000..f257c9a --- /dev/null +++ b/RxCode/App/AppState+Docs.swift @@ -0,0 +1,35 @@ +#if os(macOS) +import Foundation +import RxCodeCore + +/// High-level docs intents layered over `DocsService`. Views and the docs hook +/// call these; they never build requests directly. +extension AppState { + + // MARK: - Status (batch) + + /// Refreshes `docsStatusByRepo` for every open project's GitHub repo so the + /// docs hook can decide whether to surface the "set up docs" banner. Leaves + /// the previous cache untouched on transient errors. + func refreshDocsStatuses() async { + guard isSignedIn else { docsStatusByRepo = [:]; return } + let repos = Array(Set(projects.compactMap(\.gitHubRepo))) + guard !repos.isEmpty else { docsStatusByRepo = [:]; return } + do { + let statuses = try await docs.statuses(forRepos: repos) + docsStatusByRepo = Dictionary( + statuses.map { ($0.repository.lowercased(), $0) }, + uniquingKeysWith: { _, last in last } + ) + } catch { + // Keep the prior cache; a failed poll shouldn't flip the banner. + } + } + + /// Whether the project's linked repo has docs indexed. Returns `nil` when we + /// have no status for the repo yet (caller should treat as "unknown"). + func projectDocsState(_ repoFullName: String) -> Bool? { + docsStatusByRepo[repoFullName.lowercased()]?.hasDocs + } +} +#endif diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index ed02fc0..ba9068e 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -443,6 +443,11 @@ final class AppState { /// Secret" affordance so it only shows where secrets exist. Memory only. var secretsStatusByRepo: [String: SecretsRepoStatus] = [:] + /// Latest docs status keyed by lowercased `owner/repo`, refreshed in + /// `AppState+Docs.swift`. Lets the docs hook decide whether to surface the + /// "set up docs" banner without a per-chat round trip. Memory only. + var docsStatusByRepo: [String: DocsRepoStatus] = [:] + /// Per-project signature of the last CI failure we already notified / auto-fixed, /// so a steady-state red branch doesn't re-fire every 30s. Keyed by project id; /// persisted to UserDefaults so a fix isn't re-triggered across relaunches. @@ -918,12 +923,22 @@ final class AppState { /// Non-nil while the secret-setup form should be presented (e.g. opened from /// the autopilot `.env` banner's deep link). var secretsSetupRequest: SecretsSetupRequest? + /// Non-nil while a docs-setup new chat should be started (opened from the + /// docs banner's deep link). `MainView` consumes it to start a fresh chat + /// seeded with the docs-publishing skill. + var docsSetupRequest: DocsSetupRequest? + /// One-shot: when a docs-setup chat is kicked off, this holds the project so + /// `DocsHook.onSessionStart` injects the docs skill into exactly that chat's + /// system prompt, then clears it. + @ObservationIgnored var pendingDocsSetupProjectId: UUID? // MARK: - Services let rxAuth = RxAuthService.shared let autopilot: AutopilotService let secrets: SecretsService + /// Talks to github-pm's docs API (search, repos, documents, upload tokens). + let docs: DocsService /// Passkey-derived KEK cache for the secrets feature (macOS only). let secretsKeyVault = SecretsKeyVault() /// Cached enrollment status for the secrets feature: `nil` = unknown. @@ -1073,6 +1088,7 @@ final class AppState { self.threadStore = ThreadStore.make() self.autopilot = AutopilotService(rxAuth: RxAuthService.shared) self.secrets = SecretsService(rxAuth: RxAuthService.shared) + self.docs = DocsService(rxAuth: RxAuthService.shared) self.runService.onTasksChanged = { [weak self] in Task { @MainActor [weak self] in self?.broadcastMobileRunTasks() @@ -1134,6 +1150,7 @@ final class AppState { hookManager.register(RemoteConfigNotificationHook()) #if os(macOS) hookManager.register(AutopilotHook()) + hookManager.register(DocsHook()) #endif } diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index b832251..2410350 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -604,6 +604,10 @@ struct MainWindowRoot: View { .environment(windowState) .environment(chatBridge) .environment(\.openURL, OpenURLAction { url in + if let docs = DocsDeepLink.parse(url), docs.action == .setup { + appState.docsSetupRequest = DocsSetupRequest(repoFullName: docs.repoFullName) + return .handled + } if let request = SecretsDeepLink.parse(url) { appState.secretsSetupRequest = request return .handled @@ -617,7 +621,9 @@ struct MainWindowRoot: View { } } .onOpenURL { url in - if let request = SecretsDeepLink.parse(url) { + if let docs = DocsDeepLink.parse(url), docs.action == .setup { + appState.docsSetupRequest = DocsSetupRequest(repoFullName: docs.repoFullName) + } else if let request = SecretsDeepLink.parse(url) { appState.secretsSetupRequest = request } } @@ -683,6 +689,10 @@ struct ProjectWindowRoot: View { .environment(windowState) .environment(chatBridge) .environment(\.openURL, OpenURLAction { url in + if let docs = DocsDeepLink.parse(url), docs.action == .setup { + appState.docsSetupRequest = DocsSetupRequest(repoFullName: docs.repoFullName) + return .handled + } if let request = SecretsDeepLink.parse(url) { appState.secretsSetupRequest = request return .handled diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index de47a63..488f642 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -186,7 +186,20 @@ } }, "%@ isn't backed up to Autopilot" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@이(가) Autopilot에 백업되지 않았습니다" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 尚未备份到 Autopilot" + } + } + } }, "%@ not found" : { "extractionState" : "stale", @@ -218,6 +231,9 @@ } } } + }, + "%@ will be removed from the docs index. This can't be undone." : { + }, "%@, in progress" : { "localizations" : { @@ -372,6 +388,22 @@ } } }, + "%lld document(s)" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 %lld개" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个文档" + } + } + } + }, "%lld env · %lld secrets" : { "localizations" : { "en" : { @@ -748,6 +780,12 @@ } } } + }, + "Add Document" : { + + }, + "Add Documentation Repository" : { + }, "Add Git Skill Source" : { "localizations" : { @@ -884,7 +922,6 @@ } }, "Add Repository" : { - "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1626,6 +1663,22 @@ } } }, + "Autopilot Account" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot 계정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot 账户" + } + } + } + }, "Awaiting check" : { "localizations" : { "zh-Hans" : { @@ -1659,7 +1712,20 @@ } }, "Back up this project's secrets to Autopilot" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 프로젝트의 시크릿을 Autopilot에 백업" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将此项目的密钥备份到 Autopilot" + } + } + } }, "Bash" : { "localizations" : { @@ -2002,6 +2068,22 @@ } } }, + "CI upload token" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 업로드 토큰" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 上传令牌" + } + } + } + }, "Claude CLI Installation Check" : { "extractionState" : "stale", "localizations" : { @@ -2399,6 +2481,22 @@ } } }, + "Connect your rxlab account to import GitHub repositories, sync encrypted secrets, and use autopilot features. You can skip this and sign in later." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "rxlab 계정을 연결하여 GitHub 저장소를 가져오고, 암호화된 시크릿을 동기화하고, Autopilot 기능을 사용하세요. 이 단계를 건너뛰고 나중에 로그인할 수 있습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接你的 rxlab 账户以导入 GitHub 仓库、同步加密的密钥并使用 Autopilot 功能。你可以跳过此步骤,稍后再登录。" + } + } + } + }, "Connected" : { "localizations" : { "en" : { @@ -2666,6 +2764,9 @@ } } } + }, + "Current project" : { + }, "Custom" : { "extractionState" : "stale", @@ -2957,6 +3058,9 @@ } } } + }, + "Delete Document" : { + }, "Delete Environment" : { "localizations" : { @@ -3071,6 +3175,9 @@ } } } + }, + "Delete this document?" : { + }, "Deleting \"%@\"…" : { "localizations" : { @@ -3187,7 +3294,20 @@ } }, "Dismiss" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "무시" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "忽略" + } + } + } }, "Display name" : { "localizations" : { @@ -3199,6 +3319,74 @@ } } }, + "Document" : { + + }, + "Documentation" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文档" + } + } + } + }, + "Documentation search" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 검색" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文档搜索" + } + } + } + }, + "Documentation search · powered by the docs service" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 검색 · 문서 서비스 제공" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文档搜索 · 由文档服务提供支持" + } + } + } + }, + "Documents (%lld)" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 (%lld)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文档 (%lld)" + } + } + } + }, "Don't see your org repos?" : { "extractionState" : "stale", "localizations" : { @@ -4529,6 +4717,22 @@ } } }, + "Index your repositories' docs (design docs, API docs, code docs) so agents and ⌘K can search them. Set up CI to upload docs automatically with an upload token." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소의 문서(설계 문서, API 문서, 코드 문서)를 색인하여 에이전트와 ⌘K로 검색할 수 있습니다. CI를 설정하면 업로드 토큰으로 문서를 자동으로 업로드할 수 있습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为你的仓库文档(设计文档、API 文档、代码文档)建立索引,让智能体和 ⌘K 可以搜索它们。配置 CI 即可使用上传令牌自动上传文档。" + } + } + } + }, "Inherit global default" : { "localizations" : { "zh-Hans" : { @@ -4593,6 +4797,22 @@ } } }, + "Install a DOCS_UPLOAD_TOKEN secret so this repo's CI can upload docs. RxCode mints the token and stores it as the repository's GitHub Actions secret for you — no terminal needed." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 저장소의 CI가 문서를 업로드할 수 있도록 DOCS_UPLOAD_TOKEN 시크릿을 설치합니다. RxCode가 토큰을 생성하여 저장소의 GitHub Actions 시크릿으로 저장해 줍니다 — 터미널이 필요 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为该仓库安装 DOCS_UPLOAD_TOKEN 密钥,使其 CI 能够上传文档。RxCode 会为你创建令牌并将其保存为仓库的 GitHub Actions 密钥——无需使用终端。" + } + } + } + }, "Install ACP agents" : { "localizations" : { "zh-Hans" : { @@ -4603,6 +4823,22 @@ } } }, + "Install DOCS_UPLOAD_TOKEN secret" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "DOCS_UPLOAD_TOKEN 시크릿 설치" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装 DOCS_UPLOAD_TOKEN 密钥" + } + } + } + }, "Install additional ACP-compatible agents — RxCode downloads the right binary for macOS and probes the model list." : { "localizations" : { "zh-Hans" : { @@ -4723,6 +4959,22 @@ } } }, + "Installed %@ on %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@에 %1$@을(를) 설치했습니다" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已将 %1$@ 安装到 %2$@" + } + } + } + }, "Installed skills are managed by RxCode, sourced from OpenAI Agent Skills and compatible catalogs, and enabled for Claude Code, Codex, and ACP agents where supported." : { "localizations" : { "zh-Hans" : { @@ -4852,6 +5104,9 @@ } } } + }, + "Load from File…" : { + }, "Load more" : { "localizations" : { @@ -4994,6 +5249,7 @@ } }, "Local on-device search · archived threads included" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -5095,6 +5351,38 @@ } } }, + "Manage Docs" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 관리" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理文档" + } + } + } + }, + "Manage docs repositories, view indexed documents, and create CI upload tokens." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 저장소를 관리하고, 색인된 문서를 보고, CI 업로드 토큰을 생성합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理文档仓库、查看已索引的文档并创建 CI 上传令牌。" + } + } + } + }, "Manage GitHub Repos" : { "extractionState" : "stale", "localizations" : { @@ -5179,6 +5467,9 @@ } } } + }, + "Markdown content" : { + }, "Matched on title" : { "localizations" : { @@ -5732,6 +6023,42 @@ } } } + }, + "No documentation repositories yet. Set up docs publishing from a project's chat to index its docs." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 문서 저장소가 없습니다. 프로젝트 채팅에서 문서 게시를 설정하여 문서를 색인하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚无文档仓库。在项目的聊天中设置文档发布即可为其文档建立索引。" + } + } + } + }, + "No documents uploaded yet." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 업로드된 문서가 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未上传任何文档。" + } + } + } + }, + "No documents uploaded yet. Use the + button above to add one, or set up CI to upload them automatically." : { + }, "No editors detected" : { "localizations" : { @@ -5966,6 +6293,9 @@ } } } + }, + "No repositories available to add." : { + }, "No repositories found." : { "localizations" : { @@ -6300,6 +6630,22 @@ } } }, + "Open" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "열기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开" + } + } + } + }, "Open in Editor" : { "localizations" : { "ko" : { @@ -6489,6 +6835,9 @@ } } } + }, + "Original link (optional)" : { + }, "Other" : { "localizations" : { @@ -7315,6 +7664,22 @@ } } }, + "Remove Documentation Repository" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 저장소 제거" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除文档仓库" + } + } + } + }, "Remove MCP server?" : { "localizations" : { "zh-Hans" : { @@ -7325,6 +7690,22 @@ } } }, + "Remove this docs repository?" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 문서 저장소를 제거하시겠습니까?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除此文档仓库?" + } + } + } + }, "Remove Variable" : { "localizations" : { "zh-Hans" : { @@ -7638,6 +8019,22 @@ } } }, + "Rotate token & reinstall" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "토큰 교체 후 재설치" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "轮换令牌并重新安装" + } + } + } + }, "Run %@" : { "localizations" : { "zh-Hans" : { @@ -7839,6 +8236,23 @@ } } }, + "rxlab account" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "rxlab 계정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "rxlab 账户" + } + } + } + }, "Save" : { "localizations" : { "zh-Hans" : { @@ -7955,6 +8369,22 @@ } } }, + "Search documentation repositories" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 저장소 검색" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索文档仓库" + } + } + } + }, "Search every thread" : { "localizations" : { "zh-Hans" : { @@ -8098,8 +8528,12 @@ } } } + }, + "Search threads and docs…" : { + }, "Search threads by topic, keyword, or feel…" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -8384,7 +8818,39 @@ } }, "Set up" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" + } + } + } + }, + "Set Up Docs Search" : { + }, + "Set up documentation so it's searchable" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검색할 수 있도록 문서 설정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置文档以便进行搜索" + } + } + } }, "Set up encryption" : { "localizations" : { @@ -8730,6 +9196,22 @@ } } }, + "Sign in to your Autopilot account" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot 계정에 로그인" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录你的 Autopilot 账户" + } + } + } + }, "Sign in with GitHub" : { "extractionState" : "stale", "localizations" : { @@ -8785,6 +9267,22 @@ } } }, + "Sign in with rxlab to import GitHub repositories, sync encrypted secrets, and use autopilot features. You can skip this and sign in later from Settings." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "rxlab으로 로그인하여 GitHub 저장소를 가져오고, 암호화된 시크릿을 동기화하고, Autopilot 기능을 사용하세요. 이 단계를 건너뛰고 나중에 설정에서 로그인할 수 있습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用 rxlab 登录以导入 GitHub 仓库、同步加密的密钥并使用 Autopilot 功能。你可以跳过此步骤,稍后在“设置”中登录。" + } + } + } + }, "Sign in with rxlab to import GitHub repositories." : { "localizations" : { "ko" : { @@ -8817,6 +9315,22 @@ } } }, + "Signed in" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로그인됨" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已登录" + } + } + } + }, "sk-..." : { "localizations" : { "zh-Hans" : { @@ -8891,6 +9405,9 @@ } } } + }, + "Slug (e.g. architecture/overview)" : { + }, "Source" : { "localizations" : { @@ -9216,6 +9733,22 @@ } } }, + "This unregisters %@ from the docs service and removes its indexed documents. The repo's files are not affected." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@을(를) 문서 서비스에서 등록 해제하고 색인된 문서를 제거합니다. 저장소의 파일은 영향을 받지 않습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这将从文档服务中注销 %@ 并移除其已索引的文档。仓库中的文件不受影响。" + } + } + } + }, "This will remove the project from RxCode. The files on disk will not be deleted." : { "localizations" : { "en" : { @@ -9268,6 +9801,12 @@ } } } + }, + "Threads on-device · docs unavailable: %@" : { + + }, + "Threads searched on-device · docs from the docs service" : { + }, "Todos (%lld/%lld)" : { "localizations" : { @@ -9387,6 +9926,9 @@ } } } + }, + "Upload" : { + }, "URL" : { "localizations" : { @@ -9637,6 +10179,9 @@ } } } + }, + "Version" : { + }, "Via:" : { "localizations" : { diff --git a/RxCode/Services/Docs/DocsService.swift b/RxCode/Services/Docs/DocsService.swift new file mode 100644 index 0000000..e911f2e --- /dev/null +++ b/RxCode/Services/Docs/DocsService.swift @@ -0,0 +1,298 @@ +import Foundation +import RxCodeCore +import os + +/// Talks to github-pm's docs API (same host as `AutopilotService` / +/// `SecretsService`, `https://autopilot.rxlab.app`) using the rxauth bearer. +/// Powers docs search (⌘K + the `ide__search_docs` tool), the docs management +/// UI, and the docs-setup hook. Transport mirrors `SecretsService` exactly. +@MainActor +final class DocsService { + + enum ServiceError: LocalizedError { + case notAuthenticated + case invalidResponse + case apiError(Int, String) + case decodingError(String) + + var errorDescription: String? { + switch self { + case .notAuthenticated: + return "Not signed in. Please sign in with rxlab." + case .invalidResponse: + return "Received an invalid response from the docs service." + case .apiError(let code, let detail): + return "Docs service error (\(code)): \(detail)" + case .decodingError(let detail): + return "Failed to decode docs response: \(detail)" + } + } + } + + private let rxAuth: RxAuthService + private let logger = Logger(subsystem: "com.claudework", category: "DocsService") + private let session: URLSession = .shared + + init(rxAuth: RxAuthService) { + self.rxAuth = rxAuth + } + + var baseURL: URL { + if let override = Bundle.main.object(forInfoDictionaryKey: "DocsBaseURL") as? String, + !override.isEmpty, let url = URL(string: override) { + return url + } + if let override = Bundle.main.object(forInfoDictionaryKey: "AutopilotBaseURL") as? String, + !override.isEmpty, let url = URL(string: override) { + return url + } + return URL(string: "https://autopilot.rxlab.app")! + } + + // MARK: - Search + + /// Semantic docs search. `repo` (an `owner/repo` full name) is optional — + /// omit it to search across every docs repo the user can read. + func search(query: String, repo: String? = nil, limit: Int? = nil) async throws -> [DocsSearchHit] { + var items: [URLQueryItem] = [.init(name: "q", value: query)] + if let repo, !repo.isEmpty { items.append(.init(name: "repo", value: repo)) } + if let limit { items.append(.init(name: "limit", value: String(limit))) } + let result: DocsSearchResult = try await get(url: url("/api/v1/docs/search", query: items)) + return result.items + } + + // MARK: - Repositories + + func listRepositories(search: String? = nil, cursor: String? = nil, pageSize: Int? = nil) async throws -> DocsRepoListResponse { + var items: [URLQueryItem] = [] + if let trimmed = search?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty { + items.append(.init(name: "search", value: trimmed)) + } + if let cursor, !cursor.isEmpty { items.append(.init(name: "cursor", value: cursor)) } + if let pageSize { items.append(.init(name: "pageSize", value: String(pageSize))) } + return try await get(url: url("/api/v1/docs/repositories", query: items)) + } + + func addRepository(_ body: AddDocsRepoBody) async throws -> DocsIDResponse { + try await send(method: "POST", url: url("/api/v1/docs/repositories"), body: body) + } + + func deleteRepository(id: String) async throws { + let _: Ignored = try await send(method: "DELETE", url: url("/api/v1/docs/repositories/\(seg(id))")) + } + + /// Resolves docs status for `repos` (each `owner/repo`). Returns one entry + /// per requested repo; repos without docs report `hasDocs: false`. Used by + /// the docs hook + sidebar affordances. + /// + /// There is no batch-status endpoint server-side, so status is derived from + /// the managed-repositories listing (`GET /api/v1/docs/repositories`), which + /// is the source of truth for which repos have docs set up. A repo present + /// in that listing has docs; one that isn't does not. + func statuses(forRepos repos: [String]) async throws -> [DocsRepoStatus] { + guard !repos.isEmpty else { return [] } + let managed = try await allManagedRepositories() + let byName = Dictionary( + managed.map { ($0.repositoryFullName.lowercased(), $0) }, + uniquingKeysWith: { _, last in last } + ) + return repos.map { repo in + let match = byName[repo.lowercased()] + return DocsRepoStatus( + repository: repo, + hasDocs: match != nil, + documentsCount: match?.documentsCount, + docsRepositoryId: match?.id + ) + } + } + + /// Pages through every docs-managed repository the signed-in user can read. + /// The managed set is small (one row per set-up repo), so the full walk is + /// cheap and avoids relying on server-side `search` filtering. + private func allManagedRepositories() async throws -> [DocsRepo] { + var all: [DocsRepo] = [] + var cursor: String? + repeat { + let page = try await listRepositories(cursor: cursor, pageSize: 100) + all.append(contentsOf: page.items) + cursor = (page.pagination?.hasMore == true) ? page.pagination?.nextCursor : nil + } while cursor != nil + return all + } + + // MARK: - Documents + + func listDocuments(repoId: String, cursor: String? = nil) async throws -> DocsDocumentList { + var items: [URLQueryItem] = [] + if let cursor, !cursor.isEmpty { items.append(.init(name: "cursor", value: cursor)) } + return try await get(url: url("/api/v1/docs/repositories/\(seg(repoId))/documents", query: items)) + } + + /// Uploads (creates/updates) one or more documents. `repoId` may be the + /// internal docs-repo UUID or an `owner/repo` full name. Returns 202 with a + /// job id — embedding completes asynchronously. + @discardableResult + func uploadDocuments(repoId: String, documents: [DocsDocumentUpload]) async throws -> DocsUploadResult { + try await send( + method: "POST", + url: url("/api/v1/docs/repositories/\(seg(repoId))/documents"), + body: UploadDocumentsBody(documents: documents) + ) + } + + /// Deletes a single document by its logical `docId` (the slug it was + /// uploaded as). `repoId` may be the internal UUID or `owner/repo`. + func deleteDocument(repoId: String, docId: String) async throws { + let _: Ignored = try await send( + method: "DELETE", + url: url("/api/v1/docs/repositories/\(seg(repoId))/documents/\(seg(docId))") + ) + } + + /// Fetches a single document with its current version (full markdown lives + /// on `currentVersion.content`). `repoId` may be the UUID or `owner/repo`. + func getDocument(repoId: String, docId: String) async throws -> DocsDocumentDetail { + try await get(url: url("/api/v1/docs/repositories/\(seg(repoId))/documents/\(seg(docId))")) + } + + /// Lists a document's version history, newest-first. Each item omits the + /// markdown `content`; fetch a specific version for its body. + func listDocumentVersions(repoId: String, docId: String) async throws -> [DocsDocumentVersion] { + let list: DocsDocumentVersionList = try await get( + url: url("/api/v1/docs/repositories/\(seg(repoId))/documents/\(seg(docId))/versions") + ) + return list.items + } + + /// Fetches a single historical version (includes its full `content`). + func getDocumentVersion(repoId: String, docId: String, version: Int) async throws -> DocsDocumentVersion { + try await get( + url: url("/api/v1/docs/repositories/\(seg(repoId))/documents/\(seg(docId))/versions/\(version)") + ) + } + + // MARK: - Upload tokens + + func createUploadToken(repoId: String, name: String? = nil) async throws -> DocsUploadToken { + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) + let tokenName = (trimmed?.isEmpty == false) ? trimmed! : "RxCode CI token" + return try await send( + method: "POST", + url: url("/api/v1/docs/repositories/\(seg(repoId))/upload-tokens"), + body: CreateDocsUploadTokenBody(name: tokenName) + ) + } + + /// Mints a `DOCS_UPLOAD_TOKEN` and installs it as the repo's GitHub Actions + /// secret in one server-side step — the plaintext never reaches the client. + /// `repoId` may be the internal docs-repo UUID or an `owner/repo` full name. + /// Powers both the docs-repo UI button and the `ide__setup_docs_secret` + /// MCP tool. The repo must already be registered with the docs service. + @discardableResult + func installGithubSecret(repoId: String) async throws -> DocsGithubSecretResult { + try await send( + method: "POST", + url: url("/api/v1/docs/repositories/\(seg(repoId))/github-secret") + ) + } + + // MARK: - URL building + + /// Percent-encodes a single path segment, including any `/` in an + /// `owner/repo` identifier so it stays one segment. + private func seg(_ value: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove("/") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + private func url(_ path: String, query: [URLQueryItem] = []) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.percentEncodedPath = (components.percentEncodedPath) + path + if !query.isEmpty { components.queryItems = query } + return components.url! + } + + // MARK: - Transport (mirrors SecretsService / AutopilotService) + + private struct Ignored: Decodable {} + + private func get(url: URL) async throws -> T { + try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } + } + + private func send(method: String, url: URL, body: Body) async throws -> T { + let payload: Data + do { + payload = try JSONEncoder().encode(body) + } catch { + throw ServiceError.decodingError(error.localizedDescription) + } + return try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = payload + return request + } + } + + private func send(method: String, url: URL) async throws -> T { + try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } + } + + private func performWithRetry(_ build: (String) -> URLRequest) async throws -> T { + guard let token = await rxAuth.accessToken() else { + throw ServiceError.notAuthenticated + } + let request = build(token) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ServiceError.invalidResponse } + + if http.statusCode == 401 { + guard let refreshed = await rxAuth.accessToken() else { + NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) + throw ServiceError.notAuthenticated + } + let retried = build(refreshed) + let (data2, response2) = try await session.data(for: retried) + guard let http2 = response2 as? HTTPURLResponse else { throw ServiceError.invalidResponse } + if http2.statusCode == 401 { + NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) + throw ServiceError.notAuthenticated + } + return try decode(data: data2, response: http2) + } + return try decode(data: data, response: http) + } + + private func decode(data: Data, response: HTTPURLResponse) throws -> T { + guard (200..<300).contains(response.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "no body" + throw ServiceError.apiError(response.statusCode, body) + } + if T.self == Ignored.self { + return Ignored() as! T + } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw ServiceError.decodingError(error.localizedDescription) + } + } +} diff --git a/RxCode/Services/Docs/DocsSkill.swift b/RxCode/Services/Docs/DocsSkill.swift new file mode 100644 index 0000000..47cf587 --- /dev/null +++ b/RxCode/Services/Docs/DocsSkill.swift @@ -0,0 +1,120 @@ +import Foundation + +/// The docs-publishing skill, bundled as a string so the docs-setup hook can +/// inject it as a system prompt with no app-resource plumbing. Distilled from +/// argo-trading's docs CI (the `rxtech-lab/docs-publishing` skill is the +/// canonical, evolving copy — keep this in sync when that PR lands). +enum DocsSkill { + static let systemPrompt: String = #""" + # Skill: Set up documentation publishing + + You are setting up automatic documentation publishing for this repository so + its docs are indexed by the docs service (https://autopilot.rxlab.app) and + become searchable in RxCode (⌘K → Docs and the `ide__search_docs` tool). + + Follow these steps end to end. Inspect the repo first to choose the right doc + generators for its languages, then wire up the uploader, the CI workflow, and + the repo secret. + + ## 1. Author / collect the docs under `docs/` + + Every doc is a markdown file under `docs/` with YAML frontmatter. The `slug` + is the contract — it becomes the remote `docId` and must be unique: + + ```markdown + --- + slug: architecture/overview + title: Architecture Overview + description: High-level design of the system + --- + + # Architecture Overview + ... + ``` + + Produce these doc types as applicable to the repo: + - **Design / architecture docs** — handwritten markdown. Always include at + least an overview. + - **API docs** — if the repo exposes an API, generate/commit the OpenAPI or + Swagger spec and a markdown reference under `docs/api/`. + - **Code docs** — pick the generator for the repo's language and emit markdown + into `docs/`: + - Go: `go doc` / `gomarkdoc ./... > docs/code/go.md` + - TypeScript/JavaScript: `typedoc` (or `jsdoc`) with a markdown plugin + - Java: `javadoc` (convert to markdown or commit the HTML under `docs/`) + Give each generated file unique `slug` frontmatter. + + ## 2. Add the uploader script `scripts/upload_docs.py` + + Stdlib-only (no pip installs). It walks `docs/`, parses frontmatter, keeps + files with a `slug`, errors on duplicate slugs, and POSTs in batches of 50 to + `{DOCS_ENDPOINT}/api/v1/docs/repositories/{url-encoded repo}/documents` with + `Authorization: Bearer $DOCS_UPLOAD_TOKEN` and body + `{"documents":[{"docId": slug, "content": body}, ...]}`. Support `--dry-run` + and env config: `DOCS_ENDPOINT` (default https://autopilot.rxlab.app), + `DOCS_REPOSITORY_ID` (e.g. `owner/repo`), `DOCS_UPLOAD_TOKEN`. + + ## 3. Add the GitHub Actions workflow `.github/workflows/upload-docs.yaml` + + ```yaml + name: Upload docs to autopilot + on: + push: + branches: ["main"] + paths: + - "docs/**" + - "scripts/upload_docs.py" + - ".github/workflows/upload-docs.yaml" + workflow_dispatch: + concurrency: + group: upload-docs + cancel-in-progress: false + jobs: + upload: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Upload docs + env: + DOCS_ENDPOINT: https://autopilot.rxlab.app + DOCS_REPOSITORY_ID: / + DOCS_UPLOAD_TOKEN: ${{ secrets.DOCS_UPLOAD_TOKEN }} + run: python scripts/upload_docs.py + ``` + + ## 4. Install the `DOCS_UPLOAD_TOKEN` repo secret + + Do this yourself — don't make the user run a command. Call the + **`ide__setup_docs_secret`** MCP tool, passing `repository: "/"`. + It mints a `DOCS_UPLOAD_TOKEN` and installs it as the repo's GitHub Actions + secret in one step; the token never needs to be copied or pasted. If the repo + isn't registered with the docs service yet, the tool registers it for you + first (the RxLab GitHub App must be installed on it). If the tool reports a + missing-permission / 403 error, tell the user to re-authorize the RxLab + GitHub App (Actions → Secrets: read & write), then retry. + + Fallbacks when `ide__setup_docs_secret` isn't available (e.g. running this + skill outside RxCode): + - RxCode UI: Settings → Autopilot → Manage Docs → open the repo → "Install + DOCS_UPLOAD_TOKEN secret". + - Mint a token (`POST /api/v1/docs/repositories/{id}/upload-tokens`, shown + once) and install it manually: + ```bash + gh secret set DOCS_UPLOAD_TOKEN --body "" --repo / + ``` + + ## 5. Verify + + - `python scripts/upload_docs.py --dry-run` — confirms frontmatter parses and + batches build with no network call. + - Commit to `main` (or run the workflow via `workflow_dispatch`) and confirm + the upload succeeds. Uploads are async: the API returns `202` + a `jobId`; + docs become searchable once embedding finishes. + + Be concrete: read the repo, pick the right generators, write the files, run + the dry run, and tell the user exactly what you changed and what token to set. + """# +} diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index 2e5b3ca..113615d 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -249,4 +249,25 @@ final class AppStateHookController: HookController { overwrite: overwrite ) } + + // MARK: Docs + + func docsIndexed(repoFullName: String) async -> Bool? { + guard let app else { return nil } + do { + let statuses = try await app.docs.statuses(forRepos: [repoFullName]) + // nil (not false) only on a failed/cancelled check — a successful + // lookup that finds no row legitimately means "no docs". + return statuses.first(where: { $0.repository.lowercased() == repoFullName.lowercased() })?.hasDocs ?? false + } catch { + logger.error("docsIndexed failed: \(error.localizedDescription)") + return nil + } + } + + func consumePendingDocsSetupSkill(projectId: UUID) -> String? { + guard let app, app.pendingDocsSetupProjectId == projectId else { return nil } + app.pendingDocsSetupProjectId = nil + return DocsSkill.systemPrompt + } } diff --git a/RxCode/Services/Hooks/hooks/DocsHook.swift b/RxCode/Services/Hooks/hooks/DocsHook.swift new file mode 100644 index 0000000..baa10e1 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/DocsHook.swift @@ -0,0 +1,73 @@ +#if os(macOS) +import Foundation +import os +import RxCodeCore +import SwiftUI + +/// Surfaces a "set up docs" banner on the new-chat screen when the project's +/// GitHub repo has no documentation indexed in the docs service, and — when the +/// user accepts — injects the docs-publishing skill into that chat's system +/// prompt so the agent wires up CI doc uploads. Mirrors `AutopilotHook`. +@MainActor +final class DocsHook: Hook { + let hookID = "builtin.docs" + private let logger = Logger(subsystem: "com.claudework", category: "DocsHook") + + /// Stable banner id for a repo, distinct from the secrets banner so both can + /// coexist, e.g. repo "owner/github-pm" → "github-pm-new-project-docs". + private func bannerID(for project: Project) -> String? { + guard let repo = project.gitHubRepo else { return nil } + let repoSlug = repo.split(separator: "/").last.map(String.init) ?? repo + return "\(repoSlug)-new-project-docs" + } + + func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome { + guard let bannerID = bannerID(for: payload.project) else { return .ignored } + controller.clearBannerDismissal(id: bannerID) + controller.dismissBanner(id: bannerID, in: .newProject) + return .proceed + } + + /// On each new chat, check whether the repo has docs. If not, surface the + /// "set up docs" banner above the input box. Passive — never blocks. + func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome { + guard let project = controller.project(for: payload.projectId) else { return .ignored } + guard let repo = project.gitHubRepo else { return .ignored } + + let bannerID = bannerID(for: project) ?? "\(repo)-new-project-docs" + if controller.isBannerDismissed(id: bannerID) { return .ignored } + + guard let hasDocs = await controller.docsIndexed(repoFullName: repo) else { + // Inconclusive (signed out / offline / failed) — leave the banner + // untouched rather than flashing a stale prompt. + return .ignored + } + guard !Task.isCancelled else { return .ignored } + + if hasDocs { + // Repo already has docs — make sure no stale banner lingers. + controller.dismissBanner(id: bannerID, in: .newProject) + return .ignored + } + + logger.debug("[Hook] repo \(repo, privacy: .public) has no docs — showing setup banner \(bannerID, privacy: .public)") + controller.showBanner(in: .newProject, position: .aboveInputBox, id: bannerID, projectId: project.id) { + DocsSetupBanner(repo: repo) { + controller.markBannerDismissed(id: bannerID, in: .newProject) + } + } + return .proceed + } + + /// When the user accepted the banner, a docs-setup chat was started for this + /// project. Inject the docs-publishing skill into the system prompt for that + /// one chat so the agent knows how to set everything up. + func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome { + guard let skill = controller.consumePendingDocsSetupSkill(projectId: payload.project.id) else { + return .ignored + } + logger.debug("[Hook] injecting docs-publishing skill into session for project \(payload.project.id.uuidString, privacy: .public)") + return .output(skill) + } +} +#endif diff --git a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift index b1586f0..1a5405b 100644 --- a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift +++ b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift @@ -54,6 +54,10 @@ extension AppState: IDEToolHandling { return try await handleSendToThread(arguments: arguments) case "ide__get_usage": return await handleGetUsage() + case "ide__search_docs": + return try await handleSearchDocs(arguments: arguments) + case "ide__setup_docs_secret": + return try await handleSetupDocsSecret(arguments: arguments, sessionKey: sessionKey) case "ide__ask_user": throw IDEToolError.notSupported("ide__ask_user polyfill not implemented yet — surface the question as plain assistant text instead.") default: @@ -388,6 +392,107 @@ extension AppState: IDEToolHandling { return .object(obj) } + @MainActor + private func handleSearchDocs(arguments: JSONValue) async throws -> JSONValue { + guard let query = arguments["query"]?.stringValue, + !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw IDEToolError.invalidArguments("missing 'query'") + } + let repo = arguments["repository"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let requestedLimit = Int(arguments["limit"]?.numberValue ?? 10) + let limit = max(1, min(requestedLimit, 50)) + do { + let hits = try await docs.search(query: query, repo: (repo?.isEmpty == false) ? repo : nil, limit: limit) + let entries: [JSONValue] = hits.map { hit in + var obj: [String: JSONValue] = ["doc_id": .string(hit.docId)] + if let repository = hit.repository { obj["repository"] = .string(repository) } + if let snippet = hit.snippet { obj["snippet"] = .string(snippet) } + if let score = hit.score { obj["score"] = .number(score) } + if let link = hit.originalLink { obj["original_link"] = .string(link) } + return .object(obj) + } + return jsonTextResult(.array(entries)) + } catch { + throw IDEToolError.handlerFailed(error.localizedDescription) + } + } + + @MainActor + private func handleSetupDocsSecret(arguments: JSONValue, sessionKey: String) async throws -> JSONValue { + // Prefer an explicit `owner/repo`; otherwise fall back to the repo linked + // to the calling session's project. + let explicit = arguments["repository"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let repo: String + if let explicit, !explicit.isEmpty { + repo = explicit + } else if let thread = threadStore.fetch(id: sessionKey), + let linked = projects.first(where: { $0.id == thread.projectId })?.gitHubRepo, + !linked.isEmpty { + repo = linked + } else { + throw IDEToolError.invalidArguments( + "No 'repository' given and the current project has no linked GitHub repo. Pass repository as 'owner/repo'." + ) + } + // The install endpoint 404s on unregistered repos, so register first if + // needed (these throw IDEToolError with their own messages — keep them + // outside the catch below so they aren't re-wrapped). + let registered = try await ensureDocsRepoRegistered(repo) + do { + let result = try await docs.installGithubSecret(repoId: repo) + return jsonTextResult(.object([ + "installed": .bool(true), + "registered": .bool(registered), + "secret_name": .string(result.secretName), + "repository": .string(result.repositoryFullName), + ])) + } catch { + throw IDEToolError.handlerFailed(error.localizedDescription) + } + } + + /// Ensures `repo` (an `owner/repo`) is registered with the docs service, + /// registering it if not. Returns true when it had to register it. Throws a + /// descriptive `IDEToolError` when the repo isn't accessible to the GitHub + /// App (so it can't be registered). + @MainActor + private func ensureDocsRepoRegistered(_ repo: String) async throws -> Bool { + if let status = try? await docs.statuses(forRepos: [repo]).first, status.hasDocs { + return false + } + guard let managed = try await findManagedRepo(fullName: repo) else { + throw IDEToolError.handlerFailed( + "\(repo) isn't set up for docs and isn't accessible to the RxLab GitHub App. Install the GitHub App on this repository, then retry." + ) + } + _ = try await docs.addRepository( + AddDocsRepoBody( + installationId: managed.installationId, + repositoryId: managed.id, + repositoryFullName: managed.fullName + ) + ) + return true + } + + /// Finds the accessible GitHub repo matching `fullName` (`owner/repo`) in the + /// secrets `repositories/all` listing — the source of the `installationId` + + /// `repositoryId` the docs add-repo API needs. Pages defensively in case the + /// search filter returns more than one page. + @MainActor + private func findManagedRepo(fullName: String) async throws -> SecretsManagedRepo? { + let target = fullName.lowercased() + var cursor: String? + repeat { + let page = try await secrets.listManagedRepositories(search: fullName, cursor: cursor, pageSize: 100) + if let match = page.items.first(where: { $0.fullName.lowercased() == target }) { + return match + } + cursor = page.pagination.hasMore ? page.pagination.nextCursor : nil + } while cursor != nil + return nil + } + private func handleGetUsage() async -> JSONValue { let provider = await MainActor.run { selectedAgentProvider } let usage = await rateLimitUsage(for: provider, forceRefresh: false) diff --git a/RxCode/Views/Docs/AddDocsDocumentSheet.swift b/RxCode/Views/Docs/AddDocsDocumentSheet.swift new file mode 100644 index 0000000..39bb59c --- /dev/null +++ b/RxCode/Views/Docs/AddDocsDocumentSheet.swift @@ -0,0 +1,124 @@ +import RxCodeCore +import SwiftUI +import UniformTypeIdentifiers + +/// Form to upload a single document to a docs repository. Mirrors the CI +/// uploader's contract: a `docId` slug (unique within the repo), the markdown +/// `content`, and an optional canonical `originalLink`. Content can be typed or +/// loaded from a local markdown file. +struct AddDocsDocumentSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + /// Internal docs-repo UUID or `owner/repo` — both are accepted by the API. + let repoId: String + var onUploaded: () -> Void + + @State private var docId = "" + @State private var originalLink = "" + @State private var content = "" + @State private var isUploading = false + @State private var errorMessage: String? + @State private var showImporter = false + + private var trimmedDocId: String { docId.trimmingCharacters(in: .whitespacesAndNewlines) } + private var canSubmit: Bool { + !trimmedDocId.isEmpty && !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isUploading + } + + var body: some View { + NavigationStack { + Form { + Section("Document") { + TextField("Slug (e.g. architecture/overview)", text: $docId) + TextField("Original link (optional)", text: $originalLink) + .textContentType(.URL) + } + Section { + TextEditor(text: $content) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 220) + } header: { + HStack { + Text("Markdown content") + Spacer() + Button("Load from File…") { showImporter = true } + .font(.caption) + } + } + if let errorMessage { + Section { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + } + } + .formStyle(.grouped) + .navigationTitle("Add Document") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { await upload() } + } label: { + if isUploading { ProgressView().controlSize(.small) } else { Text("Upload") } + } + .disabled(!canSubmit) + } + } + } + .frame(width: 560, height: 560) + .fileImporter(isPresented: $showImporter, allowedContentTypes: markdownTypes) { result in + handleImport(result) + } + } + + private var markdownTypes: [UTType] { + var types: [UTType] = [.plainText, .text] + if let md = UTType(filenameExtension: "md") { types.append(md) } + if let markdown = UTType(filenameExtension: "markdown") { types.append(markdown) } + return types + } + + private func handleImport(_ result: Result) { + switch result { + case .success(let url): + let needsStop = url.startAccessingSecurityScopedResource() + defer { if needsStop { url.stopAccessingSecurityScopedResource() } } + do { + content = try String(contentsOf: url, encoding: .utf8) + if trimmedDocId.isEmpty { + docId = url.deletingPathExtension().lastPathComponent + } + } catch { + errorMessage = error.localizedDescription + } + case .failure(let error): + errorMessage = error.localizedDescription + } + } + + private func upload() async { + isUploading = true + errorMessage = nil + defer { isUploading = false } + let link = originalLink.trimmingCharacters(in: .whitespacesAndNewlines) + do { + _ = try await appState.docs.uploadDocuments( + repoId: repoId, + documents: [ + DocsDocumentUpload( + docId: trimmedDocId, + content: content, + originalLink: link.isEmpty ? nil : link + ) + ] + ) + onUploaded() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCode/Views/Docs/AddDocsRepoSheet.swift b/RxCode/Views/Docs/AddDocsRepoSheet.swift new file mode 100644 index 0000000..833e09a --- /dev/null +++ b/RxCode/Views/Docs/AddDocsRepoSheet.swift @@ -0,0 +1,156 @@ +import RxCodeCore +import SwiftUI + +/// Picks a GitHub repository to register for docs. The docs add-repo API needs +/// `installationId` + `repositoryId` + `repositoryFullName`, none of which the +/// docs listing carries — so we source the accessible-repo list (which does +/// carry the installation/repository ids) from the secrets `repositories/all` +/// endpoint, the same listing `SecretsManageSheet` uses. +struct AddDocsRepoSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + /// Full names already registered for docs, so they're hidden from the picker. + let existingRepoFullNames: Set + /// Called after a repo is successfully registered, with its `owner/repo`. + var onAdded: (String) -> Void + + @State private var repos: [SecretsManagedRepo] = [] + @State private var search = "" + @State private var nextCursor: String? + @State private var hasMore = false + @State private var isLoading = false + @State private var addingFullName: String? + @State private var errorMessage: String? + @State private var searchTask: Task? + + private var visibleRepos: [SecretsManagedRepo] { + repos.filter { !existingRepoFullNames.contains($0.fullName.lowercased()) } + } + + var body: some View { + NavigationStack { + content + .navigationTitle("Add Documentation Repository") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .frame(width: 520, height: 520) + .task { await reload() } + } + + @ViewBuilder + private var content: some View { + List { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search repositories", text: $search) + .textFieldStyle(.plain) + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + ForEach(visibleRepos) { repo in + Button { + Task { await add(repo) } + } label: { + HStack(spacing: 10) { + Image(systemName: repo.isPrivate ? "lock.fill" : "book.closed") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(repo.fullName).font(.body) + if repo.isCurrent { + Text("Current project").font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if addingFullName == repo.fullName { + ProgressView().controlSize(.small) + } else { + Image(systemName: "plus.circle").foregroundStyle(.tint) + } + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(addingFullName != nil) + } + if hasMore { + Button { + Task { await loadMore() } + } label: { + HStack { Spacer(); Text("Load more"); Spacer() } + } + .disabled(isLoading) + } + if visibleRepos.isEmpty, !isLoading, errorMessage == nil { + Text("No repositories available to add.") + .foregroundStyle(.secondary).font(.callout) + } + } + .overlay { + if isLoading, repos.isEmpty { ProgressView() } + } + .onChange(of: search) { _, _ in + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await reload() + } + } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let page = try await appState.secrets.listManagedRepositories(search: search) + repos = page.items + nextCursor = page.pagination.nextCursor + hasMore = page.pagination.hasMore + } catch { + errorMessage = error.localizedDescription + repos = [] + hasMore = false + } + } + + private func loadMore() async { + guard let cursor = nextCursor else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await appState.secrets.listManagedRepositories(search: search, cursor: cursor) + repos.append(contentsOf: page.items) + nextCursor = page.pagination.nextCursor + hasMore = page.pagination.hasMore + } catch { + errorMessage = error.localizedDescription + } + } + + private func add(_ repo: SecretsManagedRepo) async { + addingFullName = repo.fullName + errorMessage = nil + defer { addingFullName = nil } + do { + _ = try await appState.docs.addRepository( + AddDocsRepoBody( + installationId: repo.installationId, + repositoryId: repo.id, + repositoryFullName: repo.fullName + ) + ) + onAdded(repo.fullName) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCode/Views/Docs/DocsDeepLink.swift b/RxCode/Views/Docs/DocsDeepLink.swift new file mode 100644 index 0000000..c0e65e3 --- /dev/null +++ b/RxCode/Views/Docs/DocsDeepLink.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Request to kick off docs setup for a repo: start a fresh chat seeded with +/// the docs-publishing skill so the agent wires up CI doc uploads. +struct DocsSetupRequest: Identifiable, Hashable { + let id = UUID() + var repoFullName: String? +} + +/// Parses `rxcode://docs/setup?repo=` and +/// `rxcode://docs/manage?repo=` deep links. Returns nil for +/// unrelated URLs. +enum DocsDeepLink { + static let scheme = "rxcode" + + enum Action: String { + case setup + case manage + } + + struct Parsed { + let action: Action + let repoFullName: String? + } + + static func parse(_ url: URL) -> Parsed? { + guard url.scheme == scheme else { return nil } + // Accept both rxcode://docs/setup and rxcode:docs/setup forms. + let host = url.host + let firstPath = url.pathComponents.first(where: { $0 != "/" }) + let segment = host ?? firstPath + guard segment == "docs" else { return nil } + + // The action is the path component after the host (e.g. "setup"). + let actionRaw = (host != nil ? url.pathComponents.first(where: { $0 != "/" }) : url.pathComponents.dropFirst().first(where: { $0 != "/" })) + let action = Action(rawValue: actionRaw ?? "setup") ?? .setup + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let items = components?.queryItems ?? [] + let repo = items.first(where: { $0.name == "repo" })?.value?.removingPercentEncoding + return Parsed(action: action, repoFullName: repo) + } + + /// Builds the deep link the docs banner's button opens. + static func setupURL(repo: String) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = "docs" + components.path = "/setup" + components.queryItems = [URLQueryItem(name: "repo", value: repo)] + return components.url + } + + static func manageURL(repo: String) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = "docs" + components.path = "/manage" + components.queryItems = [URLQueryItem(name: "repo", value: repo)] + return components.url + } +} diff --git a/RxCode/Views/Docs/DocsDocumentDetailView.swift b/RxCode/Views/Docs/DocsDocumentDetailView.swift new file mode 100644 index 0000000..b2bb741 --- /dev/null +++ b/RxCode/Views/Docs/DocsDocumentDetailView.swift @@ -0,0 +1,168 @@ +import RxCodeChatKit +import RxCodeCore +import SwiftUI + +/// Read-only viewer for a single document. Loads the full markdown from the +/// docs service and offers a version picker that swaps in any historical +/// version's content. Used from both ⌘K docs search and the docs manager. +struct DocsDocumentDetailView: View { + /// Internal docs-repo UUID or `owner/repo` — both are accepted by the API. + /// When `nil` we can't fetch (e.g. a search hit with no repo), so we render + /// `fallbackContent` only. + let repoId: String? + let docId: String + var fallbackTitle: String? + /// Shown when the full document can't be fetched (e.g. the search snippet). + var fallbackContent: String? + var originalLink: String? + + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var versions: [DocsDocumentVersion] = [] + @State private var currentVersion: Int? + @State private var selectedVersion: Int? + @State private var contentByVersion: [Int: String] = [:] + @State private var resolvedOriginalLink: String? + @State private var isLoading = false + @State private var isLoadingVersion = false + @State private var errorMessage: String? + + private var title: String { fallbackTitle ?? docId } + + private var displayedContent: String? { + if let v = selectedVersion, let cached = contentByVersion[v] { return cached } + return nil + } + + private var link: URL? { + guard let s = resolvedOriginalLink ?? originalLink, let url = URL(string: s) else { return nil } + return url + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + body(for: displayedContent) + } + .frame(width: 680, height: 600) + .task { await load() } + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) + .lineLimit(2) + Text(docId) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + Spacer() + if versions.count > 1 { + versionPicker + } + if let link { + Link("Open", destination: link) + } + Button("Done") { dismiss() } + } + .padding(16) + } + + private var versionPicker: some View { + Picker("Version", selection: Binding( + get: { selectedVersion ?? currentVersion ?? 0 }, + set: { newValue in + selectedVersion = newValue + Task { await loadVersion(newValue) } + } + )) { + ForEach(versions) { v in + Text(versionLabel(v)).tag(v.version) + } + } + .labelsHidden() + .frame(maxWidth: 160) + .disabled(isLoadingVersion) + } + + private func versionLabel(_ v: DocsDocumentVersion) -> String { + v.version == currentVersion ? "v\(v.version) (current)" : "v\(v.version)" + } + + // MARK: - Body + + @ViewBuilder + private func body(for content: String?) -> some View { + if isLoading { + VStack { Spacer(); ProgressView(); Spacer() } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 24, weight: .light)) + .foregroundStyle(.secondary) + Text(errorMessage) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + ZStack(alignment: .top) { + MarkdownContentView(text: content ?? fallbackContent ?? "") + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + if isLoadingVersion { + ProgressView().controlSize(.small).padding(.top, 12) + } + } + } + } + } + + // MARK: - Loading + + private func load() async { + guard let repoId else { return } // No repo → fallbackContent only. + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + async let detailTask = appState.docs.getDocument(repoId: repoId, docId: docId) + async let versionsTask = appState.docs.listDocumentVersions(repoId: repoId, docId: docId) + let detail = try await detailTask + let cur = detail.document.currentVersion ?? detail.currentVersion?.version + currentVersion = cur + selectedVersion = cur + if let cur, let body = detail.currentVersion?.content { + contentByVersion[cur] = body + } + if let resolved = detail.document.originalLink, !resolved.isEmpty { + resolvedOriginalLink = resolved + } + versions = (try? await versionsTask) ?? [] + } catch { + errorMessage = error.localizedDescription + } + } + + private func loadVersion(_ version: Int) async { + guard let repoId, contentByVersion[version] == nil else { return } + isLoadingVersion = true + defer { isLoadingVersion = false } + do { + let v = try await appState.docs.getDocumentVersion(repoId: repoId, docId: docId, version: version) + if let body = v.content { contentByVersion[version] = body } + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCode/Views/Docs/DocsManageSheet.swift b/RxCode/Views/Docs/DocsManageSheet.swift new file mode 100644 index 0000000..dbd55f4 --- /dev/null +++ b/RxCode/Views/Docs/DocsManageSheet.swift @@ -0,0 +1,166 @@ +import RxCodeCore +import SwiftUI + +/// Top-level "Manage Docs" sheet: lists every docs-managed repository and drills +/// into a repo to view its documents and mint CI upload tokens. Mirrors +/// `SecretsManageSheet`. +struct DocsManageSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + /// Optional repo to pin/auto-open (e.g. from the docs banner deep link). + var currentRepoFullName: String? + + @State private var repos: [DocsRepo] = [] + @State private var search = "" + @State private var nextCursor: String? + @State private var hasMore = false + @State private var isLoading = false + @State private var errorMessage: String? + @State private var searchTask: Task? + @State private var path = NavigationPath() + @State private var didAutoNavigate = false + @State private var showAddRepo = false + + /// The list endpoint doesn't filter server-side, so narrow the loaded page + /// by the search box client-side. + private var visibleRepos: [DocsRepo] { + let q = search.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !q.isEmpty else { return repos } + return repos.filter { $0.repositoryFullName.lowercased().contains(q) } + } + + var body: some View { + NavigationStack(path: $path) { + VStack(spacing: 0) { + content + } + .navigationTitle("Manage Docs") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button { + showAddRepo = true + } label: { + Label("Add Repository", systemImage: "plus") + } + } + } + .navigationDestination(for: DocsRepo.self) { repo in + DocsRepoDetailView(repo: repo, onClose: { dismiss() }) + } + } + .frame(width: 560, height: 560) + .task { await reload() } + .sheet(isPresented: $showAddRepo) { + AddDocsRepoSheet( + existingRepoFullNames: Set(repos.map { $0.repositoryFullName.lowercased() }), + onAdded: { _ in Task { await reload() } } + ) + .environment(appState) + } + } + + @ViewBuilder + private var content: some View { + List { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search documentation repositories", text: $search) + .textFieldStyle(.plain) + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + ForEach(visibleRepos) { repo in + NavigationLink(value: repo) { + DocsRepoRow(repo: repo) + } + } + if hasMore { + Button { + Task { await loadMore() } + } label: { + HStack { Spacer(); Text("Load more"); Spacer() } + } + .disabled(isLoading) + } + if repos.isEmpty, !isLoading, errorMessage == nil { + Text("No documentation repositories yet. Set up docs publishing from a project's chat to index its docs.") + .foregroundStyle(.secondary) + .font(.callout) + } + } + .overlay { + if isLoading, repos.isEmpty { ProgressView() } + } + .onChange(of: search) { _, _ in + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await reload() + } + } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let page = try await appState.docs.listRepositories(search: search) + repos = page.items + nextCursor = page.pagination?.nextCursor + hasMore = page.pagination?.hasMore ?? false + autoNavigateToCurrentRepoIfNeeded() + } catch { + errorMessage = error.localizedDescription + repos = [] + hasMore = false + } + } + + private func autoNavigateToCurrentRepoIfNeeded() { + guard !didAutoNavigate, + let currentRepoFullName, + let match = repos.first(where: { $0.repositoryFullName == currentRepoFullName }) + else { return } + didAutoNavigate = true + path.append(match) + } + + private func loadMore() async { + guard let cursor = nextCursor else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await appState.docs.listRepositories(search: search, cursor: cursor) + repos.append(contentsOf: page.items) + nextCursor = page.pagination?.nextCursor + hasMore = page.pagination?.hasMore ?? false + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct DocsRepoRow: View { + let repo: DocsRepo + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "books.vertical") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(repo.repositoryFullName).font(.body) + Text("\(repo.documentsCount ?? 0) document(s)") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 2) + } +} diff --git a/RxCode/Views/Docs/DocsRepoDetailView.swift b/RxCode/Views/Docs/DocsRepoDetailView.swift new file mode 100644 index 0000000..0340b4e --- /dev/null +++ b/RxCode/Views/Docs/DocsRepoDetailView.swift @@ -0,0 +1,225 @@ +import AppKit +import RxCodeCore +import SwiftUI + +/// Detail for one docs repository: lists its documents and lets the user +/// install a `DOCS_UPLOAD_TOKEN` GitHub Actions secret in one click — RxCode +/// mints the token and stores it on the repo server-side. Mirrors +/// `SecretsRepoDetailView`. +struct DocsRepoDetailView: View { + let repo: DocsRepo + var onClose: () -> Void + + @Environment(AppState.self) private var appState + + @State private var documents: [DocsDocument] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + @State private var isInstallingSecret = false + @State private var installedSecret: DocsGithubSecretResult? + @State private var installError: String? + + @State private var showDeleteConfirm = false + @State private var showAddDocument = false + @State private var documentToDelete: DocsDocument? + @State private var selectedDocument: DocsDocument? + + var body: some View { + List { + tokenSection + documentsSection + dangerSection + } + .navigationTitle(repo.repositoryFullName) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showAddDocument = true + } label: { + Label("Add Document", systemImage: "plus") + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Done") { onClose() } + } + } + .task { await loadDocuments() } + .sheet(isPresented: $showAddDocument) { + AddDocsDocumentSheet(repoId: repo.id, onUploaded: { Task { await loadDocuments() } }) + .environment(appState) + } + .sheet(item: $selectedDocument) { doc in + DocsDocumentDetailView( + repoId: repo.id, + docId: doc.docId, + fallbackTitle: doc.title ?? doc.docId, + fallbackContent: nil, + originalLink: nil + ) + .environment(appState) + } + .alert("Remove this docs repository?", isPresented: $showDeleteConfirm) { + Button("Remove", role: .destructive) { Task { await deleteRepo() } } + Button("Cancel", role: .cancel) {} + } message: { + Text("This unregisters \(repo.repositoryFullName) from the docs service and removes its indexed documents. The repo's files are not affected.") + } + .alert("Delete this document?", isPresented: deleteDocumentBinding) { + Button("Delete", role: .destructive) { + if let doc = documentToDelete { Task { await deleteDocument(doc) } } + } + Button("Cancel", role: .cancel) { documentToDelete = nil } + } message: { + Text("\(documentToDelete?.docId ?? "This document") will be removed from the docs index. This can't be undone.") + } + } + + private var deleteDocumentBinding: Binding { + Binding( + get: { documentToDelete != nil }, + set: { if !$0 { documentToDelete = nil } } + ) + } + + // MARK: - Upload token + + @ViewBuilder + private var tokenSection: some View { + Section("CI upload token") { + Text("Install a DOCS_UPLOAD_TOKEN secret so this repo's CI can upload docs. RxCode mints the token and stores it as the repository's GitHub Actions secret for you — no terminal needed.") + .font(.caption) + .foregroundStyle(.secondary) + + if let installed = installedSecret { + Label("Installed \(installed.secretName) on \(installed.repositoryFullName)", systemImage: "checkmark.seal.fill") + .font(.callout) + .foregroundStyle(.green) + } + + if let installError { + Text(installError).foregroundStyle(.red).font(.caption) + } + + Button { + Task { await installSecret() } + } label: { + if isInstallingSecret { + ProgressView().controlSize(.small) + } else { + Text(installedSecret == nil ? "Install DOCS_UPLOAD_TOKEN secret" : "Rotate token & reinstall") + } + } + .disabled(isInstallingSecret) + } + } + + // MARK: - Documents + + @ViewBuilder + private var documentsSection: some View { + Section { + if isLoading, documents.isEmpty { + HStack { Spacer(); ProgressView().controlSize(.small); Spacer() } + } else if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } else if documents.isEmpty { + Text("No documents uploaded yet. Use the + button above to add one, or set up CI to upload them automatically.") + .foregroundStyle(.secondary).font(.callout) + } else { + ForEach(documents) { doc in + HStack(spacing: 10) { + Image(systemName: "doc.text").foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(doc.title ?? doc.docId).font(.body) + Text(doc.docId).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if let status = doc.embeddingStatus { + Text(status) + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(status == "ready" ? Color.green.opacity(0.15) : Color.secondary.opacity(0.15)) + .clipShape(Capsule()) + } + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + .onTapGesture { selectedDocument = doc } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + documentToDelete = doc + } label: { + Label("Delete", systemImage: "trash") + } + } + .contextMenu { + Button(role: .destructive) { + documentToDelete = doc + } label: { + Label("Delete Document", systemImage: "trash") + } + } + } + } + } header: { + Text("Documents (\(documents.count))") + } + } + + // MARK: - Danger zone + + @ViewBuilder + private var dangerSection: some View { + Section { + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + Text("Remove Documentation Repository") + } + } + } + + // MARK: - Actions + + private func loadDocuments() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + documents = try await appState.docs.listDocuments(repoId: repo.id).items + } catch { + errorMessage = error.localizedDescription + } + } + + private func installSecret() async { + isInstallingSecret = true + installError = nil + defer { isInstallingSecret = false } + do { + installedSecret = try await appState.docs.installGithubSecret(repoId: repo.id) + } catch { + installError = error.localizedDescription + } + } + + private func deleteDocument(_ doc: DocsDocument) async { + documentToDelete = nil + do { + try await appState.docs.deleteDocument(repoId: repo.id, docId: doc.docId) + documents.removeAll { $0.docId == doc.docId } + } catch { + errorMessage = error.localizedDescription + } + } + + private func deleteRepo() async { + do { + try await appState.docs.deleteRepository(id: repo.id) + onClose() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCode/Views/Hooks/DocsSetupBanner.swift b/RxCode/Views/Hooks/DocsSetupBanner.swift new file mode 100644 index 0000000..96b458a --- /dev/null +++ b/RxCode/Views/Hooks/DocsSetupBanner.swift @@ -0,0 +1,82 @@ +import RxCodeChatKit +import RxCodeCore +import SwiftUI + +/// Banner shown on the new-project screen when a project's repo has no docs +/// indexed. Built by `DocsHook` and rendered via `HookBannerHost`. The "Set up" +/// button opens the docs deep link, which `RxCodeApp`/`MainView` route to a new +/// chat seeded with the docs-publishing skill. Mirrors `SecretsEnvBanner`. +struct DocsSetupBanner: View { + let repo: String + /// Called when the user taps the close button. The hook persists the + /// dismissal so the banner won't reappear. + let onDismiss: () -> Void + + @Environment(\.openURL) private var openURL + @State private var isHovered = false + @State private var isCloseHovered = false + + var body: some View { + HStack(spacing: 8) { + Button(action: open) { + HStack(spacing: 10) { + Image(systemName: "books.vertical.fill") + .font(.system(size: ClaudeTheme.size(16), weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + + Text("Set up documentation so it's searchable") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(2) + + Spacer(minLength: 8) + + Text("Set up") + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(ClaudeTheme.accent, in: Capsule()) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .pointerCursorOnHover() + + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: ClaudeTheme.size(11), weight: .bold)) + .foregroundStyle(ClaudeTheme.textSecondary.opacity(isCloseHovered ? 1 : 0.6)) + .frame(width: 20, height: 20) + .background( + Circle().fill(ClaudeTheme.textSecondary.opacity(isCloseHovered ? 0.12 : 0)) + ) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .onHover { isCloseHovered = $0 } + .pointerCursorOnHover() + .help("Dismiss") + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .fill(ClaudeTheme.accentSubtle) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .strokeBorder(ClaudeTheme.accent.opacity(isHovered ? 0.55 : 0.35), lineWidth: 1) + ) + .padding(.horizontal, 16) + .padding(.bottom, 6) + .animation(.easeInOut(duration: 0.12), value: isHovered) + .animation(.easeInOut(duration: 0.12), value: isCloseHovered) + } + + private func open() { + guard let url = DocsDeepLink.setupURL(repo: repo) else { return } + openURL(url) + } +} diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index ac04c82..c919d33 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -303,6 +303,22 @@ struct MainView: View { ) .environment(appState) } + .onChange(of: appState.docsSetupRequest?.id) { _, _ in + guard let request = appState.docsSetupRequest else { return } + // Start a fresh chat in this project; DocsHook.onSessionStart injects + // the docs-publishing skill into its system prompt on first send. + appState.pendingDocsSetupProjectId = windowState.selectedProject?.id + appState.startNewChat(in: windowState) + let repoText = request.repoFullName.map { " for \($0)" } ?? "" + let prompt = "Set up documentation publishing\(repoText) by following the docs-publishing skill: inspect the repo, author the docs under docs/, add the uploader script and CI workflow, and tell me exactly what DOCS_UPLOAD_TOKEN to set." + appState.docsSetupRequest = nil + // Send the prompt straight to the agent — mirror the IDE `send_to_thread` + // new-thread flow (AppState.sendCrossProject), which calls sendPrompt + // directly rather than routing through the composer's inputText. Going + // through inputText lets the composer auto-collapse the long text into + // an attachment thumbnail before it's sent. + Task { await appState.sendPrompt(prompt, in: windowState) } + } .sheet(item: Bindable(windowState).diffFile) { file in FileDiffView( filePath: file.path, diff --git a/RxCode/Views/Onboarding/OnboardingAutopilotPreview.swift b/RxCode/Views/Onboarding/OnboardingAutopilotPreview.swift new file mode 100644 index 0000000..323bb81 --- /dev/null +++ b/RxCode/Views/Onboarding/OnboardingAutopilotPreview.swift @@ -0,0 +1,150 @@ +import RxAuthSwift +import RxCodeCore +import SwiftUI + +/// Onboarding slide visual that lets the user sign in to their Autopilot +/// (rxlab) account. Mirrors the styling of the other onboarding previews and +/// reuses `RxAuthSignInView` for the actual OAuth/passkey flow. +struct AutopilotSignInPreview: View { + let appState: AppState + @State private var showSignIn = false + @State private var isSigningOut = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "person.badge.shield.checkmark.fill") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + Text("Autopilot Account") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + Spacer() + } + + Text("Sign in with rxlab to import GitHub repositories, sync encrypted secrets, and use autopilot features. You can skip this and sign in later from Settings.") + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.62)) + + if appState.isSignedIn { + signedInCard + } else { + signedOutCard + } + + Spacer(minLength: 0) + } + .padding(20) + .frame(maxWidth: 620) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.black.opacity(0.34)) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 1) + ) + .sheet(isPresented: $showSignIn) { + RxAuthSignInView() + .environment(appState) + } + } + + private var signedInCard: some View { + HStack(spacing: 12) { + avatar + VStack(alignment: .leading, spacing: 2) { + Text(appState.rxUser?.name ?? "rxlab account") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + if let email = appState.rxUser?.email { + Text(verbatim: email) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.6)) + } + } + Spacer(minLength: 8) + HStack(spacing: 6) { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(ClaudeTheme.statusSuccess) + Text("Signed in") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ClaudeTheme.statusSuccess) + } + Button { + Task { + isSigningOut = true + defer { isSigningOut = false } + await appState.signOutRxAuth() + } + } label: { + if isSigningOut { + ProgressView().controlSize(.small) + } else { + Text("Sign Out") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.85)) + } + } + .buttonStyle(.plain) + .disabled(isSigningOut) + } + .padding(12) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private var signedOutCard: some View { + HStack(spacing: 12) { + Image(systemName: "person.crop.circle.badge.questionmark") + .font(.system(size: 22)) + .foregroundStyle(.white.opacity(0.7)) + .frame(width: 40, height: 40) + VStack(alignment: .leading, spacing: 2) { + Text("Not signed in") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + Text("Connect your rxlab account to enable autopilot.") + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.6)) + } + Spacer(minLength: 8) + Button { + showSignIn = true + } label: { + Text("Sign In") + .font(.system(size: 12, weight: .semibold)) + .frame(width: 88, height: 30) + .foregroundStyle(.white) + .background(ClaudeTheme.accent, in: Capsule()) + } + .buttonStyle(.plain) + } + .padding(12) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + @ViewBuilder + private var avatar: some View { + if let urlString = appState.rxUser?.image, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + default: + avatarPlaceholder + } + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } else { + avatarPlaceholder + } + } + + private var avatarPlaceholder: some View { + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 34)) + .foregroundStyle(.white.opacity(0.7)) + .frame(width: 40, height: 40) + } +} diff --git a/RxCode/Views/Onboarding/OnboardingView.swift b/RxCode/Views/Onboarding/OnboardingView.swift index 72c972a..8a41e52 100644 --- a/RxCode/Views/Onboarding/OnboardingView.swift +++ b/RxCode/Views/Onboarding/OnboardingView.swift @@ -273,6 +273,8 @@ struct OnboardingView: View { apiKeyDraft: $summarizationAPIKeyDraft, hasLoadedModels: $hasLoadedSummarizationModels ) + case .autopilotSignIn: + AutopilotSignInPreview(appState: appState) case .mobilePairing: MobilePairingPreview( token: pairingToken, @@ -491,6 +493,7 @@ struct OnboardingSlide: Identifiable { case cliSetup case acpSetup case summarizationModel + case autopilotSignIn case mobilePairing case mcpSetup } @@ -537,6 +540,13 @@ struct OnboardingSlide: Identifiable { visual: .summarizationModel, canSkip: true ), + OnboardingSlide( + id: "autopilot", + title: "Sign in to your Autopilot account", + subtitle: "Connect your rxlab account to import GitHub repositories, sync encrypted secrets, and use autopilot features. You can skip this and sign in later.", + visual: .autopilotSignIn, + canSkip: true + ), OnboardingSlide( id: "mobile", title: "Pair your phone", diff --git a/RxCode/Views/Search/GlobalSearchOverlay.swift b/RxCode/Views/Search/GlobalSearchOverlay.swift index c31f75a..c475cad 100644 --- a/RxCode/Views/Search/GlobalSearchOverlay.swift +++ b/RxCode/Views/Search/GlobalSearchOverlay.swift @@ -11,30 +11,70 @@ struct GlobalSearchOverlay: View { @State private var query: String = "" @State private var groups: [ThreadSearchService.Group] = [] @State private var inThreadHits: [ThreadSearchService.InThreadHit] = [] - @State private var isSearching: Bool = false + @State private var isSearchingThreads: Bool = false + @State private var isSearchingDocs: Bool = false @State private var hasSearched: Bool = false @State private var selectedIndex: Int = 0 + @State private var filter: SearchFilter = .all @FocusState private var inputFocused: Bool + /// Which kind of result the list is scoped to. `.all` interleaves threads + /// and docs; the others narrow the list to a single source. + private enum SearchFilter: String, CaseIterable, Identifiable { + case all + case threads + case docs + + var id: String { rawValue } + + var title: String { + switch self { + case .all: return "All" + case .threads: return "Threads" + case .docs: return "Docs" + } + } + } + + private var showThreads: Bool { filter != .docs } + private var showDocs: Bool { filter != .threads } + + /// Docs results live alongside threads in a single, unified result list — + /// one query searches on-device threads and github-pm's docs index at once, + /// with no scope to pick. + @State private var docsHits: [DocsSearchHit] = [] + @State private var docsError: String? + @State private var selectedDoc: DocsSearchHit? + + private var isSearching: Bool { isSearchingThreads || isSearchingDocs } + /// Flattened selectable rows in display order. Drives arrow-key navigation /// and Enter activation; recomputed on every render so it stays in sync /// with the current results. private enum SelectableRow: Hashable { case inThread(UUID) case thread(String) + case doc(String) } private var flatRows: [SelectableRow] { var rows: [SelectableRow] = [] - for hit in inThreadHits { rows.append(.inThread(hit.id)) } - for group in groups { - for hit in group.hits { rows.append(.thread(hit.threadId)) } + if showThreads { + for hit in inThreadHits { rows.append(.inThread(hit.id)) } + for group in groups { + for hit in group.hits { rows.append(.thread(hit.threadId)) } + } + } + if showDocs { + for hit in docsHits { rows.append(.doc(hit.id)) } } return rows } private var hasResults: Bool { - !groups.isEmpty || !inThreadHits.isEmpty + if showThreads, !groups.isEmpty || !inThreadHits.isEmpty { return true } + if showDocs, !docsHits.isEmpty { return true } + return false } private var currentThreadTitle: String? { @@ -45,9 +85,16 @@ struct GlobalSearchOverlay: View { private let cardWidth: CGFloat = 720 private let cardHeight: CGFloat = 520 + /// How long to wait after the last keystroke before kicking off the + /// expensive work (semantic thread embedding + the docs network call). + /// The cheap in-thread literal scan still runs immediately so typing + /// stays responsive. + private let debounceDelay: Duration = .seconds(1) + var body: some View { VStack(spacing: 0) { searchField + filterTabs Divider().background(ClaudeTheme.borderSubtle) resultsScroll footer @@ -81,8 +128,20 @@ struct GlobalSearchOverlay: View { await runSearch() } .onChange(of: query) { _, _ in selectedIndex = 0 } + .onChange(of: filter) { _, _ in selectedIndex = 0 } .onChange(of: inThreadHits) { _, _ in clampSelection() } .onChange(of: groups) { _, _ in clampSelection() } + .onChange(of: docsHits) { _, _ in clampSelection() } + .sheet(item: $selectedDoc) { hit in + DocsDocumentDetailView( + repoId: hit.repositoryFullName, + docId: hit.docId, + fallbackTitle: hit.title ?? hit.docId, + fallbackContent: hit.snippet, + originalLink: hit.originalLink + ) + .environment(appState) + } } // MARK: - Search field @@ -93,7 +152,7 @@ struct GlobalSearchOverlay: View { .foregroundStyle(ClaudeTheme.textSecondary) .font(.system(size: ClaudeTheme.size(14), weight: .medium)) - TextField("Search threads by topic, keyword, or feel…", text: $query) + TextField("Search threads and docs…", text: $query) .textFieldStyle(.plain) .font(.system(size: ClaudeTheme.size(15))) .foregroundStyle(ClaudeTheme.textPrimary) @@ -116,6 +175,62 @@ struct GlobalSearchOverlay: View { .padding(.vertical, 14) } + // MARK: - Filter tabs + + private var filterTabs: some View { + HStack(spacing: 6) { + ForEach(SearchFilter.allCases) { item in + filterTab(item) + } + Spacer() + } + .padding(.horizontal, 14) + .padding(.bottom, 10) + } + + private func filterTab(_ item: SearchFilter) -> some View { + let isSelected = filter == item + let count = resultCount(for: item) + return Button { + filter = item + } label: { + HStack(spacing: 5) { + Text(item.title) + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + if let count { + Text("\(count)") + .font(.system(size: ClaudeTheme.size(11), weight: .semibold).monospacedDigit()) + .foregroundStyle(isSelected ? ClaudeTheme.accent : ClaudeTheme.textTertiary) + } + } + .foregroundStyle(isSelected ? ClaudeTheme.accent : ClaudeTheme.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(isSelected ? ClaudeTheme.accent.opacity(0.14) : ClaudeTheme.surfaceSecondary) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .strokeBorder(isSelected ? ClaudeTheme.accent.opacity(0.45) : .clear, lineWidth: 1) + ) + .contentShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) + } + .buttonStyle(.plain) + } + + /// Total matches a given filter would surface, or `nil` before any search + /// has run (so the tabs stay clean until there's something to count). + private func resultCount(for item: SearchFilter) -> Int? { + guard hasSearched else { return nil } + let threadCount = inThreadHits.count + groups.reduce(0) { $0 + $1.hits.count } + switch item { + case .all: return threadCount + docsHits.count + case .threads: return threadCount + case .docs: return docsHits.count + } + } + // MARK: - Results @ViewBuilder @@ -123,8 +238,8 @@ struct GlobalSearchOverlay: View { if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { placeholder( icon: "sparkles", - title: "Search past threads", - subtitle: "Describe what you remember and we'll find it across every project. Indexing runs locally; nothing leaves your Mac." + title: "Search threads and docs", + subtitle: "Describe what you remember — we search every project's threads on-device and your published docs at once. Nothing manual to pick." ) } else if !hasResults && hasSearched && !isSearching { placeholder( @@ -136,11 +251,16 @@ struct GlobalSearchOverlay: View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 12) { - if !inThreadHits.isEmpty { - currentThreadSection + if showThreads { + if !inThreadHits.isEmpty { + currentThreadSection + } + ForEach(groups, id: \.projectId) { group in + projectSection(group) + } } - ForEach(groups, id: \.projectId) { group in - projectSection(group) + if showDocs, !docsHits.isEmpty { + docsSection } } .padding(.horizontal, 12) @@ -390,16 +510,88 @@ struct GlobalSearchOverlay: View { return AttributedString(text) } + // MARK: - Docs section + + private var docsSection: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "books.vertical.fill") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + Text("Documentation") + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + Text("· \(docsHits.count)") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .padding(.horizontal, 8) + .padding(.bottom, 2) + + VStack(spacing: 2) { + ForEach(docsHits) { hit in + docsRow(hit: hit) + } + } + } + } + + private func docsRow(hit: DocsSearchHit) -> some View { + let row: SelectableRow = .doc(hit.id) + let isSelected = currentSelection() == row + return Button { + selectedDoc = hit + } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "doc.text") + .font(.system(size: ClaudeTheme.size(13))) + .foregroundStyle(ClaudeTheme.textTertiary) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(hit.title ?? hit.docId) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + if let score = hit.score { scoreBadge(Float(score)) } + } + if let repo = hit.repository { + Text(repo) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + } + if let snippet = hit.snippet, !snippet.isEmpty { + Text(Self.inlineMarkdown(snippet)) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(ClaudeTheme.textSecondary) + .lineLimit(3) + } + } + Spacer(minLength: 8) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(rowBackground(isSelected: isSelected)) + .contentShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) + } + .buttonStyle(.plain) + .id(row) + } + // MARK: - Footer private var footer: some View { HStack(spacing: 8) { - Image(systemName: "lock.shield") + Image(systemName: "sparkle.magnifyingglass") .font(.system(size: ClaudeTheme.size(10))) .foregroundStyle(ClaudeTheme.textTertiary) - Text("Local on-device search · archived threads included") + Text(docsError == nil + ? "Threads searched on-device · docs from the docs service" + : "Threads on-device · docs unavailable: \(docsError ?? "")") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) Spacer() } .padding(.horizontal, 16) @@ -449,6 +641,8 @@ struct GlobalSearchOverlay: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { appState.selectSession(id: id, in: windowState) } + case .doc(let hitId): + selectedDoc = docsHits.first(where: { $0.id == hitId }) } } @@ -467,22 +661,35 @@ struct GlobalSearchOverlay: View { guard !q.isEmpty else { groups = [] inThreadHits = [] + docsHits = [] + docsError = nil hasSearched = false return } - // Full-text scan of the current thread runs immediately so typing feels - // responsive even before the semantic search debounces. + + // The cheap, literal in-thread scan runs immediately so typing feels + // responsive even before the expensive work kicks in. let liveMessages = appState.messages(in: windowState) inThreadHits = ThreadSearchService.searchInThread(q, in: liveMessages) - // Debounce: wait briefly so we don't re-embed on every keystroke. - try? await Task.sleep(for: .milliseconds(180)) + // Single debounce gate: wait briefly so we don't re-embed threads or + // hit the docs API on every keystroke. `.task(id: query)` cancels this + // sleep the moment the query changes, so only the final pause fires. + try? await Task.sleep(for: debounceDelay) if Task.isCancelled { return } if q != query.trimmingCharacters(in: .whitespacesAndNewlines) { return } - isSearching = true + // Threads (on-device) and docs (network) run together — one query, one + // result list — now that the debounce has settled. + async let docsRun: Void = runDocsSearch(q) + await runThreadsSearch(q) + await docsRun + } + + private func runThreadsSearch(_ q: String) async { + isSearchingThreads = true let results = await appState.searchService.search(q, limit: 50) - if Task.isCancelled { return } + if Task.isCancelled { isSearchingThreads = false; return } if q == query.trimmingCharacters(in: .whitespacesAndNewlines) { // Drop the current thread from the semantic groups when we already // have in-thread literal matches — same thread shouldn't appear twice. @@ -494,6 +701,30 @@ struct GlobalSearchOverlay: View { } hasSearched = true } - isSearching = false + isSearchingThreads = false + } + + /// Semantic docs search across every docs repo the user can read. The + /// caller already debounced before invoking this. + private func runDocsSearch(_ q: String) async { + docsError = nil + if Task.isCancelled { return } + if q != query.trimmingCharacters(in: .whitespacesAndNewlines) { return } + + isSearchingDocs = true + defer { isSearchingDocs = false } + do { + let hits = try await appState.docs.search(query: q, repo: nil, limit: 30) + if Task.isCancelled { return } + if q == query.trimmingCharacters(in: .whitespacesAndNewlines) { + docsHits = hits + hasSearched = true + } + } catch { + if Task.isCancelled { return } + docsHits = [] + docsError = error.localizedDescription + hasSearched = true + } } } diff --git a/RxCode/Views/Settings/AutopilotSettingsTab.swift b/RxCode/Views/Settings/AutopilotSettingsTab.swift index 682595c..c2b95db 100644 --- a/RxCode/Views/Settings/AutopilotSettingsTab.swift +++ b/RxCode/Views/Settings/AutopilotSettingsTab.swift @@ -13,6 +13,8 @@ struct AutopilotSettingsTab: View { @State private var isEnrollingSecrets = false @State private var secretsError: String? + @State private var showManageDocs = false + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { @@ -22,6 +24,8 @@ struct AutopilotSettingsTab: View { if appState.isSignedIn { Divider() secretsSection + Divider() + docsSection } } .padding(24) @@ -37,9 +41,47 @@ struct AutopilotSettingsTab: View { SecretsManageSheet() .environment(appState) } + .sheet(isPresented: $showManageDocs, onDismiss: { + Task { await appState.refreshDocsStatuses() } + }) { + DocsManageSheet() + .environment(appState) + } .task { await appState.refreshSecretsEnrollment() } } + // MARK: - Docs Section + + private var docsSection: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text("Documentation") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + Text("Index your repositories' docs (design docs, API docs, code docs) so agents and ⌘K can search them. Set up CI to upload docs automatically with an upload token.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + secretsCard { + HStack(spacing: 12) { + Image(systemName: "books.vertical.fill") + .font(.system(size: ClaudeTheme.size(18))) + .foregroundStyle(ClaudeTheme.accent) + VStack(alignment: .leading, spacing: 2) { + Text("Documentation search") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text("Manage docs repositories, view indexed documents, and create CI upload tokens.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Button("Manage Docs") { showManageDocs = true } + .buttonStyle(.borderedProminent) + } + } + } + } + // MARK: - Secrets Section private var secretsSection: some View { diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index cc66dca..f3142a6 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -11,6 +11,7 @@ struct ProjectTreeView: View { @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState @Environment(\.openWindow) private var openWindow + @Environment(\.openURL) private var openURL @State private var expandedProjectIds: Set = [] @State private var renameProject: Project? = nil @@ -263,7 +264,13 @@ private struct SummarySidebarSection: View { appState.startNewChat(in: windowState) }, onDownloadSecret: { downloadSecretProject = project }, - hasSecrets: appState.projectHasSecrets(project) + hasSecrets: appState.projectHasSecrets(project), + onSetupDocsSearch: { + guard let repo = project.gitHubRepo, + let url = DocsDeepLink.setupURL(repo: repo) else { return } + openURL(url) + }, + canSetupDocsSearch: project.gitHubRepo != nil ) if expandedProjectIds.contains(project.id) { @@ -309,6 +316,8 @@ private struct ProjectTreeRow: View { let onNewChat: () -> Void let onDownloadSecret: () -> Void let hasSecrets: Bool + let onSetupDocsSearch: () -> Void + let canSetupDocsSearch: Bool @State private var isHovered = false @State private var showLocationPopover = false @@ -372,25 +381,7 @@ private struct ProjectTreeRow: View { // Always reserve space; fade in on hover so layout doesn't shift. HStack(spacing: 2) { Menu { - Button { onNewChat() } label: { - Label("New Chat", systemImage: "square.and.pencil") - } - Button { onOpenInNewWindow() } label: { - Label("Open in New Window", systemImage: "macwindow.badge.plus") - } - Divider() - if hasSecrets { - Button { onDownloadSecret() } label: { - Label("Download Secret", systemImage: "key.fill") - } - Divider() - } - Button { onRename() } label: { - Label("Rename Project", systemImage: "pencil") - } - Button(role: .destructive) { onDelete() } label: { - Label("Delete Project", systemImage: "trash") - } + projectMenuItems } label: { Image(systemName: "ellipsis") .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) @@ -434,35 +425,39 @@ private struct ProjectTreeRow: View { onOpenInNewWindow() } .contextMenu { - Button { - onNewChat() - } label: { - Label("New Chat", systemImage: "square.and.pencil") + projectMenuItems + } + } + + /// Shared items for both the "More" (ellipsis) menu and the right-click + /// context menu so the two stay in sync. + @ViewBuilder + private var projectMenuItems: some View { + Button { onNewChat() } label: { + Label("New Chat", systemImage: "square.and.pencil") + } + Button { onOpenInNewWindow() } label: { + Label("Open in New Window", systemImage: "macwindow.badge.plus") + } + Divider() + if canSetupDocsSearch { + Button { onSetupDocsSearch() } label: { + Label("Set Up Docs Search", systemImage: "books.vertical.fill") } - Button { - onOpenInNewWindow() - } label: { - Label("Open in New Window", systemImage: "macwindow.badge.plus") + } + if hasSecrets { + Button { onDownloadSecret() } label: { + Label("Download Secret", systemImage: "key.fill") } + } + if canSetupDocsSearch || hasSecrets { Divider() - if hasSecrets { - Button { - onDownloadSecret() - } label: { - Label("Download Secret", systemImage: "key.fill") - } - Divider() - } - Button { - onRename() - } label: { - Label("Rename Project", systemImage: "pencil") - } - Button(role: .destructive) { - onDelete() - } label: { - Label("Delete Project", systemImage: "trash") - } + } + Button { onRename() } label: { + Label("Rename Project", systemImage: "pencil") + } + Button(role: .destructive) { onDelete() } label: { + Label("Delete Project", systemImage: "trash") } } } diff --git a/docs/api/relay-server.md b/docs/api/relay-server.md new file mode 100644 index 0000000..8b8e7d4 --- /dev/null +++ b/docs/api/relay-server.md @@ -0,0 +1,123 @@ +--- +slug: api/relay-server +title: Relay Server API +description: HTTP and WebSocket API of the RxCode mobile-sync relay (rxcode-relay). +--- + +# Relay Server API + +`rxcode-relay` (`relay-server/`) is a stateless Go WebSocket relay and APNs/FCM +forwarder for the RxCode desktop ↔ mobile sync channel. It never decrypts +payloads: all sync messages are E2E encrypted between device pairs using +Curve25519 + ChaCha20-Poly1305, and the relay only sees opaque envelopes +(`{v, to, from, nonce, ct}`) plus a destination pubkey. + +```bash +# Run locally +go mod tidy +go run . -addr :8787 +``` + +## Endpoints + +### `GET /ws?pubkey=<64-hex>` + +Upgrades to a WebSocket. The connecting client claims the `pubkey`; the relay +does not verify ownership because the E2E layer already prevents reading or +forging messages. **Drop-on-offline:** if the recipient pubkey isn't currently +connected, the envelope is dropped and the sender receives a `delivery_failed` +notice. + +### `POST /push` + +The desktop submits APNs or FCM pushes. The optional `provider` field selects +`"apns"` (default) or `"fcm"`. For APNs, `push_type` selects one of three +delivery modes (defaults to `alert`). + +**`alert`** — encrypted banner (decrypted on-device by the iOS Notification +Service Extension): + +```json +{ + "device_token": "", + "apns_environment": "sandbox", + "encrypted_alert": "", + "category": "permission_request", + "collapse_id": "" +} +``` + +**`liveactivity`** — ActivityKit start/update/end push. `apns_payload` is +forwarded verbatim and the topic is suffixed with `.push-type.liveactivity`. +Live Activity content-state is **not** E2E encrypted: + +```json +{ + "device_token": "", + "apns_environment": "sandbox", + "push_type": "liveactivity", + "apns_payload": { "aps": { "event": "update", "content-state": {} } }, + "collapse_id": "" +} +``` + +**`background`** — silent `content-available` push to refresh the home-screen +widget. Body is `{ device_token, apns_environment, push_type: "background", +apns_payload }`, forwarded verbatim at low priority. + +**FCM alert** — encrypted Android banner sent as an FCM HTTP v1 data message; +the Android app decrypts `enc` locally: + +```json +{ + "provider": "fcm", + "device_token": "", + "encrypted_alert": "", + "category": "permission_request", + "collapse_id": "" +} +``` + +The relay signs a JWT with the configured APNs auth key, keeps both sandbox and +production APNs clients alive, and routes each push by `apns_environment`. If +older desktop clients omit the field, `APNS_PRODUCTION` is used as the +compatibility default. + +### `GET /healthz` + +Liveness probe. Reports the active routing mode as +`"mode": "single-node" | "multi-node"`. + +## Configuration + +Every option is settable via CLI flag or environment variable; precedence is +**flag > env > `.env` file**. + +| Flag | Env var | Purpose | +| --- | --- | --- | +| `-addr` | `RELAY_ADDR` | Listen address (default `:8787`). | +| `-apns-key` | `APNS_KEY_PATH` | Path to a `.p8` auth key file. | +| *(none)* | `APNS_KEY_B64` | `.p8` contents base64-encoded — preferred for env. Wins over `APNS_KEY_PATH`. | +| `-apns-key-id` | `APNS_KEY_ID` | 10-char Key ID from the Apple developer portal. | +| `-apns-team-id` | `APNS_TEAM_ID` | 10-char Team ID. | +| `-apns-topic` | `APNS_TOPIC` | iOS app bundle identifier (e.g. `app.rxlab.rxcodemobile`). | +| `-apns-production` | `APNS_PRODUCTION` | Compatibility default when a push omits `apns_environment`. | +| `-fcm-project-id` | `FCM_PROJECT_ID` | Firebase project ID (optional if present in the service-account JSON). | +| `-fcm-service-account` | `GOOGLE_APPLICATION_CREDENTIALS` | Path to Firebase service-account JSON. | +| *(none)* | `FCM_SERVICE_ACCOUNT_JSON` | Raw Firebase service-account JSON. | +| *(none)* | `FCM_SERVICE_ACCOUNT_B64` | Base64-encoded service-account JSON, preferred for container secrets. | +| `-redis-url` | `REDIS_URL` | Redis URL for the multi-node backplane. Empty = single-node. | + +## Scaling + +- **Single-node (default).** Leave `REDIS_URL` unset and run exactly 1 replica; + routing is in-memory and offline recipients yield a `delivery_failed` notice. +- **Multi-node.** Set `REDIS_URL`. Envelopes whose recipient isn't on the local + pod are published to the `relay:route` channel; per-pubkey presence keys + (`relay:presence:*`) keep `delivery_failed` accurate cluster-wide. Scale to + any replica count — no sticky sessions required. + +Kubernetes manifests for a multi-replica deployment live in `relay-server/k8s/`. + +See [Architecture Overview](../architecture/overview) for where the relay fits +in the overall system. diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md new file mode 100644 index 0000000..73bd8ff --- /dev/null +++ b/docs/architecture/data-flow.md @@ -0,0 +1,47 @@ +--- +slug: architecture/data-flow +title: Data Flow +description: How a user message travels through AppState, the selected backend, and the streaming pipeline. +--- + +# Data Flow + +RxCode drives every agent interaction through a single entry point on the +observable app state and a shared streaming pipeline, regardless of which +backend (Claude Code, Codex, or ACP) serves the request. + +## Request lifecycle + +1. User input enters `AppState.send(in:)`. +2. `AppState` resolves the selected agent provider, model, effort, permission + mode, working directory, and optional Git worktree. +3. The selected backend — `ClaudeService`, `CodexAppServer`, or `ACPService` — + emits an `AsyncStream`. +4. `AppState+Stream.swift` processes stream events: updates chat state, tracks + tool calls, handles permission requests, and persists messages. +5. Permission requests route through the UI and, where needed, through + `PermissionServer` (CLI hooks) or the ACP/Codex protocol response channels. +6. Thread summaries, branch briefings, change tracking, search indexes, + widgets, and mobile snapshots update from persisted session state. + +## Permission handling + +Permission approval is risk-aware and queued in the UI. CLI-based agents call +back into a local HTTP server (`PermissionServer`) that hands the request to +the approval UI; protocol-based agents (ACP, Codex) bridge permission requests +through their respective protocol responses. `BashSafety` provides read-only +command validation for agent-exposed shell helpers. + +## Persistence and downstream consumers + +`PersistenceService` / `ThreadStore` back session and thread state with JSON +under Application Support. Once a session is persisted, several consumers update +off the same source of truth: + +- `ThreadSearchService` re-indexes threads for on-device natural-language search. +- Branch briefings and change tracking recompute from session state. +- `RxCodeWidget` reflects active work and usage. +- `MobileSyncService` produces E2E-encrypted snapshots for paired devices. + +See [Services](services) for the full service catalog and +[Architecture Overview](overview) for the core patterns these flows rely on. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..b0915c9 --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,60 @@ +--- +slug: architecture/overview +title: Architecture Overview +description: High-level design of RxCode, a native macOS client for AI coding agents. +--- + +# Architecture Overview + +RxCode is a native macOS desktop client for AI coding agents, written in Swift +and SwiftUI. It provides a project-centric UI for Claude Code, Codex, and Agent +Client Protocol (ACP) clients, with streaming chat, permission approval flows, +run profiles, Git worktree support, natural-language thread search, mobile +sync, and briefing / change tracking. + +The repository also hosts companion targets — widgets, mobile apps (iOS and +Android), a Go relay server for mobile sync, and the public website. + +## Targets and components + +| Component | Path | Purpose | +| --- | --- | --- | +| macOS app | `RxCode/` | Main desktop client: app entry point, SwiftUI views, services, integrations. | +| Shared core | `Packages/Sources/RxCodeCore/` | Models, theme, utilities, run-profile models, Git helpers, CLI parsing, backend contracts. | +| Chat kit | `Packages/Sources/RxCodeChatKit/` | Reusable chat UI: message rendering, input bar, slash commands, diffs, plans. | +| Sync | `Packages/Sources/RxCodeSync/` | End-to-end encrypted sync protocol, pairing, APNs/FCM payloads, transport types. | +| Widget | `RxCodeWidget/` | Widget and Live Activity support for active work and usage. | +| Mobile | `RxCodeMobile/`, `RxCodeAndroid/` | iOS and Android companion clients. | +| Relay | `relay-server/` | Stateless Go WebSocket relay + APNs/FCM forwarder for desktop ↔ mobile sync. | +| Website | `website/` | Public Next.js website and assets. | + +## Platform and tooling + +- macOS app deployment target: macOS 26.0+ +- Swift tools version: 6.2 +- Main app bundle ID: `com.rxlab.RxCode` +- App-level dependencies: SwiftTerm, Sparkle +- Package dependencies: ViewInspector, Textual, MarkdownUI +- `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` and + `SWIFT_APPROACHABLE_CONCURRENCY = YES` are enabled for app/mobile targets. +- App Sandbox is disabled for the main macOS app because RxCode integrates with + local developer tools and projects. + +## Core design patterns + +- **Observable app state.** `RxCode/App/AppState.swift` is a + `@MainActor @Observable` container; behavior is split across `AppState+*.swift` + extensions by domain. +- **SwiftUI-only UI.** No Storyboards or XIBs. +- **Actor-based services.** Services owning mutable shared state are actors or + otherwise isolate concurrency through existing patterns. +- **Backend abstraction.** Claude Code, Codex, and ACP flows share backend + contracts from `RxCodeCore/Backend`, avoiding agent-specific branching where + the shared protocol suffices. +- **Package boundaries.** `RxCodeCore` stays broadly reusable and free of + app-only UI; chat-specific UI lives in `RxCodeChatKit`; sync protocol code in + `RxCodeSync`; app orchestration in `RxCode/`. + +See [Data Flow](data-flow) for the request lifecycle, [Services](services) for +the runtime service catalog, [Swift Packages](packages) for package layout, and +the [Relay Server API](../api/relay-server) for the mobile-sync transport. diff --git a/docs/architecture/packages.md b/docs/architecture/packages.md new file mode 100644 index 0000000..8e8018d --- /dev/null +++ b/docs/architecture/packages.md @@ -0,0 +1,49 @@ +--- +slug: architecture/packages +title: Swift Packages +description: Package boundaries for RxCodeCore, RxCodeChatKit, and RxCodeSync. +--- + +# Swift Packages + +Shared logic lives in a local Swift package under `Packages/` (Swift tools +version 6.2). Keeping these packages free of app-only dependencies allows them +to be reused across the macOS app, widgets, and mobile targets. + +```bash +# Build the package +swift build --package-path Packages + +# Run package tests +swift test --package-path Packages +``` + +## Package boundaries + +| Package | Path | Responsibility | +| --- | --- | --- | +| `RxCodeCore` | `Packages/Sources/RxCodeCore/` | Shared models, theme, utilities, run-profile models, Git helpers, CLI session parsing, backend contracts, and reusable non-app UI primitives. Must stay broadly reusable and free of app-only UI dependencies. | +| `RxCodeChatKit` | `Packages/Sources/RxCodeChatKit/` | Reusable chat UI: message list, input bar, slash commands, shortcuts, diffs, queue UI, plan/question views. | +| `RxCodeSync` | `Packages/Sources/RxCodeSync/` | End-to-end encrypted sync protocol, pairing, APNs alert payloads, and mobile/desktop transport data structures. | + +Additional UI helper sources live alongside under `Packages/Sources/` +(`DiffView`, `MessageList`). + +## Boundary rules + +- Put chat-specific SwiftUI components in `RxCodeChatKit`, not `RxCodeCore`. +- Put sync protocol code in `RxCodeSync`; keep transport types + forward/backward compatible because paired desktop and mobile versions differ. +- Keep app orchestration (`AppState`, services) in the `RxCode/` app target, not + in the packages. +- Backend contracts shared by Claude Code, Codex, and ACP belong in + `RxCodeCore/Backend` so the app can avoid agent-specific branches. + +## Testing + +Package tests live under `Packages/Tests/` covering core, chat kit, and sync +logic. Run focused `swift test --package-path Packages` tests when practical for +package-level changes. + +See [Architecture Overview](overview) for how the packages relate to the app +targets and [Runtime Services](services) for the app-side consumers. diff --git a/docs/architecture/services.md b/docs/architecture/services.md new file mode 100644 index 0000000..f5a2a82 --- /dev/null +++ b/docs/architecture/services.md @@ -0,0 +1,52 @@ +--- +slug: architecture/services +title: Runtime Services +description: Catalog of RxCode's agent runtime and supporting services. +--- + +# Runtime Services + +RxCode isolates integrations behind actor-based and service-oriented types under +`RxCode/Services/`. They fall into two groups: agent runtime services that run +and stream from coding agents, and supporting services that back persistence, +search, sync, Git, updates, and platform integrations. + +## Agent runtime services + +| Service | Role | +| --- | --- | +| `ClaudeService` | Runs Claude Code: process discovery, streaming, summaries, CLI session integration. | +| `CodexAppServer` | Runs Codex app-server sessions; parses protocol events; fetches Codex models and rate limits. | +| `ACPService` | Runs ACP clients (e.g. OpenCode, Gemini CLI); manages pooled sessions, protocol I/O, model discovery, permission bridging. | +| `ACPRegistryService` / `ACPInstallerService` | Fetches ACP registry data and installs compatible ACP client binaries. | +| `PermissionServer` | Local HTTP server for CLI permission hooks and approval handoff to the UI. | +| `MCPService` | Reads, writes, probes, and adapts MCP server configuration for supported agent runtimes. | +| `IDEServer` tools | Exposes project / thread / search / memory tools to agents via the in-app IDE MCP server. | + +## Supporting services + +| Service | Role | +| --- | --- | +| `PersistenceService` / `ThreadStore` | JSON-backed persistence under Application Support; thread/session storage. | +| `ThreadSearchService` | On-device embedding and natural-language search over chat threads. | +| `MobileSyncService` | E2E-encrypted mobile pairing, relay communication, APNs fan-out, live sync events. | +| `RunService` / `RunProfileDetector` | Run profile execution and detection for Xcode, npm, make, and shell workflows. | +| `GitHubService` | OAuth device flow, Keychain token storage, SSH key management, repo browsing, cloning. | +| `MarketplaceService` | Skill / plugin catalog fetching and installation. | +| `RateLimitService` | Claude usage API polling, OAuth token refresh, usage tracking. | +| `UpdateService` | Sparkle-based update manager. | +| `BashSafety` | Read-only command validation for agent-exposed shell helpers. | + +## Implementation guidelines + +- Prefer existing services, models, theme tokens, and helpers over new abstractions. +- Avoid blocking the main actor with process, file, network, or parsing work. +- When adding agent features, account for Claude Code, Codex, and ACP behavior + unless the feature is explicitly provider-specific. +- When changing sync payloads, preserve backward/forward compatibility — mobile + and desktop versions can differ. +- When changing persistence formats, add migration or tolerant decoding rather + than assuming fresh data. + +See [Data Flow](data-flow) for how these services participate in a request and +[Swift Packages](packages) for the package boundaries they respect. diff --git a/scripts/upload_docs.py b/scripts/upload_docs.py new file mode 100644 index 0000000..6de8f69 --- /dev/null +++ b/scripts/upload_docs.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Upload markdown docs under docs/ to the autopilot docs service. + +Stdlib-only. Walks docs/, parses YAML frontmatter, keeps files that declare a +`slug`, errors on duplicate slugs, and POSTs documents in batches of 50 to: + + {DOCS_ENDPOINT}/api/v1/docs/repositories/{url-encoded repo}/documents + +with `Authorization: Bearer $DOCS_UPLOAD_TOKEN` and body: + + {"documents": [{"docId": slug, "content": body}, ...]} + +Environment: + DOCS_ENDPOINT default https://autopilot.rxlab.app + DOCS_REPOSITORY_ID e.g. owner/repo (required unless --dry-run) + DOCS_UPLOAD_TOKEN bearer token (required unless --dry-run) + +Usage: + python scripts/upload_docs.py [--dry-run] [--docs-dir docs] +""" + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +DEFAULT_ENDPOINT = "https://autopilot.rxlab.app" +BATCH_SIZE = 50 + + +def parse_frontmatter(text): + """Return (frontmatter_dict, body) for a markdown file. + + Only a tiny, dependency-free subset of YAML is supported: a leading + `---` fenced block of `key: value` lines. Returns ({}, text) when no + frontmatter is present. + """ + if not text.startswith("---"): + return {}, text + lines = text.splitlines() + if lines[0].strip() != "---": + return {}, text + fm = {} + body_start = None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + body_start = i + 1 + break + line = lines[i] + if not line.strip() or line.lstrip().startswith("#"): + continue + if ":" not in line: + continue + key, _, value = line.partition(":") + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + fm[key.strip()] = value + if body_start is None: + return {}, text + body = "\n".join(lines[body_start:]).lstrip("\n") + return fm, body + + +def collect_documents(docs_dir): + """Walk docs_dir and return a list of {docId, content}, erroring on dupes.""" + documents = [] + seen = {} + for root, _dirs, files in os.walk(docs_dir): + for name in sorted(files): + if not name.endswith(".md"): + continue + path = os.path.join(root, name) + with open(path, "r", encoding="utf-8") as fh: + text = fh.read() + fm, body = parse_frontmatter(text) + slug = fm.get("slug") + if not slug: + print(f" skip (no slug): {path}") + continue + if slug in seen: + raise SystemExit( + f"Duplicate slug '{slug}' in {path} and {seen[slug]}" + ) + seen[slug] = path + documents.append({"docId": slug, "content": body}) + print(f" + {slug} ({path})") + return documents + + +def post_batch(endpoint, repo_id, token, batch): + url = "{}/api/v1/docs/repositories/{}/documents".format( + endpoint.rstrip("/"), urllib.parse.quote(repo_id, safe="") + ) + data = json.dumps({"documents": batch}).encode("utf-8") + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("Authorization", "Bearer " + token) + with urllib.request.urlopen(req) as resp: + status = resp.getcode() + payload = resp.read().decode("utf-8", "replace") + return status, payload + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Upload docs/ to autopilot.") + parser.add_argument("--dry-run", action="store_true", + help="Parse and batch only; no network calls.") + parser.add_argument("--docs-dir", default="docs", + help="Docs directory to walk (default: docs).") + args = parser.parse_args(argv) + + endpoint = os.environ.get("DOCS_ENDPOINT", DEFAULT_ENDPOINT) + repo_id = os.environ.get("DOCS_REPOSITORY_ID") + token = os.environ.get("DOCS_UPLOAD_TOKEN") + + if not os.path.isdir(args.docs_dir): + raise SystemExit(f"Docs directory not found: {args.docs_dir}") + + print(f"Collecting docs from {args.docs_dir}/ ...") + documents = collect_documents(args.docs_dir) + if not documents: + raise SystemExit("No documents with a slug found; nothing to upload.") + + batches = [documents[i:i + BATCH_SIZE] + for i in range(0, len(documents), BATCH_SIZE)] + print(f"\n{len(documents)} document(s) in {len(batches)} batch(es) " + f"of up to {BATCH_SIZE}.") + + if args.dry_run: + print("\n[dry-run] No network calls made. Endpoint would be:") + print(f" {endpoint}/api/v1/docs/repositories/" + f"{urllib.parse.quote(repo_id or '', safe='')}" + f"/documents") + return 0 + + if not repo_id: + raise SystemExit("DOCS_REPOSITORY_ID is required (e.g. owner/repo).") + if not token: + raise SystemExit("DOCS_UPLOAD_TOKEN is required.") + + for idx, batch in enumerate(batches, start=1): + try: + status, payload = post_batch(endpoint, repo_id, token, batch) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + raise SystemExit( + f"Batch {idx} failed: HTTP {exc.code} {exc.reason}\n{body}") + except urllib.error.URLError as exc: + raise SystemExit(f"Batch {idx} failed: {exc.reason}") + print(f"Batch {idx}/{len(batches)}: HTTP {status} {payload}") + + print("\nDone. Uploads are async; docs become searchable once embedding " + "finishes.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())