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

Refine chat layout and inline assistant job results.

Improve the home chat experience by moving search result cards into assistant messages, tightening bubble layout behavior, and keeping text wrapping responsive during resize.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 пре 3 недеља
родитељ
комит
295d33eb59
1 измењених фајлова са 272 додато и 141 уклоњено
  1. 272 141
      App for Indeed/Views/DashboardView.swift

+ 272 - 141
App for Indeed/Views/DashboardView.swift

@@ -87,10 +87,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
87
     private let chatScrollView = NSScrollView()
87
     private let chatScrollView = NSScrollView()
88
     private let chatDocumentView = JobListingsDocumentView()
88
     private let chatDocumentView = JobListingsDocumentView()
89
     private let chatStack = NSStackView()
89
     private let chatStack = NSStackView()
90
-    private let jobListingsScrollView = NSScrollView()
91
-    /// Flipped so short result lists stay visually under the search bar instead of leaving a gap above the cards.
92
-    private let jobListingsContainer = JobListingsDocumentView()
93
-    private let jobListingsStack = NSStackView()
94
     /// Shown when a sidebar item other than Home is selected.
90
     /// Shown when a sidebar item other than Home is selected.
95
     private let nonHomeHost = NSView()
91
     private let nonHomeHost = NSView()
96
     private let nonHomeGenericContainer = NSView()
92
     private let nonHomeGenericContainer = NSView()
@@ -107,9 +103,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
107
 
103
 
108
     private var currentSidebarItems: [SidebarItem] = []
104
     private var currentSidebarItems: [SidebarItem] = []
109
     private var selectedSidebarIndex: Int = 0
105
     private var selectedSidebarIndex: Int = 0
110
-    /// Last successful search result set (used when removing a card with the dismiss control).
106
+    /// All jobs that have been shown in the current chat session, oldest first. Used to deduplicate continuation searches so "show more" doesn't re-display the same listings.
111
     private var lastSearchResults: [JobListing] = []
107
     private var lastSearchResults: [JobListing] = []
112
-    private var lastNoResultsQuery: String?
113
     /// Most recently saved jobs appear first; persisted across launches.
108
     /// Most recently saved jobs appear first; persisted across launches.
114
     private var savedJobOrder: [JobListing] = []
109
     private var savedJobOrder: [JobListing] = []
115
     private var chatMessages: [ChatMessage] = []
110
     private var chatMessages: [ChatMessage] = []
@@ -132,6 +127,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
132
         findJobsCTAGradientLayer?.frame = findJobsCTAChrome.bounds
127
         findJobsCTAGradientLayer?.frame = findJobsCTAChrome.bounds
133
         updateFindJobsCTAShadowPath()
128
         updateFindJobsCTAShadowPath()
134
         updateJobListingDescriptionWidths()
129
         updateJobListingDescriptionWidths()
130
+        updateChatBubbleWidths()
135
     }
131
     }
136
 
132
 
137
     func render(_ data: DashboardData) {
133
     func render(_ data: DashboardData) {
@@ -143,7 +139,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
143
         }
139
         }
144
         configureSidebar()
140
         configureSidebar()
145
         savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load())
141
         savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load())
146
-        configureJobListings([], noResultsForQuery: nil)
147
         resetChatState()
142
         resetChatState()
148
         updateMainContentVisibility()
143
         updateMainContentVisibility()
149
     }
144
     }
@@ -220,46 +215,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
220
 
215
 
221
         let midSpacer = NSView()
216
         let midSpacer = NSView()
222
         midSpacer.translatesAutoresizingMaskIntoConstraints = false
217
         midSpacer.translatesAutoresizingMaskIntoConstraints = false
223
-        midSpacer.heightAnchor.constraint(equalToConstant: 20).isActive = true
224
-
225
-        let listingsTopSpacer = NSView()
226
-        listingsTopSpacer.translatesAutoresizingMaskIntoConstraints = false
227
-        listingsTopSpacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
228
-
229
-        jobListingsContainer.translatesAutoresizingMaskIntoConstraints = false
230
-        jobListingsStack.orientation = .vertical
231
-        jobListingsStack.spacing = 14
232
-        // `.leading` keeps cards left-anchored; explicit width constraints below stretch each card across the full row.
233
-        jobListingsStack.alignment = .leading
234
-        jobListingsStack.distribution = .fill
235
-        jobListingsStack.translatesAutoresizingMaskIntoConstraints = false
236
-        jobListingsStack.setContentHuggingPriority(.defaultHigh, for: .vertical)
237
-        jobListingsStack.setHuggingPriority(.defaultLow, for: .horizontal)
238
-        jobListingsContainer.addSubview(jobListingsStack)
239
-        NSLayoutConstraint.activate([
240
-            jobListingsStack.leadingAnchor.constraint(equalTo: jobListingsContainer.leadingAnchor),
241
-            jobListingsStack.trailingAnchor.constraint(equalTo: jobListingsContainer.trailingAnchor),
242
-            jobListingsStack.topAnchor.constraint(equalTo: jobListingsContainer.topAnchor),
243
-            jobListingsStack.bottomAnchor.constraint(equalTo: jobListingsContainer.bottomAnchor)
244
-        ])
218
+        midSpacer.heightAnchor.constraint(equalToConstant: 18).isActive = true
219
+
220
+        let chatTopSpacer = NSView()
221
+        chatTopSpacer.translatesAutoresizingMaskIntoConstraints = false
222
+        chatTopSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true
245
 
