Просмотр исходного кода

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 недель назад: 3
Родитель
Сommit
acee5cdaa7
1 измененных файлов с 188 добавлено и 24 удалено
  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 {
89 89
     private let statusBannerRow = NSStackView()
90 90
     private let chatStatusSymbolContainer = NSView()
91 91
     private let chatStatusIcon = NSImageView()
92
-    private let chatStatusLoadingIndicator = NSProgressIndicator()
92
+    private let bannerChatThinkingIndicator = ChatThinkingIndicatorView(compact: true)
93 93
     private let chatStatusLabel = NSTextField(wrappingLabelWithString: "Opening the vault...")
94 94
     private let statusBrandTrailing = NSStackView()
95 95
     private let statusBrandStarIcon = NSImageView()
@@ -124,6 +124,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
124 124
     private var savedJobOrder: [JobListing] = []
125 125
     private var chatMessages: [ChatMessage] = []
126 126
     private var isAwaitingResponse = false
127
+    /// Shown under the latest user message while a job search request is in flight.
128
+    private var chatThinkingRowHost: NSView?
129
+    private var chatStatusSymbolWidthConstraint: NSLayoutConstraint?
127 130
     private let jobSearchService = OpenAIJobSearchService()
128 131
 
129 132
     /// 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 {
136 139
     }
137 140
 
138 141
     private static let chatStatusSparklePulseKey = "chatStatusSparklePulse"
142
+    /// Status label value while the model/API is working; drives the thinking affordance in the banner and chat.
143
+    private static let chatStatusLoadingMessage = "Searching…"
139 144
     private static let welcomeSubtitleBreathKey = "welcomeSubtitleBreath"
140 145
 
141 146
     override init(frame frameRect: NSRect) {
@@ -365,27 +370,22 @@ final class DashboardView: NSView, NSTextFieldDelegate {
365 370
         chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
366 371
         chatStatusIcon.contentTintColor = .white
367 372
 
368
-        chatStatusLoadingIndicator.translatesAutoresizingMaskIntoConstraints = false
369
-        chatStatusLoadingIndicator.style = .spinning
370
-        chatStatusLoadingIndicator.isIndeterminate = true
371
-        chatStatusLoadingIndicator.isDisplayedWhenStopped = false
372
-        chatStatusLoadingIndicator.controlSize = .regular
373
-        chatStatusLoadingIndicator.isHidden = true
374
-        chatStatusLoadingIndicator.setAccessibilityRole(.progressIndicator)
375
-        chatStatusLoadingIndicator.setAccessibilityLabel("Searching for jobs")
373
+        bannerChatThinkingIndicator.translatesAutoresizingMaskIntoConstraints = false
374
+        bannerChatThinkingIndicator.isHidden = true
376 375
 
377 376
         chatStatusSymbolContainer.addSubview(chatStatusIcon)
378
-        chatStatusSymbolContainer.addSubview(chatStatusLoadingIndicator)
377
+        chatStatusSymbolContainer.addSubview(bannerChatThinkingIndicator)
378
+
379
+        let symbolWidth = chatStatusSymbolContainer.widthAnchor.constraint(equalToConstant: 40)
380
+        chatStatusSymbolWidthConstraint = symbolWidth
379 381
 
380 382
         NSLayoutConstraint.activate([
381
-            chatStatusSymbolContainer.widthAnchor.constraint(equalToConstant: 40),
383
+            symbolWidth,
382 384
             chatStatusSymbolContainer.heightAnchor.constraint(equalToConstant: 40),
383 385
             chatStatusIcon.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
384 386
             chatStatusIcon.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor),
385
-            chatStatusLoadingIndicator.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
386
-            chatStatusLoadingIndicator.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor),
387
-            chatStatusLoadingIndicator.widthAnchor.constraint(equalToConstant: 26),
388
-            chatStatusLoadingIndicator.heightAnchor.constraint(equalToConstant: 26)
387
+            bannerChatThinkingIndicator.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
388
+            bannerChatThinkingIndicator.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor)
389 389
         ])
390 390
 
391 391
         chatStatusLabel.font = .systemFont(ofSize: 13, weight: .regular)
