|
|
@@ -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 }
|