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

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 let savedStore: MockSavedJobsStore
     private var searchTask: Task<Void, Never>?
     private var searchTask: Task<Void, Never>?
     private var filterDebounceTask: 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) {
     init(dependencies: AppDependencies) {
         self.pipeline = dependencies.searchPipeline
         self.pipeline = dependencies.searchPipeline
@@ -77,11 +79,11 @@ final class JobSearchViewModel {
     }
     }
 
 
     func applyFiltersAndRefresh() {
     func applyFiltersAndRefresh() {
-        guard hasSearched else { return }
+        guard hasSearched, !suppressFilterRefresh else { return }
         filterDebounceTask?.cancel()
         filterDebounceTask?.cancel()
         filterDebounceTask = Task {
         filterDebounceTask = Task {
             try? await Task.sleep(nanoseconds: 350_000_000)
             try? await Task.sleep(nanoseconds: 350_000_000)
-            if Task.isCancelled { return }
+            if Task.isCancelled || suppressFilterRefresh { return }
             refreshResultsOnly()
             refreshResultsOnly()
         }
         }
     }
     }
@@ -127,8 +129,8 @@ final class JobSearchViewModel {
                 for try await progress in stream {
                 for try await progress in stream {
                     if Task.isCancelled { return }
                     if Task.isCancelled { return }
 
 
-                    criteria = progress.interpreted.criteria
-                    results = progress.listings
+                    applyCriteriaFromPipeline(progress.interpreted.criteria)
+                    applyProgressToResults(progress)
                     totalResultCount = progress.totalEstimated ?? progress.listings.count
                     totalResultCount = progress.totalEstimated ?? progress.listings.count
                     isLoadingMore = progress.isLoadingMore
                     isLoadingMore = progress.isLoadingMore
 
 
@@ -138,6 +140,11 @@ final class JobSearchViewModel {
                         state = .empty
                         state = .empty
                     }
                     }
                     // else: still loading primary/enrichment with no rows yet
                     // 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 }
                 if Task.isCancelled { return }
@@ -146,6 +153,7 @@ final class JobSearchViewModel {
                     state = results.isEmpty ? .empty : .success
                     state = results.isEmpty ? .empty : .success
                 }
                 }
             } catch is CancellationError {
             } catch is CancellationError {
+                isLoadingMore = false
                 return
                 return
             } catch {
             } catch {
                 isLoadingMore = false
                 isLoadingMore = false
@@ -167,4 +175,37 @@ final class JobSearchViewModel {
     func retry() {
     func retry() {
         submitSearch()
         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…")
                     Text("Searching Indeed…")
                 case .success, .empty:
                 case .success, .empty:
                     if viewModel.isLoadingMore {
                     if viewModel.isLoadingMore {
-                        Text("\(viewModel.totalResultCount.formatted()) jobs · Loading more from Indeed…")
+                        Text("\(viewModel.totalResultCount.formatted()) jobs · Searching more results…")
                     } else {
                     } else {
                         Text("\(viewModel.totalResultCount.formatted()) jobs found")
                         Text("\(viewModel.totalResultCount.formatted()) jobs found")
                     }
                     }
@@ -251,13 +251,13 @@ struct AIJobSearchView: View {
         HStack(spacing: AppSpacing.sm) {
         HStack(spacing: AppSpacing.sm) {
             ProgressView()
             ProgressView()
                 .controlSize(.small)
                 .controlSize(.small)
-            Text("Loading more Indeed jobs…")
+            Text("Searching more results…")
                 .font(AppTypography.meta)
                 .font(AppTypography.meta)
                 .foregroundStyle(AppColors.textSecondary)
                 .foregroundStyle(AppColors.textSecondary)
             Spacer(minLength: 0)
             Spacer(minLength: 0)
         }
         }
         .padding(.vertical, AppSpacing.sm)
         .padding(.vertical, AppSpacing.sm)
         .accessibilityElement(children: .combine)
         .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`.
     /// 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 {
         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()
         let configuration = WKWebViewConfiguration()
         // Persistent cookies help when Indeed has already verified this Mac in Safari/WebKit.
         // Persistent cookies help when Indeed has already verified this Mac in Safari/WebKit.
         configuration.websiteDataStore = .default()
         configuration.websiteDataStore = .default()
@@ -91,7 +104,7 @@ final class IndeedWebKitPageFetcher: NSObject {
         continuation.resume(returning: html)
         continuation.resume(returning: html)
     }
     }
 
 
-    private func fail(_ error: Error) {
+    fileprivate func fail(_ error: Error) {
         timeoutTask?.cancel()
         timeoutTask?.cancel()
         timeoutTask = nil
         timeoutTask = nil
         guard let continuation else { return }
         guard let continuation else { return }
@@ -136,9 +149,9 @@ final class IndeedWebKitPageFetcher: NSObject {
 
 
 extension IndeedWebKitPageFetcher: WKNavigationDelegate {
 extension IndeedWebKitPageFetcher: WKNavigationDelegate {
     func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
     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
         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 }
             guard self.webView === webView, self.continuation != nil else { return }
             self.extractHTML(from: webView)
             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
         let hasEnrichment = enrichmentDataSource != nil
 
 
+        // Push web_search rows to the UI immediately — before WebKit scrape takes MainActor.
         if !primaryListings.isEmpty, hasEnrichment {
         if !primaryListings.isEmpty, hasEnrichment {
+            Self.log.info(
+                "Yielding \(primaryListings.count, privacy: .public) primary listings for immediate UI"
+            )
             continuation.yield(
             continuation.yield(
                 JobSearchPipelineProgress(
                 JobSearchPipelineProgress(
                     interpreted: noteWrapped,
                     interpreted: noteWrapped,
@@ -174,15 +178,25 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
                     phase: .partial
                     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()
         try Task.checkCancellation()
 
 
         // Phase 2 — live scrape enrichment (or sole source if primary empty/failed).
         // 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
         var merged = primaryListings
         if let enrichment = enrichmentDataSource {
         if let enrichment = enrichmentDataSource {
             do {
             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")
                 Self.log.info("Enrichment fetch \(fetched.listings.count, privacy: .public) raw listings")
                 let processed = try await processListings(
                 let processed = try await processListings(
                     fetched.listings,
                     fetched.listings,
@@ -203,11 +217,14 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
                 logResultRows(appended, label: "enrichment-new")
                 logResultRows(appended, label: "enrichment-new")
             } catch is CancellationError {
             } catch is CancellationError {
                 throw CancellationError()
                 throw CancellationError()
+            } catch let error as IndeedDataError where error == .cancelled {
+                throw CancellationError()
             } catch {
             } catch {
                 Self.log.warning(
                 Self.log.warning(
                     "Enrichment Indeed source failed: \(error.localizedDescription, privacy: .public)"
                     "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 {
                 if primaryListings.isEmpty, let primaryError {
                     throw primaryError
                     throw primaryError
                 }
                 }
@@ -293,14 +310,16 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
     private func fetchPages(
     private func fetchPages(
         from source: IndeedJobDataSource,
         from source: IndeedJobDataSource,
         criteria: SearchCriteria,
         criteria: SearchCriteria,
-        query: String
+        query: String,
+        pageLimit: Int? = nil
     ) async throws -> (listings: [JobListing], totalEstimated: Int?) {
     ) async throws -> (listings: [JobListing], totalEstimated: Int?) {
         var all: [JobListing] = []
         var all: [JobListing] = []
         var totalEstimated: Int?
         var totalEstimated: Int?
         var cursor: String?
         var cursor: String?
         var pageIndex = 0
         var pageIndex = 0
+        let pagesToFetch = max(1, pageLimit ?? maxPages)
 
 
-        while pageIndex < maxPages {
+        while pageIndex < pagesToFetch {
             let page: IndeedSearchPage
             let page: IndeedSearchPage
             do {
             do {
                 page = try await source.searchPage(
                 page = try await source.searchPage(