Sfoglia il codice sorgente

Fix follow-up searches using stale filter titles and speed up filter refreshes.

Clear AI-filled title/keywords on new queries, skip WebKit enrichment on filter-only updates, and improve search submit UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali 2 ore fa
parent
commit
9f6f7115de

+ 4 - 2
indeed_ai_job_search/DesignSystem/Components/SearchComposerView.swift

@@ -149,8 +149,10 @@ struct SearchComposerView: View {
                 .frame(maxHeight: .infinity)
             }
             .buttonStyle(CompactSearchButtonStyle())
-            .disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSearching)
-            .keyboardShortcut(.return, modifiers: .command)
+            // Keep enabled while searching so a new query can cancel and replace the in-flight one.
+            .disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+            .keyboardShortcut(.defaultAction)
+            .help(isSearching ? "Search again (cancels current search)" : "Search")
         }
         .frame(height: barHeight)
     }

+ 5 - 1
indeed_ai_job_search/Domain/Protocols/JobSearchProtocols.swift

@@ -50,15 +50,19 @@ struct JobSearchPipelineRequest: Sendable {
     var userCriteria: SearchCriteria
     /// When false, skips OpenAI interpretation and uses `userCriteria` as-is.
     var reinterpret: Bool
+    /// When false, skips the live Indeed scrape enrichment pass (faster filter refreshes).
+    var includeEnrichment: Bool
 
     init(
         naturalLanguageQuery: String,
         userCriteria: SearchCriteria,
-        reinterpret: Bool = true
+        reinterpret: Bool = true,
+        includeEnrichment: Bool = true
     ) {
         self.naturalLanguageQuery = naturalLanguageQuery
         self.userCriteria = userCriteria
         self.reinterpret = reinterpret
+        self.includeEnrichment = includeEnrichment
     }
 }
 

+ 63 - 11
indeed_ai_job_search/Features/JobSearch/ViewModels/JobSearchViewModel.swift

