Просмотр исходного кода

Show web search results before scrape enrichment completes.

Prevent filter sync from starting overlapping Indeed loads, yield partial results for immediate UI, append scrape rows afterward, and cancel superseded WebKit fetches instead of surfacing busy errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali 16 часов назад
Родитель
Сommit
e08e889988

+ 45 - 4
indeed_ai_job_search/Features/JobSearch/ViewModels/JobSearchViewModel.swift

@@ -37,6 +37,8 @@ final class JobSearchViewModel {
     private let savedStore: MockSavedJobsStore
     private var searchTask: Task<Void, Never>?
     private var filterDebounceTask: Task<Void, Never>?
+    /// Ignores filter-panel onChange while pipeline-applied criteria propagates to the UI.
+    private var suppressFilterRefresh = false
 
     init(dependencies: AppDependencies) {
         self.pipeline = dependencies.searchPipeline
@@ -77,11 +79,11 @@ final class JobSearchViewModel {
     }
 
     func applyFiltersAndRefresh() {
-        guard hasSearched else { return }
+        guard hasSearched, !suppressFilterRefresh else { return }
         filterDebounceTask?.cancel()
         filterDebounceTask = Task {
             try? await Task.sleep(nanoseconds: 350_000_000)
-            if Task.isCancelled { return }
+            if Task.isCancelled || suppressFilterRefresh { return }
             refreshResultsOnly()
         }
     }
@@ -127,8 +129,8 @@ final class JobSearchViewModel {
                 for try await progress in stream {
                     if Task.isCancelled { return }
 
-                    criteria = progress.interpreted.criteria
-                    results = progress.listings
+                    applyCriteriaFromPipeline(progress.interpreted.criteria)
+                    applyProgressToResults(progress)
                     totalResultCount = progress.totalEstimated ?? progress.listings.count
                     isLoadingMore = progress.isLoadingMore
 
@@ -138,6 +140,11 @@ final class JobSearchViewModel {
                         state = .empty
                     }
                     // else: still loading primary/enrichment with no rows yet
+
+                    // Yield so SwiftUI can paint web_search rows before scrape resumes.
+                    if progress.phase == .partial {
+                        await Task.yield()
+                    }
                 }
 
                 if Task.isCancelled { return }
@@ -146,6 +153,7 @@ final class JobSearchViewModel {
                     state = results.isEmpty ? .empty : .success
                 }
             } catch is CancellationError {
+                isLoadingMore = false
                 return
             } catch {
                 isLoadingMore = false
@@ -167,4 +175,37 @@ final class JobSearchViewModel {
     func retry() {
         submitSearch()
     }
+
+    /// Updates filters from search interpretation without kicking off a second fetch.
+    private func applyCriteriaFromPipeline(_ newCriteria: SearchCriteria) {
+        guard newCriteria != criteria else { return }
+        suppressFilterRefresh = true
+        criteria = newCriteria
+        Task { @MainActor in
+            // Cover FiltersPanel onChange + its 350ms debounce so interpretation
+            // does not start a second search on top of enrichment.
+            try? await Task.sleep(nanoseconds: 500_000_000)
+            suppressFilterRefresh = false
+        }
+    }
+
+    /// Partial = show web_search immediately. Complete = keep those rows and append scrape-only jobs.
+    private func applyProgressToResults(_ progress: JobSearchPipelineProgress) {
+        switch progress.phase {
+        case .partial:
+            results = progress.listings
+        case .complete:
+            guard !results.isEmpty, isLoadingMore else {
+                results = progress.listings
+                return
+            }
+            let existingKeys = Set(results.map(\.indeedJobKey))
+            let appended = progress.listings.filter { !existingKeys.contains($0.indeedJobKey) }
+            if appended.isEmpty {
+                // Scrape overlapped fully — keep what the user already sees.
+                return
+            }
+            results.append(contentsOf: appended)
+        }
+    }
 }

+ 3 - 3
indeed_ai_job_search/Features/JobSearch/Views/AIJobSearchView.swift

@@ -111,7 +111,7 @@ struct AIJobSearchView: View {
                     Text("Searching Indeed…")
                 case .success, .empty:
                     if viewModel.isLoadingMore {
-                        Text("\(viewModel.totalResultCount.formatted()) jobs · Loading more from Indeed…")
+                        Text("\(viewModel.totalResultCount.formatted()) jobs · Searching more results…")
                     } else {
                         Text("\(viewModel.totalResultCount.formatted()) jobs found")
                     }
@@ -251,13 +251,13 @@ struct AIJobSearchView: View {
         HStack(spacing: AppSpacing.sm) {
             ProgressView()
                 .controlSize(.small)
-            Text("Loading more Indeed jobs…")
+            Text("Searching more results…")
                 .font(AppTypography.meta)
                 .foregroundStyle(AppColors.textSecondary)
             Spacer(minLength: 0)
         }
         .padding(.vertical, AppSpacing.sm)
         .accessibilityElement(children: .combine)
-        .accessibilityLabel("Loading more Indeed jobs")
+        .accessibilityLabel("Searching more results")
     }
 }

+ 19 - 6
indeed_ai_job_search/Services/Indeed/IndeedWebKitPageFetcher.swift

@@ -32,12 +32,25 @@ final class IndeedWebKitPageFetcher: NSObject {
     }
 
     /// Fetches fully rendered page HTML for `url`.
-    func html(from url: URL, timeoutSeconds: TimeInterval = 35) async throws -> String {
-        // Serialize loads — one WKWebView at a time.
+    /// Overlapping callers cancel the in-flight load instead of failing with a busy error.
+    func html(from url: URL, timeoutSeconds: TimeInterval = 25) async throws -> String {
+        try Task.checkCancellation()
+
+        // A newer search supersedes any in-flight scrape.
         if continuation != nil {
-            throw IndeedDataError.network("Another Indeed page load is already in progress.")
+            fail(IndeedDataError.cancelled)
+        }
+
+        return try await withTaskCancellationHandler {
+            try await self.performLoad(url: url, timeoutSeconds: timeoutSeconds)
+        } onCancel: {
+            Task { @MainActor in
+                IndeedWebKitPageFetcher.shared.fail(IndeedDataError.cancelled)
+            }
         }
+    }
 
+    private func performLoad(url: URL, timeoutSeconds: TimeInterval) async throws -> String {
         let configuration = WKWebViewConfiguration()
         // Persistent cookies help when Indeed has already verified this Mac in Safari/WebKit.
         configuration.websiteDataStore = .default()
@@ -91,7 +104,7 @@ final class IndeedWebKitPageFetcher: NSObject {
         continuation.resume(returning: html)
     }
 
-    private func fail(_ error: Error) {
+    fileprivate func fail(_ error: Error) {
         timeoutTask?.cancel()
         timeoutTask = nil
         guard let continuation else { return }
@@ -136,9 +149,9 @@ final class IndeedWebKitPageFetcher: NSObject {
 
 extension IndeedWebKitPageFetcher: WKNavigationDelegate {
     func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
-        // Give mosaic job-card scripts a moment to hydrate.
+        // Brief pause for mosaic job-card hydration (kept short for perceived speed).
         Task { @MainActor in
-            try? await Task.sleep(nanoseconds: 900_000_000)
+            try? await Task.sleep(nanoseconds: 400_000_000)
             guard self.webView === webView, self.continuation != nil else { return }
             self.extractHTML(from: webView)
         }

+ 23 - 4
indeed_ai_job_search/Services/Search/JobSearchPipeline.swift

@@ -164,7 +164,11 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
 
         let hasEnrichment = enrichmentDataSource != nil
 
+        // Push web_search rows to the UI immediately — before WebKit scrape takes MainActor.
         if !primaryListings.isEmpty, hasEnrichment {
+            Self.log.info(
+                "Yielding \(primaryListings.count, privacy: .public) primary listings for immediate UI"
+            )
             continuation.yield(
                 JobSearchPipelineProgress(
                     interpreted: noteWrapped,
@@ -174,15 +178,25 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
                     phase: .partial
                 )
             )
+            // Let the ViewModel/SwiftUI paint partial results before IndeedWebKitPageFetcher
+            // claims the main actor for the enrichment load.
+            await Task.yield()
+            try? await Task.sleep(nanoseconds: 120_000_000)
         }
 
         try Task.checkCancellation()
 
         // Phase 2 — live scrape enrichment (or sole source if primary empty/failed).
+        // One page is enough for append-under-primary; keeps enrichment fast.
         var merged = primaryListings
         if let enrichment = enrichmentDataSource {
             do {
-                let fetched = try await fetchPages(from: enrichment, criteria: criteria, query: trimmed)
+                let fetched = try await fetchPages(
+                    from: enrichment,
+                    criteria: criteria,
+                    query: trimmed,
+                    pageLimit: primaryListings.isEmpty ? maxPages : 1
+                )
                 Self.log.info("Enrichment fetch \(fetched.listings.count, privacy: .public) raw listings")
                 let processed = try await processListings(
                     fetched.listings,
@@ -203,11 +217,14 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
                 logResultRows(appended, label: "enrichment-new")
             } catch is CancellationError {
                 throw CancellationError()
+            } catch let error as IndeedDataError where error == .cancelled {
+                throw CancellationError()
             } catch {
                 Self.log.warning(
                     "Enrichment Indeed source failed: \(error.localizedDescription, privacy: .public)"
                 )
-                // Keep primary results if we already have them.
+                // Keep primary results if we already have them — never replace the
+                // partial list with a full-screen error when enrichment races.
                 if primaryListings.isEmpty, let primaryError {
                     throw primaryError
                 }
@@ -293,14 +310,16 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
     private func fetchPages(
         from source: IndeedJobDataSource,
         criteria: SearchCriteria,
-        query: String
+        query: String,
+        pageLimit: Int? = nil
     ) async throws -> (listings: [JobListing], totalEstimated: Int?) {
         var all: [JobListing] = []
         var totalEstimated: Int?
         var cursor: String?
         var pageIndex = 0
+        let pagesToFetch = max(1, pageLimit ?? maxPages)
 
-        while pageIndex < maxPages {
+        while pageIndex < pagesToFetch {
             let page: IndeedSearchPage
             do {
                 page = try await source.searchPage(