Pārlūkot izejas kodu

Use API web search for live job results

Switch job queries from local filtering to API-driven web search so users get real listings with title, description, and link, then present results in compact row-wise chat formatting for easier scanning.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 mēneši atpakaļ
vecāks
revīzija
7cafe328fb

+ 6 - 0
App for Indeed.xcodeproj/project.pbxproj

@@ -248,6 +248,7 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_ENTITLEMENTS = "App for Indeed/App for Indeed.entitlements";
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
@@ -257,11 +258,13 @@
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+				INFOPLIST_KEY_OPENAI_API_KEY = "$(OPENAI_API_KEY)";
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 				);
 				MARKETING_VERSION = 1.0;
+				OPENAI_API_KEY = "";
 				PRODUCT_BUNDLE_IDENTIFIER = "MQL-DEV.App-for-Indeed";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				REGISTER_APP_GROUPS = YES;
@@ -279,6 +282,7 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_ENTITLEMENTS = "App for Indeed/App for Indeed.entitlements";
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
@@ -288,11 +292,13 @@
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+				INFOPLIST_KEY_OPENAI_API_KEY = "$(OPENAI_API_KEY)";
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 				);
 				MARKETING_VERSION = 1.0;
+				OPENAI_API_KEY = "";
 				PRODUCT_BUNDLE_IDENTIFIER = "MQL-DEV.App-for-Indeed";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				REGISTER_APP_GROUPS = YES;

+ 12 - 0
App for Indeed/App for Indeed.entitlements

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>com.apple.security.app-sandbox</key>
+	<true/>
+	<key>com.apple.security.files.user-selected.read-only</key>
+	<true/>
+	<key>com.apple.security.network.client</key>
+	<true/>
+</dict>
+</plist>

+ 11 - 5
App for Indeed/Models/DashboardModels.swift

