diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 9eb16d62d..194723a94 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -191,6 +191,7 @@ F10000000000000000000006 /* SettingsDiagnosticsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000006 /* SettingsDiagnosticsSection.swift */; }; G10000000000000000000001 /* PrModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000001 /* PrModels.swift */; }; G10000000000000000000002 /* PrHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000002 /* PrHelpers.swift */; }; + G10000000000000000000032 /* PrGitHubDescriptionParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000032 /* PrGitHubDescriptionParser.swift */; }; G10000000000000000000003 /* PrListRowModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000003 /* PrListRowModifier.swift */; }; G10000000000000000000004 /* PrFiltersCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000004 /* PrFiltersCard.swift */; }; G10000000000000000000005 /* PrRowCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000005 /* PrRowCard.swift */; }; @@ -200,6 +201,7 @@ G10000000000000000000006 /* PrsRootScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000006 /* PrsRootScreen.swift */; }; G10000000000000000000007 /* PrDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000007 /* PrDetailScreen.swift */; }; G10000000000000000000008 /* PrDetailOverviewTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000008 /* PrDetailOverviewTab.swift */; }; + G10000000000000000000033 /* PrDetailHeaderComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000033 /* PrDetailHeaderComponents.swift */; }; G10000000000000000000009 /* PrDetailFilesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000009 /* PrDetailFilesTab.swift */; }; G1000000000000000000000A /* PrDetailChecksTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = G2000000000000000000000A /* PrDetailChecksTab.swift */; }; G1000000000000000000000B /* PrDetailActivityTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = G2000000000000000000000B /* PrDetailActivityTab.swift */; }; @@ -208,7 +210,6 @@ G1000000000000000000000E /* CreatePrWizardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = G2000000000000000000000E /* CreatePrWizardView.swift */; }; G10000000000000000000027 /* PrTargetBranchPickerDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000027 /* PrTargetBranchPickerDropdown.swift */; }; G1000000000000000000000F /* PrMergeGateCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = G2000000000000000000000F /* PrMergeGateCard.swift */; }; - G10000000000000000000020 /* PrAiSummaryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000020 /* PrAiSummaryCard.swift */; }; G10000000000000000000021 /* PrCommitRailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000021 /* PrCommitRailView.swift */; }; G10000000000000000000023 /* PrStackDiagramView.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000023 /* PrStackDiagramView.swift */; }; G10000000000000000000024 /* PrRebaseScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000024 /* PrRebaseScreen.swift */; }; @@ -244,6 +245,7 @@ DD20000000000000000000B1 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = DD10000000000000000000B1 /* SwiftTerm */; }; A90000000000000000000001 /* ProductAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90000000000000000000011 /* ProductAnalytics.swift */; }; A90000000000000000000003 /* ProductAnalyticsPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90000000000000000000013 /* ProductAnalyticsPolicyTests.swift */; }; + A90000000000000000000004 /* PrGitHubDescriptionParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90000000000000000000014 /* PrGitHubDescriptionParserTests.swift */; }; A90100000000000000000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A90100000000000000000011 /* PrivacyInfo.xcprivacy */; }; A90200000000000000000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A90200000000000000000011 /* PrivacyInfo.xcprivacy */; }; A90300000000000000000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A90300000000000000000011 /* PrivacyInfo.xcprivacy */; }; @@ -484,6 +486,7 @@ F20000000000000000000006 /* SettingsDiagnosticsSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SettingsDiagnosticsSection.swift; path = ADE/Views/Settings/SettingsDiagnosticsSection.swift; sourceTree = ""; }; G20000000000000000000001 /* PrModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrModels.swift; path = ADE/Views/PRs/PrModels.swift; sourceTree = ""; }; G20000000000000000000002 /* PrHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrHelpers.swift; path = ADE/Views/PRs/PrHelpers.swift; sourceTree = ""; }; + G20000000000000000000032 /* PrGitHubDescriptionParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrGitHubDescriptionParser.swift; path = ADE/Views/PRs/PrGitHubDescriptionParser.swift; sourceTree = ""; }; G20000000000000000000003 /* PrListRowModifier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrListRowModifier.swift; path = ADE/Views/PRs/PrListRowModifier.swift; sourceTree = ""; }; G20000000000000000000004 /* PrFiltersCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrFiltersCard.swift; path = ADE/Views/PRs/PrFiltersCard.swift; sourceTree = ""; }; G20000000000000000000026 /* PrsRootScreenPreviews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrsRootScreenPreviews.swift; path = ADE/Views/PRs/PrsRootScreenPreviews.swift; sourceTree = ""; }; @@ -493,6 +496,7 @@ G20000000000000000000006 /* PrsRootScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrsRootScreen.swift; path = ADE/Views/PRs/PrsRootScreen.swift; sourceTree = ""; }; G20000000000000000000007 /* PrDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrDetailScreen.swift; path = ADE/Views/PRs/PrDetailScreen.swift; sourceTree = ""; }; G20000000000000000000008 /* PrDetailOverviewTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrDetailOverviewTab.swift; path = ADE/Views/PRs/PrDetailOverviewTab.swift; sourceTree = ""; }; + G20000000000000000000033 /* PrDetailHeaderComponents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrDetailHeaderComponents.swift; path = ADE/Views/PRs/PrDetailHeaderComponents.swift; sourceTree = ""; }; G20000000000000000000009 /* PrDetailFilesTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrDetailFilesTab.swift; path = ADE/Views/PRs/PrDetailFilesTab.swift; sourceTree = ""; }; G2000000000000000000000A /* PrDetailChecksTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrDetailChecksTab.swift; path = ADE/Views/PRs/PrDetailChecksTab.swift; sourceTree = ""; }; G2000000000000000000000B /* PrDetailActivityTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrDetailActivityTab.swift; path = ADE/Views/PRs/PrDetailActivityTab.swift; sourceTree = ""; }; @@ -501,7 +505,6 @@ G2000000000000000000000E /* CreatePrWizardView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CreatePrWizardView.swift; path = ADE/Views/PRs/CreatePrWizardView.swift; sourceTree = ""; }; G20000000000000000000027 /* PrTargetBranchPickerDropdown.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrTargetBranchPickerDropdown.swift; path = ADE/Views/PRs/PrTargetBranchPickerDropdown.swift; sourceTree = ""; }; G2000000000000000000000F /* PrMergeGateCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrMergeGateCard.swift; path = ADE/Views/PRs/PrMergeGateCard.swift; sourceTree = ""; }; - G20000000000000000000020 /* PrAiSummaryCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrAiSummaryCard.swift; path = ADE/Views/PRs/PrAiSummaryCard.swift; sourceTree = ""; }; G20000000000000000000021 /* PrCommitRailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrCommitRailView.swift; path = ADE/Views/PRs/PrCommitRailView.swift; sourceTree = ""; }; G20000000000000000000023 /* PrStackDiagramView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrStackDiagramView.swift; path = ADE/Views/PRs/PrStackDiagramView.swift; sourceTree = ""; }; G20000000000000000000024 /* PrRebaseScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrRebaseScreen.swift; path = ADE/Views/PRs/PrRebaseScreen.swift; sourceTree = ""; }; @@ -519,6 +522,7 @@ B5D5B5B87564C73F2FF34B0D /* KeychainService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = KeychainService.swift; path = ADE/Services/KeychainService.swift; sourceTree = ""; }; A90000000000000000000011 /* ProductAnalytics.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductAnalytics.swift; path = ADE/Services/ProductAnalytics.swift; sourceTree = ""; }; A90000000000000000000013 /* ProductAnalyticsPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProductAnalyticsPolicyTests.swift; path = ADETests/ProductAnalyticsPolicyTests.swift; sourceTree = ""; }; + A90000000000000000000014 /* PrGitHubDescriptionParserTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrGitHubDescriptionParserTests.swift; path = ADETests/PrGitHubDescriptionParserTests.swift; sourceTree = ""; }; A90100000000000000000011 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = ADE/PrivacyInfo.xcprivacy; sourceTree = ""; }; A90200000000000000000011 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = ADEWidgets/PrivacyInfo.xcprivacy; sourceTree = ""; }; A90300000000000000000011 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = ADEClip/PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -720,6 +724,7 @@ children = ( G20000000000000000000001 /* PrModels.swift */, G20000000000000000000002 /* PrHelpers.swift */, + G20000000000000000000032 /* PrGitHubDescriptionParser.swift */, G20000000000000000000003 /* PrListRowModifier.swift */, G20000000000000000000004 /* PrFiltersCard.swift */, G20000000000000000000005 /* PrRowCard.swift */, @@ -728,6 +733,7 @@ G20000000000000000000026 /* PrsRootScreenPreviews.swift */, G20000000000000000000007 /* PrDetailScreen.swift */, G20000000000000000000008 /* PrDetailOverviewTab.swift */, + G20000000000000000000033 /* PrDetailHeaderComponents.swift */, G20000000000000000000031 /* PrDetailOverviewPreviews.swift */, G20000000000000000000009 /* PrDetailFilesTab.swift */, G2000000000000000000000A /* PrDetailChecksTab.swift */, @@ -738,7 +744,6 @@ G20000000000000000000027 /* PrTargetBranchPickerDropdown.swift */, G2000000000000000000000F /* PrMergeGateCard.swift */, G20000000000000000000030 /* PrMergeChecklist.swift */, - G20000000000000000000020 /* PrAiSummaryCard.swift */, G20000000000000000000021 /* PrCommitRailView.swift */, G20000000000000000000023 /* PrStackDiagramView.swift */, G20000000000000000000024 /* PrRebaseScreen.swift */, @@ -1053,6 +1058,7 @@ FA000000000000000000C2C3 /* WorkAssistantRenderingTests.swift */, FA000000000000000000C2C4 /* WorkLiveRosterHydrationTests.swift */, A90000000000000000000013 /* ProductAnalyticsPolicyTests.swift */, + A90000000000000000000014 /* PrGitHubDescriptionParserTests.swift */, C5A00000000000000000000E /* SSHBootstrapTests.swift */, ); name = ADETests; @@ -1431,6 +1437,7 @@ F10000000000000000000006 /* SettingsDiagnosticsSection.swift in Sources */, G10000000000000000000001 /* PrModels.swift in Sources */, G10000000000000000000002 /* PrHelpers.swift in Sources */, + G10000000000000000000032 /* PrGitHubDescriptionParser.swift in Sources */, G10000000000000000000003 /* PrListRowModifier.swift in Sources */, G10000000000000000000004 /* PrFiltersCard.swift in Sources */, G10000000000000000000005 /* PrRowCard.swift in Sources */, @@ -1440,6 +1447,7 @@ G10000000000000000000006 /* PrsRootScreen.swift in Sources */, G10000000000000000000007 /* PrDetailScreen.swift in Sources */, G10000000000000000000008 /* PrDetailOverviewTab.swift in Sources */, + G10000000000000000000033 /* PrDetailHeaderComponents.swift in Sources */, G10000000000000000000009 /* PrDetailFilesTab.swift in Sources */, G1000000000000000000000A /* PrDetailChecksTab.swift in Sources */, G1000000000000000000000B /* PrDetailActivityTab.swift in Sources */, @@ -1449,7 +1457,6 @@ G10000000000000000000027 /* PrTargetBranchPickerDropdown.swift in Sources */, G1000000000000000000000F /* PrMergeGateCard.swift in Sources */, G10000000000000000000030 /* PrMergeChecklist.swift in Sources */, - G10000000000000000000020 /* PrAiSummaryCard.swift in Sources */, G10000000000000000000021 /* PrCommitRailView.swift in Sources */, G10000000000000000000023 /* PrStackDiagramView.swift in Sources */, G10000000000000000000024 /* PrRebaseScreen.swift in Sources */, @@ -1549,6 +1556,7 @@ FB000000000000000000C2C3 /* WorkAssistantRenderingTests.swift in Sources */, FB000000000000000000C2C4 /* WorkLiveRosterHydrationTests.swift in Sources */, A90000000000000000000003 /* ProductAnalyticsPolicyTests.swift in Sources */, + A90000000000000000000004 /* PrGitHubDescriptionParserTests.swift in Sources */, C5B00000000000000000000E /* SSHBootstrapTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index a14a0c70d..8ccf1af2d 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -3943,13 +3943,6 @@ struct PrDeployment: Codable, Identifiable, Equatable { var updatedAt: String? } -struct AiReviewSummary: Codable, Equatable { - var summary: String - var potentialIssues: [String] - var recommendations: [String] - var mergeReadiness: String -} - struct PullRequestSnapshotHydration: Codable, Equatable, Identifiable { var id: String { prId } var prId: String diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index f17bcb18d..9da7fd8c6 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -7040,14 +7040,6 @@ final class SyncService: ObservableObject { try await sendDecodableCommand(action: "prs.getDeployments", args: ["prId": prId], as: [PrDeployment].self) } - func fetchPullRequestAiSummary(prId: String, model: String? = nil) async throws -> AiReviewSummary { - var args: [String: Any] = ["prId": prId] - if let model, !model.isEmpty { - args["model"] = model - } - return try await sendDecodableCommand(action: "prs.aiReviewSummary", args: args, as: AiReviewSummary.self) - } - // MARK: - Durable PR in-flight action registry /// Register an in-flight PR action. Returns an opaque token that must be diff --git a/apps/ios/ADE/Views/PRs/PrAiSummaryCard.swift b/apps/ios/ADE/Views/PRs/PrAiSummaryCard.swift deleted file mode 100644 index 9d3548e5d..000000000 --- a/apps/ios/ADE/Views/PRs/PrAiSummaryCard.swift +++ /dev/null @@ -1,150 +0,0 @@ -import SwiftUI - -/// Derives a coarse risk tone (low/medium/high) from the AI review summary's -/// freeform `mergeReadiness` string. The desktop writes values like `ready`, -/// `needs_changes`, `blocked`, etc; we map those onto a 3-step risk scale. -private func prInferRiskTone(_ readiness: String?) -> (label: String, tint: Color) { - let raw = (readiness ?? "").lowercased() - if raw.contains("block") || raw.contains("high") { - return ("high risk", PrGlassPalette.danger) - } - if raw.contains("needs") || raw.contains("medium") || raw.contains("warn") || raw.contains("attention") { - return ("medium risk", PrGlassPalette.warning) - } - if raw.contains("ready") || raw.contains("low") { - return ("low risk", PrGlassPalette.success) - } - return ("summary", PrGlassPalette.purple) -} - -/// AI summary card on the PR Overview tab. Liquid-glass treatment: -/// purple-gradient icon disc with a soft halo, eyebrow label, body text, -/// and a tinted chip row. Parent owns the fetch; this view is pure -/// presentation + a regenerate CTA. -struct PrAiSummaryCard: View { - let summary: AiReviewSummary? - let additions: Int - let deletions: Int - let fileCount: Int - let isLoading: Bool - let isLive: Bool - let onRegenerate: () -> Void - - var body: some View { - let risk = prInferRiskTone(summary?.mergeReadiness) - return VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { - iconDisc - - VStack(alignment: .leading, spacing: 6) { - PrsEyebrowLabel(text: "AI REVIEW", tint: PrGlassPalette.purpleBright) - - if let summary, !summary.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(summary.summary) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(ADEColor.textPrimary) - .lineSpacing(3) - .fixedSize(horizontal: false, vertical: true) - } else if isLoading { - HStack(spacing: 8) { - ProgressView().tint(PrGlassPalette.purpleBright) - Text("Generating AI summary…") - .font(.system(size: 13)) - .foregroundStyle(ADEColor.textSecondary) - } - } else { - Text("No AI review summary has been generated yet.") - .font(.system(size: 13)) - .foregroundStyle(ADEColor.textSecondary) - } - } - - Spacer(minLength: 0) - } - - HStack(spacing: 6) { - PrAiSummaryChip(text: risk.label, tint: risk.tint) - PrAiSummaryChip(text: "+\(additions) / −\(deletions)", tint: PrGlassPalette.blue) - PrAiSummaryChip(text: "\(fileCount) file\(fileCount == 1 ? "" : "s")", tint: PrGlassPalette.purple) - Spacer(minLength: 0) - Button(action: onRegenerate) { - HStack(spacing: 4) { - Image(systemName: "arrow.clockwise") - .font(.system(size: 11, weight: .semibold)) - Text(summary == nil ? "Generate" : "Regenerate") - .font(.system(size: 11, weight: .semibold)) - } - .foregroundStyle(PrGlassPalette.purpleBright) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background( - Capsule(style: .continuous) - .fill(PrGlassPalette.purple.opacity(0.14)) - ) - .overlay( - Capsule(style: .continuous) - .strokeBorder(PrGlassPalette.purple.opacity(0.35), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - .disabled(!isLive || isLoading) - .opacity(isLive && !isLoading ? 1 : 0.5) - } - - if let summary, !summary.potentialIssues.isEmpty { - VStack(alignment: .leading, spacing: 5) { - ForEach(summary.potentialIssues.prefix(3), id: \.self) { issue in - HStack(alignment: .top, spacing: 7) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 9)) - .foregroundStyle(PrGlassPalette.warning) - .padding(.top, 3) - Text(issue) - .font(.system(size: 11.5)) - .foregroundStyle(ADEColor.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .padding(.top, 2) - } - } - } - - /// Flat accent icon disc. No blur halo, no continuous symbol animation — - /// both forced constant re-rendering inside the scrolling thread. - private var iconDisc: some View { - ZStack { - Circle() - .fill(PrGlassPalette.purple.opacity(0.14)) - .frame(width: 28, height: 28) - Circle() - .strokeBorder(PrGlassPalette.purple.opacity(0.35), lineWidth: 1) - .frame(width: 28, height: 28) - Image(systemName: "sparkles") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(PrGlassPalette.purple) - } - } -} - -private struct PrAiSummaryChip: View { - let text: String - let tint: Color - - var body: some View { - Text(text) - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(tint) - .padding(.horizontal, 9) - .padding(.vertical, 4) - .background( - Capsule(style: .continuous) - .fill(tint.opacity(0.16)) - ) - .overlay( - Capsule(style: .continuous) - .strokeBorder(tint.opacity(0.40), lineWidth: 0.5) - ) - } -} diff --git a/apps/ios/ADE/Views/PRs/PrDetailHeaderComponents.swift b/apps/ios/ADE/Views/PRs/PrDetailHeaderComponents.swift new file mode 100644 index 000000000..d2e999333 --- /dev/null +++ b/apps/ios/ADE/Views/PRs/PrDetailHeaderComponents.swift @@ -0,0 +1,446 @@ +import SwiftUI + +/// PR description rendered as the first card of the thread. +struct PrThreadDescriptionCard: View { + let author: String? + private let blocks: [PrGitHubDescriptionBlock] + @State private var expandedDisclosureIds: Set = [] + + init(author: String?, text: String) { + self.author = author + blocks = parsePrGitHubDescriptionBlocks(text) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Description") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(ADEColor.textPrimary) + Spacer(minLength: 0) + if let author, !author.isEmpty { + Text("@\(author)") + .font(.system(size: 12)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + } + + Divider().overlay(PrGlassPalette.cardBorder) + + ForEach(blocks) { block in + switch block { + case .markdown(_, let markdown): + PrMarkdownRenderer(markdown: markdown) + case .disclosure(let id, let title, let markdown): + DisclosureGroup(isExpanded: disclosureBinding(for: id)) { + PrMarkdownRenderer(markdown: markdown) + .padding(.top, 8) + } label: { + Text(title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .contentShape(Rectangle()) + } + .tint(ADEColor.textSecondary) + .accessibilityValue(expandedDisclosureIds.contains(id) ? "Expanded" : "Collapsed") + } + } + } + .padding(.horizontal, 4) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func disclosureBinding(for id: String) -> Binding { + Binding( + get: { expandedDisclosureIds.contains(id) }, + set: { expanded in + if expanded { + expandedDisclosureIds.insert(id) + } else { + expandedDisclosureIds.remove(id) + } + } + ) + } +} + +/// Compact mobile summary that replaces the old PR hero card. It keeps the +/// state/actions context above the thread without forcing a large title card at +/// the top of every detail screen. +struct PrDetailSummarySection: View { + let pr: PullRequestListItem + let snapshot: PullRequestSnapshot? + let mergeGate: PrMergeGateInfo + @Binding var commitsExpanded: Bool + let onChecksTap: () -> Void + let onFilesTap: () -> Void + let onCommitTap: (PrCommit) -> Void + + private var state: String { snapshot?.status?.state ?? pr.state } + private var stateTint: Color { prStateTint(state) } + private var checksStatus: String { snapshot?.status?.checksStatus ?? pr.checksStatus } + private var files: [PrFile] { snapshot?.files ?? [] } + private var commits: [PrCommit] { snapshot?.commits ?? [] } + + private var additions: Int { + files.isEmpty ? pr.additions : files.reduce(0) { $0 + $1.additions } + } + + private var deletions: Int { + files.isEmpty ? pr.deletions : files.reduce(0) { $0 + $1.deletions } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 8) { + PrTagChip(label: state.isEmpty ? "unknown" : state, color: stateTint) + Text(mergeGate.subline) + .font(.system(size: 12.5)) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.top, 13) + .padding(.bottom, 10) + + Divider().overlay(PrGlassPalette.cardBorder) + + HStack(spacing: 0) { + PrSummaryMetricButton( + title: "Checks", + value: prChecksLabel(checksStatus), + tint: prChecksTint(checksStatus), + action: onChecksTap + ) + + PrSummaryMetricDivider() + + PrSummaryMetricButton( + title: "Changes", + value: "\(files.count) file\(files.count == 1 ? "" : "s")", + detail: "+\(additions) / −\(deletions)", + tint: ADEColor.info, + action: onFilesTap + ) + + PrSummaryMetricDivider() + + PrSummaryMetricButton( + title: "Commits", + value: "\(commits.count) commit\(commits.count == 1 ? "" : "s")", + tint: ADEColor.accent, + isExpanded: commits.isEmpty ? nil : commitsExpanded, + action: { + guard !commits.isEmpty else { return } + withAnimation(.easeInOut(duration: 0.18)) { + commitsExpanded.toggle() + } + } + ) + } + + if commitsExpanded, !commits.isEmpty { + Divider().overlay(PrGlassPalette.cardBorder) + VStack(spacing: 0) { + ForEach(Array(commits.prefix(25).enumerated()), id: \.element.id) { index, commit in + PrSummaryCommitRow(commit: commit) { + onCommitTap(commit) + } + if index < min(commits.count, 25) - 1 { + Divider() + .padding(.leading, 14) + .overlay(PrGlassPalette.cardBorder) + } + } + if commits.count > 25 { + Divider() + .padding(.leading, 14) + .overlay(PrGlassPalette.cardBorder) + Text("+ \(commits.count - 25) older commits") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .prGlassCard(cornerRadius: 16) + } +} + +struct PrDetailSummarySkeleton: View { + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + ADESkeletonView(width: 58, height: 22, cornerRadius: 7) + ADESkeletonView(width: 190, height: 13, cornerRadius: 5) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 13) + + Divider().overlay(PrGlassPalette.cardBorder) + + HStack(spacing: 0) { + ForEach(0..<3, id: \.self) { index in + VStack(alignment: .leading, spacing: 6) { + ADESkeletonView(width: 48, height: 10, cornerRadius: 4) + ADESkeletonView(width: index == 1 ? 68 : 58, height: 14, cornerRadius: 4) + } + .frame(maxWidth: .infinity, minHeight: 58, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + + if index < 2 { + PrSummaryMetricDivider() + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .prGlassCard(cornerRadius: 16) + } +} + +private struct PrSummaryMetricButton: View { + let title: String + let value: String + var detail: String? + let tint: Color + var isExpanded: Bool? = nil + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + Spacer(minLength: 0) + if let isExpanded { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + } + Text(value) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.75) + if let detail, !detail.isEmpty { + Text(detail) + .font(.system(size: 10.5)) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, minHeight: 58, alignment: .topLeading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +private struct PrSummaryMetricDivider: View { + var body: some View { + Rectangle() + .fill(PrGlassPalette.cardBorder) + .frame(width: 1, height: 44) + } +} + +private struct PrSummaryCommitRow: View { + let commit: PrCommit + let action: () -> Void + + private var author: String? { + commit.authorLogin ?? commit.authorName + } + + private var message: String { + commit.message + .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init)? + .trimmingCharacters(in: .whitespacesAndNewlines) + ?? "Commit" + } + + private var shortSha: String { + commit.shortSha.isEmpty ? String(commit.sha.prefix(7)) : commit.shortSha + } + + var body: some View { + Button(action: action) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "smallcircle.filled.circle") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(prChecksTint(commit.checkStatus ?? "none")) + .frame(width: 15, height: 18) + VStack(alignment: .leading, spacing: 3) { + Text(message) + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + HStack(spacing: 7) { + Text(shortSha) + .font(.system(size: 10.5, weight: .semibold, design: .monospaced)) + .foregroundStyle(ADEColor.accent) + if let author, !author.isEmpty { + Text(author) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + Spacer(minLength: 0) + Text(prCompactRelativeTime(commit.committedDate)) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + } + } + Image(systemName: "arrow.down.to.line.compact") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + .padding(.top, 2) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +/// Amber banner shown at the top of the thread when the PR is not mapped to an +/// ADE lane. Primary action is auto-map ("Create lane from PR branch", gated on +/// host support); mapping to an existing lane and opening GitHub stay available +/// without leaving the full detail view. +struct PrUnmappedThreadBanner: View { + let canAutoMap: Bool + let canMap: Bool + @Binding var isExpanded: Bool + let onAutoMap: () -> Void + let onMap: () -> Void + let onOpenInGitHub: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.warning) + Text("Not mapped to a lane") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textPrimary) + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .padding(.horizontal, 13) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Not mapped to a lane") + .accessibilityValue(isExpanded ? "Expanded" : "Collapsed") + .accessibilityHint(isExpanded ? "Collapses lane mapping actions" : "Shows lane mapping actions") + + if isExpanded { + Divider().overlay(ADEColor.warning.opacity(0.22)) + + VStack(alignment: .leading, spacing: 0) { + Text("Connect this GitHub branch to an ADE lane to unlock local actions.") + .font(.system(size: 12)) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 13) + .padding(.vertical, 10) + + if canAutoMap { + PrUnmappedActionRow( + title: "Create lane from PR branch", + systemImage: "arrow.triangle.branch", + tint: ADEColor.accent, + action: onAutoMap + ) + } + + if canMap { + PrUnmappedActionRow( + title: "Map to existing lane", + systemImage: "link", + tint: ADEColor.accent, + action: onMap + ) + } + + PrUnmappedActionRow( + title: "Open in GitHub", + systemImage: "arrow.up.right.square", + trailingSystemImage: "arrow.up.right", + tint: ADEColor.textSecondary, + action: onOpenInGitHub + ) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(ADEColor.warning.opacity(0.06), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .strokeBorder(ADEColor.warning.opacity(0.24), lineWidth: 0.7) + ) + } +} + +private struct PrUnmappedActionRow: View { + let title: String + let systemImage: String + var trailingSystemImage = "chevron.right" + let tint: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 18) + Text(title) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(ADEColor.textPrimary) + Spacer(minLength: 0) + Image(systemName: trailingSystemImage) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + } + .padding(.horizontal, 13) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/apps/ios/ADE/Views/PRs/PrDetailOverviewPreviews.swift b/apps/ios/ADE/Views/PRs/PrDetailOverviewPreviews.swift index e65a6b8f3..3ae3d5b8e 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailOverviewPreviews.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailOverviewPreviews.swift @@ -107,7 +107,19 @@ private enum PrDetailPreviewFixtures { static let detail = PrDetail( prId: "pr-fixture", - body: "Rebuilds the mobile PR detail view: flat adaptive palette, per-row list virtualization, and `desktop` merge-rail parity.", + body: """ + Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 3 to 4. + +
+ Release notes +

v4.0.0

+

What's changed

+ +
+ """, assignees: [PrUser(login: "arul28", avatarUrl: nil)], author: PrUser(login: "arul28", avatarUrl: nil), isDraft: false, @@ -125,6 +137,57 @@ private enum PrDetailPreviewFixtures { PrReview(reviewer: "codex", state: "approved", body: nil, submittedAt: iso(minutesAgo: 55)), ] + static let pr = PullRequestListItem( + id: "pr-fixture", + laneId: "lane-prs-mobile", + laneName: "PRs mobile polish", + projectId: "project-ade", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 855, + githubUrl: "https://github.com/arul28/ADE/pull/855", + title: "build(deps): bump dorny/paths-filter from 3 to 4", + state: "open", + baseBranch: "main", + headBranch: "dependabot/github_actions/dorny/paths-filter-4", + checksStatus: "passing", + reviewStatus: "requested", + additions: 3, + deletions: 1, + lastSyncedAt: nil, + createdAt: iso(minutesAgo: 60 * 24), + updatedAt: iso(minutesAgo: 60 * 24), + adeKind: "single", + linkedGroupId: nil, + linkedGroupType: nil, + linkedGroupName: nil, + linkedGroupPosition: nil, + linkedGroupCount: 0, + workflowDisplayState: nil, + cleanupState: nil + ) + + static let snapshot = PullRequestSnapshot( + detail: detail, + status: PrStatus( + prId: pr.id, + state: "open", + checksStatus: "passing", + reviewStatus: "requested", + isMergeable: true, + mergeConflicts: false, + behindBaseBy: 0, + reviewDecision: .reviewRequired, + approvalsCount: 0, + requiredApprovals: 1 + ), + checks: checks, + reviews: reviews, + comments: [], + files: files, + commits: commits + ) + static let unresolvedThread = PrReviewThread( id: "thread-1", isResolved: false, @@ -182,20 +245,23 @@ private enum PrDetailPreviewFixtures { private struct PrDetailOverviewPreviewScreen: View { @State private var replyDraft = "" @State private var commentInput = "" + @State private var commitsExpanded = false var body: some View { List { - PrAiSummaryCard( - summary: nil, - additions: 698, - deletions: 559, - fileCount: PrDetailPreviewFixtures.files.count, - isLoading: false, - isLive: true, - onRegenerate: {} + PrDetailSummarySection( + pr: PrDetailPreviewFixtures.pr, + snapshot: PrDetailPreviewFixtures.snapshot, + mergeGate: PrMergeGateInfo( + tone: .amber, + subline: "0/1 approvals · 1 approval needed", + target: .reviews + ), + commitsExpanded: $commitsExpanded, + onChecksTap: {}, + onFilesTap: {}, + onCommitTap: { _ in } ) - .padding(14) - .prGlassCard(cornerRadius: 16) .prListRow() PrThreadDescriptionCard( @@ -261,6 +327,30 @@ private struct PrDetailOverviewPreviewScreen: View { } } +private struct PrUnmappedBannerPreviewScreen: View { + @State private var expanded = true + + var body: some View { + VStack(spacing: 20) { + PrUnmappedThreadBanner( + canAutoMap: true, + canMap: true, + isExpanded: $expanded, + onAutoMap: {}, + onMap: {}, + onOpenInGitHub: {} + ) + + Text(expanded ? "Expanded" : "Collapsed") + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(prLiquidGlassBackdrop().ignoresSafeArea()) + } +} + #Preview("PR detail · Overview thread") { PrDetailOverviewPreviewScreen() .preferredColorScheme(.dark) @@ -270,3 +360,8 @@ private struct PrDetailOverviewPreviewScreen: View { PrDetailOverviewPreviewScreen() .preferredColorScheme(.light) } + +#Preview("PR detail · Unmapped banner") { + PrUnmappedBannerPreviewScreen() + .preferredColorScheme(.dark) +} diff --git a/apps/ios/ADE/Views/PRs/PrDetailOverviewTab.swift b/apps/ios/ADE/Views/PRs/PrDetailOverviewTab.swift index 2d120f25b..d9640e318 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailOverviewTab.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailOverviewTab.swift @@ -9,7 +9,7 @@ import UIKit // row components that `PrDetailScreen.overviewThreadRows` emits as SIBLING // List rows: // -// unmapped banner → AI summary → description → chronological event feed → +// unmapped banner → description → chronological event feed → // review threads → composer → merge rail (with requirement checklist) → // metadata cards (checks / commits / files / people / stack / cleanup). // @@ -38,352 +38,6 @@ struct PrThreadSectionHeader: View { } } -/// PR description rendered as the first card of the thread. -struct PrThreadDescriptionCard: View { - let author: String? - let text: String - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { - Image(systemName: "text.alignleft") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(ADEColor.accent) - Text("DESCRIPTION") - .font(.system(size: 10, weight: .bold)) - .tracking(1.0) - .foregroundStyle(ADEColor.textSecondary) - Spacer(minLength: 0) - if let author, !author.isEmpty { - Text("@\(author)") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - } - } - PrMarkdownRenderer(markdown: text) - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .prGlassCard(cornerRadius: 16) - } -} - -/// Compact mobile summary that replaces the old PR hero card. It keeps the -/// state/actions context above the thread without forcing a large title card at -/// the top of every detail screen. -struct PrDetailSummarySection: View { - let pr: PullRequestListItem - let snapshot: PullRequestSnapshot? - let mergeGate: PrMergeGateInfo - @Binding var commitsExpanded: Bool - let onStatusTap: () -> Void - let onChecksTap: () -> Void - let onFilesTap: () -> Void - let onCommitTap: (PrCommit) -> Void - - private var state: String { snapshot?.status?.state ?? pr.state } - private var stateTint: Color { prStateTint(state) } - private var checksStatus: String { snapshot?.status?.checksStatus ?? pr.checksStatus } - private var files: [PrFile] { snapshot?.files ?? [] } - private var commits: [PrCommit] { snapshot?.commits ?? [] } - - private var additions: Int { - files.isEmpty ? pr.additions : files.reduce(0) { $0 + $1.additions } - } - - private var deletions: Int { - files.isEmpty ? pr.deletions : files.reduce(0) { $0 + $1.deletions } - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 8) { - PrTagChip(label: state.isEmpty ? "unknown" : state, color: stateTint) - Text(mergeGate.subline) - .font(.system(size: 11.5, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(2) - Spacer(minLength: 0) - } - .padding(.horizontal, 14) - .padding(.top, 13) - .padding(.bottom, 10) - - Divider().overlay(PrGlassPalette.cardBorder) - - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 0), - GridItem(.flexible(), spacing: 0), - ], - spacing: 0 - ) { - PrSummaryMetricButton( - title: "Status", - value: titleCase(state), - icon: "arrow.triangle.pull", - tint: stateTint, - action: onStatusTap - ) - PrSummaryMetricButton( - title: "Checks", - value: prChecksLabel(checksStatus), - icon: "checklist.checked", - tint: prChecksTint(checksStatus), - action: onChecksTap - ) - PrSummaryMetricButton( - title: "Diff", - value: "\(files.count) file\(files.count == 1 ? "" : "s")", - detail: "+\(additions) / -\(deletions)", - icon: "doc.text.magnifyingglass", - tint: ADEColor.info, - action: onFilesTap - ) - PrSummaryMetricButton( - title: "Commits", - value: "\(commits.count)", - detail: commits.count == 1 ? "commit" : "commits", - icon: "point.topleft.down.curvedto.point.bottomright.up", - tint: ADEColor.accent, - action: { - withAnimation(.easeInOut(duration: 0.18)) { - commitsExpanded.toggle() - } - } - ) - } - - if !commits.isEmpty { - Divider().overlay(PrGlassPalette.cardBorder) - Button { - withAnimation(.easeInOut(duration: 0.18)) { - commitsExpanded.toggle() - } - } label: { - HStack(spacing: 8) { - Text("Commits") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(ADEColor.textPrimary) - Spacer(minLength: 0) - Text(commitsExpanded ? "Hide" : "Show") - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - .rotationEffect(.degrees(commitsExpanded ? 90 : 0)) - } - .padding(.horizontal, 14) - .padding(.vertical, 11) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if commitsExpanded { - VStack(spacing: 0) { - ForEach(Array(commits.prefix(25).enumerated()), id: \.element.id) { index, commit in - PrSummaryCommitRow(commit: commit) { - onCommitTap(commit) - } - if index < min(commits.count, 25) - 1 { - Divider() - .padding(.leading, 14) - .overlay(PrGlassPalette.cardBorder) - } - } - if commits.count > 25 { - Divider() - .padding(.leading, 14) - .overlay(PrGlassPalette.cardBorder) - Text("+ \(commits.count - 25) older commits") - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 14) - .padding(.vertical, 10) - } - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .prGlassCard(cornerRadius: 16) - } -} - -private struct PrSummaryMetricButton: View { - let title: String - let value: String - var detail: String? - let icon: String - let tint: Color - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(alignment: .top, spacing: 9) { - Image(systemName: icon) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(tint) - .frame(width: 17, height: 18) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - Text(value) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.8) - if let detail, !detail.isEmpty { - Text(detail) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - } - } - Spacer(minLength: 0) - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -private struct PrSummaryCommitRow: View { - let commit: PrCommit - let action: () -> Void - - private var author: String? { - commit.authorLogin ?? commit.authorName - } - - private var message: String { - commit.message - .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) - .first - .map(String.init)? - .trimmingCharacters(in: .whitespacesAndNewlines) - ?? "Commit" - } - - private var shortSha: String { - commit.shortSha.isEmpty ? String(commit.sha.prefix(7)) : commit.shortSha - } - - var body: some View { - Button(action: action) { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "smallcircle.filled.circle") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(prChecksTint(commit.checkStatus ?? "none")) - .frame(width: 15, height: 18) - VStack(alignment: .leading, spacing: 3) { - Text(message) - .font(.system(size: 12.5, weight: .medium)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(2) - HStack(spacing: 7) { - Text(shortSha) - .font(.system(size: 10.5, weight: .semibold, design: .monospaced)) - .foregroundStyle(ADEColor.accent) - if let author, !author.isEmpty { - Text(author) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - } - Spacer(minLength: 0) - Text(prCompactRelativeTime(commit.committedDate)) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - } - } - Image(systemName: "arrow.down.to.line.compact") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - .padding(.top, 2) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -/// Amber banner shown at the top of the thread when the PR is not mapped to an -/// ADE lane. Primary action is auto-map ("Create lane from PR branch", gated on -/// host support); mapping to an existing lane and opening GitHub stay available -/// without leaving the full detail view. -struct PrUnmappedThreadBanner: View { - let canAutoMap: Bool - let canMap: Bool - let onAutoMap: () -> Void - let onMap: () -> Void - let onOpenInGitHub: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 14)) - .foregroundStyle(ADEColor.warning) - .padding(.top, 1) - VStack(alignment: .leading, spacing: 3) { - Text("Not mapped to a lane") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text("Create a lane from this PR's branch to track and act on it inside ADE.") - .font(.system(size: 11.5)) - .foregroundStyle(ADEColor.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 0) - } - - if canAutoMap { - Button(action: onAutoMap) { - Label("Create lane from PR branch", systemImage: "arrow.triangle.branch") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity, minHeight: 44) - .background(ADEColor.accentDeep, in: RoundedRectangle(cornerRadius: 11, style: .continuous)) - } - .buttonStyle(.plain) - } - - HStack(spacing: 8) { - if canMap { - Button(action: onMap) { - Label("Map to lane", systemImage: "link") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.accent) - .frame(maxWidth: .infinity, minHeight: 44) - .background(ADEColor.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) - } - .buttonStyle(.plain) - } - Button(action: onOpenInGitHub) { - Label("Open in GitHub", systemImage: "arrow.up.right.square") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - .frame(maxWidth: .infinity, minHeight: 44) - .background(ADEColor.textMuted.opacity(0.10), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) - } - .buttonStyle(.plain) - } - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .prGlassCard(cornerRadius: 16, tint: ADEColor.warning, shadow: false) - } -} /// Locked comment composer shown when the PR is unmapped (desktop parity). struct PrLockedComposerBar: View { diff --git a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift index 205b97511..98ec5fbae 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift @@ -31,7 +31,6 @@ struct PrDetailView: View { @State private var actionRuns: [PrActionRun] = [] @State private var activityEvents: [PrActivityEvent] = [] @State private var deployments: [PrDeployment] = [] - @State private var aiSummary: AiReviewSummary? @State private var groupMembers: [PrGroupMemberSummary] = [] @State private var capabilities: PrActionCapabilities? @State private var unavailableDetailParts: [String] = [] @@ -55,6 +54,8 @@ struct PrDetailView: View { @State private var summaryCommitsExpanded = false @State private var pendingTimelineScrollId: String? @State private var timelineSectionMounted = false + @SceneStorage("ade.prs.expandedUnmappedPrIds") + private var expandedUnmappedPrIdsRaw = "" // MARK: - Derived models (computed off the render path) // @@ -94,16 +95,35 @@ struct PrDetailView: View { /// Main action funnel key (merge/close/reopen/comment/edit/etc.). private var detailActionKey: String { "pr-detail:\(prId)" } - /// AI review-summary regeneration key. - private var aiSummaryKey: String { "pr-ai-summary:\(prId)" } - /// In-flight label of the main detail action, if any (durable across tab /// switches via the service registry). private var detailBusyLabel: String? { syncService.prActionLabel(forKey: detailActionKey) } private var isDetailBusy: Bool { detailBusyLabel != nil } - private var isAiSummaryLoading: Bool { syncService.isPrActionInFlight(key: aiSummaryKey) } + + private var unmappedBannerExpanded: Binding { + Binding( + get: { expandedUnmappedPrIds.contains(prId) }, + set: { expanded in + var ids = expandedUnmappedPrIds + if expanded { + ids.insert(prId) + } else { + ids.remove(prId) + } + expandedUnmappedPrIdsRaw = ids.sorted().joined(separator: "\n") + } + ) + } + + private var expandedUnmappedPrIds: Set { + Set( + expandedUnmappedPrIdsRaw + .split(separator: "\n") + .map(String.init) + ) + } private var prsStatus: SyncDomainStatus { syncService.status(for: .prs) @@ -539,7 +559,7 @@ struct PrDetailView: View { } if isAwaitingInitialPrDetail { - ADECardSkeleton(rows: 4) + PrDetailSummarySkeleton() .prListRow() } else if isPrDetailUnavailable { ADENoticeCard( @@ -559,7 +579,6 @@ struct PrDetailView: View { snapshot: snapshot, mergeGate: mergeGateInfo, commitsExpanded: $summaryCommitsExpanded, - onStatusTap: { selectedTab = .overview }, onChecksTap: { selectedTab = .checks }, onFilesTap: { selectedTab = .files }, onCommitTap: focusCommitInTimeline @@ -597,9 +616,10 @@ struct PrDetailView: View { .prListRow() } } - } + } .listStyle(.plain) .listRowSpacing(12) + .contentMargins(.bottom, 88, for: .scrollContent) .scrollContentBackground(.hidden) .background(prLiquidGlassBackdrop().ignoresSafeArea()) .adeNavigationGlass() @@ -618,7 +638,6 @@ struct PrDetailView: View { .refreshable { await reload(refreshRemote: true) } - .adeNavigationZoomTransition(id: transitionNamespace == nil ? nil : "pr-container-\(prId)", in: transitionNamespace) .task(id: "\(syncService.prsProjectionRevision):\(syncService.prsRemoteRevision)") { // Seed from the warm cache first so an instant render is shown and, when // the cached entry is fresh, `hasLoadedLiveSidecars` is set BEFORE the @@ -793,6 +812,10 @@ struct PrDetailView: View { .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) .truncationMode(.tail) + .adeMatchedGeometry( + id: transitionNamespace == nil ? nil : "pr-title-\(prId)", + in: transitionNamespace + ) Text(detailHeaderMetaText) .font(.system(size: 10.5, weight: .medium, design: .monospaced)) .foregroundStyle(ADEColor.textSecondary) @@ -964,8 +987,8 @@ struct PrDetailView: View { // MARK: - Overview thread rows (desktop Timeline+Rails, stacked for mobile) // - // Desktop reading order adapted to one column: AI summary → description → - // chronological event feed → review threads → composer → merge rail → + // Desktop reading order adapted to one column: description → chronological + // event feed → review threads → composer → merge rail → // metadata cards (checks / commits / files / people / stack). Every card is // its own List row. @ViewBuilder @@ -974,6 +997,7 @@ struct PrDetailView: View { PrUnmappedThreadBanner( canAutoMap: canAutoMapCurrentPr, canMap: canMapCurrentPr, + isExpanded: unmappedBannerExpanded, onAutoMap: autoMapCurrentPr, onMap: { if let githubItem { @@ -985,24 +1009,6 @@ struct PrDetailView: View { .prListRow() } - // AI summary — desktop pins it at the top of the timeline. +/- totals come - // from the snapshot's file list (same source as the Files card) so the two - // never disagree; the PR-row totals are only a fallback when files haven't - // synced yet. - let summaryFiles = snapshot?.files ?? [] - PrAiSummaryCard( - summary: aiSummary, - additions: summaryFiles.isEmpty ? currentPr.additions : summaryFiles.reduce(0) { $0 + $1.additions }, - deletions: summaryFiles.isEmpty ? currentPr.deletions : summaryFiles.reduce(0) { $0 + $1.deletions }, - fileCount: summaryFiles.count, - isLoading: isAiSummaryLoading, - isLive: canRunPrActions, - onRegenerate: refreshAiSummary - ) - .padding(14) - .prGlassCard(cornerRadius: 16) - .prListRow() - // Description (PR body) — the first card of the chronological thread. let descriptionText = (snapshot?.detail?.body ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if !descriptionText.isEmpty { @@ -1142,10 +1148,6 @@ struct PrDetailView: View { .prListRow() } - Color.clear - .frame(height: 88) - .accessibilityHidden(true) - .prListRow() } private func timelineAnchorId(for item: PrTimelineDisplayItem) -> String { @@ -1427,10 +1429,9 @@ struct PrDetailView: View { activityEvents = mobileDetail.activity } unavailableDetailParts = mobileDetail.unavailableParts - deployments = [] - aiSummary = nil - capabilities = nil - groupMembers = [] + // The aggregate GitHub payload does not include deployments, + // capabilities, or group membership. Preserve any enrichment already + // loaded for this route instead of treating absent fields as empty. // Partial failures retain the last good values above and use the // normal 25-second freshness window as retry backoff. Explicit Retry // still bypasses the gate immediately. @@ -1475,7 +1476,6 @@ struct PrDetailView: View { let actionRunsTask = shouldFetchLiveSidecars ? Task { try? await syncService.fetchPullRequestActionRuns(prId: sidecarPrId) } : nil let activityTask = shouldFetchLiveSidecars ? Task { try? await syncService.fetchPullRequestActivity(prId: sidecarPrId) } : nil let deploymentsTask = shouldFetchLiveSidecars ? Task { try? await syncService.fetchPullRequestDeployments(prId: sidecarPrId) } : nil - let aiSummaryTask = shouldFetchLiveSidecars ? Task { try? await syncService.fetchPullRequestAiSummary(prId: sidecarPrId) } : nil if let reviewThreadsTask { reviewThreads = await reviewThreadsTask.value ?? [] } @@ -1488,9 +1488,6 @@ struct PrDetailView: View { if let deploymentsTask { deployments = await deploymentsTask.value ?? [] } - if let summary = await aiSummaryTask?.value { - aiSummary = summary - } if let capabilitiesTask { capabilities = await capabilitiesTask.value } @@ -1536,7 +1533,6 @@ struct PrDetailView: View { actionRuns = entry.actionRuns activityEvents = entry.activityEvents deployments = entry.deployments - aiSummary = entry.aiSummary groupMembers = entry.groupMembers capabilities = entry.capabilities unavailableDetailParts = entry.unavailableParts @@ -1566,7 +1562,6 @@ struct PrDetailView: View { actionRuns: actionRuns, activityEvents: activityEvents, deployments: deployments, - aiSummary: aiSummary, groupMembers: groupMembers, capabilities: capabilities, unavailableParts: unavailableDetailParts, @@ -1703,23 +1698,6 @@ struct PrDetailView: View { runPrAction("Re-running checks") { try await syncService.rerunPullRequestChecks(prId: effectivePrId) } } - private func refreshAiSummary() { - guard canRunPrActions, !isAiSummaryLoading else { return } - let service = syncService - let key = aiSummaryKey - let actionablePrId = effectivePrId - let token = service.beginPrAction(key: key, label: "Regenerating AI summary") - Task { @MainActor in - defer { service.endPrAction(key: key, token: token) } - do { - let summary = try await service.fetchPullRequestAiSummary(prId: actionablePrId) - aiSummary = summary - } catch { - errorMessage = error.localizedDescription - } - } - } - private func submitComment() { let trimmed = commentInput.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } diff --git a/apps/ios/ADE/Views/PRs/PrGitHubDescriptionParser.swift b/apps/ios/ADE/Views/PRs/PrGitHubDescriptionParser.swift new file mode 100644 index 000000000..bdd3044f6 --- /dev/null +++ b/apps/ios/ADE/Views/PRs/PrGitHubDescriptionParser.swift @@ -0,0 +1,578 @@ +import Foundation + +enum PrGitHubDescriptionBlock: Identifiable, Equatable { + case markdown(id: String, markdown: String) + case disclosure(id: String, title: String, markdown: String) + + var id: String { + switch self { + case .markdown(let id, _), .disclosure(let id, _, _): + return id + } + } +} + +private enum PrGitHubDescriptionRegex { + static let summary = expression( + #"]*>(.*?)"#, + options: [.caseInsensitive, .dotMatchesLineSeparators] + ) + static let scriptOrStyle = htmlExpression( + #"<(?:script|style)\b[^>]*>.*?"# + ) + static let code = htmlExpression(#"]*>(.*?)"#) + static let strong = htmlExpression(#"<(?:strong|b)\b[^>]*>(.*?)"#) + static let emphasis = htmlExpression(#"<(?:em|i)\b[^>]*>(.*?)"#) + static let link = htmlExpression(#"]*\bhref\s*=\s*["']([^"']+)["'][^>]*>(.*?)"#) + static let heading = htmlExpression(#"]*>(.*?)"#) + static let listItem = htmlExpression(#"]*>(.*?)"#) + static let image = htmlExpression(#"]*\balt\s*=\s*["']([^"']+)["'][^>]*>"#) + static let lineBreak = expression(#""#, options: [.caseInsensitive]) + static let paragraph = expression(#"]*>"#, options: [.caseInsensitive]) + static let list = expression(#"]*>"#, options: [.caseInsensitive]) + static let blockquoteOpen = expression(#"]*>"#, options: [.caseInsensitive]) + static let blockquoteClose = expression(#""#, options: [.caseInsensitive]) + static let horizontalRule = expression(#""#, options: [.caseInsensitive]) + static let anyTag = expression( + #"])(?:[^>"']|"[^"]*"|'[^']*')*>"#, + options: [.caseInsensitive] + ) + static let unsafeAngleExpression = expression( + #"<(?:javascript|data|vbscript)\s*:[^>\n]*>"#, + options: [.caseInsensitive] + ) + static let repeatedBlankLines = expression(#"\n{3,}"#) + static let inlineCode = expression(#"(`+)[^\n]*?\1"#) + static let safeAutolink = expression( + #"<(?:https?://[^\s<>]+|[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)+)>"#, + options: [.caseInsensitive] + ) + static let fenceOpening = expression( + #"^[ \t]*(`{3,}|~{3,})[^\n]*(?:\n|$)"#, + options: [.anchorsMatchLines] + ) + static let fenceClosing = expression( + #"^[ \t]*(`{3,}|~{3,})[ \t]*(?=\n|$)"#, + options: [.anchorsMatchLines] + ) + static let numericEntity = htmlExpression(#"&#(x?[0-9a-f]+);"#) + + private static func htmlExpression(_ pattern: String) -> NSRegularExpression { + expression(pattern, options: [.caseInsensitive, .dotMatchesLineSeparators]) + } + + private static func expression( + _ pattern: String, + options: NSRegularExpression.Options = [] + ) -> NSRegularExpression { + // All patterns are compile-time literals covered by parser tests. + try! NSRegularExpression(pattern: pattern, options: options) + } +} + +private struct PrGitHubDetailsRange { + let full: NSRange + let inner: NSRange +} + +private struct PrGitHubDetailsTag { + enum Kind { + case opening + case closing + } + + let kind: Kind + let range: NSRange +} + +/// GitHub PR bodies are Markdown with a small amount of embedded HTML. Apple's +/// Markdown parser treats those tags as literal prose, so Dependabot release +/// notes can render as a wall of `
`, `
  • `, and `` tags. Split +/// details blocks into native disclosures and normalize the remaining safe +/// GitHub HTML into Markdown before it reaches the shared renderer. +func parsePrGitHubDescriptionBlocks(_ text: String) -> [PrGitHubDescriptionBlock] { + let protectedMarkdown = prProtectMarkdownSyntax(in: normalizePrMarkdownText(text)) + let source = protectedMarkdown.text + + func normalizeFragment(_ fragment: String) -> String { + prRestoreMarkdownSyntax( + in: normalizePrGitHubHtmlFragment(fragment), + segments: protectedMarkdown.segments + ) + } + + let nsSource = source as NSString + let matches = prGitHubDetailsRanges(in: nsSource) + guard !matches.isEmpty else { + return [.markdown(id: "description-markdown-0", markdown: normalizeFragment(source))] + } + + var blocks: [PrGitHubDescriptionBlock] = [] + var cursor = 0 + + func appendMarkdown(_ raw: String) { + let markdown = normalizeFragment(raw) + guard !markdown.isEmpty else { return } + blocks.append(.markdown(id: "description-markdown-\(blocks.count)", markdown: markdown)) + } + + for match in matches { + if match.full.location > cursor { + appendMarkdown(nsSource.substring(with: NSRange( + location: cursor, + length: match.full.location - cursor + ))) + } + + let inner = nsSource.substring(with: match.inner) + let nsInner = inner as NSString + let summaryMatch = PrGitHubDescriptionRegex.summary.firstMatch( + in: inner, + range: NSRange(location: 0, length: nsInner.length) + ) + let title: String + let body: String + if let summaryMatch, summaryMatch.range.location != NSNotFound { + let titleRange = summaryMatch.range(at: 1) + title = titleRange.location == NSNotFound + ? "Details" + : prRestoreMarkdownSyntax( + in: prGitHubHtmlPlainText(nsInner.substring(with: titleRange)), + segments: protectedMarkdown.segments + ) + let mutableBody = NSMutableString(string: inner) + mutableBody.replaceCharacters(in: summaryMatch.range, with: "") + body = mutableBody as String + } else { + title = "Details" + body = inner + } + + let markdown = normalizeFragment(body) + if !markdown.isEmpty { + blocks.append(.disclosure( + id: "description-disclosure-\(blocks.count)", + title: title.isEmpty ? "Details" : title, + markdown: markdown + )) + } + cursor = NSMaxRange(match.full) + } + + if cursor < nsSource.length { + appendMarkdown(nsSource.substring(from: cursor)) + } + + return blocks.isEmpty + ? [.markdown(id: "description-markdown-0", markdown: normalizeFragment(source))] + : blocks +} + +private func prGitHubDetailsRanges(in source: NSString) -> [PrGitHubDetailsRange] { + var ranges: [PrGitHubDetailsRange] = [] + var cursor = 0 + var depth = 0 + var outerStart = 0 + var innerStart = 0 + + while let tag = prNextGitHubDetailsTag(in: source, from: cursor) { + switch tag.kind { + case .opening: + if depth == 0 { + outerStart = tag.range.location + innerStart = NSMaxRange(tag.range) + } + depth += 1 + case .closing: + guard depth > 0 else { + cursor = NSMaxRange(tag.range) + continue + } + depth -= 1 + if depth == 0 { + ranges.append(PrGitHubDetailsRange( + full: NSRange( + location: outerStart, + length: NSMaxRange(tag.range) - outerStart + ), + inner: NSRange( + location: innerStart, + length: tag.range.location - innerStart + ) + )) + } + } + cursor = NSMaxRange(tag.range) + } + + return ranges +} + +private func prNextGitHubDetailsTag( + in source: NSString, + from start: Int +) -> PrGitHubDetailsTag? { + var cursor = start + while cursor < source.length { + let openingBracket = source.range( + of: "<", + options: [], + range: NSRange(location: cursor, length: source.length - cursor) + ) + guard openingBracket.location != NSNotFound else { return nil } + + var nameStart = NSMaxRange(openingBracket) + let isClosing = nameStart < source.length && source.character(at: nameStart) == 47 + if isClosing { + nameStart += 1 + } + + let nameLength = 7 + let nameEnd = nameStart + nameLength + guard nameEnd <= source.length, + source.substring(with: NSRange(location: nameStart, length: nameLength)) + .caseInsensitiveCompare("details") == .orderedSame, + nameEnd == source.length || prIsGitHubHtmlTagBoundary(source.character(at: nameEnd)) + else { + cursor = NSMaxRange(openingBracket) + continue + } + + var tagEnd = nameEnd + var quote: unichar? + while tagEnd < source.length { + let character = source.character(at: tagEnd) + if let activeQuote = quote { + if character == activeQuote { + quote = nil + } + } else if character == 34 || character == 39 { + quote = character + } else if character == 62 { + return PrGitHubDetailsTag( + kind: isClosing ? .closing : .opening, + range: NSRange( + location: openingBracket.location, + length: tagEnd - openingBracket.location + 1 + ) + ) + } + tagEnd += 1 + } + + return nil + } + return nil +} + +private func prIsGitHubHtmlTagBoundary(_ character: unichar) -> Bool { + character == 9 + || character == 10 + || character == 12 + || character == 13 + || character == 32 + || character == 47 + || character == 62 +} + +func normalizePrGitHubHtmlFragment(_ source: String) -> String { + let protectedMarkdown = prProtectMarkdownSyntax(in: normalizePrMarkdownText(source)) + var value = protectedMarkdown.text + + value = prReplaceHtmlMatches( + in: value, + regex: PrGitHubDescriptionRegex.scriptOrStyle + ) { _, _ in "" } + value = prReplaceHtmlMatches( + in: value, + regex: PrGitHubDescriptionRegex.code + ) { match, original in + let inner = prHtmlCapture(match, in: original, index: 1) + let code = prDecodeHtmlEntities(prStripHtmlTags(inner)) + .replacingOccurrences(of: "`", with: "\\`") + return code.isEmpty ? "" : "`\(code)`" + } + value = prReplaceHtmlMatches( + in: value, + regex: PrGitHubDescriptionRegex.strong + ) { match, original in + let inner = prDecodeHtmlEntities(prStripHtmlTags(prHtmlCapture(match, in: original, index: 1))) + return inner.isEmpty ? "" : "**\(inner)**" + } + value = prReplaceHtmlMatches( + in: value, + regex: PrGitHubDescriptionRegex.emphasis + ) { match, original in + let inner = prDecodeHtmlEntities(prStripHtmlTags(prHtmlCapture(match, in: original, index: 1))) + return inner.isEmpty ? "" : "*\(inner)*" + } + value = prReplaceHtmlMatches( + in: value, + regex: PrGitHubDescriptionRegex.link + ) { match, original in + let href = prDecodeHtmlEntities(prHtmlCapture(match, in: original, index: 1)) + let label = prDecodeHtmlEntities(prStripHtmlTags(prHtmlCapture(match, in: original, index: 2))) + guard let url = URL(string: href), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { + return label + } + let escapedLabel = label + .replacingOccurrences(of: "[", with: "\\[") + .replacingOccurrences(of: "]", with: "\\]") + return "[\(escapedLabel)](\(url.absoluteString))" + } + value = prReplaceHtmlMatches( + in: value, + regex: PrGitHubDescriptionRegex.heading + ) { match, original in + let level = Int(prHtmlCapture(match, in: original, index: 1)) ?? 2 + let title = prDecodeHtmlEntities(prStripHtmlTags(prHtmlCapture(match, in: original, index: 2))) + return "\n\(String(repeating: "#", count: level)) \(title)\n" + } + value = prReplaceHtmlMatches( + in: value, + regex: PrGitHubDescriptionRegex.listItem + ) { match, original in + let item = prDecodeHtmlEntities(prStripHtmlTags(prHtmlCapture(match, in: original, index: 1))) + .trimmingCharacters(in: .whitespacesAndNewlines) + return item.isEmpty ? "" : "\n- \(item)" + } + value = prReplaceHtmlMatches( + in: value, + regex: PrGitHubDescriptionRegex.image + ) { match, original in + let alt = prDecodeHtmlEntities(prHtmlCapture(match, in: original, index: 1)) + return alt.isEmpty ? "" : "[Image: \(alt)]" + } + + value = prReplacingHtmlTag(value, regex: PrGitHubDescriptionRegex.lineBreak, replacement: "\n") + value = prReplacingHtmlTag(value, regex: PrGitHubDescriptionRegex.paragraph, replacement: "\n\n") + value = prReplacingHtmlTag(value, regex: PrGitHubDescriptionRegex.list, replacement: "\n") + value = prReplacingHtmlTag(value, regex: PrGitHubDescriptionRegex.blockquoteOpen, replacement: "\n> ") + value = prReplacingHtmlTag(value, regex: PrGitHubDescriptionRegex.blockquoteClose, replacement: "\n") + value = prReplacingHtmlTag(value, regex: PrGitHubDescriptionRegex.horizontalRule, replacement: "\n---\n") + value = prReplacingHtmlTag(value, regex: PrGitHubDescriptionRegex.anyTag, replacement: "") + value = prReplacingHtmlTag( + value, + regex: PrGitHubDescriptionRegex.unsafeAngleExpression, + replacement: "" + ) + value = prDecodeHtmlEntities(value) + + value = value + .split(separator: "\n", omittingEmptySubsequences: false) + .map(prTrimmingTrailingHorizontalWhitespace) + .joined(separator: "\n") + value = PrGitHubDescriptionRegex.repeatedBlankLines.stringByReplacingMatches( + in: value, + range: NSRange(location: 0, length: (value as NSString).length), + withTemplate: "\n\n" + ) + value = prTrimmingBlankLines(value) + return prRestoreMarkdownSyntax(in: value, segments: protectedMarkdown.segments) +} + +private func prTrimmingTrailingHorizontalWhitespace(_ line: Substring) -> String { + String(line.reversed().drop(while: { $0 == " " || $0 == "\t" }).reversed()) +} + +private func prTrimmingBlankLines(_ source: String) -> String { + var lines = source.split(separator: "\n", omittingEmptySubsequences: false) + while lines.first.map({ String($0).trimmingCharacters(in: .whitespaces).isEmpty }) == true { + lines.removeFirst() + } + while lines.last.map({ String($0).trimmingCharacters(in: .whitespaces).isEmpty }) == true { + lines.removeLast() + } + return lines.joined(separator: "\n") +} + +private struct PrProtectedMarkdownSegment { + let token: String + let value: String +} + +private func prProtectMarkdownSyntax( + in source: String +) -> (text: String, segments: [PrProtectedMarkdownSegment]) { + var text = source + var segments: [PrProtectedMarkdownSegment] = [] + + func protect(regex: NSRegularExpression) { + let original = text as NSString + let matches = regex.matches( + in: text, + range: NSRange(location: 0, length: original.length) + ) + guard !matches.isEmpty else { return } + let result = NSMutableString(string: text) + for match in matches.reversed() { + let token = "\u{E000}ADEPRCODE\(segments.count)\u{E001}" + segments.append(PrProtectedMarkdownSegment( + token: token, + value: original.substring(with: match.range) + )) + result.replaceCharacters(in: match.range, with: token) + } + text = result as String + } + + text = prProtectMarkdownFences(in: text, segments: &segments) + protect(regex: PrGitHubDescriptionRegex.inlineCode) + protect(regex: PrGitHubDescriptionRegex.safeAutolink) + return (text, segments) +} + +private func prProtectMarkdownFences( + in source: String, + segments: inout [PrProtectedMarkdownSegment] +) -> String { + let openingRegex = PrGitHubDescriptionRegex.fenceOpening + let closingRegex = PrGitHubDescriptionRegex.fenceClosing + + let original = source as NSString + var protectedRanges: [NSRange] = [] + var searchLocation = 0 + + while searchLocation < original.length, + let opening = openingRegex.firstMatch( + in: source, + range: NSRange(location: searchLocation, length: original.length - searchLocation) + ) + { + let openingDelimiter = original.substring(with: opening.range(at: 1)) + let openingCharacter = openingDelimiter.first + let closingSearchStart = NSMaxRange(opening.range) + var closingSearchLocation = closingSearchStart + var matchingClosing: NSTextCheckingResult? + + while closingSearchLocation < original.length, + let candidate = closingRegex.firstMatch( + in: source, + range: NSRange( + location: closingSearchLocation, + length: original.length - closingSearchLocation + ) + ) + { + let closingDelimiter = original.substring(with: candidate.range(at: 1)) + if closingDelimiter.first == openingCharacter, + closingDelimiter.count >= openingDelimiter.count + { + matchingClosing = candidate + break + } + closingSearchLocation = NSMaxRange(candidate.range) + } + + guard let matchingClosing else { + searchLocation = NSMaxRange(opening.range) + continue + } + + let blockRange = NSRange( + location: opening.range.location, + length: NSMaxRange(matchingClosing.range) - opening.range.location + ) + protectedRanges.append(blockRange) + searchLocation = NSMaxRange(matchingClosing.range) + } + + guard !protectedRanges.isEmpty else { return source } + let result = NSMutableString(string: source) + for range in protectedRanges.reversed() { + let token = "\u{E000}ADEPRCODE\(segments.count)\u{E001}" + segments.append(PrProtectedMarkdownSegment( + token: token, + value: original.substring(with: range) + )) + result.replaceCharacters(in: range, with: token) + } + return result as String +} + +private func prRestoreMarkdownSyntax( + in source: String, + segments: [PrProtectedMarkdownSegment] +) -> String { + segments.reduce(into: source) { value, segment in + value = value.replacingOccurrences(of: segment.token, with: segment.value) + } +} + +private func prGitHubHtmlPlainText(_ source: String) -> String { + prDecodeHtmlEntities(prStripHtmlTags(source)) + .trimmingCharacters(in: .whitespacesAndNewlines) +} + +private func prStripHtmlTags(_ source: String) -> String { + PrGitHubDescriptionRegex.anyTag.stringByReplacingMatches( + in: source, + range: NSRange(location: 0, length: (source as NSString).length), + withTemplate: "" + ) +} + +private func prReplaceHtmlMatches( + in source: String, + regex: NSRegularExpression, + transform: (NSTextCheckingResult, NSString) -> String +) -> String { + let original = source as NSString + let matches = regex.matches( + in: source, + range: NSRange(location: 0, length: original.length) + ) + guard !matches.isEmpty else { return source } + let result = NSMutableString(string: source) + for match in matches.reversed() { + result.replaceCharacters(in: match.range, with: transform(match, original)) + } + return result as String +} + +private func prReplacingHtmlTag( + _ source: String, + regex: NSRegularExpression, + replacement: String +) -> String { + regex.stringByReplacingMatches( + in: source, + range: NSRange(location: 0, length: (source as NSString).length), + withTemplate: replacement + ) +} + +private func prHtmlCapture(_ match: NSTextCheckingResult, in source: NSString, index: Int) -> String { + guard index < match.numberOfRanges else { return "" } + let range = match.range(at: index) + guard range.location != NSNotFound else { return "" } + return source.substring(with: range) +} + +private func prDecodeHtmlEntities(_ source: String) -> String { + var value = source + .replacingOccurrences(of: " ", with: " ", options: .caseInsensitive) + .replacingOccurrences(of: """, with: "\"", options: .caseInsensitive) + .replacingOccurrences(of: "'", with: "'", options: .caseInsensitive) + .replacingOccurrences(of: "'", with: "'", options: .caseInsensitive) + .replacingOccurrences(of: "<", with: "<", options: .caseInsensitive) + .replacingOccurrences(of: ">", with: ">", options: .caseInsensitive) + .replacingOccurrences(of: "&", with: "&", options: .caseInsensitive) + + value = prReplaceHtmlMatches( + in: value, + regex: PrGitHubDescriptionRegex.numericEntity + ) { match, original in + let raw = prHtmlCapture(match, in: original, index: 1) + let radix = raw.lowercased().hasPrefix("x") ? 16 : 10 + let digits = radix == 16 ? String(raw.dropFirst()) : raw + guard let number = UInt32(digits, radix: radix), + let scalar = UnicodeScalar(number) + else { return "" } + return String(Character(scalar)) + } + return value +} diff --git a/apps/ios/ADE/Views/PRs/PrModels.swift b/apps/ios/ADE/Views/PRs/PrModels.swift index 8b909dd85..9980c64af 100644 --- a/apps/ios/ADE/Views/PRs/PrModels.swift +++ b/apps/ios/ADE/Views/PRs/PrModels.swift @@ -401,7 +401,6 @@ struct PrDetailWarmEntry { var actionRuns: [PrActionRun] var activityEvents: [PrActivityEvent] var deployments: [PrDeployment] - var aiSummary: AiReviewSummary? var groupMembers: [PrGroupMemberSummary] var capabilities: PrActionCapabilities? var unavailableParts: [String] diff --git a/apps/ios/ADE/Views/PRs/PrRowCard.swift b/apps/ios/ADE/Views/PRs/PrRowCard.swift index a27760339..d43f9453b 100644 --- a/apps/ios/ADE/Views/PRs/PrRowCard.swift +++ b/apps/ios/ADE/Views/PRs/PrRowCard.swift @@ -33,15 +33,14 @@ struct PrRowCard: View { var body: some View { let stateColors = PrRowDesktopPalette.stateColors(data.state) - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .top, spacing: 11) { Image(systemName: stateSymbol) - .font(.body.weight(.semibold)) + .font(.system(size: 17, weight: .semibold)) .foregroundStyle(stateColors.text) - .frame(width: 22, height: 22) - .padding(.top, 1) + .frame(width: 20, height: 22) - VStack(alignment: .leading, spacing: 7) { - HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: 10) { Text(data.title) .font(.body.weight(.semibold)) .foregroundStyle(PrsGlass.textPrimary) @@ -52,15 +51,15 @@ struct PrRowCard: View { if !data.timeAgo.isEmpty { Text(data.timeAgo) - .font(.caption2.monospaced()) + .font(.caption) .foregroundStyle(PrsGlass.textMuted) .lineLimit(1) - .fixedSize() + .fixedSize(horizontal: true, vertical: false) } } metadataRow - signalsRow + branchAndSignalsRow if !data.isUnmapped, let warnMessage = data.warnMessage { PrWarnBanner(text: warnMessage) @@ -68,7 +67,7 @@ struct PrRowCard: View { } } .padding(.horizontal, 16) - .padding(.vertical, 13) + .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) .background { if isSelectedTransitionSource { @@ -92,7 +91,6 @@ struct PrRowCard: View { .frame(height: 1) .padding(.leading, 50) } - .adeMatchedTransitionSource(id: isSelectedTransitionSource ? "pr-container-\(data.id)" : nil, in: transitionNamespace) .accessibilityElement(children: .combine) .accessibilityLabel(accessibilitySummary) .adeInspectable( @@ -150,65 +148,72 @@ struct PrRowCard: View { Text("\(data.repoOwner)/\(data.repoName)") .lineLimit(1) } + if data.isUnmapped { + Text("Unmapped") + .font(.caption2.weight(.semibold)) + .foregroundStyle(PrsGlass.draftTop) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + Capsule(style: .continuous) + .fill(PrsGlass.draftTop.opacity(0.12)) + ) + .fixedSize(horizontal: true, vertical: false) + } else if let laneLabel = data.laneLabel, !laneLabel.isEmpty { + Label(laneLabel, systemImage: "rectangle.stack") + .font(.caption2.weight(.medium)) + .foregroundStyle(PrsGlass.textSecondary) + .lineLimit(1) + } Spacer(minLength: 0) } - .font(.caption.monospaced()) + .font(.caption) .foregroundStyle(PrsGlass.textMuted) } @ViewBuilder - private var signalsRow: some View { - HStack(spacing: 12) { + private var branchAndSignalsRow: some View { + HStack(spacing: 10) { if let head = data.headBranch, let base = data.baseBranch { - HStack(spacing: 4) { - Text(head) - .lineLimit(1) - .truncationMode(.middle) - Text("→") - Text(base) - .lineLimit(1) - } - .font(.caption2.monospaced()) - .foregroundStyle(PrsGlass.textSecondary) - .layoutPriority(1) + Text("\(head) → \(base)") + .font(.caption2.monospaced()) + .foregroundStyle(PrsGlass.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) } Spacer(minLength: 0) - if let ci = data.ciIndicator { - Image(systemName: ci.symbol) - .foregroundStyle(ci.color) - .accessibilityLabel(ci.title) - } - - if let review = data.reviewIndicator { - Image(systemName: reviewSymbol) - .foregroundStyle(review.color) - .accessibilityLabel(review.label) - } + HStack(spacing: 9) { + if let ci = data.ciIndicator { + Image(systemName: ci.symbol) + .foregroundStyle(ci.color) + .accessibilityLabel(ci.title) + } - if data.commentCount > 0 { - Label("\(data.commentCount)", systemImage: "text.bubble") - .labelStyle(.titleAndIcon) - } + if let review = data.reviewIndicator { + Image(systemName: reviewSymbol) + .foregroundStyle(review.color) + .accessibilityLabel(review.label) + } - if data.isUnmapped { - Label("Unmapped", systemImage: "link.badge.plus") - .foregroundStyle(PrsGlass.draftTop) - } else if let laneLabel = data.laneLabel { - Label(laneLabel, systemImage: "rectangle.stack") - .lineLimit(1) - } + if data.commentCount > 0 { + Label("\(data.commentCount)", systemImage: "text.bubble") + .labelStyle(.titleAndIcon) + } - if let groupId = data.stackGroupId, let groupCount = data.stackGroupCount, groupCount > 0 { - Button { - onShowStack(groupId, data.stackGroupName) - } label: { - Label("\(groupCount)", systemImage: "list.number") + if let groupId = data.stackGroupId, let groupCount = data.stackGroupCount, groupCount > 0 { + Button { + onShowStack(groupId, data.stackGroupName) + } label: { + Label("\(groupCount)", systemImage: "list.number") + } + .buttonStyle(.plain) + .accessibilityLabel("Open stack of \(groupCount) pull requests") } - .buttonStyle(.plain) - .accessibilityLabel("Open stack of \(groupCount) pull requests") } + .fixedSize(horizontal: true, vertical: false) } .font(.caption2.weight(.medium)) .foregroundStyle(PrsGlass.textMuted) @@ -223,6 +228,34 @@ struct PrRowCard: View { } } +struct PrRowCardSkeleton: View { + var body: some View { + HStack(alignment: .top, spacing: 11) { + ADESkeletonView(width: 20, height: 20, cornerRadius: 10) + + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 10) { + ADESkeletonView(height: 16, cornerRadius: 5) + ADESkeletonView(width: 52, height: 12, cornerRadius: 4) + } + HStack(spacing: 8) { + ADESkeletonView(width: 42, height: 11, cornerRadius: 4) + ADESkeletonView(width: 86, height: 11, cornerRadius: 4) + ADESkeletonView(width: 58, height: 16, cornerRadius: 8) + } + HStack(spacing: 10) { + ADESkeletonView(width: 180, height: 10, cornerRadius: 4) + Spacer(minLength: 0) + ADESkeletonView(width: 42, height: 10, cornerRadius: 4) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + private enum PrRowDesktopPalette { struct StateColors { let background: Color diff --git a/apps/ios/ADE/Views/PRs/PrRowCardPreviews.swift b/apps/ios/ADE/Views/PRs/PrRowCardPreviews.swift index d254e4031..e58e53dd6 100644 --- a/apps/ios/ADE/Views/PRs/PrRowCardPreviews.swift +++ b/apps/ios/ADE/Views/PRs/PrRowCardPreviews.swift @@ -145,6 +145,7 @@ private enum PrRowCardPreviewData { #Preview("GitHub PR list rows") { PrRowCardPreviewData.githubListRows + .preferredColorScheme(.dark) } #Preview("GitHub PR list rows · light") { diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index cf9bc3e2e..a9adfbf68 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -322,7 +322,7 @@ struct PRsTabView: View { if isLoadingSkeleton { ForEach(0..<3, id: \.self) { _ in - ADECardSkeleton(rows: 3) + PrRowCardSkeleton() .prListRow() } } else { @@ -419,6 +419,7 @@ struct PRsTabView: View { .listStyle(.plain) .listRowSpacing(0) .contentMargins(.horizontal, 0, for: .scrollContent) + .contentMargins(.bottom, 88, for: .scrollContent) .scrollContentBackground(.hidden) .adeScreenBackground() .adeNavigationGlass() @@ -973,7 +974,6 @@ struct PRsTabView: View { actionRuns: [], activityEvents: [], deployments: [], - aiSummary: nil, groupMembers: [], capabilities: nil, unavailableParts: [], diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index e85ce5bf2..74c3f1a71 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -9747,7 +9747,6 @@ final class ADETests: XCTestCase { actionRuns: [], activityEvents: [], deployments: [], - aiSummary: nil, groupMembers: [], capabilities: nil, unavailableParts: [], diff --git a/apps/ios/ADETests/PrGitHubDescriptionParserTests.swift b/apps/ios/ADETests/PrGitHubDescriptionParserTests.swift new file mode 100644 index 000000000..61cc94c10 --- /dev/null +++ b/apps/ios/ADETests/PrGitHubDescriptionParserTests.swift @@ -0,0 +1,237 @@ +import XCTest +@testable import ADE + +final class PrGitHubDescriptionParserTests: XCTestCase { + func testDetailsBecomeNativeDisclosureContent() { + let blocks = parsePrGitHubDescriptionBlocks(""" + Bumps dorny/paths-filter from 3 to 4. +
    + Release notes +

    v4.0.0

    +
    +
    + """) + + XCTAssertEqual(blocks.count, 2) + XCTAssertEqual( + blocks.first, + .markdown(id: "description-markdown-0", markdown: "Bumps dorny/paths-filter from 3 to 4.") + ) + + guard case .disclosure(_, let title, let markdown) = blocks[1] else { + return XCTFail("Expected the GitHub details block to become a native disclosure") + } + XCTAssertEqual(title, "Release notes") + XCTAssertTrue(markdown.contains("## v4.0.0")) + XCTAssertTrue(markdown.contains("- feat: update runtime by [`@octocat`](https://github.com/octocat)")) + XCTAssertTrue(markdown.contains("- **Full changelog**")) + XCTAssertFalse(markdown.contains("<")) + } + + func testHtmlNormalizationKeepsSafeLinksAndDropsUnsafeMarkup() { + let markdown = normalizePrGitHubHtmlFragment(""" +

    See ADE & unsafe.

    + + """) + + XCTAssertTrue(markdown.contains("[ADE](https://github.com/ADE) & unsafe.")) + XCTAssertFalse(markdown.contains("javascript:")) + XCTAssertFalse(markdown.contains("` in `Result`. + + ```swift + struct Box { + let value: T + } + ``` + """) + + XCTAssertTrue(markdown.contains("``")) + XCTAssertTrue(markdown.contains("`Result`")) + XCTAssertTrue(markdown.contains("struct Box")) + XCTAssertTrue(markdown.contains("```swift")) + } + + func testHtmlNormalizationPreservesSafeMarkdownAutolinks() { + let markdown = normalizePrGitHubHtmlFragment(""" + Visit . + Email . + Strip HTML and . + """) + + XCTAssertTrue(markdown.contains("")) + XCTAssertTrue(markdown.contains("")) + XCTAssertTrue(markdown.contains("Strip HTML and .")) + XCTAssertFalse(markdown.contains("")) + XCTAssertFalse(markdown.contains(" or when 1 < 2 and 3 > 2. + + + """) + + XCTAssertTrue( + markdown.contains("Replace or when 1 < 2 and 3 > 2.") + ) + XCTAssertTrue(markdown.contains("Fallback")) + XCTAssertFalse(markdown.contains("")) + XCTAssertFalse(markdown.contains("Examplecontent
  • + ``` + """) + + XCTAssertEqual(blocks.count, 1) + guard case .markdown(_, let markdown) = blocks[0] else { + return XCTFail("Expected fenced HTML to remain Markdown code") + } + XCTAssertTrue(markdown.contains("```html")) + XCTAssertTrue(markdown.contains("
    Examplecontent
    ")) + XCTAssertFalse(blocks.contains { block in + if case .disclosure = block { return true } + return false + }) + } + + func testDescriptionParserPreservesFourBacktickFence() { + let blocks = parsePrGitHubDescriptionBlocks(""" + ````markdown +
    Examplecontent
    + ``` + ```` + """) + + XCTAssertEqual(blocks.count, 1) + guard case .markdown(_, let markdown) = blocks[0] else { + return XCTFail("Expected the four-backtick fence to remain Markdown") + } + XCTAssertTrue(markdown.contains("````markdown")) + XCTAssertTrue(markdown.contains("
    Examplecontent
    ")) + XCTAssertFalse(markdown.contains("ADEPRCODE")) + } + + func testDescriptionParserAllowsLongerClosingFence() { + let blocks = parsePrGitHubDescriptionBlocks(""" + ```html +
    Examplecontent
    + ```` + """) + + XCTAssertEqual(blocks.count, 1) + guard case .markdown(_, let markdown) = blocks[0] else { + return XCTFail("Expected the longer closing fence to remain Markdown") + } + XCTAssertTrue(markdown.contains("```html")) + XCTAssertTrue(markdown.contains("````")) + XCTAssertTrue(markdown.contains("
    Examplecontent
    ")) + XCTAssertFalse(markdown.contains("ADEPRCODE")) + } + + func testDescriptionParserRestoresInlineCodeInDisclosureTitle() { + let blocks = parsePrGitHubDescriptionBlocks(""" +
    + About `Result` + Body +
    + """) + + XCTAssertEqual(blocks.count, 1) + guard case .disclosure(_, let title, let markdown) = blocks[0] else { + return XCTFail("Expected a disclosure block") + } + XCTAssertEqual(title, "About `Result`") + XCTAssertEqual(markdown, "Body") + XCTAssertFalse(title.contains("ADEPRCODE")) + } + + func testDescriptionParserKeepsNestedDetailsInsideOuterDisclosure() { + let blocks = parsePrGitHubDescriptionBlocks(""" + Before +
    + Outer title + Outer beginning +
    + Inner title + Inner body +
    + Outer end +
    + After + """) + + XCTAssertEqual(blocks.count, 3) + XCTAssertEqual(blocks[0], .markdown(id: "description-markdown-0", markdown: "Before")) + guard case .disclosure(_, let title, let markdown) = blocks[1] else { + return XCTFail("Expected the balanced outer details block to become a disclosure") + } + XCTAssertEqual(title, "Outer title") + XCTAssertTrue(markdown.contains("Outer beginning")) + XCTAssertTrue(markdown.contains("Inner title")) + XCTAssertTrue(markdown.contains("Inner body")) + XCTAssertTrue(markdown.contains("Outer end")) + XCTAssertFalse(markdown.contains(" + Outer title + Outer beginning +
    + Inner title + Inner body +
    + """) + + XCTAssertEqual(blocks.count, 1) + guard case .markdown(_, let markdown) = blocks[0] else { + return XCTFail("Expected malformed details markup to remain Markdown") + } + XCTAssertTrue(markdown.contains("Outer title")) + XCTAssertTrue(markdown.contains("Outer beginning")) + XCTAssertTrue(markdown.contains("Inner title")) + XCTAssertTrue(markdown.contains("Inner body")) + XCTAssertFalse(markdown.localizedCaseInsensitiveContains("/` regions into native disclosure rows, avoiding raw +Dependabot release-note markup. Other Markdown bodies are normalized for +escaped GitHub newlines and rendered through the shared mobile Work markdown +renderer; collapsed comment/thread previews stay cheap text so large PRs do not pay markdown layout cost for offscreen or folded content. diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 1c073f3ff..b53c4ca2c 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -342,6 +342,10 @@ apps/ios/ │ │ │ # sibling List rows, not a monolith), │ │ │ # PrDetailActivityTab (timeline builder + │ │ │ # commit-group folding), PrDetailOverviewTab, +│ │ │ # PrDetailHeaderComponents (compact summary, +│ │ │ # collapsible unmapped notice, description), +│ │ │ # PrGitHubDescriptionParser (safe embedded +│ │ │ # HTML → Markdown/native disclosures), │ │ │ # PrMergeGateCard (PrGlassPalette tokens), │ │ │ # PrHelpers, PrModels, PrRowCard, │ │ │ # PrListRowModifier, @@ -1546,18 +1550,23 @@ the view coalesces bursts for 300 ms and reads the newest projected snapshot once with row revalidation disabled, so freshness requires neither a timer nor one request per PR. Manual refresh runs PR and lane refreshes concurrently. -`PrRowCard` keeps scroll-time rendering deliberately compact: a state symbol, -two-line title, age, identity line, and one signal line for branch/lane/checks/ -reviews/comments. It does not fetch author avatars from the network. Filtering, -sorting, counts, and mapped-row reconciliation are recomputed only when their -inputs change rather than during each SwiftUI `body` pass. +`PrRowCard` keeps scroll-time rendering deliberately compact: a fixed state +symbol, two-line title with age, one identity line with a non-wrapping +`Unmapped` capsule or lane label, then a branch line whose trailing checks / +reviews / comments remain horizontal. It does not fetch author avatars from +the network. Filtering, sorting, counts, and mapped-row reconciliation are +recomputed only when their inputs change rather than during each SwiftUI +`body` pass. `PrRowCardSkeleton` mirrors that three-line geometry so loading +does not jump into the final layout. ### PR detail screen Source: `apps/ios/ADE/Views/PRs/PrDetailScreen.swift` (the `PrDetailView` struct), with the timeline builder + commit-group folding in -`PrDetailActivityTab.swift` and card views in `PrDetailOverviewTab.swift` / -`PrHelpers.swift`. +`PrDetailActivityTab.swift`, the compact header/description views in +`PrDetailHeaderComponents.swift`, the embedded GitHub HTML normalizer in +`PrGitHubDescriptionParser.swift`, and the remaining thread cards in +`PrDetailOverviewTab.swift` / `PrHelpers.swift`. The detail screen is a single-column adaptation of the desktop Timeline+Rails PR view. Its Overview is emitted as sibling `List` rows @@ -1567,29 +1576,32 @@ every scroll frame. The navigation header uses a plain back chevron, centered PR title with `#number · lane · branch`, and a plain ellipsis actions button. Reading order (desktop parity, folded to one column): -1. a compact summary section (`PrDetailSummarySection`) with state, merge/check - status, diff totals, and an expandable commit list whose rows jump to the - matching timeline anchor; -2. an unmapped-PR banner (`PrUnmappedThreadBanner`) when the PR has no ADE - lane, offering auto-map (`prs.createLaneFromPrBranch`), map to an existing - lane (`prs.linkToLane`), or Open in GitHub; -3. the AI summary card (`PrAiSummaryCard`, +/- totals from the file list); -4. the PR description (`PrThreadDescriptionCard`); -5. a chronological event feed — one row per timeline event or folded +1. a compact summary section (`PrDetailSummarySection`) with a state/approval + line and three metrics: Checks, Changes, and Commits. Commits expand inline + from their metric and jump to the matching timeline anchor; +2. a collapsed-by-default unmapped-PR notice (`PrUnmappedThreadBanner`) when + the PR has no ADE lane. Its expanded state is remembered per PR for the + current scene; expanded actions offer auto-map + (`prs.createLaneFromPrBranch`), map to an existing lane + (`prs.linkToLane`), or Open in GitHub; +3. the PR description (`PrThreadDescriptionCard`). GitHub's embedded HTML is + normalized into safe Markdown, while `
    /` regions become + native `DisclosureGroup` rows instead of visible raw tags; +4. a chronological event feed — one row per timeline event or folded commit group, ascending oldest → newest, built by `buildPullRequestTimeline` and folded via `buildPrTimelineDisplayItems` (`PrDetailActivityTab.swift`) so runs of same-author commits collapse into a single group row; -6. review threads (unresolved first, resolved folded into a collapsible +5. review threads (unresolved first, resolved folded into a collapsible section). Individual thread/comment cards are also collapsible on mobile: folded rows use cheap inline preview text, while expanded rows render the full normalized markdown body through `WorkMarkdownRenderer`; -7. the comment composer (locked for unmapped PRs); -8. the inline merge rail (`PrOverviewMergeRail`) carrying the desktop +6. the comment composer (locked for unmapped PRs); +7. the inline merge rail (`PrOverviewMergeRail`) carrying the desktop GitHub-style requirement checklist (`PrMergeChecklist` — conflicts / behind-base / checks / review), the merge-method sheet, and admin-bypass gating; -9. metadata cards — checks, commits, files, people, and the stack card, +8. metadata cards — checks, commits, files, people, and the stack card, plus a post-merge cleanup banner. There is no separate Activity sub-tab — the activity feed lives inside @@ -1624,9 +1636,9 @@ the previous stacked radial-gradient / `.plusLighter` backdrop was dropped because it forced expensive re-compositing under every scroll frame. **Freshness.** `PrDetailView` re-fetches its action sidecars (review threads, -activity feed, action runs, deployments, AI summary, capabilities) on a task -keyed by both the replicated PR projection revision and the lightweight remote -GitHub projection revision, throttled by a warm-cache freshness window +activity feed, action runs, deployments, capabilities) on a task keyed by both +the replicated PR projection revision and the lightweight remote GitHub +projection revision, throttled by a warm-cache freshness window (`detailFreshnessWindow = 25 s`). It first seeds from the service warm cache for an instant render; when the cached entry is younger than 25 s the revision-driven reload skips the cold sidecar fan-out (8+ network calls) and