223
 
246
-        jobListingsScrollView.translatesAutoresizingMaskIntoConstraints = false
247
-        jobListingsScrollView.hasVerticalScroller = true
248
-        jobListingsScrollView.hasHorizontalScroller = false
249
-        jobListingsScrollView.autohidesScrollers = true
250
-        jobListingsScrollView.drawsBackground = false
251
-        jobListingsScrollView.borderType = .noBorder
252
-        jobListingsScrollView.documentView = jobListingsContainer
253
-        jobListingsScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
254
-        jobListingsScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
224
+        let chatBottomSpacer = NSView()
225
+        chatBottomSpacer.translatesAutoresizingMaskIntoConstraints = false
226
+        chatBottomSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true
255
 
227
 
256
         mainOverlay.addArrangedSubview(topInset)
228
         mainOverlay.addArrangedSubview(topInset)
257
         mainOverlay.addArrangedSubview(titleBlock)
229
         mainOverlay.addArrangedSubview(titleBlock)
258
         mainOverlay.addArrangedSubview(midSpacer)
230
         mainOverlay.addArrangedSubview(midSpacer)
259
         mainOverlay.addArrangedSubview(chatStatusStack)
231
         mainOverlay.addArrangedSubview(chatStatusStack)
260
-        mainOverlay.addArrangedSubview(listingsTopSpacer)
232
+        mainOverlay.addArrangedSubview(chatTopSpacer)
261
         mainOverlay.addArrangedSubview(chatScrollView)
233
         mainOverlay.addArrangedSubview(chatScrollView)
262
-        mainOverlay.addArrangedSubview(jobListingsScrollView)
234
+        mainOverlay.addArrangedSubview(chatBottomSpacer)
263
         mainOverlay.addArrangedSubview(searchBarShadowHost)
235
         mainOverlay.addArrangedSubview(searchBarShadowHost)
264
 
236
 
265
         contentStack.addArrangedSubview(sidebar)
237
         contentStack.addArrangedSubview(sidebar)
@@ -292,12 +264,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
292
             searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
264
             searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
293
             chatStatusStack.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
265
             chatStatusStack.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
294
             chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
266
             chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
295
-            jobListingsScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
296
-
297
-            jobListingsContainer.topAnchor.constraint(equalTo: jobListingsScrollView.contentView.topAnchor),
298
-            jobListingsContainer.leadingAnchor.constraint(equalTo: jobListingsScrollView.contentView.leadingAnchor),
299
-            jobListingsContainer.widthAnchor.constraint(equalTo: jobListingsScrollView.contentView.widthAnchor),
300
-            jobListingsScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 200),
301
 
267
 
302
             greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 16),
268
             greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 16),
303
             greetingLabel.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor, constant: -16),
269
             greetingLabel.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor, constant: -16),
