|
@@ -111,6 +111,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
private var isAwaitingResponse = false
|
|
private var isAwaitingResponse = false
|
|
|
private let jobSearchService = OpenAIJobSearchService()
|
|
private let jobSearchService = OpenAIJobSearchService()
|
|
|
|
|
|
|
|
|
|
+ private static let chatStatusSparklePulseKey = "chatStatusSparklePulse"
|
|
|
|
|
+ private static let welcomeSubtitleBreathKey = "welcomeSubtitleBreath"
|
|
|
|
|
+
|
|
|
override init(frame frameRect: NSRect) {
|
|
override init(frame frameRect: NSRect) {
|
|
|
super.init(frame: frameRect)
|
|
super.init(frame: frameRect)
|
|
|
setupLayout()
|
|
setupLayout()
|
|
@@ -200,6 +203,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
subtitleLabel.textColor = Theme.welcomeSubtitleText
|
|
subtitleLabel.textColor = Theme.welcomeSubtitleText
|
|
|
subtitleLabel.alignment = .center
|
|
subtitleLabel.alignment = .center
|
|
|
subtitleLabel.maximumNumberOfLines = 2
|
|
subtitleLabel.maximumNumberOfLines = 2
|
|
|
|
|
+ subtitleLabel.wantsLayer = true
|
|
|
|
|
|
|
|
let topInset = NSView()
|
|
let topInset = NSView()
|
|
|
topInset.translatesAutoresizingMaskIntoConstraints = false
|
|
topInset.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -279,6 +283,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
chatStatusStack.translatesAutoresizingMaskIntoConstraints = false
|
|
chatStatusStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
chatStatusIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
chatStatusIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ chatStatusIcon.wantsLayer = true
|
|
|
chatStatusIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 36, weight: .regular)
|
|
chatStatusIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 36, weight: .regular)
|
|
|
chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
|
|
chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
|
|
|
chatStatusIcon.contentTintColor = Theme.brandBlue
|
|
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() {
|
|
private func updateJobListingDescriptionWidths() {
|
|
|
updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
|
|
updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
|
|
|
walkChatJobStacks { stack in
|
|
walkChatJobStacks { stack in
|
|
@@ -1155,6 +1230,13 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title
|
|
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.
|
|
/// 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))
|
|
chatMessages.append(ChatMessage(role: "user", content: prompt))
|
|
|
jobKeywordsField.stringValue = ""
|
|
jobKeywordsField.stringValue = ""
|
|
|
isAwaitingResponse = true
|
|
isAwaitingResponse = true
|
|
|
- chatStatusLabel.stringValue = "Thinking..."
|
|
|
|
|
|
|
+ setChatStatusLabel("Thinking...")
|
|
|
setInputEnabled(false)
|
|
setInputEnabled(false)
|
|
|
let contextMessages = chatMessages
|
|
let contextMessages = chatMessages
|
|
|
jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages) { [weak self] result in
|
|
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.chatMessages.append(ChatMessage(role: "assistant", content: reply))
|
|
|
self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
|
|
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):
|
|
case .failure(let error):
|
|
|
self.appendChatBubble(text: error.localizedDescription, isUser: false)
|
|
self.appendChatBubble(text: error.localizedDescription, isUser: false)
|
|
|
if error is URLError {
|
|
if error is URLError {
|
|
|
- self.chatStatusLabel.stringValue = "Could not reach API. Try again."
|
|
|
|
|
|
|
+ self.setChatStatusLabel("Could not reach API. Try again.")
|
|
|
} else {
|
|
} 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) {
|
|
func controlTextDidBeginEditing(_ obj: Notification) {
|
|
|
applySearchFieldInsertionPoint(obj.object)
|
|
applySearchFieldInsertionPoint(obj.object)
|
|
|
if (obj.object as? NSTextField) === jobKeywordsField {
|
|
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."
|
|
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))
|
|
chatMessages.append(ChatMessage(role: "assistant", content: welcome))
|
|
|
appendChatBubble(text: welcome, isUser: false)
|
|
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) {
|
|
private func appendChatBubble(text: String, isUser: Bool, jobs: [JobListing]? = nil) {
|
|
@@ -1376,9 +1458,25 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
chatStack.addArrangedSubview(host)
|
|
chatStack.addArrangedSubview(host)
|
|
|
host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
|
|
host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
|
|
|
|
|
|
|
|
|
|
+ if prefersReducedMotion {
|
|
|
|
|
+ host.alphaValue = 1
|
|
|
|
|
+ } else {
|
|
|
|
|
+ host.alphaValue = 0
|
|
|
|
|
+ }
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
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()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|