瀏覽代碼

Polish dashboard welcome hero and chat with motion-aware animations.

Add subtle pulse on the status sparkles and breathing opacity on the welcome subtitle when the home overlay is visible, respecting Reduce Motion. Route chat status updates through a helper so animations stay in sync. Fade new chat bubbles in unless reduced motion is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 月之前
父節點
當前提交
17c35b12a0
共有 1 個文件被更改,包括 106 次插入8 次删除
  1. 106 8
      App for Indeed/Views/DashboardView.swift

+ 106 - 8
App for Indeed/Views/DashboardView.swift

@@ -111,6 +111,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private var isAwaitingResponse = false
     private let jobSearchService = OpenAIJobSearchService()
 
+    private static let chatStatusSparklePulseKey = "chatStatusSparklePulse"
+    private static let welcomeSubtitleBreathKey = "welcomeSubtitleBreath"
+
     override init(frame frameRect: NSRect) {
         super.init(frame: frameRect)
         setupLayout()
@@ -200,6 +203,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         subtitleLabel.textColor = Theme.welcomeSubtitleText
         subtitleLabel.alignment = .center
         subtitleLabel.maximumNumberOfLines = 2
+        subtitleLabel.wantsLayer = true
 
         let topInset = NSView()
         topInset.translatesAutoresizingMaskIntoConstraints = false
@@ -279,6 +283,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         chatStatusStack.translatesAutoresizingMaskIntoConstraints = false
 
         chatStatusIcon.translatesAutoresizingMaskIntoConstraints = false
+        chatStatusIcon.wantsLayer = true
         chatStatusIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 36, weight: .regular)
         chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
         chatStatusIcon.contentTintColor = Theme.brandBlue
@@ -325,6 +330,76 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         ])
     }
 
+    private func setChatStatusLabel(_ text: String) {
+        chatStatusLabel.stringValue = text
+        syncChatStatusSparkleAnimation()
+    }
+
+    private func isWelcomeHeroVisible() -> Bool {
+        !mainOverlay.isHidden
+    }
+
+    private var prefersReducedMotion: Bool {
+        NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
+    }
+
+    private func shouldAnimateChatStatusSparkles(for statusText: String) -> Bool {
+        statusText == "Ask me to find jobs"
+            || statusText == "Ask for another role, company, or skill match"
+    }
+
+    private func syncChatStatusSparkleAnimation() {
+        guard !prefersReducedMotion else {
+            stopChatStatusSparkleAnimation()
+            return
+        }
+        guard isWelcomeHeroVisible(), shouldAnimateChatStatusSparkles(for: chatStatusLabel.stringValue) else {
+            stopChatStatusSparkleAnimation()
+            return
+        }
+        guard let layer = chatStatusIcon.layer, layer.animation(forKey: Self.chatStatusSparklePulseKey) == nil else { return }
+
+        let scale = CABasicAnimation(keyPath: "transform.scale")
+        scale.fromValue = 1.0
+        scale.toValue = 1.1
+        scale.duration = 1.25
+        scale.autoreverses = true
+        scale.repeatCount = .greatestFiniteMagnitude
+        scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+        layer.add(scale, forKey: Self.chatStatusSparklePulseKey)
+    }
+
+    private func stopChatStatusSparkleAnimation() {
+        chatStatusIcon.layer?.removeAnimation(forKey: Self.chatStatusSparklePulseKey)
+        chatStatusIcon.layer?.transform = CATransform3DIdentity
+    }
+
+    private func syncWelcomeSubtitleBreathingAnimation() {
+        guard !prefersReducedMotion else {
+            stopWelcomeSubtitleBreathingAnimation()
+            return
+        }
+        guard isWelcomeHeroVisible(), !subtitleLabel.stringValue.isEmpty else {
+            stopWelcomeSubtitleBreathingAnimation()
+            return
+        }
+        guard let layer = subtitleLabel.layer, layer.animation(forKey: Self.welcomeSubtitleBreathKey) == nil else { return }
+
+        let pulse = CABasicAnimation(keyPath: "opacity")
+        pulse.fromValue = 1.0
+        pulse.toValue = 0.86
+        pulse.duration = 2.4
+        pulse.autoreverses = true
+        pulse.repeatCount = .greatestFiniteMagnitude
+        pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+        layer.add(pulse, forKey: Self.welcomeSubtitleBreathKey)
+    }
+
+    private func stopWelcomeSubtitleBreathingAnimation() {
+        subtitleLabel.layer?.removeAnimation(forKey: Self.welcomeSubtitleBreathKey)
+        subtitleLabel.layer?.opacity = 1
+    }
+
     private func updateJobListingDescriptionWidths() {
         updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
         walkChatJobStacks { stack in
@@ -1155,6 +1230,13 @@ final class DashboardView: NSView, NSTextFieldDelegate {
                 nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title
             }
         }
+        if home {
+            syncWelcomeSubtitleBreathingAnimation()
+            syncChatStatusSparkleAnimation()
+        } else {
+            stopWelcomeSubtitleBreathingAnimation()
+            stopChatStatusSparkleAnimation()
+        }
     }
 
     /// Restores the main job-search experience: cleared query and a fresh chat history.
