Parcourir la source

Improve chat thinking affordance during job search

Replace banner spinner with ChatThinkingIndicatorView (sparkle + dot wave),
use a consistent "Searching…" status label, widen the status symbol while
loading, and show an inline thinking row under the chat while requests run.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 il y a 2 mois
Parent
commit
acee5cdaa7
1 fichiers modifiés avec 188 ajouts et 24 suppressions
  1. 188 24
      App for Indeed/Views/DashboardView.swift

+ 188 - 24
App for Indeed/Views/DashboardView.swift

@@ -89,7 +89,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private let statusBannerRow = NSStackView()
     private let chatStatusSymbolContainer = NSView()
     private let chatStatusIcon = NSImageView()
-    private let chatStatusLoadingIndicator = NSProgressIndicator()
+    private let bannerChatThinkingIndicator = ChatThinkingIndicatorView(compact: true)
     private let chatStatusLabel = NSTextField(wrappingLabelWithString: "Opening the vault...")
     private let statusBrandTrailing = NSStackView()
     private let statusBrandStarIcon = NSImageView()
@@ -124,6 +124,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private var savedJobOrder: [JobListing] = []
     private var chatMessages: [ChatMessage] = []
     private var isAwaitingResponse = false
+    /// Shown under the latest user message while a job search request is in flight.
+    private var chatThinkingRowHost: NSView?
+    private var chatStatusSymbolWidthConstraint: NSLayoutConstraint?
     private let jobSearchService = OpenAIJobSearchService()
 
     /// Upper bound sent to the model per request (was fixed at 8 in the prompt). Clamped when calling the API.
@@ -136,6 +139,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     }
 
     private static let chatStatusSparklePulseKey = "chatStatusSparklePulse"
+    /// Status label value while the model/API is working; drives the thinking affordance in the banner and chat.
+    private static let chatStatusLoadingMessage = "Searching…"
     private static let welcomeSubtitleBreathKey = "welcomeSubtitleBreath"
 
     override init(frame frameRect: NSRect) {
@@ -365,27 +370,22 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
         chatStatusIcon.contentTintColor = .white
 
-        chatStatusLoadingIndicator.translatesAutoresizingMaskIntoConstraints = false
-        chatStatusLoadingIndicator.style = .spinning
-        chatStatusLoadingIndicator.isIndeterminate = true
-        chatStatusLoadingIndicator.isDisplayedWhenStopped = false
-        chatStatusLoadingIndicator.controlSize = .regular
-        chatStatusLoadingIndicator.isHidden = true
-        chatStatusLoadingIndicator.setAccessibilityRole(.progressIndicator)
-        chatStatusLoadingIndicator.setAccessibilityLabel("Searching for jobs")
+        bannerChatThinkingIndicator.translatesAutoresizingMaskIntoConstraints = false
+        bannerChatThinkingIndicator.isHidden = true
 
         chatStatusSymbolContainer.addSubview(chatStatusIcon)
-        chatStatusSymbolContainer.addSubview(chatStatusLoadingIndicator)
+        chatStatusSymbolContainer.addSubview(bannerChatThinkingIndicator)
+
+        let symbolWidth = chatStatusSymbolContainer.widthAnchor.constraint(equalToConstant: 40)
+        chatStatusSymbolWidthConstraint = symbolWidth
 
         NSLayoutConstraint.activate([
-            chatStatusSymbolContainer.widthAnchor.constraint(equalToConstant: 40),
+            symbolWidth,
             chatStatusSymbolContainer.heightAnchor.constraint(equalToConstant: 40),
             chatStatusIcon.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
             chatStatusIcon.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor),
-            chatStatusLoadingIndicator.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
-            chatStatusLoadingIndicator.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor),
-            chatStatusLoadingIndicator.widthAnchor.constraint(equalToConstant: 26),
-            chatStatusLoadingIndicator.heightAnchor.constraint(equalToConstant: 26)
+            bannerChatThinkingIndicator.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
+            bannerChatThinkingIndicator.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor)
         ])
 
         chatStatusLabel.font = .systemFont(ofSize: 13, weight: .regular)
