Sfoglia il codice sorgente

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 2 mesi fa
parent
commit
295d33eb59
1 ha cambiato i file con 272 aggiunte e 141 eliminazioni
  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 {
     private let chatScrollView = NSScrollView()
     private let chatDocumentView = JobListingsDocumentView()
     private let chatStack = NSStackView()
-    private let jobListingsScrollView = NSScrollView()
-    /// Flipped so short result lists stay visually under the search bar instead of leaving a gap above the cards.
-    private let jobListingsContainer = JobListingsDocumentView()
-    private let jobListingsStack = NSStackView()
     /// Shown when a sidebar item other than Home is selected.
     private let nonHomeHost = NSView()
     private let nonHomeGenericContainer = NSView()
@@ -107,9 +103,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
     private var currentSidebarItems: [SidebarItem] = []
     private var selectedSidebarIndex: Int = 0
-    /// Last successful search result set (used when removing a card with the dismiss control).
+    /// 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.
     private var lastSearchResults: [JobListing] = []
-    private var lastNoResultsQuery: String?
     /// Most recently saved jobs appear first; persisted across launches.
     private var savedJobOrder: [JobListing] = []
     private var chatMessages: [ChatMessage] = []
@@ -132,6 +127,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         findJobsCTAGradientLayer?.frame = findJobsCTAChrome.bounds
         updateFindJobsCTAShadowPath()
         updateJobListingDescriptionWidths()
+        updateChatBubbleWidths()
     }
 
     func render(_ data: DashboardData) {
@@ -143,7 +139,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         }
         configureSidebar()
         savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load())
-        configureJobListings([], noResultsForQuery: nil)
         resetChatState()
         updateMainContentVisibility()
     }
@@ -220,46 +215,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
         let midSpacer = NSView()
         midSpacer.translatesAutoresizingMaskIntoConstraints = false
-        midSpacer.heightAnchor.constraint(equalToConstant: 20).isActive = true
-
-        let listingsTopSpacer = NSView()
-        listingsTopSpacer.translatesAutoresizingMaskIntoConstraints = false
-        listingsTopSpacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
-
-        jobListingsContainer.translatesAutoresizingMaskIntoConstraints = false
-        jobListingsStack.orientation = .vertical
-        jobListingsStack.spacing = 14
-        // `.leading` keeps cards left-anchored; explicit width constraints below stretch each card across the full row.
-        jobListingsStack.alignment = .leading
-        jobListingsStack.distribution = .fill
-        jobListingsStack.translatesAutoresizingMaskIntoConstraints = false
-        jobListingsStack.setContentHuggingPriority(.defaultHigh, for: .vertical)
-        jobListingsStack.setHuggingPriority(.defaultLow, for: .horizontal)
-        jobListingsContainer.addSubview(jobListingsStack)
-        NSLayoutConstraint.activate([
-            jobListingsStack.leadingAnchor.constraint(equalTo: jobListingsContainer.leadingAnchor),
-            jobListingsStack.trailingAnchor.constraint(equalTo: jobListingsContainer.trailingAnchor),
-            jobListingsStack.topAnchor.constraint(equalTo: jobListingsContainer.topAnchor),
-            jobListingsStack.bottomAnchor.constraint(equalTo: jobListingsContainer.bottomAnchor)
-        ])
+        midSpacer.heightAnchor.constraint(equalToConstant: 18).isActive = true
+
+        let chatTopSpacer = NSView()
+        chatTopSpacer.translatesAutoresizingMaskIntoConstraints = false
+        chatTopSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true
 
-        jobListingsScrollView.translatesAutoresizingMaskIntoConstraints = false
-        jobListingsScrollView.hasVerticalScroller = true
-        jobListingsScrollView.hasHorizontalScroller = false
-        jobListingsScrollView.autohidesScrollers = true
-        jobListingsScrollView.drawsBackground = false
-        jobListingsScrollView.borderType = .noBorder
-        jobListingsScrollView.documentView = jobListingsContainer
-        jobListingsScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
-        jobListingsScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+        let chatBottomSpacer = NSView()
+        chatBottomSpacer.translatesAutoresizingMaskIntoConstraints = false
+        chatBottomSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true
 
         mainOverlay.addArrangedSubview(topInset)
         mainOverlay.addArrangedSubview(titleBlock)
         mainOverlay.addArrangedSubview(midSpacer)
         mainOverlay.addArrangedSubview(chatStatusStack)