@@ -327,16 +293,16 @@ final class DashboardView: NSView, NSTextFieldDelegate {
327
 
293
 
328
         chatDocumentView.translatesAutoresizingMaskIntoConstraints = false
294
         chatDocumentView.translatesAutoresizingMaskIntoConstraints = false
329
         chatStack.orientation = .vertical
295
         chatStack.orientation = .vertical
330
-        chatStack.spacing = 10
296
+        chatStack.spacing = 18
331
         chatStack.alignment = .width
297
         chatStack.alignment = .width
332
         chatStack.distribution = .fill
298
         chatStack.distribution = .fill
333
         chatStack.translatesAutoresizingMaskIntoConstraints = false
299
         chatStack.translatesAutoresizingMaskIntoConstraints = false
334
         chatDocumentView.addSubview(chatStack)
300
         chatDocumentView.addSubview(chatStack)
335
         NSLayoutConstraint.activate([
301
         NSLayoutConstraint.activate([
336
-            chatStack.leadingAnchor.constraint(equalTo: chatDocumentView.leadingAnchor),
337
-            chatStack.trailingAnchor.constraint(equalTo: chatDocumentView.trailingAnchor),
338
-            chatStack.topAnchor.constraint(equalTo: chatDocumentView.topAnchor, constant: 4),
339
-            chatStack.bottomAnchor.constraint(equalTo: chatDocumentView.bottomAnchor, constant: -4)
302
+            chatStack.leadingAnchor.constraint(equalTo: chatDocumentView.leadingAnchor, constant: 4),
303
+            chatStack.trailingAnchor.constraint(equalTo: chatDocumentView.trailingAnchor, constant: -4),
304
+            chatStack.topAnchor.constraint(equalTo: chatDocumentView.topAnchor, constant: 8),
305
+            chatStack.bottomAnchor.constraint(equalTo: chatDocumentView.bottomAnchor, constant: -8)
340
         ])
306
         ])
341
 
307
 
342
         chatScrollView.translatesAutoresizingMaskIntoConstraints = false
308
         chatScrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -347,12 +313,41 @@ final class DashboardView: NSView, NSTextFieldDelegate {
347
         chatScrollView.borderType = .noBorder
313
         chatScrollView.borderType = .noBorder
348
         chatScrollView.documentView = chatDocumentView
314
         chatScrollView.documentView = chatDocumentView
349
         chatScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
315
         chatScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
350
-        chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 260).isActive = true
316
+        chatScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
317
+        chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true
351
     }
318
     }
352
 
319
 
353
     private func updateJobListingDescriptionWidths() {
320
     private func updateJobListingDescriptionWidths() {
354
-        updateDescriptionColumnWidths(in: jobListingsStack, containerWidth: jobListingsContainer.bounds.width)
355
         updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
321
         updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
322
+        walkChatJobStacks { stack in
323
+            updateDescriptionColumnWidths(in: stack, containerWidth: stack.bounds.width)
324
+        }
325
+    }
326
+
327
+    private func walkChatJobStacks(_ visitor: (ChatJobsStackView) -> Void) {
328
+        func walk(_ view: NSView) {
329
+            if let stack = view as? ChatJobsStackView {
330
+                visitor(stack)
331
+            }
332
+            for sub in view.subviews { walk(sub) }
333
+        }
334
+        walk(chatStack)
335
+    }
336
+
337
+    /// Chat bubble text fields are wrapping labels whose `preferredMaxLayoutWidth` needs to track the available row width so long strings reflow correctly when the window resizes.
338
+    private func updateChatBubbleWidths() {
339
+        func walk(_ view: NSView) {
340
+            if let label = view as? ChatBubbleLabel,
341
+               let bubble = label.superview, bubble.bounds.width > 1 {
342
+                let target = max(40, bubble.bounds.width - 28)
343
+                if abs(label.preferredMaxLayoutWidth - target) > 0.5 {
344
+                    label.preferredMaxLayoutWidth = target
345
+                    label.invalidateIntrinsicContentSize()
346
+                }
347
+            }
348
+            for sub in view.subviews { walk(sub) }
349
+        }
350
+        walk(chatStack)
356
     }
351
     }
357
 
352
 
358
     private func updateDescriptionColumnWidths(in stack: NSStackView, containerWidth: CGFloat) {
353
     private func updateDescriptionColumnWidths(in stack: NSStackView, containerWidth: CGFloat) {
@@ -383,34 +378,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
383
         }
378
         }
384
     }
379
     }
385
 
380
 
