Преглед изворни кода

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 пре 3 недеља
родитељ
комит
7cafe328fb

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

@@ -248,6 +248,7 @@
248 248
 			buildSettings = {
249 249
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
250 250
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
251
+				CODE_SIGN_ENTITLEMENTS = "App for Indeed/App for Indeed.entitlements";
251 252
 				CODE_SIGN_STYLE = Automatic;
252 253
 				COMBINE_HIDPI_IMAGES = YES;
253 254
 				CURRENT_PROJECT_VERSION = 1;
@@ -257,11 +258,13 @@
257 258
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
258 259
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
259 260
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
261
+				INFOPLIST_KEY_OPENAI_API_KEY = "$(OPENAI_API_KEY)";
260 262
 				LD_RUNPATH_SEARCH_PATHS = (
261 263
 					"$(inherited)",
262 264
 					"@executable_path/../Frameworks",
263 265
 				);
264 266
 				MARKETING_VERSION = 1.0;
267
+				OPENAI_API_KEY = "";
265 268
 				PRODUCT_BUNDLE_IDENTIFIER = "MQL-DEV.App-for-Indeed";
266 269
 				PRODUCT_NAME = "$(TARGET_NAME)";
267 270
 				REGISTER_APP_GROUPS = YES;
@@ -279,6 +282,7 @@
279 282
 			buildSettings = {
280 283
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
281 284
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
285
+				CODE_SIGN_ENTITLEMENTS = "App for Indeed/App for Indeed.entitlements";
282 286
 				CODE_SIGN_STYLE = Automatic;
283 287
 				COMBINE_HIDPI_IMAGES = YES;
284 288
 				CURRENT_PROJECT_VERSION = 1;
@@ -288,11 +292,13 @@
288 292
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
289 293
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
290 294
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
295
+				INFOPLIST_KEY_OPENAI_API_KEY = "$(OPENAI_API_KEY)";
291 296
 				LD_RUNPATH_SEARCH_PATHS = (
292 297
 					"$(inherited)",
293 298
 					"@executable_path/../Frameworks",
294 299
 				);
295 300
 				MARKETING_VERSION = 1.0;
301
+				OPENAI_API_KEY = "";
296 302
 				PRODUCT_BUNDLE_IDENTIFIER = "MQL-DEV.App-for-Indeed";
297 303
 				PRODUCT_NAME = "$(TARGET_NAME)";
298 304
 				REGISTER_APP_GROUPS = YES;

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

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

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

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

+ 14 - 0
App for Indeed/OpenAIConfiguration.swift

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

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

@@ -5,6 +5,7 @@
5 5
 
6 6
 import Cocoa
7 7
 import QuartzCore
8
+import Security
8 9
 
9 10
 private enum JobListingCardContext {
10 11
     case homeSearchResults
@@ -80,6 +81,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
80 81
     private let findJobsCTAHost = NSView()
81 82
     private let findJobsCTAChrome = HoverableView()
82 83
     private var findJobsCTAGradientLayer: CAGradientLayer?
84
+    private let chatStatusStack = NSStackView()
85
+    private let chatStatusIcon = NSImageView()
86
+    private let chatStatusLabel = NSTextField(labelWithString: "Opening the vault...")
87
+    private let chatScrollView = NSScrollView()
88
+    private let chatDocumentView = JobListingsDocumentView()
89
+    private let chatStack = NSStackView()
83 90
     private let jobListingsScrollView = NSScrollView()
84 91
     /// Flipped so short result lists stay visually under the search bar instead of leaving a gap above the cards.
85 92
     private let jobListingsContainer = JobListingsDocumentView()
@@ -100,13 +107,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
100 107
 
101 108
     private var currentSidebarItems: [SidebarItem] = []
102 109
     private var selectedSidebarIndex: Int = 0
103
-    /// Full list from `DashboardData`; results are shown after the user runs a search.
104
-    private var catalogJobListings: [JobListing] = []
105 110
     /// Last successful search result set (used when removing a card with the dismiss control).
106 111
     private var lastSearchResults: [JobListing] = []
107 112
     private var lastNoResultsQuery: String?
108 113
     /// Most recently saved jobs appear first; persisted across launches.
109 114
     private var savedJobOrder: [JobListing] = []
115
+    private var chatMessages: [ChatMessage] = []
116
+    private var isAwaitingResponse = false
117
+    private let jobSearchService = OpenAIJobSearchService()
110 118
 
111 119
     override init(frame frameRect: NSRect) {
112 120
         super.init(frame: frameRect)
@@ -134,9 +142,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
134 142
             selectedSidebarIndex = max(0, currentSidebarItems.count - 1)
135 143
         }
136 144
         configureSidebar()
137
-        catalogJobListings = data.jobListings
138 145
         savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load())
139 146
         configureJobListings([], noResultsForQuery: nil)
147
+        resetChatState()
140 148
         updateMainContentVisibility()
141 149
     }
142 150
 
@@ -203,6 +211,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
203 211
         topInset.heightAnchor.constraint(equalToConstant: 18).isActive = true
204 212
 
205 213
         configureSearchBar()
214
+        configureChatViews()
206 215
 
207 216
         let titleBlock = NSStackView(views: [greetingLabel, subtitleLabel])
208 217
         titleBlock.orientation = .vertical
@@ -247,9 +256,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
247 256
         mainOverlay.addArrangedSubview(topInset)
248 257
         mainOverlay.addArrangedSubview(titleBlock)
249 258
         mainOverlay.addArrangedSubview(midSpacer)
250
-        mainOverlay.addArrangedSubview(searchBarShadowHost)
259
+        mainOverlay.addArrangedSubview(chatStatusStack)
251 260
         mainOverlay.addArrangedSubview(listingsTopSpacer)
252
-        mainOverlay.addArrangedSubview(jobListingsScrollView)
261
+        mainOverlay.addArrangedSubview(chatScrollView)
262
+        mainOverlay.addArrangedSubview(searchBarShadowHost)
253 263
 
254 264
         contentStack.addArrangedSubview(sidebar)
255 265
         contentStack.addArrangedSubview(mainHost)
@@ -279,7 +289,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
279 289
             nonHomeHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
280 290
 
281 291
             searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
282
-            jobListingsScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
292
+            chatStatusStack.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
293
+            chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
283 294
 
284 295
             jobListingsContainer.topAnchor.constraint(equalTo: jobListingsScrollView.contentView.topAnchor),
285 296
             jobListingsContainer.leadingAnchor.constraint(equalTo: jobListingsScrollView.contentView.leadingAnchor),
@@ -292,6 +303,50 @@ final class DashboardView: NSView, NSTextFieldDelegate {
292 303
         ])