-        mainOverlay.addArrangedSubview(listingsTopSpacer)
+        mainOverlay.addArrangedSubview(chatTopSpacer)
         mainOverlay.addArrangedSubview(chatScrollView)
-        mainOverlay.addArrangedSubview(jobListingsScrollView)
+        mainOverlay.addArrangedSubview(chatBottomSpacer)
         mainOverlay.addArrangedSubview(searchBarShadowHost)
 
         contentStack.addArrangedSubview(sidebar)
@@ -292,12 +264,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
             searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
             chatStatusStack.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
             chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
-            jobListingsScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
-
-            jobListingsContainer.topAnchor.constraint(equalTo: jobListingsScrollView.contentView.topAnchor),
-            jobListingsContainer.leadingAnchor.constraint(equalTo: jobListingsScrollView.contentView.leadingAnchor),
-            jobListingsContainer.widthAnchor.constraint(equalTo: jobListingsScrollView.contentView.widthAnchor),
-            jobListingsScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 200),
 
             greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 16),
             greetingLabel.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor, constant: -16),
@@ -327,16 +293,16 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
         chatDocumentView.translatesAutoresizingMaskIntoConstraints = false
         chatStack.orientation = .vertical
-        chatStack.spacing = 10
+        chatStack.spacing = 18
         chatStack.alignment = .width
         chatStack.distribution = .fill
         chatStack.translatesAutoresizingMaskIntoConstraints = false
         chatDocumentView.addSubview(chatStack)
         NSLayoutConstraint.activate([
-            chatStack.leadingAnchor.constraint(equalTo: chatDocumentView.leadingAnchor),
-            chatStack.trailingAnchor.constraint(equalTo: chatDocumentView.trailingAnchor),
-            chatStack.topAnchor.constraint(equalTo: chatDocumentView.topAnchor, constant: 4),
-            chatStack.bottomAnchor.constraint(equalTo: chatDocumentView.bottomAnchor, constant: -4)
+            chatStack.leadingAnchor.constraint(equalTo: chatDocumentView.leadingAnchor, constant: 4),
+            chatStack.trailingAnchor.constraint(equalTo: chatDocumentView.trailingAnchor, constant: -4),
+            chatStack.topAnchor.constraint(equalTo: chatDocumentView.topAnchor, constant: 8),
+            chatStack.bottomAnchor.constraint(equalTo: chatDocumentView.bottomAnchor, constant: -8)
         ])
 
         chatScrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -347,12 +313,41 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         chatScrollView.borderType = .noBorder
         chatScrollView.documentView = chatDocumentView
         chatScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
-        chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 260).isActive = true
+        chatScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+        chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true
     }
 
     private func updateJobListingDescriptionWidths() {
-        updateDescriptionColumnWidths(in: jobListingsStack, containerWidth: jobListingsContainer.bounds.width)
         updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
+        walkChatJobStacks { stack in
+            updateDescriptionColumnWidths(in: stack, containerWidth: stack.bounds.width)
+        }
+    }
+
+    private func walkChatJobStacks(_ visitor: (ChatJobsStackView) -> Void) {
+        func walk(_ view: NSView) {
+            if let stack = view as? ChatJobsStackView {
+                visitor(stack)
+            }
+            for sub in view.subviews { walk(sub) }
+        }
+        walk(chatStack)
+    }
+
+    /// 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.
+    private func updateChatBubbleWidths() {
+        func walk(_ view: NSView) {
+            if let label = view as? ChatBubbleLabel,
+               let bubble = label.superview, bubble.bounds.width > 1 {
+                let target = max(40, bubble.bounds.width - 28)
+                if abs(label.preferredMaxLayoutWidth - target) > 0.5 {
+                    label.preferredMaxLayoutWidth = target
+                    label.invalidateIntrinsicContentSize()
+                }
+            }
+            for sub in view.subviews { walk(sub) }
+        }
+        walk(chatStack)
     }
 
     private func updateDescriptionColumnWidths(in stack: NSStackView, containerWidth: CGFloat) {
@@ -383,34 +378,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         }
     }
 