386
-    private func configureJobListings(_ jobs: [JobListing], noResultsForQuery: String?, updateLastResults: Bool = true) {
387
-        if updateLastResults {
388
-            lastSearchResults = jobs
389
-            lastNoResultsQuery = noResultsForQuery
390
-        }
391
-        jobListingsStack.arrangedSubviews.forEach {
392
-            jobListingsStack.removeArrangedSubview($0)
393
-            $0.removeFromSuperview()
394
-        }
395
-        if jobs.isEmpty, let query = noResultsForQuery, !query.isEmpty {
396
-            let empty = NSTextField(wrappingLabelWithString: "No jobs match “\(query)”. Try different keywords or browse the full list with an empty search.")
397
-            empty.font = .systemFont(ofSize: 14, weight: .regular)
398
-            empty.textColor = Theme.secondaryText
399
-            empty.alignment = .center
400
-            empty.maximumNumberOfLines = 0
401
-            empty.translatesAutoresizingMaskIntoConstraints = false
402
-            jobListingsStack.addArrangedSubview(empty)
403
-            empty.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
404
-            return
405
-        }
406
-        for job in jobs {
407
-            let card = makeJobListingCard(job, context: .homeSearchResults)
408
-            jobListingsStack.addArrangedSubview(card)
409
-            // Force every card to span the full row instead of hugging its intrinsic content width.
410
-            card.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
411
-        }
412
-    }
413
-
414
     private static func normalizedSavedJobs(_ jobs: [JobListing]) -> [JobListing] {
381
     private static func normalizedSavedJobs(_ jobs: [JobListing]) -> [JobListing] {
415
         var seen = Set<JobListing>()
382
         var seen = Set<JobListing>()
416
         var out: [JobListing] = []
383
         var out: [JobListing] = []
@@ -632,15 +599,30 @@ final class DashboardView: NSView, NSTextFieldDelegate {
632
         guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return }
599
         guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return }
633
         switch button.cardContext {
600
         switch button.cardContext {
634
         case .homeSearchResults:
601
         case .homeSearchResults:
635
-            lastSearchResults.removeAll { $0 == job }
636
-            configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery)
602
+            removeJobCardFromChat(originating: button, job: job)
637
         case .savedJobsPage:
603
         case .savedJobsPage:
638
             applySavedState(false, for: job)
604
             applySavedState(false, for: job)
639
             reloadSavedJobsListings()
605
             reloadSavedJobsListings()
640
-            if isHomeSidebarIndex(selectedSidebarIndex), !lastSearchResults.isEmpty {
641
-                configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery, updateLastResults: false)
606
+        }
607
+    }
608
+
609
+    /// Walks up from a dismiss button until it finds the enclosing chat job stack, then removes only the card that owns the button. Other chat history (older searches, the assistant summary text) is untouched.
610
+    private func removeJobCardFromChat(originating button: NSView, job: JobListing) {
611
+        var node: NSView? = button
612
+        var card: NSView?
613
+        var stack: ChatJobsStackView?
614
+        while let v = node {
615
+            if let parent = v.superview as? ChatJobsStackView {
616
+                card = v
617
+                stack = parent
618
+                break
642
             }
619
             }
620
+            node = v.superview
643
         }
621
         }
622
+        guard let card, let stack else { return }
623
+        stack.removeArrangedSubview(card)
624
+        card.removeFromSuperview()
625
+        lastSearchResults.removeAll { $0 == job }
644
     }
626
     }
645
 
627
 
646
     private func configureSearchBar() {
628
     private func configureSearchBar() {
@@ -1166,15 +1148,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1166
         }
1148
         }
1167
     }
1149
     }
1168
 
1150
 