@@ -14,6 +14,7 @@ struct SidebarItem {
 struct JobListing: Hashable, Codable {
     let title: String
     let description: String
+    let url: String?
 }
 
 struct DashboardData {
@@ -40,23 +41,28 @@ final class MockDashboardDataProvider: DashboardDataProviding {
             jobListings: [
                 JobListing(
                     title: "Senior iOS Engineer",
-                    description: "Build polished native software in Swift and SwiftUI. Remote-friendly team, strong product focus, and mentorship for mid-level engineers."
+                    description: "Build polished native software in Swift and SwiftUI. Remote-friendly team, strong product focus, and mentorship for mid-level engineers.",
+                    url: "https://www.indeed.com/jobs?q=Senior+iOS+Engineer"
                 ),
                 JobListing(
                     title: "Product Designer",
-                    description: "Own end-to-end UX for job seeker flows—from discovery to apply. Figma systems, accessibility, and close collaboration with engineering."
+                    description: "Own end-to-end UX for job seeker flows—from discovery to apply. Figma systems, accessibility, and close collaboration with engineering.",
+                    url: "https://www.indeed.com/jobs?q=Product+Designer"
                 ),
                 JobListing(
                     title: "Machine Learning Engineer",
-                    description: "Improve search and recommendations using large-scale data. Python, PyTorch, and production software for ML pipelines; research-to-ship mindset."
+                    description: "Improve search and recommendations using large-scale data. Python, PyTorch, and production software for ML pipelines; research-to-ship mindset.",
+                    url: "https://www.indeed.com/jobs?q=Machine+Learning+Engineer"
                 ),
                 JobListing(
                     title: "Technical Recruiter",
-                    description: "Partner with hiring managers to grow engineering teams. Full-cycle recruiting, inclusive sourcing, and a high-trust candidate experience."
+                    description: "Partner with hiring managers to grow engineering teams. Full-cycle recruiting, inclusive sourcing, and a high-trust candidate experience.",
+                    url: "https://www.indeed.com/jobs?q=Technical+Recruiter"
                 ),
                 JobListing(
                     title: "Customer Success Manager",
-                    description: "Help employers get the most from their hiring tools. Onboarding, training, and proactive check-ins with a metrics-driven playbook."
+                    description: "Help employers get the most from their hiring tools. Onboarding, training, and proactive check-ins with a metrics-driven playbook.",
+                    url: "https://www.indeed.com/jobs?q=Customer+Success+Manager"
                 )
             ]
         )

+ 14 - 0
App for Indeed/OpenAIConfiguration.swift

@@ -0,0 +1,14 @@
+import Foundation
+
+enum OpenAIConfiguration {
+    /// Read key from Info.plist (`OPENAI_API_KEY`) populated by build settings.
+    static var apiKey: String {
+        let fromPlist = Bundle.main.object(forInfoDictionaryKey: "OPENAI_API_KEY") as? String ?? ""
+        return fromPlist.trimmingCharacters(in: .whitespacesAndNewlines)
+    }
+
+    /// Whether `apiKey` is currently populated with a real value.
+    static var hasAPIKey: Bool {
+        !apiKey.isEmpty
+    }
+}

+ 422 - 25
App for Indeed/Views/DashboardView.swift

@@ -5,6 +5,7 @@
 
 import Cocoa
 import QuartzCore
+import Security
 
 private enum JobListingCardContext {
     case homeSearchResults
@@ -80,6 +81,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private let findJobsCTAHost = NSView()
     private let findJobsCTAChrome = HoverableView()
     private var findJobsCTAGradientLayer: CAGradientLayer?
+    private let chatStatusStack = NSStackView()
+    private let chatStatusIcon = NSImageView()
+    private let chatStatusLabel = NSTextField(labelWithString: "Opening the vault...")
+    private let chatScrollView = NSScrollView()
+    private let chatDocumentView = JobListingsDocumentView()
+    private let chatStack = NSStackView()
     private let jobListingsScrollView = NSScrollView()
     /// Flipped so short result lists stay visually under the search bar instead of leaving a gap above the cards.
     private let jobListingsContainer = JobListingsDocumentView()
@@ -100,13 +107,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
     private var currentSidebarItems: [SidebarItem] = []
     private var selectedSidebarIndex: Int = 0
-    /// Full list from `DashboardData`; results are shown after the user runs a search.
-    private var catalogJobListings: [JobListing] = []
     /// Last successful search result set (used when removing a card with the dismiss control).
     private var lastSearchResults: [JobListing] = []
     private var lastNoResultsQuery: String?
     /// Most recently saved jobs appear first; persisted across launches.
     private var savedJobOrder: [JobListing] = []
+    private var chatMessages: [ChatMessage] = []
+    private var isAwaitingResponse = false
+    private let jobSearchService = OpenAIJobSearchService()
 
     override init(frame frameRect: NSRect) {
         super.init(frame: frameRect)
@@ -134,9 +142,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
             selectedSidebarIndex = max(0, currentSidebarItems.count - 1)
         }
         configureSidebar()
-        catalogJobListings = data.jobListings
         savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load())
         configureJobListings([], noResultsForQuery: nil)
+        resetChatState()
         updateMainContentVisibility()
     }
 
@@ -203,6 +211,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         topInset.heightAnchor.constraint(equalToConstant: 18).isActive = true
 
         configureSearchBar()
+        configureChatViews()
 
         let titleBlock = NSStackView(views: [greetingLabel, subtitleLabel])
         titleBlock.orientation = .vertical
@@ -247,9 +256,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         mainOverlay.addArrangedSubview(topInset)
         mainOverlay.addArrangedSubview(titleBlock)
         mainOverlay.addArrangedSubview(midSpacer)
-        mainOverlay.addArrangedSubview(searchBarShadowHost)
+        mainOverlay.addArrangedSubview(chatStatusStack)
         mainOverlay.addArrangedSubview(listingsTopSpacer)
-        mainOverlay.addArrangedSubview(jobListingsScrollView)
+        mainOverlay.addArrangedSubview(chatScrollView)
+        mainOverlay.addArrangedSubview(searchBarShadowHost)
 
         contentStack.addArrangedSubview(sidebar)
         contentStack.addArrangedSubview(mainHost)
@@ -279,7 +289,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
             nonHomeHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
 
             searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
-            jobListingsScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
+            chatStatusStack.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
+            chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
 
             jobListingsContainer.topAnchor.constraint(equalTo: jobListingsScrollView.contentView.topAnchor),
             jobListingsContainer.leadingAnchor.constraint(equalTo: jobListingsScrollView.contentView.leadingAnchor),
@@ -292,6 +303,50 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         ])
     }
 