@@ -75,14 +75,33 @@ final class JobSearchViewModel {
     func submitSearch() {
         let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !trimmed.isEmpty else { return }
-        runProgressiveSearch(query: trimmed, criteria: criteria, reinterpret: true)
+        filterDebounceTask?.cancel()
+
+        // A new search-box query owns title/keywords. Previous AI-filled filter values
+        // must not win in SearchCriteriaMerge, or Indeed keeps searching the old title.
+        var searchCriteria = criteria
+        searchCriteria.jobTitle = ""
+        searchCriteria.keywords = ""
+        searchCriteria.exactPhrase = false
+
+        runProgressiveSearch(
+            query: trimmed,
+            criteria: searchCriteria,
+            reinterpret: true,
+            includeEnrichment: true
+        )
     }
 
     func applyFiltersAndRefresh() {
         guard hasSearched, !suppressFilterRefresh else { return }
+
+        // Instant local narrowing so checkboxes/text feel responsive while the
+        // network refresh is debounced.
+        applyLocalFilterPreview()
+
         filterDebounceTask?.cancel()
         filterDebounceTask = Task {
-            try? await Task.sleep(nanoseconds: 350_000_000)
+            try? await Task.sleep(nanoseconds: 160_000_000)
             if Task.isCancelled || suppressFilterRefresh { return }
             refreshResultsOnly()
         }
@@ -99,30 +118,63 @@ final class JobSearchViewModel {
     }
 
     /// Re-runs fetch/filter/rank with current criteria without re-interpreting the query.
+    /// Skips live scrape enrichment so filter changes stay fast.
     private func refreshResultsOnly() {
         let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
-        runProgressiveSearch(query: trimmed, criteria: criteria, reinterpret: false)
+        runProgressiveSearch(
+            query: trimmed,
+            criteria: criteria,
+            reinterpret: false,
+            includeEnrichment: false
+        )
+    }
+
+    /// Narrows already-loaded rows immediately; network refresh corrects/expands afterward.
+    private func applyLocalFilterPreview() {
+        guard !results.isEmpty else { return }
+        let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+        let narrowed = JobListingFilter.filter(results, criteria: criteria, query: trimmed)
+        guard narrowed.count != results.count else { return }
+        results = narrowed
+        totalResultCount = narrowed.count
+        if narrowed.isEmpty {
+            state = .loading
+        } else if state != .loading {
+            state = .success
+        }
     }
 
     private func runProgressiveSearch(
         query trimmed: String,
         criteria searchCriteria: SearchCriteria,
-        reinterpret: Bool
+        reinterpret: Bool,
+        includeEnrichment: Bool
     ) {
         searchTask?.cancel()
+        // Filter refreshes keep the local preview on screen; full NL searches clear first.
+        // Do not set `isLoadingMore` here — that flag means "append enrichment under primary",
+        // and would prevent replacing results with the newly filtered fetch.
+        let preserveResultsWhileLoading = !reinterpret && !results.isEmpty
         searchTask = Task {
-            state = .loading
-            isLoadingMore = false
+            if preserveResultsWhileLoading {
+                if state != .success && state != .loading {
+                    state = .loading
+                }
+            } else {
+                state = .loading
+                isLoadingMore = false
+                results = []
+                totalResultCount = 0
+            }
             hasSearched = true
-            results = []
-            totalResultCount = 0
 
             do {
                 let stream = pipeline.searchProgressive(
                     JobSearchPipelineRequest(
                         naturalLanguageQuery: trimmed,
                         userCriteria: searchCriteria,
-                        reinterpret: reinterpret
+                        reinterpret: reinterpret,
+                        includeEnrichment: includeEnrichment
                     )
                 )
 
@@ -187,9 +239,9 @@ final class JobSearchViewModel {
         suppressFilterRefresh = true
         criteria = newCriteria
         Task { @MainActor in
-            // Cover FiltersPanel onChange + its 350ms debounce so interpretation
+            // Cover FiltersPanel onChange + its debounce so interpretation
             // does not start a second search on top of enrichment.
-            try? await Task.sleep(nanoseconds: 500_000_000)
+            try? await Task.sleep(nanoseconds: 400_000_000)
             suppressFilterRefresh = false
         }
     }

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

@@ -162,7 +162,8 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
             )
         }
 
-        let hasEnrichment = enrichmentDataSource != nil
+        let enrichmentSource = request.includeEnrichment ? enrichmentDataSource : nil
+        let hasEnrichment = enrichmentSource != nil
 
         // Push web_search rows to the UI immediately — before WebKit scrape takes MainActor.
         if !primaryListings.isEmpty, hasEnrichment {
@@ -188,8 +189,9 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
 
         // Phase 2 — live scrape enrichment (or sole source if primary empty/failed).
         // One page is enough for append-under-primary; keeps enrichment fast.
+        // Skipped on filter-only refreshes (`includeEnrichment: false`) for snappier UI.
         var merged = primaryListings
-        if let enrichment = enrichmentDataSource {
+        if let enrichment = enrichmentSource {
             do {
                 let fetched = try await fetchPages(
                     from: enrichment,

+ 4 - 0
indeed_ai_job_search/Services/Search/SearchCriteriaMerge.swift

@@ -7,6 +7,10 @@ import Foundation
 
 enum SearchCriteriaMerge {
     /// Prefer explicit user filter edits over AI-extracted values when present.
+    ///
+    /// Callers submitting a fresh natural-language query should clear `jobTitle` /
+    /// `keywords` on `userEdited` first so a previous search's AI-filled title
+    /// cannot override the newly interpreted role.
     static func merge(interpreted: SearchCriteria, userEdited: SearchCriteria) -> SearchCriteria {
         var merged = interpreted
 

+ 19 - 0
indeed_ai_job_searchTests/JobSearchPipelineTests.swift

@@ -114,6 +114,25 @@ final class SearchCriteriaMergeTests: XCTestCase {
         let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user)
         XCTAssertEqual(merged.country, "Pakistan")
     }
+
+    /// New search-box queries clear title/keywords before merge so the latest
+    /// interpreted role wins instead of a stale AI-filled filter title.
+    func testClearedTitleLetsNewInterpretationWin() {
+        var interpreted = SearchCriteria()
+        interpreted.jobTitle = "DevOps Engineer"
+        interpreted.country = "Pakistan"
+
+        var user = SearchCriteria()
+        user.jobTitle = "" // cleared by submitSearch
+        user.keywords = ""
+        user.country = "Pakistan"
+        user.remoteTypes = [.remote]
+
+        let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user)
+        XCTAssertEqual(merged.jobTitle, "DevOps Engineer")
+        XCTAssertEqual(merged.country, "Pakistan")
+        XCTAssertEqual(merged.remoteTypes, [.remote])
+    }
 }
 
 final class IndeedBrowseURLBuilderTests: XCTestCase {