1169
-    /// Removes result cards or the “no matches” message so a new search starts from a blank listing area.
1170
-    private func clearJobSearchResultsUI() {
1171
-        configureJobListings([], noResultsForQuery: nil)
1172
-        if let doc = jobListingsScrollView.documentView {
1173
-            doc.scroll(NSPoint(x: 0, y: 0))
1174
-        }
1175
-    }
1176
-
1177
-    /// Restores the main job-search experience: cleared query and no listings until the user searches again.
1151
+    /// Restores the main job-search experience: cleared query and a fresh chat history.
1178
     private func applyHomeState() {
1152
     private func applyHomeState() {
1179
         jobKeywordsField.stringValue = ""
1153
         jobKeywordsField.stringValue = ""
1180
         resetChatState()
1154
         resetChatState()
@@ -1196,7 +1170,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1196
     @objc private func didSubmitSearch() {
1170
     @objc private func didSubmitSearch() {
1197
         let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
1171
         let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
1198
         guard !prompt.isEmpty, !isAwaitingResponse else { return }
1172
         guard !prompt.isEmpty, !isAwaitingResponse else { return }
1199
-        clearJobSearchResultsUI()
1173
+        let isContinuation = isContinuationPrompt(prompt)
1174
+        let effectiveQuery = resolvedSearchQuery(for: prompt)
1200
         appendChatBubble(text: prompt, isUser: true)
1175
         appendChatBubble(text: prompt, isUser: true)
1201
         chatMessages.append(ChatMessage(role: "user", content: prompt))
1176
         chatMessages.append(ChatMessage(role: "user", content: prompt))
1202
         jobKeywordsField.stringValue = ""
1177
         jobKeywordsField.stringValue = ""
@@ -1204,7 +1179,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1204
         chatStatusLabel.stringValue = "Thinking..."
1179
         chatStatusLabel.stringValue = "Thinking..."
1205
         setInputEnabled(false)
1180
         setInputEnabled(false)
1206
         let contextMessages = chatMessages
1181
         let contextMessages = chatMessages
1207
-        jobSearchService.searchJobs(query: prompt, conversation: contextMessages) { [weak self] result in
1182
+        jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages) { [weak self] result in
1208
             DispatchQueue.main.async {
1183
             DispatchQueue.main.async {
1209
                 guard let self else { return }
1184
                 guard let self else { return }
1210
                 self.isAwaitingResponse = false
1185
                 self.isAwaitingResponse = false
@@ -1212,15 +1187,26 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1212
                 switch result {
1187
                 switch result {
1213
                 case .success(let output):
1188
                 case .success(let output):
1214
                     let normalizedJobs = self.normalizedJobs(output.jobs)
1189
                     let normalizedJobs = self.normalizedJobs(output.jobs)
1215
-                    self.configureJobListings(normalizedJobs, noResultsForQuery: prompt)
1216
-                    let reply = self.makeAssistantSearchReply(query: prompt, jobs: normalizedJobs)
1190
+                    let freshJobs: [JobListing]
1191
+                    if isContinuation {
1192
+                        // Continuations append only the *new* matches; previous cards already live in their own assistant message above.
1193
+                        let alreadySeen = Set(self.lastSearchResults)
1194
+                        freshJobs = normalizedJobs.filter { !alreadySeen.contains($0) }
1195
+                    } else {
1196
+                        freshJobs = normalizedJobs
1197
+                    }
1198
+                    self.lastSearchResults.append(contentsOf: freshJobs)
1199
+                    let reply = self.makeAssistantSearchReply(
1200
+                        query: effectiveQuery,
1201
+                        newJobsCount: freshJobs.count,
1202
+                        isContinuation: isContinuation
1203
+                    )
1217
                     self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
1204
                     self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
1218
-                    self.appendChatBubble(text: reply, isUser: false)
1219
-                    self.chatStatusLabel.stringValue = "Ask for another job, company, or skill match"
1205
+                    self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
1206
+                    self.chatStatusLabel.stringValue = "Ask for another role, company, or skill match"
1220
                 case .failure(let error):
1207
                 case .failure(let error):
1221
                     self.appendChatBubble(text: error.localizedDescription, isUser: false)
1208
                     self.appendChatBubble(text: error.localizedDescription, isUser: false)
1222
                     self.chatStatusLabel.stringValue = "Could not reach API. Try again."
1209
                     self.chatStatusLabel.stringValue = "Could not reach API. Try again."
1223
-                    self.configureJobListings([], noResultsForQuery: prompt)
1224
                 }
1210
                 }
1225
             }
1211
             }
1226
         }
1212
         }
@@ -1238,14 +1224,48 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1238
         return trimmed.filter { !$0.title.isEmpty && !$0.description.isEmpty }
1224
         return trimmed.filter { !$0.title.isEmpty && !$0.description.isEmpty }
1239
     }
1225
     }
1240
 
1226
 
1241
-    private func makeAssistantSearchReply(query: String, jobs: [JobListing]) -> String {
1242
-        if jobs.isEmpty {
1243
-            return "No jobs were found for \"\(query)\". Try another title, skill, company, or location."
1227
+    private func makeAssistantSearchReply(query: String, newJobsCount: Int, isContinuation: Bool) -> String {
1228
+        if newJobsCount == 0 {
1229
+            if isContinuation {
1230
+                return "I couldn't find new matches for \u{201C}\(query)\u{201D}. Try a different angle or a more specific keyword."
1231
+            }
1232
+            return "No jobs found for \u{201C}\(query)\u{201D}. Try another title, skill, company, or location."
1244
         }
1233
         }
1245
-        return """
1246
-        Found \(jobs.count) job result(s) for "\(query)".
1247
-        Each result is shown as a card with the role, job description, and an Apply button that opens the job link.
1248
-        """
1234
+        let plural = newJobsCount == 1 ? "match" : "matches"
1235
+        if isContinuation {
1236
+            return "Here are \(newJobsCount) more \(plural) for \u{201C}\(query)\u{201D}."
1237
+        }
1238
+        return "Found \(newJobsCount) \(plural) for \u{201C}\(query)\u{201D}. Tap Apply to open the listing or Save to revisit later."
1239
+    }
1240
+
1241
+    private func resolvedSearchQuery(for prompt: String) -> String {
1242
+        guard isContinuationPrompt(prompt) else { return prompt }
1243
+        for message in chatMessages.reversed() where message.role == "user" {
1244
+            let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
1245
+            if !candidate.isEmpty, !isContinuationPrompt(candidate) {
1246
+                return candidate
1247
+            }
1248
+        }
1249
+        return prompt
1250
+    }
1251
+
1252
+    private func isContinuationPrompt(_ prompt: String) -> Bool {
1253
+        let normalized = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
1254
+        let continuationPhrases: Set<String> = [
1255
+            "more",
1256
+            "show more",
1257
+            "more jobs",
1258
+            "more results",
1259
+            "do more searches",
1260
+            "more searches",
1261
+            "search more",
1262
+            "continue",
1263
+            "next"
1264
+        ]
1265
+        if continuationPhrases.contains(normalized) {
1266
+            return true
1267
+        }
1268
+        return normalized.contains("more search") || normalized.contains("more job")
1249
     }
1269
     }
1250
 
1270
 
1251
     func controlTextDidBeginEditing(_ obj: Notification) {
1271
     func controlTextDidBeginEditing(_ obj: Notification) {
@@ -1276,6 +1296,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1276
 
1296
 
1277
     private func resetChatState() {
1297
     private func resetChatState() {
1278
         chatMessages.removeAll()
1298
         chatMessages.removeAll()
1299
+        lastSearchResults.removeAll()
1279
         chatStack.arrangedSubviews.forEach {
1300
         chatStack.arrangedSubviews.forEach {
1280
             chatStack.removeArrangedSubview($0)
1301
             chatStack.removeArrangedSubview($0)
1281
             $0.removeFromSuperview()
1302
             $0.removeFromSuperview()
@@ -1283,48 +1304,159 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1283
         let welcome = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary."
1304
         let welcome = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary."
1284
         chatMessages.append(ChatMessage(role: "assistant", content: welcome))
1305
         chatMessages.append(ChatMessage(role: "assistant", content: welcome))
1285
         appendChatBubble(text: welcome, isUser: false)
1306
         appendChatBubble(text: welcome, isUser: false)
1286
-        chatStatusLabel.stringValue = "Opening the vault..."
1307
+        chatStatusLabel.stringValue = "Ask me to find jobs"
1287
     }
1308
     }
1288
 
1309
 
1289
-    private func appendChatBubble(text: String, isUser: Bool) {
1310
+    private func appendChatBubble(text: String, isUser: Bool, jobs: [JobListing]? = nil) {
1290
         let host = NSView()
1311
         let host = NSView()
1291
         host.translatesAutoresizingMaskIntoConstraints = false
1312
         host.translatesAutoresizingMaskIntoConstraints = false
1292
 
1313
 
1293
-        let bubble = NSTextField(wrappingLabelWithString: text)
1294
-        bubble.font = .systemFont(ofSize: 13, weight: .regular)
1295
-        bubble.maximumNumberOfLines = 0
1296
-        bubble.lineBreakMode = .byWordWrapping
1297
-        bubble.alignment = .left
1298
-        bubble.textColor = isUser ? .white : Theme.primaryText
1299
-        bubble.wantsLayer = true
1300
-        bubble.layer?.cornerRadius = 12
1301
-        bubble.layer?.masksToBounds = true
1302
-        bubble.layer?.backgroundColor = (isUser ? Theme.brandBlue : Theme.chromeBackground).cgColor
1303
-        bubble.translatesAutoresizingMaskIntoConstraints = false
1314
+        if isUser {
1315
+            installUserBubble(text: text, into: host)
1316
+        } else {
1317
+            installAssistantBubble(text: text, jobs: jobs, into: host)
1318
+        }
1304
 
1319
 
1305
-        host.addSubview(bubble)
1306
         chatStack.addArrangedSubview(host)
1320
         chatStack.addArrangedSubview(host)
1307
         host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
1321
         host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
1308
 
1322
 
1309
-        let leading = bubble.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 8)
1310
-        let trailing = bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor, constant: -8)
1311
-        let maxWidth = bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78)
1312
-        maxWidth.priority = .required
1323
+        DispatchQueue.main.async { [weak self] in
1324
+            self?.updateChatBubbleWidths()
1325
+            self?.scrollChatToBottom()
1326
+        }
1327
+    }
1313
 
1328
 
1329
+    private func installUserBubble(text: String, into host: NSView) {
1330
+        let bubble = makeChatBubbleContainer(text: text, isUser: true)
1331
+        host.addSubview(bubble)
1314
         NSLayoutConstraint.activate([
1332
         NSLayoutConstraint.activate([
1315
             bubble.topAnchor.constraint(equalTo: host.topAnchor),
1333
             bubble.topAnchor.constraint(equalTo: host.topAnchor),
1316
             bubble.bottomAnchor.constraint(equalTo: host.bottomAnchor),
1334
             bubble.bottomAnchor.constraint(equalTo: host.bottomAnchor),
1317
-            bubble.heightAnchor.constraint(greaterThanOrEqualToConstant: 34),
1318
-            maxWidth,
1319
-            isUser ? leading.withPriority(.defaultLow) : leading.withPriority(.required),
1320
-            isUser ? trailing.withPriority(.required) : trailing.withPriority(.defaultLow)
1335
+            bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor),
1336
+            bubble.leadingAnchor.constraint(greaterThanOrEqualTo: host.leadingAnchor, constant: 64),
1337
+            bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78)
1338
+        ])
1339
+    }
1340
+
1341
+    private func installAssistantBubble(text: String, jobs: [JobListing]?, into host: NSView) {
1342
+        let avatar = makeAssistantAvatarView()
1343
+        let nameLabel = NSTextField(labelWithString: "AI Job Finder")
1344
+        nameLabel.font = .systemFont(ofSize: 11, weight: .semibold)
1345
+        nameLabel.textColor = Theme.secondaryText
1346
+        nameLabel.translatesAutoresizingMaskIntoConstraints = false
1347
+
1348
+        let bubble = makeChatBubbleContainer(text: text, isUser: false)
1349
+
1350
+        let column = NSStackView(views: [nameLabel, bubble])
1351
+        column.orientation = .vertical
1352
+        column.spacing = 6
1353
+        column.alignment = .leading
1354
+        column.translatesAutoresizingMaskIntoConstraints = false
1355
+
1356
+        if let jobs, !jobs.isEmpty {
1357
+            let jobsStack = makeChatJobsStackView(jobs: jobs)
1358
+            column.addArrangedSubview(jobsStack)
1359
+            jobsStack.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true
1360
+        }
1361
+
1362
+        host.addSubview(avatar)
1363
+        host.addSubview(column)
1364
+        NSLayoutConstraint.activate([
1365
+            avatar.leadingAnchor.constraint(equalTo: host.leadingAnchor),
1366
+            avatar.topAnchor.constraint(equalTo: host.topAnchor),
1367
+            avatar.widthAnchor.constraint(equalToConstant: 36),
1368
+            avatar.heightAnchor.constraint(equalToConstant: 36),
1369
+
1370
+            column.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 12),
1371
+            column.trailingAnchor.constraint(equalTo: host.trailingAnchor),
1372
+            column.topAnchor.constraint(equalTo: host.topAnchor),
1373
+            column.bottomAnchor.constraint(equalTo: host.bottomAnchor)
1321
         ])
1374
         ])
1375
+    }
1322
 