@@ -490,7 +490,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private func updateStatusBannerLabelWidth() {
         guard statusBannerContainer.bounds.width > 1 else { return }
         let trailingWidth = max(96, statusBrandTrailing.fittingSize.width)
-        let chrome: CGFloat = 14 + 40 + 12 + 16 + 16 + trailingWidth
+        let symbolChrome = chatStatusSymbolWidthConstraint?.constant ?? 40
+        let chrome: CGFloat = 14 + symbolChrome + 12 + 16 + 16 + trailingWidth
         let target = max(80, statusBannerContainer.bounds.width - chrome)
         if abs(chatStatusLabel.preferredMaxLayoutWidth - target) > 0.5 {
             chatStatusLabel.preferredMaxLayoutWidth = target
@@ -500,7 +501,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
     /// Leading status glyph: sparkles for prompts, magnifying glass for search-result summaries and errors.
     private func syncChatStatusBannerVisuals(forStatusText text: String) {
-        if text == "Thinking..." { return }
+        if text == Self.chatStatusLoadingMessage { return }
         let lower = text.lowercased()
         let useMagnifyingGlass =
             text.hasPrefix("Found ")
@@ -522,16 +523,18 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     }
 
     private func syncChatStatusLoadingIndicator(forStatusText text: String) {
-        let loading = (text == "Thinking...")
+        let loading = (text == Self.chatStatusLoadingMessage)
         if loading {
             chatStatusIcon.isHidden = true
-            chatStatusLoadingIndicator.isHidden = false
-            chatStatusLoadingIndicator.startAnimation(nil)
-            chatStatusSymbolContainer.layer?.backgroundColor = Theme.featureIconWell.cgColor
+            bannerChatThinkingIndicator.isHidden = false
+            bannerChatThinkingIndicator.startAnimatingIfNeeded()
+            chatStatusSymbolContainer.layer?.backgroundColor = NSColor.clear.cgColor
+            chatStatusSymbolWidthConstraint?.constant = 56
         } else {
-            chatStatusLoadingIndicator.stopAnimation(nil)
+            bannerChatThinkingIndicator.stopAnimating()
+            bannerChatThinkingIndicator.isHidden = true
             chatStatusIcon.isHidden = false
-            chatStatusLoadingIndicator.isHidden = true
+            chatStatusSymbolWidthConstraint?.constant = 40
             chatStatusSymbolContainer.layer?.backgroundColor = Theme.brandBlue.cgColor
         }
     }
@@ -1587,13 +1590,15 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
     private func startJobSearchRequest(effectiveQuery: String, isContinuation: Bool) {
         isAwaitingResponse = true
-        setChatStatusLabel("Thinking...")
+        setChatStatusLabel(Self.chatStatusLoadingMessage)
+        addInlineChatThinkingRow()
         setInputEnabled(false)
         let contextMessages = chatMessages
         let maxJobs = Self.clampedJobsPerRequest()
         jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages, maxJobs: maxJobs) { [weak self] result in
             DispatchQueue.main.async {
                 guard let self else { return }
+                self.removeInlineChatThinkingRow()
                 self.isAwaitingResponse = false
                 self.setInputEnabled(true)
                 switch result {
@@ -1753,6 +1758,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     }
 
     private func resetChatState() {
+        removeInlineChatThinkingRow()
         trailingLoadMoreJobsRow = nil
         trailingLoadMoreJobsButton = nil
         chatMessages.removeAll()
@@ -1965,6 +1971,37 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         chatScrollView.reflectScrolledClipView(chatScrollView.contentView)
     }
 
+    private func addInlineChatThinkingRow() {
+        removeInlineChatThinkingRow()
+        let host = NSView()
+        host.translatesAutoresizingMaskIntoConstraints = false
+        let indicator = ChatThinkingIndicatorView(compact: false)
+        host.addSubview(indicator)
+        NSLayoutConstraint.activate([
+            indicator.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 8),
+            indicator.topAnchor.constraint(equalTo: host.topAnchor),
+            indicator.bottomAnchor.constraint(equalTo: host.bottomAnchor, constant: -2)
+        ])
+        chatThinkingRowHost = host
+        chatStack.addArrangedSubview(host)
+        host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
+        indicator.startAnimatingIfNeeded()
+        DispatchQueue.main.async { [weak self] in
+            self?.updateChatBubbleWidths()
+            self?.scrollChatToBottom()
+        }
+    }
+
+    private func removeInlineChatThinkingRow() {
+        guard let host = chatThinkingRowHost else { return }
+        for sub in host.subviews {
+            (sub as? ChatThinkingIndicatorView)?.stopAnimating()
+        }
+        chatStack.removeArrangedSubview(host)
+        host.removeFromSuperview()
+        chatThinkingRowHost = nil
+    }
+
     private func setInputEnabled(_ enabled: Bool) {
         jobKeywordsField.isEnabled = enabled
         findJobsButton.isEnabled = enabled
@@ -2820,6 +2857,133 @@ private class HoverableView: NSView {
     }
 }
 
+/// Single sparkle with a soft warm glow and three dots whose opacity animates in a staggered wave (typing-style “thinking” affordance).
+private final class ChatThinkingIndicatorView: NSView {
+    private enum AnimationKey {
+        static let dotOpacity = "thinkingDotOpacity"
+        static let sparklePulse = "thinkingSparklePulse"
+    }
+
+    private let sparkleView = NSImageView()
+    private let dotStack = NSStackView()
+    private var dotViews: [NSView] = []
+
+    init(compact: Bool) {
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+
+        let sparklePoint: CGFloat = compact ? 11 : 14
+        let dotSize: CGFloat = compact ? 4 : 5
+        let sparkleDotGap: CGFloat = compact ? 7 : 10
+        let dotSpacing: CGFloat = compact ? 4 : 5
+
+        sparkleView.translatesAutoresizingMaskIntoConstraints = false
+        sparkleView.image = NSImage(systemSymbolName: "sparkle", accessibilityDescription: nil)
+        sparkleView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: sparklePoint, weight: .medium)
+        sparkleView.contentTintColor = NSColor(srgbRed: 232 / 255, green: 104 / 255, blue: 128 / 255, alpha: 1)
+        sparkleView.wantsLayer = true
+        sparkleView.layer?.masksToBounds = false
+        sparkleView.layer?.shadowColor = NSColor(srgbRed: 242 / 255, green: 96 / 255, blue: 132 / 255, alpha: 1).cgColor
+        sparkleView.layer?.shadowRadius = compact ? 5 : 9
+        sparkleView.layer?.shadowOpacity = 0.55
+        sparkleView.layer?.shadowOffset = .zero
+
+        NSLayoutConstraint.activate([
+            sparkleView.widthAnchor.constraint(equalToConstant: sparklePoint + 6),
+            sparkleView.heightAnchor.constraint(equalToConstant: sparklePoint + 6)
+        ])
+
+        dotStack.orientation = .horizontal
+        dotStack.spacing = dotSpacing
+        dotStack.alignment = .centerY
+        dotStack.translatesAutoresizingMaskIntoConstraints = false
+
+        let dotFill = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 122 / 255, alpha: 1)
+        for _ in 0..<3 {
+            let dot = NSView()
+            dot.translatesAutoresizingMaskIntoConstraints = false
+            dot.wantsLayer = true
+            dot.layer?.cornerRadius = dotSize / 2
+            dot.layer?.backgroundColor = dotFill.cgColor
+            NSLayoutConstraint.activate([
+                dot.widthAnchor.constraint(equalToConstant: dotSize),
+                dot.heightAnchor.constraint(equalToConstant: dotSize)
+            ])
+            dotStack.addArrangedSubview(dot)
+            dotViews.append(dot)
+        }
+
+        let row = NSStackView(views: [sparkleView, dotStack])
+        row.orientation = .horizontal
+        row.spacing = sparkleDotGap
+        row.alignment = .centerY
+        row.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(row)
+        NSLayoutConstraint.activate([
+            row.leadingAnchor.constraint(equalTo: leadingAnchor),
+            row.trailingAnchor.constraint(equalTo: trailingAnchor),
+            row.topAnchor.constraint(equalTo: topAnchor),
+            row.bottomAnchor.constraint(equalTo: bottomAnchor)
+        ])
+
+        setAccessibilityElement(true)
+        setAccessibilityRole(.group)
+        setAccessibilityLabel("Assistant is searching")
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    private static var reducedMotion: Bool {
+        NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
+    }
+
+    func startAnimatingIfNeeded() {
+        stopAnimating()
+        guard !Self.reducedMotion else {
+            dotViews.forEach { $0.layer?.opacity = 0.78 }
+            return
+        }
+        guard let sparkleLayer = sparkleView.layer else { return }
+        let waveDuration: CFTimeInterval = 0.52
+        let n = max(1, dotViews.count)
+        for (i, dot) in dotViews.enumerated() {
+            guard let layer = dot.layer else { continue }
+            layer.opacity = 1
+            let pulse = CABasicAnimation(keyPath: "opacity")
+            pulse.fromValue = 0.2
+            pulse.toValue = 1.0
+            pulse.duration = waveDuration
+            pulse.autoreverses = true
+            pulse.repeatCount = .greatestFiniteMagnitude
+            pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+            pulse.timeOffset = waveDuration * Double(i) / Double(n)
+            layer.add(pulse, forKey: AnimationKey.dotOpacity)
+        }
+
+        let scale = CABasicAnimation(keyPath: "transform.scale")
+        scale.fromValue = 1.0
+        scale.toValue = 1.07
+        scale.duration = 1.15
+        scale.autoreverses = true
+        scale.repeatCount = .greatestFiniteMagnitude
+        scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+        sparkleLayer.add(scale, forKey: AnimationKey.sparklePulse)
+    }
+
+    func stopAnimating() {
+        sparkleView.layer?.removeAnimation(forKey: AnimationKey.sparklePulse)
+        sparkleView.layer?.transform = CATransform3DIdentity
+        for dot in dotViews {
+            dot.layer?.removeAnimation(forKey: AnimationKey.dotOpacity)
+            dot.layer?.opacity = 1
+        }
+    }
+}
+
 /// Document view for the job list `NSScrollView`; flipped coordinates keep short result sets aligned to the top of the clip (avoids a large empty band above the cards on macOS).
 private final class JobListingsDocumentView: NSView {
     override var isFlipped: Bool { true }