|
|
@@ -29,8 +29,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
|
|
|
static let tertiaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
|
|
|
static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
|
|
|
- /// Home status strip and soft accents (light blue panel in the reference UI).
|
|
|
- static let statusBannerBackground = NSColor(srgbRed: 232 / 255, green: 242 / 255, blue: 252 / 255, alpha: 1)
|
|
|
static let featureIconWell = NSColor(srgbRed: 220 / 255, green: 235 / 255, blue: 252 / 255, alpha: 1)
|
|
|
/// Job search bar outer stroke (soft blue-gray, pill field in the reference UI).
|
|
|
static let searchBarBorder = NSColor(srgbRed: 180 / 255, green: 200 / 255, blue: 228 / 255, alpha: 1)
|
|
|
@@ -84,16 +82,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
private let findJobsCTAHost = NSView()
|
|
|
private let findJobsCTAChrome = HoverableView()
|
|
|
private var findJobsCTAGradientLayer: CAGradientLayer?
|
|
|
- private let statusBannerContainer = NSView()
|
|
|
- private let statusBannerInner = NSView()
|
|
|
- private let statusBannerRow = NSStackView()
|
|
|
- private let chatStatusSymbolContainer = NSView()
|
|
|
- private let chatStatusIcon = NSImageView()
|
|
|
- private let bannerChatThinkingIndicator = ChatThinkingIndicatorView(compact: true)
|
|
|
- private let chatStatusLabel = NSTextField(wrappingLabelWithString: "Opening the vault...")
|
|
|
- private let statusBrandTrailing = NSStackView()
|
|
|
- private let statusBrandStarIcon = NSImageView()
|
|
|
- private let statusBrandLabel = NSTextField(labelWithString: "AI Job Finder")
|
|
|
private let welcomeSparkleIcon = NSImageView()
|
|
|
private let featureCardsRow = NSStackView()
|
|
|
private let chatScrollView = NSScrollView()
|
|
|
@@ -126,7 +114,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
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.
|
|
|
@@ -138,9 +125,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
min(jobsPerSearchMaxCap, max(jobsPerSearchMin, requested))
|
|
|
}
|
|
|
|
|
|
- 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) {
|
|
|
@@ -160,7 +144,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
updateFindJobsCTAShadowPath()
|
|
|
updateJobListingDescriptionWidths()
|
|
|
updateChatBubbleWidths()
|
|
|
- updateStatusBannerLabelWidth()
|
|
|
}
|
|
|
|
|
|
func render(_ data: DashboardData) {
|
|
|
@@ -270,7 +253,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
mainOverlay.addArrangedSubview(titleBlock)
|
|
|
mainOverlay.addArrangedSubview(featureCardsRow)
|
|
|
mainOverlay.addArrangedSubview(midSpacer)
|
|
|
- mainOverlay.addArrangedSubview(statusBannerContainer)
|
|
|
mainOverlay.addArrangedSubview(chatTopSpacer)
|
|
|
mainOverlay.addArrangedSubview(chatScrollView)
|
|
|
mainOverlay.addArrangedSubview(chatBottomSpacer)
|
|
|
@@ -304,7 +286,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
nonHomeHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
|
|
|
|
|
|
searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
- statusBannerContainer.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
featureCardsRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
|
|
|
@@ -340,111 +321,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
|
|
|
|
private func configureChatViews() {
|
|
|
- statusBannerContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- statusBannerContainer.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
|
|
-
|
|
|
- statusBannerInner.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- statusBannerInner.wantsLayer = true
|
|
|
- statusBannerInner.layer?.backgroundColor = Theme.statusBannerBackground.cgColor
|
|
|
- statusBannerInner.layer?.cornerRadius = 14
|
|
|
- if #available(macOS 11.0, *) {
|
|
|
- statusBannerInner.layer?.cornerCurve = .continuous
|
|
|
- }
|
|
|
- statusBannerInner.layer?.masksToBounds = true
|
|
|
-
|
|
|
- statusBannerContainer.addSubview(statusBannerInner)
|
|
|
-
|
|
|
- statusBannerRow.orientation = .horizontal
|
|
|
- statusBannerRow.spacing = 12
|
|
|
- statusBannerRow.alignment = .top
|
|
|
- statusBannerRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
-
|
|
|
- chatStatusSymbolContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- chatStatusSymbolContainer.wantsLayer = true
|
|
|
- chatStatusSymbolContainer.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
- chatStatusSymbolContainer.layer?.cornerRadius = 20
|
|
|
-
|
|
|
- chatStatusIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- chatStatusIcon.wantsLayer = true
|
|
|
- chatStatusIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
|
|
|
- chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
|
|
|
- chatStatusIcon.contentTintColor = .white
|
|
|
-
|
|
|
- bannerChatThinkingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- bannerChatThinkingIndicator.isHidden = true
|
|
|
-
|
|
|
- chatStatusSymbolContainer.addSubview(chatStatusIcon)
|
|
|
- chatStatusSymbolContainer.addSubview(bannerChatThinkingIndicator)
|
|
|
-
|
|
|
- let symbolWidth = chatStatusSymbolContainer.widthAnchor.constraint(equalToConstant: 40)
|
|
|
- chatStatusSymbolWidthConstraint = symbolWidth
|
|
|
-
|
|
|
- NSLayoutConstraint.activate([
|
|
|
- symbolWidth,
|
|
|
- chatStatusSymbolContainer.heightAnchor.constraint(equalToConstant: 40),
|
|
|
- chatStatusIcon.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
|
|
|
- chatStatusIcon.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor),
|
|
|
- bannerChatThinkingIndicator.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
|
|
|
- bannerChatThinkingIndicator.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor)
|
|
|
- ])
|
|
|
-
|
|
|
- chatStatusLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
|
|
- chatStatusLabel.textColor = Theme.primaryText
|
|
|
- chatStatusLabel.alignment = .left
|
|
|
- chatStatusLabel.maximumNumberOfLines = 0
|
|
|
- chatStatusLabel.lineBreakMode = .byWordWrapping
|
|
|
- chatStatusLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
- chatStatusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
- chatStatusLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
-
|
|
|
- statusBrandStarIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- statusBrandStarIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
|
- statusBrandStarIcon.image = NSImage(systemSymbolName: "sparkle", accessibilityDescription: nil)
|
|
|
- statusBrandStarIcon.contentTintColor = Theme.brandBlue
|
|
|
-
|
|
|
- statusBrandLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
|
- statusBrandLabel.textColor = Theme.brandBlue
|
|
|
- statusBrandLabel.alignment = .right
|
|
|
- statusBrandLabel.maximumNumberOfLines = 1
|
|
|
-
|
|
|
- statusBrandTrailing.orientation = .horizontal
|
|
|
- statusBrandTrailing.spacing = 5
|
|
|
- statusBrandTrailing.alignment = .centerY
|
|
|
- statusBrandTrailing.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- statusBrandTrailing.addArrangedSubview(statusBrandStarIcon)
|
|
|
- statusBrandTrailing.addArrangedSubview(statusBrandLabel)
|
|
|
- statusBrandTrailing.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
- statusBrandTrailing.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
-
|
|
|
- statusBannerRow.addArrangedSubview(chatStatusSymbolContainer)
|
|
|
- statusBannerRow.addArrangedSubview(chatStatusLabel)
|
|
|
-
|
|
|
- statusBannerInner.addSubview(statusBannerRow)
|
|
|
- statusBannerContainer.addSubview(statusBannerInner)
|
|
|
- statusBannerContainer.addSubview(statusBrandTrailing)
|
|
|
- statusBannerInner.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
|
|
- statusBannerInner.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
-
|
|
|
- NSLayoutConstraint.activate([
|
|
|
- statusBannerInner.leadingAnchor.constraint(equalTo: statusBannerContainer.leadingAnchor),
|
|
|
- statusBannerInner.topAnchor.constraint(equalTo: statusBannerContainer.topAnchor),
|
|
|
- statusBannerInner.bottomAnchor.constraint(equalTo: statusBannerContainer.bottomAnchor),
|
|
|
- statusBannerInner.trailingAnchor.constraint(lessThanOrEqualTo: statusBrandTrailing.leadingAnchor, constant: -16),
|
|
|
-
|
|
|
- statusBrandTrailing.trailingAnchor.constraint(equalTo: statusBannerContainer.trailingAnchor),
|
|
|
- statusBrandTrailing.centerYAnchor.constraint(equalTo: statusBannerContainer.centerYAnchor),
|
|
|
-
|
|
|
- statusBannerRow.leadingAnchor.constraint(equalTo: statusBannerInner.leadingAnchor, constant: 14),
|
|
|
- statusBannerRow.trailingAnchor.constraint(equalTo: statusBannerInner.trailingAnchor, constant: -16),
|
|
|
- statusBannerRow.topAnchor.constraint(equalTo: statusBannerInner.topAnchor, constant: 10),
|
|
|
- statusBannerRow.bottomAnchor.constraint(equalTo: statusBannerInner.bottomAnchor, constant: -10),
|
|
|
-
|
|
|
- statusBannerContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 52)
|
|
|
- ])
|
|
|
-
|
|
|
- syncChatStatusLoadingIndicator(forStatusText: chatStatusLabel.stringValue)
|
|
|
- syncChatStatusBannerVisuals(forStatusText: chatStatusLabel.stringValue)
|
|
|
-
|
|
|
chatDocumentView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
chatStack.orientation = .vertical
|
|
|
chatStack.spacing = 18
|
|
|
@@ -479,66 +355,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
])
|
|
|
}
|
|
|
|
|
|
- private func setChatStatusLabel(_ text: String) {
|
|
|
- chatStatusLabel.stringValue = text
|
|
|
- syncChatStatusLoadingIndicator(forStatusText: text)
|
|
|
- syncChatStatusBannerVisuals(forStatusText: text)
|
|
|
- syncChatStatusSparkleAnimation()
|
|
|
- updateStatusBannerLabelWidth()
|
|
|
- }
|
|
|
-
|
|
|
- private func updateStatusBannerLabelWidth() {
|
|
|
- guard statusBannerContainer.bounds.width > 1 else { return }
|
|
|
- let trailingWidth = max(96, statusBrandTrailing.fittingSize.width)
|
|
|
- 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
|
|
|
- chatStatusLabel.invalidateIntrinsicContentSize()
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /// Leading status glyph: sparkles for prompts, magnifying glass for search-result summaries and errors.
|
|
|
- private func syncChatStatusBannerVisuals(forStatusText text: String) {
|
|
|
- if text == Self.chatStatusLoadingMessage { return }
|
|
|
- let lower = text.lowercased()
|
|
|
- let useMagnifyingGlass =
|
|
|
- text.hasPrefix("Found ")
|
|
|
- || text.hasPrefix("Here are")
|
|
|
- || text.hasPrefix("No jobs")
|
|
|
- || text.contains("couldn't find")
|
|
|
- || lower.contains("try again")
|
|
|
- || lower.contains("could not reach")
|
|
|
- || lower.contains("search did not finish")
|
|
|
- let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
|
|
|
- chatStatusIcon.symbolConfiguration = config
|
|
|
- if useMagnifyingGlass {
|
|
|
- chatStatusIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search status")
|
|
|
- } else {
|
|
|
- chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
|
|
|
- }
|
|
|
- chatStatusIcon.contentTintColor = .white
|
|
|
- chatStatusSymbolContainer.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
- }
|
|
|
-
|
|
|
- private func syncChatStatusLoadingIndicator(forStatusText text: String) {
|
|
|
- let loading = (text == Self.chatStatusLoadingMessage)
|
|
|
- if loading {
|
|
|
- chatStatusIcon.isHidden = true
|
|
|
- bannerChatThinkingIndicator.isHidden = false
|
|
|
- bannerChatThinkingIndicator.startAnimatingIfNeeded()
|
|
|
- chatStatusSymbolContainer.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
- chatStatusSymbolWidthConstraint?.constant = 56
|
|
|
- } else {
|
|
|
- bannerChatThinkingIndicator.stopAnimating()
|
|
|
- bannerChatThinkingIndicator.isHidden = true
|
|
|
- chatStatusIcon.isHidden = false
|
|
|
- chatStatusSymbolWidthConstraint?.constant = 40
|
|
|
- chatStatusSymbolContainer.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
private func isWelcomeHeroVisible() -> Bool {
|
|
|
!mainOverlay.isHidden
|
|
|
}
|
|
|
@@ -547,37 +363,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
|
}
|
|
|
|
|
|
- private func shouldAnimateChatStatusSparkles(for statusText: String) -> Bool {
|
|
|
- statusText == "Ask me to find jobs"
|
|
|
- || statusText == "Opening the vault..."
|
|
|
- }
|
|
|
-
|
|
|
- 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()
|
|
|
@@ -1520,10 +1305,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
|
if home {
|
|
|
syncWelcomeSubtitleBreathingAnimation()
|
|
|
- syncChatStatusSparkleAnimation()
|
|
|
} else {
|
|
|
stopWelcomeSubtitleBreathingAnimation()
|
|
|
- stopChatStatusSparkleAnimation()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -1590,7 +1373,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
private func startJobSearchRequest(effectiveQuery: String, isContinuation: Bool) {
|
|
|
isAwaitingResponse = true
|
|
|
- setChatStatusLabel(Self.chatStatusLoadingMessage)
|
|
|
addInlineChatThinkingRow()
|
|
|
setInputEnabled(false)
|
|
|
let contextMessages = chatMessages
|
|
|
@@ -1620,14 +1402,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
)
|
|
|
self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
|
|
|
self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
|
|
|
- self.setChatStatusLabel(reply)
|
|
|
case .failure(let error):
|
|
|
self.appendChatBubble(text: error.localizedDescription, isUser: false)
|
|
|
- if error is URLError {
|
|
|
- self.setChatStatusLabel("Could not reach API. Try again.")
|
|
|
- } else {
|
|
|
- self.setChatStatusLabel("Search did not finish. Try again.")
|
|
|
- }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -1733,9 +1509,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
func controlTextDidBeginEditing(_ obj: Notification) {
|
|
|
applySearchFieldInsertionPoint(obj.object)
|
|
|
- if (obj.object as? NSTextField) === jobKeywordsField {
|
|
|
- setChatStatusLabel("Opening the vault...")
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
func controlTextDidChange(_ obj: Notification) {
|
|
|
@@ -1770,7 +1543,6 @@ 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)
|
|
|
- setChatStatusLabel("Ask me to find jobs")
|
|
|
}
|
|
|
|
|
|
private func appendChatBubble(text: String, isUser: Bool, jobs: [JobListing]? = nil) {
|