@@ -490,7 +490,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
490 490
     private func updateStatusBannerLabelWidth() {
491 491
         guard statusBannerContainer.bounds.width > 1 else { return }
492 492
         let trailingWidth = max(96, statusBrandTrailing.fittingSize.width)
493
-        let chrome: CGFloat = 14 + 40 + 12 + 16 + 16 + trailingWidth
493
+        let symbolChrome = chatStatusSymbolWidthConstraint?.constant ?? 40
494
+        let chrome: CGFloat = 14 + symbolChrome + 12 + 16 + 16 + trailingWidth
494 495
         let target = max(80, statusBannerContainer.bounds.width - chrome)
495 496
         if abs(chatStatusLabel.preferredMaxLayoutWidth - target) > 0.5 {
496 497
             chatStatusLabel.preferredMaxLayoutWidth = target
@@ -500,7 +501,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
500 501
 
501 502
     /// Leading status glyph: sparkles for prompts, magnifying glass for search-result summaries and errors.
502 503
     private func syncChatStatusBannerVisuals(forStatusText text: String) {
503
-        if text == "Thinking..." { return }
504
+        if text == Self.chatStatusLoadingMessage { return }
504 505
         let lower = text.lowercased()
505 506
         let useMagnifyingGlass =
506 507
             text.hasPrefix("Found ")
@@ -522,16 +523,18 @@ final class DashboardView: NSView, NSTextFieldDelegate {
522 523
     }
523 524
 
524 525
     private func syncChatStatusLoadingIndicator(forStatusText text: String) {
525
-        let loading = (text == "Thinking...")
526
+        let loading = (text == Self.chatStatusLoadingMessage)
526 527
         if loading {
527 528
             chatStatusIcon.isHidden = true
528
-            chatStatusLoadingIndicator.isHidden = false
529
-            chatStatusLoadingIndicator.startAnimation(nil)
530
-            chatStatusSymbolContainer.layer?.backgroundColor = Theme.featureIconWell.cgColor
529
+            bannerChatThinkingIndicator.isHidden = false
530
+            bannerChatThinkingIndicator.startAnimatingIfNeeded()
531
+            chatStatusSymbolContainer.layer?.backgroundColor = NSColor.clear.cgColor
532
+            chatStatusSymbolWidthConstraint?.constant = 56
531 533
         } else {
532
-            chatStatusLoadingIndicator.stopAnimation(nil)
534
+            bannerChatThinkingIndicator.stopAnimating()
535
+            bannerChatThinkingIndicator.isHidden = true
533 536
             chatStatusIcon.isHidden = false
534
-            chatStatusLoadingIndicator.isHidden = true
537
+            chatStatusSymbolWidthConstraint?.constant = 40
535 538
             chatStatusSymbolContainer.layer?.backgroundColor = Theme.brandBlue.cgColor
536 539
         }
537 540
     }
@@ -1587,13 +1590,15 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1587 1590
 
1588 1591
     private func startJobSearchRequest(effectiveQuery: String, isContinuation: Bool) {
1589 1592
         isAwaitingResponse = true
1590
-        setChatStatusLabel("Thinking...")
1593
+        setChatStatusLabel(Self.chatStatusLoadingMessage)
1594
+        addInlineChatThinkingRow()
1591 1595
         setInputEnabled(false)
1592 1596
         let contextMessages = chatMessages
1593 1597
         let maxJobs = Self.clampedJobsPerRequest()
1594 1598
         jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages, maxJobs: maxJobs) { [weak self] result in
1595 1599
             DispatchQueue.main.async {
1596 1600
                 guard let self else { return }
1601
+                self.removeInlineChatThinkingRow()
1597 1602
                 self.isAwaitingResponse = false
1598 1603
                 self.setInputEnabled(true)
1599 1604
                 switch result {
@@ -1753,6 +1758,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1753 1758
     }
1754 1759
 
1755 1760
     private func resetChatState() {
1761
+        removeInlineChatThinkingRow()
1756 1762
         trailingLoadMoreJobsRow = nil
1757 1763
         trailingLoadMoreJobsButton = nil
1758 1764
         chatMessages.removeAll()
@@ -1965,6 +1971,37 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1965 1971
         chatScrollView.reflectScrolledClipView(chatScrollView.contentView)
1966 1972
     }
1967 1973
 
1974
+    private func addInlineChatThinkingRow() {
1975
+        removeInlineChatThinkingRow()
1976
+        let host = NSView()
1977
+        host.translatesAutoresizingMaskIntoConstraints = false
1978
+        let indicator = ChatThinkingIndicatorView(compact: false)
1979
+        host.addSubview(indicator)
1980
+        NSLayoutConstraint.activate([
1981
+            indicator.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 8),
1982
+            indicator.topAnchor.constraint(equalTo: host.topAnchor),
1983
+            indicator.bottomAnchor.constraint(equalTo: host.bottomAnchor, constant: -2)
1984
+        ])
1985
+        chatThinkingRowHost = host
1986
+        chatStack.addArrangedSubview(host)
1987
+        host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
1988
+        indicator.startAnimatingIfNeeded()
1989
+        DispatchQueue.main.async { [weak self] in
1990
+            self?.updateChatBubbleWidths()
1991
+            self?.scrollChatToBottom()
1992
+        }
1993
+    }
1994
+
1995
+    private func removeInlineChatThinkingRow() {
1996
+        guard let host = chatThinkingRowHost else { return }
1997
+        for sub in host.subviews {
1998
+            (sub as? ChatThinkingIndicatorView)?.stopAnimating()
1999
+        }
2000
+        chatStack.removeArrangedSubview(host)
2001
+        host.removeFromSuperview()
2002
+        chatThinkingRowHost = nil
2003
+    }
2004
+
1968 2005
     private func setInputEnabled(_ enabled: Bool) {
1969 2006
         jobKeywordsField.isEnabled = enabled
1970 2007
         findJobsButton.isEnabled = enabled
@@ -2820,6 +2857,133 @@ private class HoverableView: NSView {
2820 2857
     }
2821 2858
 }
2822 2859
 
2860
+/// Single sparkle with a soft warm glow and three dots whose opacity animates in a staggered wave (typing-style “thinking” affordance).
2861
+private final class ChatThinkingIndicatorView: NSView {
2862
+    private enum AnimationKey {
2863
+        static let dotOpacity = "thinkingDotOpacity"
2864
+        static let sparklePulse = "thinkingSparklePulse"
2865
+    }
2866
+
2867
+    private let sparkleView = NSImageView()
2868
+    private let dotStack = NSStackView()
2869
+    private var dotViews: [NSView] = []
2870
+
2871
+    init(compact: Bool) {
2872
+        super.init(frame: .zero)
2873
+        translatesAutoresizingMaskIntoConstraints = false
2874
+
2875
+        let sparklePoint: CGFloat = compact ? 11 : 14
2876
+        let dotSize: CGFloat = compact ? 4 : 5
2877
+        let sparkleDotGap: CGFloat = compact ? 7 : 10
2878
+        let dotSpacing: CGFloat = compact ? 4 : 5
2879
+
2880
+        sparkleView.translatesAutoresizingMaskIntoConstraints = false
2881
+        sparkleView.image = NSImage(systemSymbolName: "sparkle", accessibilityDescription: nil)
2882
+        sparkleView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: sparklePoint, weight: .medium)
2883
+        sparkleView.contentTintColor = NSColor(srgbRed: 232 / 255, green: 104 / 255, blue: 128 / 255, alpha: 1)
2884
+        sparkleView.wantsLayer = true
2885
+        sparkleView.layer?.masksToBounds = false
2886
+        sparkleView.layer?.shadowColor = NSColor(srgbRed: 242 / 255, green: 96 / 255, blue: 132 / 255, alpha: 1).cgColor
2887
+        sparkleView.layer?.shadowRadius = compact ? 5 : 9
2888
+        sparkleView.layer?.shadowOpacity = 0.55
2889
+        sparkleView.layer?.shadowOffset = .zero
2890
+
2891
+        NSLayoutConstraint.activate([
2892
+            sparkleView.widthAnchor.constraint(equalToConstant: sparklePoint + 6),
2893
+            sparkleView.heightAnchor.constraint(equalToConstant: sparklePoint + 6)
2894
+        ])
2895
+
2896
+        dotStack.orientation = .horizontal
2897
+        dotStack.spacing = dotSpacing
2898
+        dotStack.alignment = .centerY
2899
+        dotStack.translatesAutoresizingMaskIntoConstraints = false
2900
+
2901
+        let dotFill = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 122 / 255, alpha: 1)
2902
+        for _ in 0..<3 {
2903
+            let dot = NSView()
2904
+            dot.translatesAutoresizingMaskIntoConstraints = false
2905
+            dot.wantsLayer = true
2906
+            dot.layer?.cornerRadius = dotSize / 2
2907
+            dot.layer?.backgroundColor = dotFill.cgColor
2908
+            NSLayoutConstraint.activate([
2909
+                dot.widthAnchor.constraint(equalToConstant: dotSize),
2910
+                dot.heightAnchor.constraint(equalToConstant: dotSize)
2911
+            ])
2912
+            dotStack.addArrangedSubview(dot)
2913
+            dotViews.append(dot)
2914
+        }
2915
+
2916
+        let row = NSStackView(views: [sparkleView, dotStack])
2917
+        row.orientation = .horizontal
2918
+        row.spacing = sparkleDotGap
2919
+        row.alignment = .centerY
2920
+        row.translatesAutoresizingMaskIntoConstraints = false
2921
+
2922
+        addSubview(row)
2923
+        NSLayoutConstraint.activate([
2924
+            row.leadingAnchor.constraint(equalTo: leadingAnchor),
2925
+            row.trailingAnchor.constraint(equalTo: trailingAnchor),
2926
+            row.topAnchor.constraint(equalTo: topAnchor),
2927
+            row.bottomAnchor.constraint(equalTo: bottomAnchor)
2928
+        ])
2929
+
2930
+        setAccessibilityElement(true)
2931
+        setAccessibilityRole(.group)
2932
+        setAccessibilityLabel("Assistant is searching")
2933
+    }
2934
+
2935
+    @available(*, unavailable)
2936
+    required init?(coder: NSCoder) {
2937
+        fatalError("init(coder:) has not been implemented")
2938
+    }
2939
+
2940
+    private static var reducedMotion: Bool {
2941
+        NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
2942
+    }
2943
+
2944
+    func startAnimatingIfNeeded() {
2945
+        stopAnimating()
2946
+        guard !Self.reducedMotion else {
2947
+            dotViews.forEach { $0.layer?.opacity = 0.78 }
2948
+            return
2949
+        }
2950
+        guard let sparkleLayer = sparkleView.layer else { return }
2951
+        let waveDuration: CFTimeInterval = 0.52
2952
+        let n = max(1, dotViews.count)
2953
+        for (i, dot) in dotViews.enumerated() {
2954
+            guard let layer = dot.layer else { continue }
2955
+            layer.opacity = 1
2956
+            let pulse = CABasicAnimation(keyPath: "opacity")
2957
+            pulse.fromValue = 0.2
2958
+            pulse.toValue = 1.0
2959
+            pulse.duration = waveDuration
2960
+            pulse.autoreverses = true
2961
+            pulse.repeatCount = .greatestFiniteMagnitude
2962
+            pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
2963
+            pulse.timeOffset = waveDuration * Double(i) / Double(n)
2964
+            layer.add(pulse, forKey: AnimationKey.dotOpacity)
2965
+        }
2966
+
2967
+        let scale = CABasicAnimation(keyPath: "transform.scale")
2968
+        scale.fromValue = 1.0
2969
+        scale.toValue = 1.07
2970
+        scale.duration = 1.15
2971
+        scale.autoreverses = true
2972
+        scale.repeatCount = .greatestFiniteMagnitude
2973
+        scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
2974
+        sparkleLayer.add(scale, forKey: AnimationKey.sparklePulse)
2975
+    }
2976
+
2977
+    func stopAnimating() {
2978
+        sparkleView.layer?.removeAnimation(forKey: AnimationKey.sparklePulse)
2979
+        sparkleView.layer?.transform = CATransform3DIdentity
2980
+        for dot in dotViews {
2981
+            dot.layer?.removeAnimation(forKey: AnimationKey.dotOpacity)
2982
+            dot.layer?.opacity = 1
2983
+        }
2984
+    }
2985
+}
2986
+
2823 2987
 /// 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).
2824 2988
 private final class JobListingsDocumentView: NSView {
2825 2989
     override var isFlipped: Bool { true }