293 304
     }
294 305
 
306
+    private func configureChatViews() {
307
+        chatStatusStack.orientation = .vertical
308
+        chatStatusStack.spacing = 6
309
+        chatStatusStack.alignment = .centerX
310
+        chatStatusStack.translatesAutoresizingMaskIntoConstraints = false
311
+
312
+        chatStatusIcon.translatesAutoresizingMaskIntoConstraints = false
313
+        chatStatusIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 36, weight: .regular)
314
+        chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
315
+        chatStatusIcon.contentTintColor = Theme.brandBlue
316
+
317
+        chatStatusLabel.font = .systemFont(ofSize: 20, weight: .semibold)
318
+        chatStatusLabel.textColor = Theme.primaryText
319
+        chatStatusLabel.alignment = .center
320
+        chatStatusLabel.maximumNumberOfLines = 1
321
+
322
+        chatStatusStack.addArrangedSubview(chatStatusIcon)
323
+        chatStatusStack.addArrangedSubview(chatStatusLabel)
324
+
325
+        chatDocumentView.translatesAutoresizingMaskIntoConstraints = false
326
+        chatStack.orientation = .vertical
327
+        chatStack.spacing = 10
328
+        chatStack.alignment = .width
329
+        chatStack.distribution = .fill
330
+        chatStack.translatesAutoresizingMaskIntoConstraints = false
331
+        chatDocumentView.addSubview(chatStack)
332
+        NSLayoutConstraint.activate([
333
+            chatStack.leadingAnchor.constraint(equalTo: chatDocumentView.leadingAnchor),
334
+            chatStack.trailingAnchor.constraint(equalTo: chatDocumentView.trailingAnchor),
335
+            chatStack.topAnchor.constraint(equalTo: chatDocumentView.topAnchor, constant: 4),
336
+            chatStack.bottomAnchor.constraint(equalTo: chatDocumentView.bottomAnchor, constant: -4)
337
+        ])
338
+
339
+        chatScrollView.translatesAutoresizingMaskIntoConstraints = false
340
+        chatScrollView.hasVerticalScroller = true
341
+        chatScrollView.hasHorizontalScroller = false
342
+        chatScrollView.autohidesScrollers = true
343
+        chatScrollView.drawsBackground = false
344
+        chatScrollView.borderType = .noBorder
345
+        chatScrollView.documentView = chatDocumentView
346
+        chatScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
347
+        chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 260).isActive = true
348
+    }
349
+
295 350
     private func updateJobListingDescriptionWidths() {
296 351
         updateDescriptionColumnWidths(in: jobListingsStack, containerWidth: jobListingsContainer.bounds.width)
297 352
         updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
@@ -548,6 +603,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
548 603
 
549 604
     @objc private func didTapJobApply(_ sender: NSButton) {
550 605
         guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
606
+        if let rawURL = job.url, let url = URL(string: rawURL), !rawURL.isEmpty {
607
+            NSWorkspace.shared.open(url)
608
+            return
609
+        }
551 610
         let allowed = CharacterSet.urlQueryAllowed
552 611
         let q = job.title.addingPercentEncoding(withAllowedCharacters: allowed) ?? ""
553 612
         guard let url = URL(string: "https://www.indeed.com/jobs?q=\(q)") else { return }
@@ -642,7 +701,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
642 701
         jobSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Job search")
643 702
         jobSearchIcon.contentTintColor = Theme.primaryText
644 703
 
645
-        configureField(jobKeywordsField, placeholder: "Job title, keywords, or company")
704
+        configureField(jobKeywordsField, placeholder: "Ask for roles, skills, salary, or job descriptions...")
646 705
 
647 706
         let ctaHeight: CGFloat = 42
648 707
         let ctaCorner = ctaHeight / 2
@@ -685,7 +744,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
685 744
         findJobsButton.translatesAutoresizingMaskIntoConstraints = false
686 745
         findJobsButton.title = ""
687 746
         findJobsButton.attributedTitle = NSAttributedString(
688
-            string: "Find jobs",
747
+            string: "Send",
689 748
             attributes: [
690 749
                 .font: NSFont.systemFont(ofSize: 14, weight: .semibold),
691 750
                 .foregroundColor: Theme.proCTAText,
@@ -753,6 +812,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
753 812
             findJobsCTAHost.heightAnchor.constraint(equalToConstant: ctaHeight),
754 813
             findJobsCTAHost.widthAnchor.constraint(greaterThanOrEqualToConstant: 112)
755 814
         ])
815
+        searchCard.hoverHandler = nil
756 816
     }
757 817
 
758 818
     private func updateFindJobsCTAShadowPath() {
@@ -1114,7 +1174,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1114 1174
     /// Restores the main job-search experience: cleared query and no listings until the user searches again.
1115 1175
     private func applyHomeState() {
1116 1176
         jobKeywordsField.stringValue = ""
1117
-        clearJobSearchResultsUI()
1177
+        resetChatState()
1118 1178
         window?.makeFirstResponder(nil)
1119 1179
     }
1120 1180
 
@@ -1131,31 +1191,89 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1131 1191
     }
1132 1192
 
1133 1193
     @objc private func didSubmitSearch() {
1134
-        let query = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
1135
-        let results = jobsMatchingSearch(query: query, in: catalogJobListings)
1136
-        let noResultsMessage: String? = (!results.isEmpty || query.isEmpty) ? nil : query
1137
-        configureJobListings(results, noResultsForQuery: noResultsMessage)
1194
+        let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
1195
+        guard !prompt.isEmpty, !isAwaitingResponse else { return }
1196
+        clearJobSearchResultsUI()
1197
+        appendChatBubble(text: prompt, isUser: true)
1198
+        chatMessages.append(ChatMessage(role: "user", content: prompt))
1199
+        jobKeywordsField.stringValue = ""
1200
+        isAwaitingResponse = true
1201
+        chatStatusLabel.stringValue = "Thinking..."
1202
+        setInputEnabled(false)
1203
+        jobSearchService.searchJobs(query: prompt) { [weak self] result in
1204
+            DispatchQueue.main.async {
1205
+                guard let self else { return }
1206
+                self.isAwaitingResponse = false
1207
+                self.setInputEnabled(true)
1208
+                switch result {
1209
+                case .success(let output):
1210
+                    let normalizedJobs = self.normalizedJobs(output.jobs)
1211
+                    self.configureJobListings(normalizedJobs, noResultsForQuery: prompt)
1212
+                    let reply = self.makeAssistantSearchReply(query: prompt, jobs: normalizedJobs, jsonResult: output.rawJSON)
1213
+                    self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
1214
+                    self.appendChatBubble(text: reply, isUser: false)
1215
+                    self.chatStatusLabel.stringValue = "Ask for another job, company, or skill match"
1216
+                case .failure(let error):
1217
+                    self.appendChatBubble(text: error.localizedDescription, isUser: false)
1218
+                    self.chatStatusLabel.stringValue = "Could not reach API. Try again."
1219
+                    self.configureJobListings([], noResultsForQuery: prompt)
1220
+                }
1221
+            }
1222
+        }
1138 1223
         window?.makeFirstResponder(nil)
1139 1224
     }
1140 1225
 
1141
-    /// Each whitespace-separated token must appear in the title or description (case-insensitive). Empty query returns the full catalog.
1142
-    private func jobsMatchingSearch(query: String, in jobs: [JobListing]) -> [JobListing] {
1143
-        let tokens = query
1144
-            .lowercased()
1145
-            .split(whereSeparator: { $0.isWhitespace })
1146
-            .map(String.init)
1147
-            .filter { !$0.isEmpty }
1148
-        guard !tokens.isEmpty else { return jobs }
1149
-        return jobs.filter { job in
1150
-            let haystack = "\(job.title) \(job.description)".lowercased()
1151
-            return tokens.allSatisfy { haystack.contains($0) }
1226
+    private func normalizedJobs(_ jobs: [JobListing]) -> [JobListing] {
1227
+        let trimmed = jobs.map {
1228
+            JobListing(
1229
+                title: $0.title.trimmingCharacters(in: .whitespacesAndNewlines),
1230
+                description: $0.description.trimmingCharacters(in: .whitespacesAndNewlines),
1231
+                url: $0.url?.trimmingCharacters(in: .whitespacesAndNewlines)
1232
+            )
1233
+        }
1234
+        return trimmed.filter { !$0.title.isEmpty && !$0.description.isEmpty }
1235
+    }
1236
+
1237
+    private func makeAssistantSearchReply(query: String, jobs: [JobListing], jsonResult: String) -> String {
1238
+        if jobs.isEmpty {
1239
+            return """
1240
+            No jobs were found for "\(query)".
1241
+
1242
+            JSON result:
1243
+            \(jsonResult)
1244
+            """
1152 1245
         }
1246
+        let rows = jobs.prefix(8).enumerated().map { index, job in
1247
+            let fallback = "https://www.indeed.com/jobs?q=\(job.title.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")"
1248
+            let link = (job.url?.isEmpty == false) ? job.url! : fallback
1249
+            let compactDescription = compactSingleLine(job.description, maxCharacters: 110)
1250
+            return "\(index + 1)) \(job.title) | \(compactDescription) | \(link)"
1251
+        }
1252
+        return """
1253
+        Found \(jobs.count) job result(s) for "\(query)".
1254
+
1255
+        Row-wise results:
1256
+        \(rows.joined(separator: "\n"))
1257
+
1258
+        JSON result:
1259
+        \(jsonResult)
1260
+        """
1261
+    }
1262
+
1263
+    private func compactSingleLine(_ text: String, maxCharacters: Int) -> String {
1264
+        let single = text
1265
+            .replacingOccurrences(of: "\n", with: " ")
1266
+            .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
1267
+            .trimmingCharacters(in: .whitespacesAndNewlines)
1268
+        guard single.count > maxCharacters, maxCharacters > 1 else { return single }
1269
+        let end = single.index(single.startIndex, offsetBy: maxCharacters - 1)
1270
+        return String(single[..<end]) + "…"
1153 1271
     }
1154 1272
 
1155 1273
     func controlTextDidBeginEditing(_ obj: Notification) {
1156 1274
         applySearchFieldInsertionPoint(obj.object)
1157 1275
         if (obj.object as? NSTextField) === jobKeywordsField {
1158
-            clearJobSearchResultsUI()
1276
+            chatStatusLabel.stringValue = "Opening the vault..."
1159 1277
         }
1160 1278
     }
1161 1279
 
@@ -1178,6 +1296,65 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1178 1296
         textView.insertionPointColor = Theme.primaryText
1179 1297
     }
1180 1298
 
1299
+    private func resetChatState() {
1300
+        chatMessages.removeAll()
1301
+        chatStack.arrangedSubviews.forEach {
1302
+            chatStack.removeArrangedSubview($0)
1303
+            $0.removeFromSuperview()
1304
+        }
1305
+        let welcome = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary."
1306
+        chatMessages.append(ChatMessage(role: "assistant", content: welcome))
1307
+        appendChatBubble(text: welcome, isUser: false)
1308
+        chatStatusLabel.stringValue = "Opening the vault..."
1309
+    }
1310
+
1311
+    private func appendChatBubble(text: String, isUser: Bool) {
1312
+        let host = NSView()
1313
+        host.translatesAutoresizingMaskIntoConstraints = false
1314
+
1315
+        let bubble = NSTextField(wrappingLabelWithString: text)
1316
+        bubble.font = .systemFont(ofSize: 13, weight: .regular)
1317
+        bubble.maximumNumberOfLines = 0
1318
+        bubble.lineBreakMode = .byWordWrapping
1319
+        bubble.alignment = .left
1320
+        bubble.textColor = isUser ? .white : Theme.primaryText
1321
+        bubble.wantsLayer = true
1322
+        bubble.layer?.cornerRadius = 12
1323
+        bubble.layer?.masksToBounds = true
1324
+        bubble.layer?.backgroundColor = (isUser ? Theme.brandBlue : Theme.chromeBackground).cgColor
1325
+        bubble.translatesAutoresizingMaskIntoConstraints = false
1326
+
1327
+        host.addSubview(bubble)
1328
+        chatStack.addArrangedSubview(host)
1329
+        host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
1330
+
1331
+        let leading = bubble.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 8)
1332
+        let trailing = bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor, constant: -8)
1333
+        let maxWidth = bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78)
1334
+        maxWidth.priority = .required
1335
+
1336
+        NSLayoutConstraint.activate([
1337
+            bubble.topAnchor.constraint(equalTo: host.topAnchor),
1338
+            bubble.bottomAnchor.constraint(equalTo: host.bottomAnchor),
1339
+            bubble.heightAnchor.constraint(greaterThanOrEqualToConstant: 34),
1340
+            maxWidth,
1341
+            isUser ? leading.withPriority(.defaultLow) : leading.withPriority(.required),
1342
+            isUser ? trailing.withPriority(.required) : trailing.withPriority(.defaultLow)
1343
+        ])
1344
+
1345
+        DispatchQueue.main.async {
1346
+            let maxY = max(0, self.chatDocumentView.bounds.height - self.chatScrollView.contentView.bounds.height)
1347
+            self.chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY))
1348
+            self.chatScrollView.reflectScrolledClipView(self.chatScrollView.contentView)
1349
+        }
1350
+    }
1351
+
1352
+    private func setInputEnabled(_ enabled: Bool) {
1353
+        jobKeywordsField.isEnabled = enabled
1354
+        findJobsButton.isEnabled = enabled
1355
+        findJobsButton.alphaValue = enabled ? 1 : 0.65
1356
+    }
1357
+
1181 1358
     private func configureSidebar() {
1182 1359
         let items = currentSidebarItems
1183 1360
         sidebar.arrangedSubviews.forEach {
@@ -1393,6 +1570,226 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1393 1570
 
1394 1571
 }
1395 1572
 
1573
+private struct ChatMessage: Codable {
1574
+    let role: String
1575
+    let content: String
1576
+}
1577
+
1578
+private final class OpenAIJobSearchService {
1579
+    private let endpoint = URL(string: "https://api.openai.com/v1/responses")!
1580
+    private let session = URLSession(configuration: .ephemeral)
1581
+
1582
+    func searchJobs(query: String, completion: @escaping (Result<JobSearchOutput, Error>) -> Void) {
1583
+        let apiKey = OpenAIConfiguration.apiKey
1584
+        guard OpenAIConfiguration.hasAPIKey else {
1585
+            completion(.failure(NSError(
1586
+                domain: "OpenAIJobSearchService",
1587
+                code: 1,
1588
+                userInfo: [NSLocalizedDescriptionKey: "Missing API key. Set it in OpenAIConfiguration.swift."]
1589
+            )))
1590
+            return
1591
+        }
1592
+
1593
+        var request = URLRequest(url: endpoint)
1594
+        request.httpMethod = "POST"
1595
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
1596
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
1597
+        request.timeoutInterval = 45
1598
+
1599
+        let instructions = """
1600
+        Search the web for currently available jobs related to this role query: "\(query)".
1601
+        Return ONLY strict JSON that matches this schema:
1602
+        {
1603
+          "jobs": [
1604
+            {
1605
+              "title": "string",
1606
+              "description": "string",
1607
+              "url": "https://..."
1608
+            }
1609
+          ]
1610
+        }
1611
+        Keep descriptions concise (1 sentence), include real apply/listing URLs when available, and return up to 8 jobs.
1612
+        """
1613
+        let payload = OpenAIResponsesRequest(
1614
+            model: "gpt-4o-mini",
1615
+            input: instructions,
1616
+            tools: [OpenAIResponsesTool(type: "web_search_preview")]
1617
+        )
1618
+
1619
+        do {
1620
+            request.httpBody = try JSONEncoder().encode(payload)
1621
+        } catch {
1622
+            completion(.failure(error))
1623
+            return
1624
+        }
1625
+
1626
+        session.dataTask(with: request) { data, response, error in
1627
+            if let error {
1628
+                completion(.failure(error))
1629
+                return
1630
+            }
1631
+            guard let data else {
1632
+                completion(.failure(NSError(
1633
+                    domain: "OpenAIJobSearchService",
1634
+                    code: 2,
1635
+                    userInfo: [NSLocalizedDescriptionKey: "API returned an empty response."]
1636
+                )))
1637
+                return
1638
+            }
1639
+            if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
1640
+                if let apiError = try? JSONDecoder().decode(OpenAIAPIErrorResponse.self, from: data) {
1641
+                    completion(.failure(NSError(
1642
+                        domain: "OpenAIJobSearchService",
1643
+                        code: http.statusCode,
1644
+                        userInfo: [NSLocalizedDescriptionKey: apiError.error.message]
1645
+                    )))
1646
+                } else {
1647
+                    completion(.failure(NSError(
1648
+                        domain: "OpenAIJobSearchService",
1649
+                        code: http.statusCode,
1650
+                        userInfo: [NSLocalizedDescriptionKey: "Job search request failed with status \(http.statusCode)."]
1651
+                    )))
1652
+                }
1653
+                return
1654
+            }
1655
+            do {
1656
+                let decoded = try JSONDecoder().decode(OpenAIResponsesResponse.self, from: data)
1657
+                let text = decoded.bestText.trimmingCharacters(in: .whitespacesAndNewlines)
1658
+                guard !text.isEmpty else {
1659
+                    throw NSError(
1660
+                        domain: "OpenAIJobSearchService",
1661
+                        code: 4,
1662
+                        userInfo: [NSLocalizedDescriptionKey: "The API returned an empty text payload."]
1663
+                    )
1664
+                }
1665
+                let cleanedText = Self.extractJSONObject(from: text)
1666
+                let jsonData = Data(cleanedText.utf8)
1667
+                let jobsPayload = try JSONDecoder().decode(JobSearchResultsPayload.self, from: jsonData)
1668
+                completion(.success(JobSearchOutput(jobs: jobsPayload.jobs, rawJSON: cleanedText)))
1669
+            } catch {
1670
+                let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
1671
+                let message = "The API response could not be parsed as job JSON. Raw response: \(rawBody.prefix(600))"
1672
+                completion(.failure(NSError(
1673
+                    domain: "OpenAIJobSearchService",
1674
+                    code: 5,
1675
+                    userInfo: [NSLocalizedDescriptionKey: message]
1676
+                )))
1677
+            }
1678
+        }.resume()
1679
+    }
1680
+
1681
+    private static func extractJSONObject(from text: String) -> String {
1682
+        if let range = text.range(of: "\\{[\\s\\S]*\\}", options: .regularExpression) {
1683
+            return String(text[range])
1684
+        }
1685
+        return text
1686
+    }
1687
+}
1688
+
1689
+private struct OpenAIResponsesRequest: Codable {
1690
+    let model: String
1691
+    let input: String
1692
+    let tools: [OpenAIResponsesTool]
1693
+}
1694
+
1695
+private struct OpenAIResponsesTool: Codable {
1696
+    let type: String
1697
+}
1698
+
1699
+private struct OpenAIResponsesResponse: Codable {
1700
+    let outputText: String?
1701
+    let output: [OpenAIOutputItem]?
1702
+
1703
+    enum CodingKeys: String, CodingKey {
1704
+        case outputText = "output_text"
1705
+        case output
1706
+    }
1707
+
1708
+    var bestText: String {
1709
+        if let outputText, !outputText.isEmpty {
1710
+            return outputText
1711
+        }
1712
+        let collected = (output ?? [])
1713
+            .flatMap { $0.content ?? [] }
1714
+            .compactMap { chunk in
1715
+                switch chunk {
1716
+                case .outputText(let value):
1717
+                    return value.text
1718
+                case .inputText:
1719
+                    return nil
1720
+                }
1721
+            }
1722
+            .joined(separator: "\n")
1723
+        return collected
1724
+    }
1725
+}
1726
+
1727
+private struct OpenAIOutputItem: Codable {
1728
+    let content: [OpenAIOutputContent]?
1729
+}
1730
+
1731
+private enum OpenAIOutputContent: Codable {
1732
+    case outputText(OpenAITextChunk)
1733
+    case inputText(OpenAITextChunk)
1734
+
1735
+    enum CodingKeys: String, CodingKey {
1736
+        case type
1737
+        case text
1738
+    }
1739
+
1740
+    init(from decoder: Decoder) throws {
1741
+        let container = try decoder.container(keyedBy: CodingKeys.self)
1742
+        let type = try container.decode(String.self, forKey: .type)
1743
+        let text = try container.decode(String.self, forKey: .text)
1744
+        let payload = OpenAITextChunk(text: text)
1745
+        if type == "output_text" {
1746
+            self = .outputText(payload)
1747
+        } else {
1748
+            self = .inputText(payload)
1749
+        }
1750
+    }
1751
+
1752
+    func encode(to encoder: Encoder) throws {
1753
+        var container = encoder.container(keyedBy: CodingKeys.self)
1754
+        switch self {
1755
+        case .outputText(let chunk):
1756
+            try container.encode("output_text", forKey: .type)
1757
+            try container.encode(chunk.text, forKey: .text)
1758
+        case .inputText(let chunk):
1759
+            try container.encode("input_text", forKey: .type)
1760
+            try container.encode(chunk.text, forKey: .text)
1761
+        }
1762
+    }
1763
+}
1764
+
1765
+private struct OpenAITextChunk: Codable {
1766
+    let text: String
1767
+}
1768
+
1769
+private struct JobSearchResultsPayload: Codable {
1770
+    let jobs: [JobListing]
1771
+}
1772
+
1773
+private struct JobSearchOutput {
1774
+    let jobs: [JobListing]
1775
+    let rawJSON: String
1776
+}
1777
+
1778
+private struct OpenAIAPIErrorResponse: Codable {
1779
+    let error: APIError
1780
+
1781
+    struct APIError: Codable {
1782
+        let message: String
1783
+    }
1784
+}
1785
+
1786
+private extension NSLayoutConstraint {
1787
+    func withPriority(_ priority: NSLayoutConstraint.Priority) -> NSLayoutConstraint {
1788
+        self.priority = priority
1789
+        return self
1790
+    }
1791
+}
1792
+
1396 1793
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
1397 1794
 private final class JobPayloadButton: HoverableButton {
1398 1795
     var jobPayload: JobListing?