@@ -1185,7 +1267,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         chatMessages.append(ChatMessage(role: "user", content: prompt))
         jobKeywordsField.stringValue = ""
         isAwaitingResponse = true
-        chatStatusLabel.stringValue = "Thinking..."
+        setChatStatusLabel("Thinking...")
         setInputEnabled(false)
         let contextMessages = chatMessages
         jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages) { [weak self] result in
@@ -1212,13 +1294,13 @@ final class DashboardView: NSView, NSTextFieldDelegate {
                     )
                     self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
                     self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
-                    self.chatStatusLabel.stringValue = "Ask for another role, company, or skill match"
+                    self.setChatStatusLabel("Ask for another role, company, or skill match")
                 case .failure(let error):
                     self.appendChatBubble(text: error.localizedDescription, isUser: false)
                     if error is URLError {
-                        self.chatStatusLabel.stringValue = "Could not reach API. Try again."
+                        self.setChatStatusLabel("Could not reach API. Try again.")
                     } else {
-                        self.chatStatusLabel.stringValue = "Search did not finish. Try again."
+                        self.setChatStatusLabel("Search did not finish. Try again.")
                     }
                 }
             }
@@ -1327,7 +1409,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     func controlTextDidBeginEditing(_ obj: Notification) {
         applySearchFieldInsertionPoint(obj.object)
         if (obj.object as? NSTextField) === jobKeywordsField {
-            chatStatusLabel.stringValue = "Opening the vault..."
+            setChatStatusLabel("Opening the vault...")
         }
     }
 
@@ -1360,7 +1442,7 @@ 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 = "Ask me to find jobs"
+        setChatStatusLabel("Ask me to find jobs")
     }
 
     private func appendChatBubble(text: String, isUser: Bool, jobs: [JobListing]? = nil) {
@@ -1376,9 +1458,25 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         chatStack.addArrangedSubview(host)
         host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
 
+        if prefersReducedMotion {
+            host.alphaValue = 1
+        } else {
+            host.alphaValue = 0
+        }
         DispatchQueue.main.async { [weak self] in
-            self?.updateChatBubbleWidths()
-            self?.scrollChatToBottom()
+            guard let self else { return }
+            if self.prefersReducedMotion {
+                self.updateChatBubbleWidths()
+                self.scrollChatToBottom()
+                return
+            }
+            NSAnimationContext.runAnimationGroup { ctx in
+                ctx.duration = 0.3
+                ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
+                host.animator().alphaValue = 1
+            }
+            self.updateChatBubbleWidths()
+            self.scrollChatToBottom()
         }
     }