-    private func configureJobListings(_ jobs: [JobListing], noResultsForQuery: String?, updateLastResults: Bool = true) {
-        if updateLastResults {
-            lastSearchResults = jobs
-            lastNoResultsQuery = noResultsForQuery
-        }
-        jobListingsStack.arrangedSubviews.forEach {
-            jobListingsStack.removeArrangedSubview($0)
-            $0.removeFromSuperview()
-        }
-        if jobs.isEmpty, let query = noResultsForQuery, !query.isEmpty {
-            let empty = NSTextField(wrappingLabelWithString: "No jobs match “\(query)”. Try different keywords or browse the full list with an empty search.")
-            empty.font = .systemFont(ofSize: 14, weight: .regular)
-            empty.textColor = Theme.secondaryText
-            empty.alignment = .center
-            empty.maximumNumberOfLines = 0
-            empty.translatesAutoresizingMaskIntoConstraints = false
-            jobListingsStack.addArrangedSubview(empty)
-            empty.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
-            return
-        }
-        for job in jobs {
-            let card = makeJobListingCard(job, context: .homeSearchResults)
-            jobListingsStack.addArrangedSubview(card)
-            // Force every card to span the full row instead of hugging its intrinsic content width.
-            card.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
-        }
-    }
-
     private static func normalizedSavedJobs(_ jobs: [JobListing]) -> [JobListing] {
         var seen = Set<JobListing>()
         var out: [JobListing] = []
@@ -632,15 +599,30 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return }
         switch button.cardContext {
         case .homeSearchResults:
-            lastSearchResults.removeAll { $0 == job }
-            configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery)
+            removeJobCardFromChat(originating: button, job: job)
         case .savedJobsPage:
             applySavedState(false, for: job)
             reloadSavedJobsListings()
-            if isHomeSidebarIndex(selectedSidebarIndex), !lastSearchResults.isEmpty {
-                configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery, updateLastResults: false)
+        }
+    }
+
+    /// 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.
+    private func removeJobCardFromChat(originating button: NSView, job: JobListing) {
+        var node: NSView? = button
+        var card: NSView?
+        var stack: ChatJobsStackView?
+        while let v = node {
+            if let parent = v.superview as? ChatJobsStackView {
+                card = v
+                stack = parent
+                break
             }
+            node = v.superview
         }
+        guard let card, let stack else { return }
+        stack.removeArrangedSubview(card)
+        card.removeFromSuperview()
+        lastSearchResults.removeAll { $0 == job }
     }
 
     private func configureSearchBar() {
@@ -1166,15 +1148,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         }
     }
 