+    private func configureChatViews() {
+        chatStatusStack.orientation = .vertical
+        chatStatusStack.spacing = 6
+        chatStatusStack.alignment = .centerX
+        chatStatusStack.translatesAutoresizingMaskIntoConstraints = false
+
+        chatStatusIcon.translatesAutoresizingMaskIntoConstraints = false
+        chatStatusIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 36, weight: .regular)
+        chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
+        chatStatusIcon.contentTintColor = Theme.brandBlue
+
+        chatStatusLabel.font = .systemFont(ofSize: 20, weight: .semibold)
+        chatStatusLabel.textColor = Theme.primaryText
+        chatStatusLabel.alignment = .center
+        chatStatusLabel.maximumNumberOfLines = 1
+
+        chatStatusStack.addArrangedSubview(chatStatusIcon)
+        chatStatusStack.addArrangedSubview(chatStatusLabel)
+
+        chatDocumentView.translatesAutoresizingMaskIntoConstraints = false
+        chatStack.orientation = .vertical
+        chatStack.spacing = 10
+        chatStack.alignment = .width
+        chatStack.distribution = .fill
+        chatStack.translatesAutoresizingMaskIntoConstraints = false
+        chatDocumentView.addSubview(chatStack)
+        NSLayoutConstraint.activate([
+            chatStack.leadingAnchor.constraint(equalTo: chatDocumentView.leadingAnchor),
+            chatStack.trailingAnchor.constraint(equalTo: chatDocumentView.trailingAnchor),
+            chatStack.topAnchor.constraint(equalTo: chatDocumentView.topAnchor, constant: 4),
+            chatStack.bottomAnchor.constraint(equalTo: chatDocumentView.bottomAnchor, constant: -4)
+        ])
+
+        chatScrollView.translatesAutoresizingMaskIntoConstraints = false
+        chatScrollView.hasVerticalScroller = true
+        chatScrollView.hasHorizontalScroller = false
+        chatScrollView.autohidesScrollers = true
+        chatScrollView.drawsBackground = false
+        chatScrollView.borderType = .noBorder
+        chatScrollView.documentView = chatDocumentView
+        chatScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
+        chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 260).isActive = true
+    }
+
     private func updateJobListingDescriptionWidths() {
         updateDescriptionColumnWidths(in: jobListingsStack, containerWidth: jobListingsContainer.bounds.width)
         updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
@@ -548,6 +603,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
     @objc private func didTapJobApply(_ sender: NSButton) {
         guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
+        if let rawURL = job.url, let url = URL(string: rawURL), !rawURL.isEmpty {
+            NSWorkspace.shared.open(url)
+            return
+        }
         let allowed = CharacterSet.urlQueryAllowed
         let q = job.title.addingPercentEncoding(withAllowedCharacters: allowed) ?? ""
         guard let url = URL(string: "https://www.indeed.com/jobs?q=\(q)") else { return }
@@ -642,7 +701,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         jobSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Job search")
         jobSearchIcon.contentTintColor = Theme.primaryText
 
-        configureField(jobKeywordsField, placeholder: "Job title, keywords, or company")
+        configureField(jobKeywordsField, placeholder: "Ask for roles, skills, salary, or job descriptions...")
 
         let ctaHeight: CGFloat = 42
         let ctaCorner = ctaHeight / 2
@@ -685,7 +744,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         findJobsButton.translatesAutoresizingMaskIntoConstraints = false
         findJobsButton.title = ""
         findJobsButton.attributedTitle = NSAttributedString(
-            string: "Find jobs",
+            string: "Send",
             attributes: [
                 .font: NSFont.systemFont(ofSize: 14, weight: .semibold),
                 .foregroundColor: Theme.proCTAText,
@@ -753,6 +812,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
             findJobsCTAHost.heightAnchor.constraint(equalToConstant: ctaHeight),
             findJobsCTAHost.widthAnchor.constraint(greaterThanOrEqualToConstant: 112)
         ])
+        searchCard.hoverHandler = nil
     }
 
     private func updateFindJobsCTAShadowPath() {
@@ -1114,7 +1174,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     /// Restores the main job-search experience: cleared query and no listings until the user searches again.
     private func applyHomeState() {
         jobKeywordsField.stringValue = ""
-        clearJobSearchResultsUI()
+        resetChatState()
         window?.makeFirstResponder(nil)
     }
 
@@ -1131,31 +1191,89 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     }
 
     @objc private func didSubmitSearch() {
-        let query = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
-        let results = jobsMatchingSearch(query: query, in: catalogJobListings)
-        let noResultsMessage: String? = (!results.isEmpty || query.isEmpty) ? nil : query
-        configureJobListings(results, noResultsForQuery: noResultsMessage)
+        let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !prompt.isEmpty, !isAwaitingResponse else { return }
+        clearJobSearchResultsUI()
+        appendChatBubble(text: prompt, isUser: true)
+        chatMessages.append(ChatMessage(role: "user", content: prompt))
+        jobKeywordsField.stringValue = ""
+        isAwaitingResponse = true
+        chatStatusLabel.stringValue = "Thinking..."
+        setInputEnabled(false)
+        jobSearchService.searchJobs(query: prompt) { [weak self] result in
+            DispatchQueue.main.async {
+                guard let self else { return }
+                self.isAwaitingResponse = false
+                self.setInputEnabled(true)
+                switch result {
+                case .success(let output):
+                    let normalizedJobs = self.normalizedJobs(output.jobs)
+                    self.configureJobListings(normalizedJobs, noResultsForQuery: prompt)
+                    let reply = self.makeAssistantSearchReply(query: prompt, jobs: normalizedJobs, jsonResult: output.rawJSON)
+                    self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
+                    self.appendChatBubble(text: reply, isUser: false)
+                    self.chatStatusLabel.stringValue = "Ask for another job, company, or skill match"
+                case .failure(let error):
+                    self.appendChatBubble(text: error.localizedDescription, isUser: false)
+                    self.chatStatusLabel.stringValue = "Could not reach API. Try again."
+                    self.configureJobListings([], noResultsForQuery: prompt)
+                }
+            }
+        }
         window?.makeFirstResponder(nil)
     }
 
-    /// Each whitespace-separated token must appear in the title or description (case-insensitive). Empty query returns the full catalog.
-    private func jobsMatchingSearch(query: String, in jobs: [JobListing]) -> [JobListing] {
-        let tokens = query
-            .lowercased()
-            .split(whereSeparator: { $0.isWhitespace })
-            .map(String.init)
-            .filter { !$0.isEmpty }
-        guard !tokens.isEmpty else { return jobs }
-        return jobs.filter { job in
-            let haystack = "\(job.title) \(job.description)".lowercased()
-            return tokens.allSatisfy { haystack.contains($0) }
+    private func normalizedJobs(_ jobs: [JobListing]) -> [JobListing] {
+        let trimmed = jobs.map {
+            JobListing(
+                title: $0.title.trimmingCharacters(in: .whitespacesAndNewlines),
+                description: $0.description.trimmingCharacters(in: .whitespacesAndNewlines),
+                url: $0.url?.trimmingCharacters(in: .whitespacesAndNewlines)
+            )
+        }
+        return trimmed.filter { !$0.title.isEmpty && !$0.description.isEmpty }
+    }
+
+    private func makeAssistantSearchReply(query: String, jobs: [JobListing], jsonResult: String) -> String {
+        if jobs.isEmpty {
+            return """
+            No jobs were found for "\(query)".
+
+            JSON result:
+            \(jsonResult)
+            """
         }
+        let rows = jobs.prefix(8).enumerated().map { index, job in
+            let fallback = "https://www.indeed.com/jobs?q=\(job.title.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")"
+            let link = (job.url?.isEmpty == false) ? job.url! : fallback
+            let compactDescription = compactSingleLine(job.description, maxCharacters: 110)
+            return "\(index + 1)) \(job.title) | \(compactDescription) | \(link)"
+        }
+        return """
+        Found \(jobs.count) job result(s) for "\(query)".
+
+        Row-wise results:
+        \(rows.joined(separator: "\n"))
+
+        JSON result:
+        \(jsonResult)
+        """
+    }
+
+    private func compactSingleLine(_ text: String, maxCharacters: Int) -> String {
+        let single = text
+            .replacingOccurrences(of: "\n", with: " ")
+            .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        guard single.count > maxCharacters, maxCharacters > 1 else { return single }
+        let end = single.index(single.startIndex, offsetBy: maxCharacters - 1)
+        return String(single[..<end]) + "…"
     }
 
     func controlTextDidBeginEditing(_ obj: Notification) {
         applySearchFieldInsertionPoint(obj.object)
         if (obj.object as? NSTextField) === jobKeywordsField {
-            clearJobSearchResultsUI()
+            chatStatusLabel.stringValue = "Opening the vault..."
         }
     }
 
@@ -1178,6 +1296,65 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         textView.insertionPointColor = Theme.primaryText
     }
 
+    private func resetChatState() {
+        chatMessages.removeAll()
+        chatStack.arrangedSubviews.forEach {
+            chatStack.removeArrangedSubview($0)
+            $0.removeFromSuperview()
+        }
+        let welcome = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary."
+        chatMessages.append(ChatMessage(role: "assistant", content: welcome))
+        appendChatBubble(text: welcome, isUser: false)
+        chatStatusLabel.stringValue = "Opening the vault..."
+    }
+
+    private func appendChatBubble(text: String, isUser: Bool) {
+        let host = NSView()
+        host.translatesAutoresizingMaskIntoConstraints = false
+
+        let bubble = NSTextField(wrappingLabelWithString: text)
+        bubble.font = .systemFont(ofSize: 13, weight: .regular)
+        bubble.maximumNumberOfLines = 0
+        bubble.lineBreakMode = .byWordWrapping
+        bubble.alignment = .left
+        bubble.textColor = isUser ? .white : Theme.primaryText
+        bubble.wantsLayer = true
+        bubble.layer?.cornerRadius = 12
+        bubble.layer?.masksToBounds = true
+        bubble.layer?.backgroundColor = (isUser ? Theme.brandBlue : Theme.chromeBackground).cgColor
+        bubble.translatesAutoresizingMaskIntoConstraints = false
+
+        host.addSubview(bubble)
+        chatStack.addArrangedSubview(host)
+        host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
+
+        let leading = bubble.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 8)
+        let trailing = bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor, constant: -8)
+        let maxWidth = bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78)
+        maxWidth.priority = .required
+
+        NSLayoutConstraint.activate([
+            bubble.topAnchor.constraint(equalTo: host.topAnchor),
+            bubble.bottomAnchor.constraint(equalTo: host.bottomAnchor),
+            bubble.heightAnchor.constraint(greaterThanOrEqualToConstant: 34),
+            maxWidth,
+            isUser ? leading.withPriority(.defaultLow) : leading.withPriority(.required),
+            isUser ? trailing.withPriority(.required) : trailing.withPriority(.defaultLow)
+        ])
+
+        DispatchQueue.main.async {
+            let maxY = max(0, self.chatDocumentView.bounds.height - self.chatScrollView.contentView.bounds.height)
+            self.chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY))
+            self.chatScrollView.reflectScrolledClipView(self.chatScrollView.contentView)
+        }
+    }
+
+    private func setInputEnabled(_ enabled: Bool) {
+        jobKeywordsField.isEnabled = enabled
+        findJobsButton.isEnabled = enabled
+        findJobsButton.alphaValue = enabled ? 1 : 0.65
+    }
+
     private func configureSidebar() {
         let items = currentSidebarItems
         sidebar.arrangedSubviews.forEach {
@@ -1393,6 +1570,226 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
 }
 
+private struct ChatMessage: Codable {
+    let role: String
+    let content: String
+}
+
+private final class OpenAIJobSearchService {
+    private let endpoint = URL(string: "https://api.openai.com/v1/responses")!
+    private let session = URLSession(configuration: .ephemeral)
+
+    func searchJobs(query: String, completion: @escaping (Result<JobSearchOutput, Error>) -> Void) {
+        let apiKey = OpenAIConfiguration.apiKey
+        guard OpenAIConfiguration.hasAPIKey else {
+            completion(.failure(NSError(
+                domain: "OpenAIJobSearchService",
+                code: 1,
+                userInfo: [NSLocalizedDescriptionKey: "Missing API key. Set it in OpenAIConfiguration.swift."]
+            )))
+            return
+        }
+
+        var request = URLRequest(url: endpoint)
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.timeoutInterval = 45
+
+        let instructions = """
+        Search the web for currently available jobs related to this role query: "\(query)".
+        Return ONLY strict JSON that matches this schema:
+        {
+          "jobs": [
+            {
+              "title": "string",
+              "description": "string",
+              "url": "https://..."
+            }
+          ]
+        }
+        Keep descriptions concise (1 sentence), include real apply/listing URLs when available, and return up to 8 jobs.
+        """
+        let payload = OpenAIResponsesRequest(
+            model: "gpt-4o-mini",
+            input: instructions,
+            tools: [OpenAIResponsesTool(type: "web_search_preview")]
+        )
+
+        do {
+            request.httpBody = try JSONEncoder().encode(payload)
+        } catch {
+            completion(.failure(error))
+            return
+        }
+
+        session.dataTask(with: request) { data, response, error in
+            if let error {
+                completion(.failure(error))
+                return
+            }
+            guard let data else {
+                completion(.failure(NSError(
+                    domain: "OpenAIJobSearchService",
+                    code: 2,
+                    userInfo: [NSLocalizedDescriptionKey: "API returned an empty response."]
+                )))
+                return
+            }
+            if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
+                if let apiError = try? JSONDecoder().decode(OpenAIAPIErrorResponse.self, from: data) {
+                    completion(.failure(NSError(
+                        domain: "OpenAIJobSearchService",
+                        code: http.statusCode,
+                        userInfo: [NSLocalizedDescriptionKey: apiError.error.message]
+                    )))
+                } else {
+                    completion(.failure(NSError(
+                        domain: "OpenAIJobSearchService",
+                        code: http.statusCode,
+                        userInfo: [NSLocalizedDescriptionKey: "Job search request failed with status \(http.statusCode)."]
+                    )))
+                }
+                return
+            }
+            do {
+                let decoded = try JSONDecoder().decode(OpenAIResponsesResponse.self, from: data)
+                let text = decoded.bestText.trimmingCharacters(in: .whitespacesAndNewlines)
+                guard !text.isEmpty else {
+                    throw NSError(
+                        domain: "OpenAIJobSearchService",
+                        code: 4,
+                        userInfo: [NSLocalizedDescriptionKey: "The API returned an empty text payload."]
+                    )
+                }
+                let cleanedText = Self.extractJSONObject(from: text)
+                let jsonData = Data(cleanedText.utf8)
+                let jobsPayload = try JSONDecoder().decode(JobSearchResultsPayload.self, from: jsonData)
+                completion(.success(JobSearchOutput(jobs: jobsPayload.jobs, rawJSON: cleanedText)))
+            } catch {
+                let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
+                let message = "The API response could not be parsed as job JSON. Raw response: \(rawBody.prefix(600))"
+                completion(.failure(NSError(
+                    domain: "OpenAIJobSearchService",
+                    code: 5,
+                    userInfo: [NSLocalizedDescriptionKey: message]
+                )))
+            }
+        }.resume()
+    }
+
+    private static func extractJSONObject(from text: String) -> String {
+        if let range = text.range(of: "\\{[\\s\\S]*\\}", options: .regularExpression) {
+            return String(text[range])
+        }
+        return text
+    }
+}
+
+private struct OpenAIResponsesRequest: Codable {
+    let model: String
+    let input: String
+    let tools: [OpenAIResponsesTool]
+}
+
+private struct OpenAIResponsesTool: Codable {
+    let type: String
+}
+
+private struct OpenAIResponsesResponse: Codable {
+    let outputText: String?
+    let output: [OpenAIOutputItem]?
+
+    enum CodingKeys: String, CodingKey {
+        case outputText = "output_text"
+        case output
+    }
+
+    var bestText: String {
+        if let outputText, !outputText.isEmpty {
+            return outputText
+        }
+        let collected = (output ?? [])
+            .flatMap { $0.content ?? [] }
+            .compactMap { chunk in
+                switch chunk {
+                case .outputText(let value):
+                    return value.text
+                case .inputText:
+                    return nil
+                }
+            }
+            .joined(separator: "\n")
+        return collected
+    }
+}
+
+private struct OpenAIOutputItem: Codable {
+    let content: [OpenAIOutputContent]?
+}
+
+private enum OpenAIOutputContent: Codable {
+    case outputText(OpenAITextChunk)
+    case inputText(OpenAITextChunk)
+
+    enum CodingKeys: String, CodingKey {
+        case type
+        case text
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        let type = try container.decode(String.self, forKey: .type)
+        let text = try container.decode(String.self, forKey: .text)
+        let payload = OpenAITextChunk(text: text)
+        if type == "output_text" {
+            self = .outputText(payload)
+        } else {
+            self = .inputText(payload)
+        }
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        switch self {
+        case .outputText(let chunk):
+            try container.encode("output_text", forKey: .type)
+            try container.encode(chunk.text, forKey: .text)
+        case .inputText(let chunk):
+            try container.encode("input_text", forKey: .type)
+            try container.encode(chunk.text, forKey: .text)
+        }
+    }
+}
+
+private struct OpenAITextChunk: Codable {
+    let text: String
+}
+
+private struct JobSearchResultsPayload: Codable {
+    let jobs: [JobListing]
+}
+
+private struct JobSearchOutput {
+    let jobs: [JobListing]
+    let rawJSON: String
+}
+
+private struct OpenAIAPIErrorResponse: Codable {
+    let error: APIError
+
+    struct APIError: Codable {
+        let message: String
+    }
+}
+
+private extension NSLayoutConstraint {
+    func withPriority(_ priority: NSLayoutConstraint.Priority) -> NSLayoutConstraint {
+        self.priority = priority
+        return self
+    }
+}
+
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
 private final class JobPayloadButton: HoverableButton {
     var jobPayload: JobListing?