Explorar el Código

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 hace 3 semanas
padre
commit
295d33eb59
Se han modificado 1 ficheros con 272 adiciones y 141 borrados
  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 87
     private let chatScrollView = NSScrollView()
88 88
     private let chatDocumentView = JobListingsDocumentView()
89 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 90
     /// Shown when a sidebar item other than Home is selected.
95 91
     private let nonHomeHost = NSView()
96 92
     private let nonHomeGenericContainer = NSView()
@@ -107,9 +103,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
107 103
 
108 104
     private var currentSidebarItems: [SidebarItem] = []
109 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 107
     private var lastSearchResults: [JobListing] = []
112
-    private var lastNoResultsQuery: String?
113 108
     /// Most recently saved jobs appear first; persisted across launches.
114 109
     private var savedJobOrder: [JobListing] = []
115 110
     private var chatMessages: [ChatMessage] = []
@@ -132,6 +127,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
132 127
         findJobsCTAGradientLayer?.frame = findJobsCTAChrome.bounds
133 128
         updateFindJobsCTAShadowPath()
134 129
         updateJobListingDescriptionWidths()
130
+        updateChatBubbleWidths()
135 131
     }
136 132
 
137 133
     func render(_ data: DashboardData) {
@@ -143,7 +139,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
143 139
         }
144 140
         configureSidebar()
145 141
         savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load())
146
-        configureJobListings([], noResultsForQuery: nil)
147 142
         resetChatState()
148 143
         updateMainContentVisibility()
149 144
     }
@@ -220,46 +215,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
220 215
 
221 216
         let midSpacer = NSView()
222 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 228
         mainOverlay.addArrangedSubview(topInset)
257 229
         mainOverlay.addArrangedSubview(titleBlock)
258 230
         mainOverlay.addArrangedSubview(midSpacer)
259 231
         mainOverlay.addArrangedSubview(chatStatusStack)
260
-        mainOverlay.addArrangedSubview(listingsTopSpacer)
232
+        mainOverlay.addArrangedSubview(chatTopSpacer)
261 233
         mainOverlay.addArrangedSubview(chatScrollView)
262
-        mainOverlay.addArrangedSubview(jobListingsScrollView)
234
+        mainOverlay.addArrangedSubview(chatBottomSpacer)
263 235
         mainOverlay.addArrangedSubview(searchBarShadowHost)
264 236
 
265 237
         contentStack.addArrangedSubview(sidebar)
@@ -292,12 +264,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
292 264
             searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
293 265
             chatStatusStack.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
294 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 268
             greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 16),
303 269
             greetingLabel.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor, constant: -16),
@@ -327,16 +293,16 @@ final class DashboardView: NSView, NSTextFieldDelegate {
327 293
 
328 294
         chatDocumentView.translatesAutoresizingMaskIntoConstraints = false
329 295
         chatStack.orientation = .vertical
330
-        chatStack.spacing = 10
296
+        chatStack.spacing = 18
331 297
         chatStack.alignment = .width
332 298
         chatStack.distribution = .fill
333 299
         chatStack.translatesAutoresizingMaskIntoConstraints = false
334 300
         chatDocumentView.addSubview(chatStack)
335 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 308
         chatScrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -347,12 +313,41 @@ final class DashboardView: NSView, NSTextFieldDelegate {
347 313
         chatScrollView.borderType = .noBorder
348 314
         chatScrollView.documentView = chatDocumentView
349 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 320
     private func updateJobListingDescriptionWidths() {
354
-        updateDescriptionColumnWidths(in: jobListingsStack, containerWidth: jobListingsContainer.bounds.width)
355 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 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 381
     private static func normalizedSavedJobs(_ jobs: [JobListing]) -> [JobListing] {
415 382
         var seen = Set<JobListing>()
416 383
         var out: [JobListing] = []
@@ -632,15 +599,30 @@ final class DashboardView: NSView, NSTextFieldDelegate {
632 599
         guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return }
633 600
         switch button.cardContext {
634 601
         case .homeSearchResults:
635
-            lastSearchResults.removeAll { $0 == job }
636
-            configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery)
602
+            removeJobCardFromChat(originating: button, job: job)
637 603
         case .savedJobsPage:
638 604
             applySavedState(false, for: job)
639 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 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 1152
     private func applyHomeState() {
1179 1153
         jobKeywordsField.stringValue = ""
1180 1154
         resetChatState()
@@ -1196,7 +1170,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1196 1170
     @objc private func didSubmitSearch() {
1197 1171
         let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
1198 1172
         guard !prompt.isEmpty, !isAwaitingResponse else { return }
1199
-        clearJobSearchResultsUI()
1173
+        let isContinuation = isContinuationPrompt(prompt)
1174
+        let effectiveQuery = resolvedSearchQuery(for: prompt)
1200 1175
         appendChatBubble(text: prompt, isUser: true)
1201 1176
         chatMessages.append(ChatMessage(role: "user", content: prompt))
1202 1177
         jobKeywordsField.stringValue = ""
@@ -1204,7 +1179,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1204 1179
         chatStatusLabel.stringValue = "Thinking..."
1205 1180
         setInputEnabled(false)
1206 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 1183
             DispatchQueue.main.async {
1209 1184
                 guard let self else { return }
1210 1185
                 self.isAwaitingResponse = false
@@ -1212,15 +1187,26 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1212 1187
                 switch result {
1213 1188
                 case .success(let output):
1214 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 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 1207
                 case .failure(let error):
1221 1208
                     self.appendChatBubble(text: error.localizedDescription, isUser: false)
1222 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 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 1271
     func controlTextDidBeginEditing(_ obj: Notification) {
@@ -1276,6 +1296,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1276 1296
 
1277 1297
     private func resetChatState() {
1278 1298
         chatMessages.removeAll()
1299
+        lastSearchResults.removeAll()
1279 1300
         chatStack.arrangedSubviews.forEach {
1280 1301
             chatStack.removeArrangedSubview($0)
1281 1302
             $0.removeFromSuperview()
@@ -1283,48 +1304,159 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1283 1304
         let welcome = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary."
1284 1305
         chatMessages.append(ChatMessage(role: "assistant", content: welcome))
1285 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 1311
         let host = NSView()
1291 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 1320
         chatStack.addArrangedSubview(host)
1307 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 1332
         NSLayoutConstraint.activate([
1315 1333
             bubble.topAnchor.constraint(equalTo: host.topAnchor),
1316 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 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 1912
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
1788 1913
 private final class JobPayloadButton: HoverableButton {
1789 1914
     var jobPayload: JobListing?
@@ -1898,6 +2023,12 @@ private final class JobListingsDocumentView: NSView {
1898 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 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 2033
 private final class SidebarNavRowView: NSView {
1903 2034
     private let onSelect: () -> Void