-    /// Removes result cards or the “no matches” message so a new search starts from a blank listing area.
-    private func clearJobSearchResultsUI() {
-        configureJobListings([], noResultsForQuery: nil)
-        if let doc = jobListingsScrollView.documentView {
-            doc.scroll(NSPoint(x: 0, y: 0))
-        }
-    }
-
-    /// Restores the main job-search experience: cleared query and no listings until the user searches again.
+    /// Restores the main job-search experience: cleared query and a fresh chat history.
     private func applyHomeState() {
         jobKeywordsField.stringValue = ""
         resetChatState()
@@ -1196,7 +1170,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     @objc private func didSubmitSearch() {
         let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !prompt.isEmpty, !isAwaitingResponse else { return }
-        clearJobSearchResultsUI()
+        let isContinuation = isContinuationPrompt(prompt)
+        let effectiveQuery = resolvedSearchQuery(for: prompt)
         appendChatBubble(text: prompt, isUser: true)
         chatMessages.append(ChatMessage(role: "user", content: prompt))
         jobKeywordsField.stringValue = ""
@@ -1204,7 +1179,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         chatStatusLabel.stringValue = "Thinking..."
         setInputEnabled(false)
         let contextMessages = chatMessages
-        jobSearchService.searchJobs(query: prompt, conversation: contextMessages) { [weak self] result in
+        jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages) { [weak self] result in
             DispatchQueue.main.async {
                 guard let self else { return }
                 self.isAwaitingResponse = false
@@ -1212,15 +1187,26 @@ final class DashboardView: NSView, NSTextFieldDelegate {
                 switch result {
                 case .success(let output):
                     let normalizedJobs = self.normalizedJobs(output.jobs)
-                    self.configureJobListings(normalizedJobs, noResultsForQuery: prompt)
-                    let reply = self.makeAssistantSearchReply(query: prompt, jobs: normalizedJobs)
+                    let freshJobs: [JobListing]
+                    if isContinuation {
+                        // Continuations append only the *new* matches; previous cards already live in their own assistant message above.
+                        let alreadySeen = Set(self.lastSearchResults)
+                        freshJobs = normalizedJobs.filter { !alreadySeen.contains($0) }
+                    } else {
+                        freshJobs = normalizedJobs
+                    }
+                    self.lastSearchResults.append(contentsOf: freshJobs)
+                    let reply = self.makeAssistantSearchReply(
+                        query: effectiveQuery,
+                        newJobsCount: freshJobs.count,
+                        isContinuation: isContinuation
+                    )
                     self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
-                    self.appendChatBubble(text: reply, isUser: false)
-                    self.chatStatusLabel.stringValue = "Ask for another job, company, or skill match"
+                    self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
+                    self.chatStatusLabel.stringValue = "Ask for another role, company, or skill match"
                 case .failure(let error):
                     self.appendChatBubble(text: error.localizedDescription, isUser: false)
                     self.chatStatusLabel.stringValue = "Could not reach API. Try again."
-                    self.configureJobListings([], noResultsForQuery: prompt)
                 }
             }
         }
@@ -1238,14 +1224,48 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         return trimmed.filter { !$0.title.isEmpty && !$0.description.isEmpty }
     }
 
-    private func makeAssistantSearchReply(query: String, jobs: [JobListing]) -> String {
-        if jobs.isEmpty {
-            return "No jobs were found for \"\(query)\". Try another title, skill, company, or location."
+    private func makeAssistantSearchReply(query: String, newJobsCount: Int, isContinuation: Bool) -> String {
+        if newJobsCount == 0 {
+            if isContinuation {
+                return "I couldn't find new matches for \u{201C}\(query)\u{201D}. Try a different angle or a more specific keyword."
+            }
+            return "No jobs found for \u{201C}\(query)\u{201D}. Try another title, skill, company, or location."
         }
-        return """
-        Found \(jobs.count) job result(s) for "\(query)".
-        Each result is shown as a card with the role, job description, and an Apply button that opens the job link.
-        """
+        let plural = newJobsCount == 1 ? "match" : "matches"
+        if isContinuation {
+            return "Here are \(newJobsCount) more \(plural) for \u{201C}\(query)\u{201D}."
+        }
+        return "Found \(newJobsCount) \(plural) for \u{201C}\(query)\u{201D}. Tap Apply to open the listing or Save to revisit later."
+    }
+
+    private func resolvedSearchQuery(for prompt: String) -> String {
+        guard isContinuationPrompt(prompt) else { return prompt }
+        for message in chatMessages.reversed() where message.role == "user" {
+            let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
+            if !candidate.isEmpty, !isContinuationPrompt(candidate) {
+                return candidate
+            }
+        }
+        return prompt
+    }
+
+    private func isContinuationPrompt(_ prompt: String) -> Bool {
+        let normalized = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        let continuationPhrases: Set<String> = [
+            "more",
+            "show more",
+            "more jobs",
+            "more results",
+            "do more searches",
+            "more searches",
+            "search more",
+            "continue",
+            "next"
+        ]
+        if continuationPhrases.contains(normalized) {
+            return true
+        }
+        return normalized.contains("more search") || normalized.contains("more job")
     }
 
     func controlTextDidBeginEditing(_ obj: Notification) {
@@ -1276,6 +1296,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
     private func resetChatState() {
         chatMessages.removeAll()
+        lastSearchResults.removeAll()
         chatStack.arrangedSubviews.forEach {
             chatStack.removeArrangedSubview($0)
             $0.removeFromSuperview()
@@ -1283,48 +1304,159 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         let welcome = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary."
         chatMessages.append(ChatMessage(role: "assistant", content: welcome))
         appendChatBubble(text: welcome, isUser: false)
-        chatStatusLabel.stringValue = "Opening the vault..."
+        chatStatusLabel.stringValue = "Ask me to find jobs"
     }
 
-    private func appendChatBubble(text: String, isUser: Bool) {
+    private func appendChatBubble(text: String, isUser: Bool, jobs: [JobListing]? = nil) {
         let host = NSView()
         host.translatesAutoresizingMaskIntoConstraints = false
 
-        let bubble = NSTextField(wrappingLabelWithString: text)
-        bubble.font = .systemFont(ofSize: 13, weight: .regular)
-        bubble.maximumNumberOfLines = 0
-        bubble.lineBreakMode = .byWordWrapping
-        bubble.alignment = .left
-        bubble.textColor = isUser ? .white : Theme.primaryText
-        bubble.wantsLayer = true
-        bubble.layer?.cornerRadius = 12
-        bubble.layer?.masksToBounds = true
-        bubble.layer?.backgroundColor = (isUser ? Theme.brandBlue : Theme.chromeBackground).cgColor
-        bubble.translatesAutoresizingMaskIntoConstraints = false
+        if isUser {
+            installUserBubble(text: text, into: host)
+        } else {
+            installAssistantBubble(text: text, jobs: jobs, into: host)
+        }
 
-        host.addSubview(bubble)
         chatStack.addArrangedSubview(host)
         host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
 
-        let leading = bubble.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 8)
-        let trailing = bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor, constant: -8)
-        let maxWidth = bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78)
-        maxWidth.priority = .required
+        DispatchQueue.main.async { [weak self] in
+            self?.updateChatBubbleWidths()
+            self?.scrollChatToBottom()
+        }
+    }
 
+    private func installUserBubble(text: String, into host: NSView) {
+        let bubble = makeChatBubbleContainer(text: text, isUser: true)
+        host.addSubview(bubble)
         NSLayoutConstraint.activate([
             bubble.topAnchor.constraint(equalTo: host.topAnchor),
             bubble.bottomAnchor.constraint(equalTo: host.bottomAnchor),
-            bubble.heightAnchor.constraint(greaterThanOrEqualToConstant: 34),
-            maxWidth,
-            isUser ? leading.withPriority(.defaultLow) : leading.withPriority(.required),
-            isUser ? trailing.withPriority(.required) : trailing.withPriority(.defaultLow)
+            bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor),
+            bubble.leadingAnchor.constraint(greaterThanOrEqualTo: host.leadingAnchor, constant: 64),
+            bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78)
+        ])
+    }
+
+    private func installAssistantBubble(text: String, jobs: [JobListing]?, into host: NSView) {
+        let avatar = makeAssistantAvatarView()
+        let nameLabel = NSTextField(labelWithString: "AI Job Finder")
+        nameLabel.font = .systemFont(ofSize: 11, weight: .semibold)
+        nameLabel.textColor = Theme.secondaryText
+        nameLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        let bubble = makeChatBubbleContainer(text: text, isUser: false)
+
+        let column = NSStackView(views: [nameLabel, bubble])
+        column.orientation = .vertical
+        column.spacing = 6
+        column.alignment = .leading
+        column.translatesAutoresizingMaskIntoConstraints = false
+
+        if let jobs, !jobs.isEmpty {
+            let jobsStack = makeChatJobsStackView(jobs: jobs)
+            column.addArrangedSubview(jobsStack)
+            jobsStack.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true
+        }
+
+        host.addSubview(avatar)
+        host.addSubview(column)
+        NSLayoutConstraint.activate([
+            avatar.leadingAnchor.constraint(equalTo: host.leadingAnchor),
+            avatar.topAnchor.constraint(equalTo: host.topAnchor),
+            avatar.widthAnchor.constraint(equalToConstant: 36),
+            avatar.heightAnchor.constraint(equalToConstant: 36),
+
+            column.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 12),
+            column.trailingAnchor.constraint(equalTo: host.trailingAnchor),
+            column.topAnchor.constraint(equalTo: host.topAnchor),
+            column.bottomAnchor.constraint(equalTo: host.bottomAnchor)
         ])
+    }
 
-        DispatchQueue.main.async {
-            let maxY = max(0, self.chatDocumentView.bounds.height - self.chatScrollView.contentView.bounds.height)
-            self.chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY))
-            self.chatScrollView.reflectScrolledClipView(self.chatScrollView.contentView)
+    private func makeChatBubbleContainer(text: String, isUser: Bool) -> NSView {
+        let container = NSView()
+        container.translatesAutoresizingMaskIntoConstraints = false
+        container.wantsLayer = true
+        container.layer?.cornerRadius = 14
+        if #available(macOS 11.0, *) {
+            container.layer?.cornerCurve = .continuous
         }