1376
 
1323
-        DispatchQueue.main.async {
1324
-            let maxY = max(0, self.chatDocumentView.bounds.height - self.chatScrollView.contentView.bounds.height)
1325
-            self.chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY))
1326
-            self.chatScrollView.reflectScrolledClipView(self.chatScrollView.contentView)
1377
+    private func makeChatBubbleContainer(text: String, isUser: Bool) -> NSView {
1378
+        let container = NSView()
1379
+        container.translatesAutoresizingMaskIntoConstraints = false
1380
+        container.wantsLayer = true
1381
+        container.layer?.cornerRadius = 14
1382
+        if #available(macOS 11.0, *) {
1383
+            container.layer?.cornerCurve = .continuous
1327
         }
1384
         }
1385
+        container.layer?.masksToBounds = true
1386
+        if isUser {
1387
+            container.layer?.backgroundColor = Theme.brandBlue.cgColor
1388
+        } else {
1389
+            container.layer?.backgroundColor = Theme.chromeBackground.cgColor
1390
+            container.layer?.borderWidth = 1
1391
+            container.layer?.borderColor = Theme.border.cgColor
1392
+        }
1393
+
1394
+        let label = ChatBubbleLabel(wrappingLabelWithString: text)
1395
+        label.font = .systemFont(ofSize: 13.5, weight: .regular)
1396
+        label.textColor = isUser ? .white : Theme.primaryText
1397
+        label.maximumNumberOfLines = 0
1398
+        label.lineBreakMode = .byWordWrapping
1399
+        label.alignment = .left
1400
+        label.translatesAutoresizingMaskIntoConstraints = false
1401
+        label.setContentHuggingPriority(.defaultLow, for: .horizontal)
1402
+        label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
1403
+
1404
+        container.addSubview(label)
1405
+        NSLayoutConstraint.activate([
1406
+            label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14),
1407
+            label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14),
1408
+            label.topAnchor.constraint(equalTo: container.topAnchor, constant: 10),
1409
+            label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10)
1410
+        ])
1411
+        return container
1412
+    }
1413
+
1414
+    private func makeAssistantAvatarView() -> NSView {
1415
+        let view = NSView()
1416
+        view.translatesAutoresizingMaskIntoConstraints = false
1417
+        view.wantsLayer = true
1418
+        view.layer?.cornerRadius = 18
1419
+        if #available(macOS 11.0, *) {
1420
+            view.layer?.cornerCurve = .continuous
1421
+        }
1422
+        view.layer?.backgroundColor = Theme.settingsIconBackground.cgColor
1423
+        view.layer?.borderWidth = 1
1424
+        view.layer?.borderColor = Theme.proCardBorder.cgColor
1425
+
1426
+        let icon = NSImageView()
1427
+        icon.translatesAutoresizingMaskIntoConstraints = false
1428
+        icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
1429
+        icon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "AI Job Finder")
1430
+        icon.contentTintColor = Theme.brandBlue
1431
+
1432
+        view.addSubview(icon)
1433
+        NSLayoutConstraint.activate([
1434
+            icon.centerXAnchor.constraint(equalTo: view.centerXAnchor),
1435
+            icon.centerYAnchor.constraint(equalTo: view.centerYAnchor)
1436
+        ])
1437
+        return view
1438
+    }
1439
+
1440
+    private func makeChatJobsStackView(jobs: [JobListing]) -> ChatJobsStackView {
1441
+        let stack = ChatJobsStackView()
1442
+        stack.orientation = .vertical
1443
+        stack.spacing = 10
1444
+        stack.alignment = .leading
1445
+        stack.distribution = .fill
1446
+        stack.translatesAutoresizingMaskIntoConstraints = false
1447
+
1448
+        for job in jobs {
1449
+            let card = makeJobListingCard(job, context: .homeSearchResults)
1450
+            stack.addArrangedSubview(card)
1451
+            card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
1452
+        }
1453
+        return stack
1454
+    }
1455
+
1456
+    private func scrollChatToBottom() {
1457
+        let maxY = max(0, chatDocumentView.bounds.height - chatScrollView.contentView.bounds.height)
1458
+        chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY))
1459
+        chatScrollView.reflectScrolledClipView(chatScrollView.contentView)
1328
     }