+        container.layer?.masksToBounds = true
+        if isUser {
+            container.layer?.backgroundColor = Theme.brandBlue.cgColor
+        } else {
+            container.layer?.backgroundColor = Theme.chromeBackground.cgColor
+            container.layer?.borderWidth = 1
+            container.layer?.borderColor = Theme.border.cgColor
+        }
+
+        let label = ChatBubbleLabel(wrappingLabelWithString: text)
+        label.font = .systemFont(ofSize: 13.5, weight: .regular)
+        label.textColor = isUser ? .white : Theme.primaryText
+        label.maximumNumberOfLines = 0
+        label.lineBreakMode = .byWordWrapping
+        label.alignment = .left
+        label.translatesAutoresizingMaskIntoConstraints = false
+        label.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+
+        container.addSubview(label)
+        NSLayoutConstraint.activate([
+            label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14),
+            label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14),
+            label.topAnchor.constraint(equalTo: container.topAnchor, constant: 10),
+            label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10)
+        ])
+        return container
+    }
+
+    private func makeAssistantAvatarView() -> NSView {
+        let view = NSView()
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.wantsLayer = true
+        view.layer?.cornerRadius = 18
+        if #available(macOS 11.0, *) {
+            view.layer?.cornerCurve = .continuous
+        }
+        view.layer?.backgroundColor = Theme.settingsIconBackground.cgColor
+        view.layer?.borderWidth = 1
+        view.layer?.borderColor = Theme.proCardBorder.cgColor
+
+        let icon = NSImageView()
+        icon.translatesAutoresizingMaskIntoConstraints = false
+        icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
+        icon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "AI Job Finder")
+        icon.contentTintColor = Theme.brandBlue
+
+        view.addSubview(icon)
+        NSLayoutConstraint.activate([
+            icon.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+            icon.centerYAnchor.constraint(equalTo: view.centerYAnchor)
+        ])
+        return view
+    }
+
+    private func makeChatJobsStackView(jobs: [JobListing]) -> ChatJobsStackView {
+        let stack = ChatJobsStackView()
+        stack.orientation = .vertical
+        stack.spacing = 10
+        stack.alignment = .leading
+        stack.distribution = .fill
+        stack.translatesAutoresizingMaskIntoConstraints = false
+
+        for job in jobs {
+            let card = makeJobListingCard(job, context: .homeSearchResults)
+            stack.addArrangedSubview(card)
+            card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        }
+        return stack
+    }
+
+    private func scrollChatToBottom() {
+        let maxY = max(0, chatDocumentView.bounds.height - chatScrollView.contentView.bounds.height)
+        chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY))
+        chatScrollView.reflectScrolledClipView(chatScrollView.contentView)
     }
 
     private func setInputEnabled(_ enabled: Bool) {
@@ -1777,13 +1909,6 @@ private struct OpenAIAPIErrorResponse: Codable {
     }
 }
 
-private extension NSLayoutConstraint {
-    func withPriority(_ priority: NSLayoutConstraint.Priority) -> NSLayoutConstraint {
-        self.priority = priority
-        return self
-    }
-}
-
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
 private final class JobPayloadButton: HoverableButton {
     var jobPayload: JobListing?
@@ -1898,6 +2023,12 @@ private final class JobListingsDocumentView: NSView {
     override var isFlipped: Bool { true }
 }
 
+/// 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.
+private final class ChatJobsStackView: NSStackView {}
+
+/// 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.
+private final class ChatBubbleLabel: NSTextField {}
+
 /// 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.
 private final class SidebarNavRowView: NSView {
     private let onSelect: () -> Void