1460
     }
1329
 
1461
 
1330
     private func setInputEnabled(_ enabled: Bool) {
1462
     private func setInputEnabled(_ enabled: Bool) {
@@ -1777,13 +1909,6 @@ private struct OpenAIAPIErrorResponse: Codable {
1777
     }
1909
     }
1778
 }
1910
 }
1779
 
1911
 
1780
-private extension NSLayoutConstraint {
1781
-    func withPriority(_ priority: NSLayoutConstraint.Priority) -> NSLayoutConstraint {
1782
-        self.priority = priority
1783
-        return self
1784
-    }
1785
-}
1786
-
1787
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
1912
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
1788
 private final class JobPayloadButton: HoverableButton {
1913
 private final class JobPayloadButton: HoverableButton {
1789
     var jobPayload: JobListing?
1914
     var jobPayload: JobListing?
@@ -1898,6 +2023,12 @@ private final class JobListingsDocumentView: NSView {
1898
     override var isFlipped: Bool { true }
2023
     override var isFlipped: Bool { true }
1899
 }
2024
 }
1900
 
2025
 
2026
+/// Marker subclass for the per-assistant-message job stack embedded inside a chat bubble. `NSView.tag` is read-only on macOS, so a typed subclass is the cleanest way to identify these stacks during dismiss and layout passes.
2027
+private final class ChatJobsStackView: NSStackView {}
2028
+
2029
+/// Marker subclass for the wrapping label inside a chat bubble. Lets the layout pass find each bubble label to update its `preferredMaxLayoutWidth` when the chat width changes.
2030
+private final class ChatBubbleLabel: NSTextField {}
2031
+
1901
 /// Captures clicks for the full sidebar pill so icon, label, and padding behave as one tab. Manages its own hover background so non-selected rows highlight subtly on hover without disturbing the selected-row fill.
2032
 /// Captures clicks for the full sidebar pill so icon, label, and padding behave as one tab. Manages its own hover background so non-selected rows highlight subtly on hover without disturbing the selected-row fill.
1902
 private final class SidebarNavRowView: NSView {
2033
 private final class SidebarNavRowView: NSView {
1903
     private let onSelect: () -> Void
2034
     private let onSelect: () -> Void