// // DashboardView.swift // App for Indeed // import Cocoa import QuartzCore import Security private enum JobListingCardContext { case homeSearchResults case savedJobsPage } final class DashboardView: NSView, NSTextFieldDelegate { /// Indeed.com-inspired neutrals and brand blue (white surfaces, `#2557a7` accent, `#2d2d2d` / `#767676` text, `#d4d2d0` borders). private enum Theme { static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) static let chromeBackground = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1) static let sidebarBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) static let mainHostBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) /// Subtitle on the welcome hero: dark neutral gray to match the reference layout. static let welcomeSubtitleText = NSColor(srgbRed: 64 / 255, green: 64 / 255, blue: 64 / 255, alpha: 1) static let selectionFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.12) static let cardBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) static let toggleBackground = NSColor(srgbRed: 232 / 255, green: 232 / 255, blue: 232 / 255, alpha: 1) static let primaryText = NSColor(srgbRed: 45 / 255, green: 45 / 255, blue: 45 / 255, alpha: 1) 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) /// Search bar border on hover (brand-tinted, matches focus affordance elsewhere). static let searchBarBorderHover = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55) static let proCardFill = NSColor(srgbRed: 239 / 255, green: 244 / 255, blue: 252 / 255, alpha: 1) static let proCardBorder = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1) static let proAccent = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) static let proCTABackground = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) static let proCTAText = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) /// Slightly lighter blue for the top of the search-bar “Find jobs” pill (reads less flat than a solid fill). static let findJobsCTAHighlight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1) /// Hover states: darker brand blue, deeper gradient top, stronger tints, and subtle neutral fills used across CTAs, toggles, and the sidebar. static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1) static let findJobsCTAHighlightHover = NSColor(srgbRed: 44 / 255, green: 94 / 255, blue: 178 / 255, alpha: 1) static let selectionFillHover = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.2) static let neutralHoverFill = NSColor(srgbRed: 240 / 255, green: 240 / 255, blue: 240 / 255, alpha: 1) static let sidebarRowHoverFill = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.04) static let settingsPageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) static let settingsGroupBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) static let settingsIconBackground = NSColor(srgbRed: 239 / 255, green: 244 / 255, blue: 252 / 255, alpha: 1) static let settingsDivider = NSColor(srgbRed: 228 / 255, green: 228 / 255, blue: 228 / 255, alpha: 1) } /// Multiline `NSTextField` often ignores `alignment` for wrapped runs; explicit paragraph alignment matches the title. private static func jobListingDescriptionAttributedString(_ plain: String) -> NSAttributedString { let paragraph = NSMutableParagraphStyle() paragraph.alignment = .left paragraph.lineBreakMode = .byWordWrapping paragraph.baseWritingDirection = .leftToRight let font = NSFont.systemFont(ofSize: 13, weight: .regular) return NSAttributedString(string: plain, attributes: [ .font: font, .foregroundColor: Theme.secondaryText, .paragraphStyle: paragraph ]) } private let contentStack = NSStackView() private let chromeContainer = NSView() private let sidebar = NSStackView() private let mainHost = NSView() private let mainOverlay = NSStackView() private let greetingLabel = NSTextField(labelWithString: "") private let subtitleLabel = NSTextField(labelWithString: "") private let searchBarShadowHost = NSView() private let searchCard = HoverableView() private let jobSearchIcon = NSImageView() private let jobKeywordsField = NSTextField() private let findJobsButton = HoverableButton() 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 chatStatusLoadingIndicator = NSProgressIndicator() 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() private let chatDocumentView = JobListingsDocumentView() private let chatStack = NSStackView() /// Shown when a sidebar item other than Home is selected. private let nonHomeHost = NSView() private let nonHomeGenericContainer = NSView() private let nonHomeTitleLabel = NSTextField(labelWithString: "") private let nonHomeSubtitleLabel = NSTextField(wrappingLabelWithString: "") private let savedJobsPageContainer = NSView() private let savedJobsPageTitleLabel = NSTextField(labelWithString: "Saved Jobs") private let savedJobsPageSubtitleLabel = NSTextField(wrappingLabelWithString: "") private let savedJobsScrollView = NSScrollView() private let savedJobsDocumentView = JobListingsDocumentView() private let savedJobsStack = NSStackView() private let settingsPageContainer = NSView() private let themeControl = NSSegmentedControl(labels: ["System", "Light", "Dark"], trackingMode: .selectOne, target: nil, action: nil) private var currentSidebarItems: [SidebarItem] = [] private var selectedSidebarIndex: Int = 0 /// All jobs that have been shown in the current chat session, oldest first. Used to deduplicate continuation searches so "show more" doesn't re-display the same listings. private var lastSearchResults: [JobListing] = [] /// "Show more jobs" row under the latest assistant message that listed jobs; removed when a newer listing block replaces it. private var trailingLoadMoreJobsRow: NSView? private weak var trailingLoadMoreJobsButton: HoverableButton? /// Most recently saved jobs appear first; persisted across launches. private var savedJobOrder: [JobListing] = [] private var chatMessages: [ChatMessage] = [] private var isAwaitingResponse = false private let jobSearchService = OpenAIJobSearchService() /// Upper bound sent to the model per request (was fixed at 8 in the prompt). Clamped when calling the API. private static let jobsPerSearchDefault = 15 private static let jobsPerSearchMin = 1 private static let jobsPerSearchMaxCap = 25 private static func clampedJobsPerRequest(_ requested: Int = jobsPerSearchDefault) -> Int { min(jobsPerSearchMaxCap, max(jobsPerSearchMin, requested)) } private static let chatStatusSparklePulseKey = "chatStatusSparklePulse" private static let welcomeSubtitleBreathKey = "welcomeSubtitleBreath" override init(frame frameRect: NSRect) { super.init(frame: frameRect) setupLayout() } required init?(coder: NSCoder) { super.init(coder: coder) setupLayout() } override func layout() { super.layout() updateSearchBarShadowPath() findJobsCTAGradientLayer?.frame = findJobsCTAChrome.bounds updateFindJobsCTAShadowPath() updateJobListingDescriptionWidths() updateChatBubbleWidths() updateStatusBannerLabelWidth() } func render(_ data: DashboardData) { greetingLabel.stringValue = "Welcome" subtitleLabel.stringValue = data.subtitle currentSidebarItems = data.sidebarItems if selectedSidebarIndex >= currentSidebarItems.count { selectedSidebarIndex = max(0, currentSidebarItems.count - 1) } configureSidebar() savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load()) resetChatState() updateMainContentVisibility() } private func setupLayout() { wantsLayer = true layer?.backgroundColor = Theme.pageBackground.cgColor contentStack.orientation = .horizontal contentStack.spacing = 10 contentStack.distribution = .fill contentStack.translatesAutoresizingMaskIntoConstraints = false contentStack.alignment = .height // Tighter chrome insets so panels sit closer to the window edges (especially leading / top under the title bar). contentStack.edgeInsets = NSEdgeInsets(top: 10, left: 12, bottom: 20, right: 20) chromeContainer.translatesAutoresizingMaskIntoConstraints = false chromeContainer.wantsLayer = true chromeContainer.layer?.backgroundColor = Theme.chromeBackground.cgColor chromeContainer.layer?.cornerRadius = 0 addSubview(chromeContainer) chromeContainer.addSubview(contentStack) sidebar.orientation = .vertical sidebar.spacing = 10 sidebar.distribution = .fill sidebar.alignment = .leading sidebar.wantsLayer = true sidebar.layer?.backgroundColor = Theme.sidebarBackground.cgColor sidebar.layer?.cornerRadius = 16 sidebar.edgeInsets = NSEdgeInsets(top: 16, left: 12, bottom: 16, right: 12) sidebar.translatesAutoresizingMaskIntoConstraints = false mainHost.translatesAutoresizingMaskIntoConstraints = false mainHost.wantsLayer = true mainHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor mainHost.layer?.cornerRadius = 16 mainHost.layer?.masksToBounds = true sidebar.setContentHuggingPriority(.required, for: .horizontal) mainHost.setContentHuggingPriority(.defaultLow, for: .horizontal) mainHost.addSubview(mainOverlay) configureNonHomePlaceholder() mainHost.addSubview(nonHomeHost) mainOverlay.orientation = .vertical mainOverlay.spacing = 0 mainOverlay.alignment = .centerX mainOverlay.distribution = .fill mainOverlay.translatesAutoresizingMaskIntoConstraints = false mainOverlay.setContentHuggingPriority(.defaultLow, for: .vertical) greetingLabel.font = .systemFont(ofSize: 32, weight: .bold) greetingLabel.textColor = Theme.brandBlue greetingLabel.alignment = .center greetingLabel.maximumNumberOfLines = 1 subtitleLabel.font = .systemFont(ofSize: 15, weight: .regular) subtitleLabel.textColor = Theme.welcomeSubtitleText subtitleLabel.alignment = .center subtitleLabel.maximumNumberOfLines = 2 subtitleLabel.wantsLayer = true let topInset = NSView() topInset.translatesAutoresizingMaskIntoConstraints = false topInset.heightAnchor.constraint(equalToConstant: 18).isActive = true configureSearchBar() configureChatViews() welcomeSparkleIcon.translatesAutoresizingMaskIntoConstraints = false welcomeSparkleIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold) welcomeSparkleIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil) welcomeSparkleIcon.contentTintColor = Theme.brandBlue let titleBlock = NSStackView(views: [greetingLabel, subtitleLabel, welcomeSparkleIcon]) titleBlock.orientation = .vertical titleBlock.spacing = 10 titleBlock.alignment = .centerX configureFeatureShortcutCards() let midSpacer = NSView() midSpacer.translatesAutoresizingMaskIntoConstraints = false midSpacer.heightAnchor.constraint(equalToConstant: 12).isActive = true let chatTopSpacer = NSView() chatTopSpacer.translatesAutoresizingMaskIntoConstraints = false chatTopSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true let chatBottomSpacer = NSView() chatBottomSpacer.translatesAutoresizingMaskIntoConstraints = false chatBottomSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true mainOverlay.addArrangedSubview(topInset) mainOverlay.addArrangedSubview(titleBlock) mainOverlay.addArrangedSubview(featureCardsRow) mainOverlay.addArrangedSubview(midSpacer) mainOverlay.addArrangedSubview(statusBannerContainer) mainOverlay.addArrangedSubview(chatTopSpacer) mainOverlay.addArrangedSubview(chatScrollView) mainOverlay.addArrangedSubview(chatBottomSpacer) mainOverlay.addArrangedSubview(searchBarShadowHost) contentStack.addArrangedSubview(sidebar) contentStack.addArrangedSubview(mainHost) NSLayoutConstraint.activate([ chromeContainer.leadingAnchor.constraint(equalTo: leadingAnchor), chromeContainer.trailingAnchor.constraint(equalTo: trailingAnchor), chromeContainer.topAnchor.constraint(equalTo: topAnchor), chromeContainer.bottomAnchor.constraint(equalTo: bottomAnchor), contentStack.leadingAnchor.constraint(equalTo: chromeContainer.leadingAnchor), contentStack.trailingAnchor.constraint(equalTo: chromeContainer.trailingAnchor), contentStack.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), contentStack.bottomAnchor.constraint(equalTo: chromeContainer.bottomAnchor), sidebar.widthAnchor.constraint(equalToConstant: 218), mainHost.widthAnchor.constraint(greaterThanOrEqualToConstant: 720), mainOverlay.leadingAnchor.constraint(equalTo: mainHost.leadingAnchor), mainOverlay.trailingAnchor.constraint(equalTo: mainHost.trailingAnchor), mainOverlay.topAnchor.constraint(equalTo: mainHost.topAnchor), mainOverlay.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24), nonHomeHost.leadingAnchor.constraint(equalTo: mainHost.leadingAnchor), nonHomeHost.trailingAnchor.constraint(equalTo: mainHost.trailingAnchor), nonHomeHost.topAnchor.constraint(equalTo: mainHost.topAnchor), 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), greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 16), greetingLabel.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor, constant: -16), subtitleLabel.leadingAnchor.constraint(equalTo: greetingLabel.leadingAnchor), subtitleLabel.trailingAnchor.constraint(equalTo: greetingLabel.trailingAnchor) ]) } private func configureFeatureShortcutCards() { featureCardsRow.orientation = .horizontal featureCardsRow.spacing = 16 featureCardsRow.distribution = .fillEqually featureCardsRow.alignment = .top featureCardsRow.translatesAutoresizingMaskIntoConstraints = false let specs: [(symbol: String, title: String, subtitle: String, action: Selector)] = [ ("briefcase", "Role", "Explore similar or better job roles", #selector(didTapFeatureRole)), ("building.2", "Company", "Find opportunities at other companies", #selector(didTapFeatureCompany)), ("chevron.left.forwardslash.chevron.right", "Skill", "Match jobs that fit your skills", #selector(didTapFeatureSkill)) ] for spec in specs { let card = FeatureShortcutCardView( symbolName: spec.symbol, title: spec.title, subtitle: spec.subtitle, target: self, action: spec.action ) featureCardsRow.addArrangedSubview(card) } } 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 chatStatusLoadingIndicator.translatesAutoresizingMaskIntoConstraints = false chatStatusLoadingIndicator.style = .spinning chatStatusLoadingIndicator.isIndeterminate = true chatStatusLoadingIndicator.isDisplayedWhenStopped = false chatStatusLoadingIndicator.controlSize = .regular chatStatusLoadingIndicator.isHidden = true chatStatusLoadingIndicator.setAccessibilityRole(.progressIndicator) chatStatusLoadingIndicator.setAccessibilityLabel("Searching for jobs") chatStatusSymbolContainer.addSubview(chatStatusIcon) chatStatusSymbolContainer.addSubview(chatStatusLoadingIndicator) NSLayoutConstraint.activate([ chatStatusSymbolContainer.widthAnchor.constraint(equalToConstant: 40), chatStatusSymbolContainer.heightAnchor.constraint(equalToConstant: 40), chatStatusIcon.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor), chatStatusIcon.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor), chatStatusLoadingIndicator.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor), chatStatusLoadingIndicator.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor), chatStatusLoadingIndicator.widthAnchor.constraint(equalToConstant: 26), chatStatusLoadingIndicator.heightAnchor.constraint(equalToConstant: 26) ]) 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 chatStack.alignment = .width chatStack.distribution = .fill chatStack.translatesAutoresizingMaskIntoConstraints = false chatDocumentView.addSubview(chatStack) NSLayoutConstraint.activate([ chatStack.leadingAnchor.constraint(equalTo: chatDocumentView.leadingAnchor, constant: 4), chatStack.trailingAnchor.constraint(equalTo: chatDocumentView.trailingAnchor, constant: -4), chatStack.topAnchor.constraint(equalTo: chatDocumentView.topAnchor, constant: 8), chatStack.bottomAnchor.constraint(equalTo: chatDocumentView.bottomAnchor, constant: -8) ]) chatScrollView.translatesAutoresizingMaskIntoConstraints = false chatScrollView.hasVerticalScroller = true chatScrollView.hasHorizontalScroller = false // Legacy reserves a dedicated track to the right of the clip view so the thumb never sits on top of cards/buttons. chatScrollView.scrollerStyle = .legacy chatScrollView.autohidesScrollers = true chatScrollView.drawsBackground = false chatScrollView.borderType = .noBorder chatScrollView.documentView = chatDocumentView chatScrollView.setContentHuggingPriority(.defaultLow, for: .vertical) chatScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true // Match Saved Jobs: pin document width to the clip view so cards and bubbles track window width instead of sticking to a narrow intrinsic width. NSLayoutConstraint.activate([ chatDocumentView.topAnchor.constraint(equalTo: chatScrollView.contentView.topAnchor), chatDocumentView.leadingAnchor.constraint(equalTo: chatScrollView.contentView.leadingAnchor), chatDocumentView.widthAnchor.constraint(equalTo: chatScrollView.contentView.widthAnchor) ]) } 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 chrome: CGFloat = 14 + 40 + 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 == "Thinking..." { 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 == "Thinking...") if loading { chatStatusIcon.isHidden = true chatStatusLoadingIndicator.isHidden = false chatStatusLoadingIndicator.startAnimation(nil) chatStatusSymbolContainer.layer?.backgroundColor = Theme.featureIconWell.cgColor } else { chatStatusLoadingIndicator.stopAnimation(nil) chatStatusIcon.isHidden = false chatStatusLoadingIndicator.isHidden = true chatStatusSymbolContainer.layer?.backgroundColor = Theme.brandBlue.cgColor } } 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 == "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() 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 updateDescriptionColumnWidths(in: stack, containerWidth: stack.bounds.width) } } private func walkChatJobStacks(_ visitor: (ChatJobsStackView) -> Void) { func walk(_ view: NSView) { if let stack = view as? ChatJobsStackView { visitor(stack) } for sub in view.subviews { walk(sub) } } walk(chatStack) } /// Chat bubble text fields are wrapping labels whose `preferredMaxLayoutWidth` needs to track the available row width so long strings reflow correctly when the window resizes. private func updateChatBubbleWidths() { func walk(_ view: NSView) { if let label = view as? ChatBubbleLabel, let bubble = label.superview, bubble.bounds.width > 1 { let target = max(40, bubble.bounds.width - 28) if abs(label.preferredMaxLayoutWidth - target) > 0.5 { label.preferredMaxLayoutWidth = target label.invalidateIntrinsicContentSize() } } for sub in view.subviews { walk(sub) } } walk(chatStack) } private func updateDescriptionColumnWidths(in stack: NSStackView, containerWidth: CGFloat) { guard containerWidth > 1 else { return } // Matches `contentColumn` insets on the card (16 leading + 16 trailing). The description spans the full row below the buttons, so never subtract a “button strip” here — a too-narrow `preferredMaxLayoutWidth` inside a wider field makes wrapped `NSTextField` text lay out like a trailing-aligned band after resizes. let contentHorizontalInset: CGFloat = 32 var didChange = false for card in stack.arrangedSubviews { guard let desc = card.viewWithTag(502) as? NSTextField else { continue } let cardWidth = card.bounds.width > 1 ? card.bounds.width : containerWidth let fallbackColumn = max(1, cardWidth - contentHorizontalInset) let columnWidth: CGFloat if desc.bounds.width > 1 { columnWidth = desc.bounds.width } else if let column = desc.superview, column.bounds.width > 1 { columnWidth = column.bounds.width } else { columnWidth = fallbackColumn } if abs(desc.preferredMaxLayoutWidth - columnWidth) > 0.5 { desc.preferredMaxLayoutWidth = columnWidth desc.invalidateIntrinsicContentSize() didChange = true } } if didChange { stack.needsLayout = true } } private static func normalizedSavedJobs(_ jobs: [JobListing]) -> [JobListing] { var seen = Set() var out: [JobListing] = [] for job in jobs where seen.insert(job).inserted { out.append(job) } return out } private func isJobSaved(_ job: JobListing) -> Bool { savedJobOrder.contains(job) } private func persistSavedJobs() { SavedJobsStore.save(savedJobOrder) } private func applySavedState(_ saved: Bool, for job: JobListing) { if saved { savedJobOrder.removeAll { $0 == job } savedJobOrder.insert(job, at: 0) } else { savedJobOrder.removeAll { $0 == job } } persistSavedJobs() } private func jobListingHostSubtitle(_ job: JobListing) -> String { guard let raw = job.url, let url = URL(string: raw), let host = url.host?.lowercased() else { return "Indeed" } if host.hasPrefix("www.") { return String(host.dropFirst(4)) } return host } private func jobListingCategorySymbol(for job: JobListing) -> String { let blob = (job.title + " " + job.description).lowercased() if blob.contains("machine learning") || blob.contains("deep learning") || blob.contains(" ml ") { return "brain.head.profile" } if blob.contains("audio") || blob.contains(" sound ") || blob.contains("dsp") { return "waveform" } if blob.contains("ios") || blob.contains("swift") || blob.contains("mobile") { return "iphone" } if blob.contains("design") || blob.contains(" ux") || blob.contains("figma") { return "paintpalette.fill" } if blob.contains("data ") || blob.contains("analytics") { return "chart.bar.fill" } if blob.contains("ai") || blob.contains("llm") || blob.contains("nlp") { return "cpu" } return "briefcase.fill" } private func makeJobListingCard(_ job: JobListing, context: JobListingCardContext) -> NSView { let card = NSView() card.translatesAutoresizingMaskIntoConstraints = false card.wantsLayer = true card.layer?.backgroundColor = Theme.cardBackground.cgColor card.layer?.cornerRadius = 14 card.layer?.borderWidth = 1 card.layer?.borderColor = Theme.border.cgColor card.layer?.masksToBounds = true let iconBox = NSView() iconBox.translatesAutoresizingMaskIntoConstraints = false iconBox.wantsLayer = true iconBox.layer?.backgroundColor = Theme.brandBlue.cgColor iconBox.layer?.cornerRadius = 12 if #available(macOS 11.0, *) { iconBox.layer?.cornerCurve = .continuous } let categoryIcon = NSImageView() categoryIcon.translatesAutoresizingMaskIntoConstraints = false categoryIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 22, weight: .medium) categoryIcon.image = NSImage(systemSymbolName: jobListingCategorySymbol(for: job), accessibilityDescription: nil) categoryIcon.contentTintColor = .white iconBox.addSubview(categoryIcon) let titleField = NSTextField(labelWithString: job.title) titleField.font = .systemFont(ofSize: 16, weight: .semibold) titleField.textColor = Theme.brandBlue titleField.maximumNumberOfLines = 2 titleField.lineBreakMode = .byWordWrapping titleField.alignment = .left titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) titleField.translatesAutoresizingMaskIntoConstraints = false let buildingIcon = NSImageView() buildingIcon.translatesAutoresizingMaskIntoConstraints = false buildingIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .medium) buildingIcon.image = NSImage(systemSymbolName: "building.2.fill", accessibilityDescription: nil) buildingIcon.contentTintColor = Theme.welcomeSubtitleText let companyLabel = NSTextField(labelWithString: jobListingHostSubtitle(job)) companyLabel.font = .systemFont(ofSize: 12, weight: .medium) companyLabel.textColor = Theme.welcomeSubtitleText companyLabel.maximumNumberOfLines = 1 companyLabel.lineBreakMode = .byTruncatingTail companyLabel.translatesAutoresizingMaskIntoConstraints = false let companyRow = NSStackView(views: [buildingIcon, companyLabel]) companyRow.orientation = .horizontal companyRow.spacing = 5 companyRow.alignment = .centerY companyRow.translatesAutoresizingMaskIntoConstraints = false let descriptionField = NSTextField(wrappingLabelWithString: job.description) descriptionField.font = .systemFont(ofSize: 13, weight: .regular) descriptionField.textColor = Theme.secondaryText descriptionField.maximumNumberOfLines = 2 descriptionField.lineBreakMode = .byWordWrapping descriptionField.alignment = .left descriptionField.baseWritingDirection = .leftToRight descriptionField.attributedStringValue = Self.jobListingDescriptionAttributedString(job.description) if let cell = descriptionField.cell as? NSTextFieldCell { cell.alignment = .left cell.wraps = true } descriptionField.setContentHuggingPriority(.defaultLow, for: .horizontal) descriptionField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) descriptionField.tag = 502 descriptionField.translatesAutoresizingMaskIntoConstraints = false let applyButton = JobPayloadButton(title: "Apply", target: self, action: #selector(didTapJobApply(_:))) applyButton.jobPayload = job applyButton.cardContext = context applyButton.isBordered = false applyButton.bezelStyle = .rounded applyButton.font = .systemFont(ofSize: 13, weight: .semibold) applyButton.wantsLayer = true applyButton.layer?.cornerRadius = 8 applyButton.layer?.backgroundColor = Theme.brandBlue.cgColor applyButton.contentTintColor = Theme.proCTAText applyButton.focusRingType = .none applyButton.pointerCursor = true applyButton.hoverHandler = { [weak applyButton] hovering in applyButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor } applyButton.setContentHuggingPriority(.required, for: .horizontal) applyButton.setContentCompressionResistancePriority(.required, for: .horizontal) let savedOn = isJobSaved(job) let savedButton = SaveJobPayloadButton(title: savedOn ? "Saved" : "Save", target: self, action: #selector(didTapJobSaved(_:))) savedButton.jobPayload = job savedButton.cardContext = context savedButton.setButtonType(.toggle) savedButton.isBordered = false savedButton.bezelStyle = .rounded savedButton.font = .systemFont(ofSize: 13, weight: .semibold) savedButton.image = NSImage(systemSymbolName: savedOn ? "heart.fill" : "heart", accessibilityDescription: nil) savedButton.imagePosition = .imageLeading savedButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold) savedButton.focusRingType = .none savedButton.state = savedOn ? .on : .off savedButton.pointerCursor = true savedButton.hoverHandler = { [weak self, weak savedButton] _ in guard let savedButton = savedButton else { return } self?.styleJobSavedButton(savedButton) } styleJobSavedButton(savedButton) savedButton.setContentHuggingPriority(.required, for: .horizontal) savedButton.setContentCompressionResistancePriority(.required, for: .horizontal) let dismissButton = JobPayloadButton() dismissButton.jobPayload = job dismissButton.cardContext = context dismissButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Dismiss") dismissButton.imagePosition = .imageOnly dismissButton.imageScaling = .scaleProportionallyDown dismissButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) dismissButton.isBordered = false dismissButton.bezelStyle = .rounded dismissButton.contentTintColor = Theme.secondaryText dismissButton.target = self dismissButton.action = #selector(didTapJobDismiss(_:)) dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss" dismissButton.focusRingType = .none dismissButton.wantsLayer = true dismissButton.layer?.cornerRadius = 8 dismissButton.layer?.backgroundColor = NSColor.clear.cgColor dismissButton.pointerCursor = true dismissButton.hoverHandler = { [weak dismissButton] hovering in dismissButton?.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : NSColor.clear).cgColor dismissButton?.contentTintColor = hovering ? Theme.primaryText : Theme.secondaryText } dismissButton.setContentHuggingPriority(.required, for: .horizontal) let buttonRow = NSStackView(views: [applyButton, savedButton, dismissButton]) buttonRow.orientation = .horizontal buttonRow.spacing = 8 buttonRow.alignment = .top buttonRow.translatesAutoresizingMaskIntoConstraints = false buttonRow.setContentHuggingPriority(.required, for: .horizontal) buttonRow.setContentCompressionResistancePriority(.required, for: .horizontal) buttonRow.setContentHuggingPriority(.required, for: .vertical) buttonRow.setContentCompressionResistancePriority(.required, for: .vertical) applyButton.setContentCompressionResistancePriority(.required, for: .horizontal) savedButton.setContentCompressionResistancePriority(.required, for: .horizontal) dismissButton.setContentCompressionResistancePriority(.required, for: .horizontal) let middleColumn = NSStackView(views: [titleField, companyRow, descriptionField]) middleColumn.orientation = .vertical middleColumn.spacing = 5 middleColumn.alignment = .leading middleColumn.translatesAutoresizingMaskIntoConstraints = false middleColumn.setContentHuggingPriority(.defaultLow, for: .horizontal) middleColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let contentRow = NSStackView(views: [iconBox, middleColumn]) contentRow.orientation = .horizontal contentRow.spacing = 14 contentRow.alignment = .top contentRow.distribution = .fill contentRow.translatesAutoresizingMaskIntoConstraints = false card.addSubview(contentRow) card.addSubview(buttonRow) let actionCornerInset: CGFloat = 8 let contentToActionsGap: CGFloat = 12 let bodyTrailingInset: CGFloat = 16 NSLayoutConstraint.activate([ contentRow.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), contentRow.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -bodyTrailingInset), contentRow.topAnchor.constraint(equalTo: card.topAnchor, constant: 14), contentRow.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14), buttonRow.topAnchor.constraint(equalTo: card.topAnchor, constant: actionCornerInset), buttonRow.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -actionCornerInset), middleColumn.trailingAnchor.constraint(lessThanOrEqualTo: buttonRow.leadingAnchor, constant: -contentToActionsGap), iconBox.widthAnchor.constraint(equalToConstant: 58), iconBox.heightAnchor.constraint(equalToConstant: 58), categoryIcon.centerXAnchor.constraint(equalTo: iconBox.centerXAnchor), categoryIcon.centerYAnchor.constraint(equalTo: iconBox.centerYAnchor), buildingIcon.widthAnchor.constraint(equalToConstant: 14), buildingIcon.heightAnchor.constraint(equalToConstant: 14), applyButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76), applyButton.heightAnchor.constraint(equalToConstant: 32), savedButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 84), savedButton.heightAnchor.constraint(equalToConstant: 32), dismissButton.widthAnchor.constraint(equalToConstant: 32), dismissButton.heightAnchor.constraint(equalToConstant: 32), descriptionField.widthAnchor.constraint(equalTo: middleColumn.widthAnchor) ]) return card } private func styleJobSavedButton(_ button: NSButton) { button.wantsLayer = true button.layer?.cornerRadius = 10 let hovering = (button as? HoverableButton)?.isHovering ?? false // Reference: white surface, soft blue outline, brand blue icon + label (no tinted fill on hover). button.layer?.backgroundColor = Theme.cardBackground.cgColor button.layer?.borderWidth = 1 button.layer?.borderColor = (hovering ? Theme.searchBarBorderHover : Theme.searchBarBorder).cgColor button.contentTintColor = Theme.brandBlue } @objc private func didTapJobApply(_ sender: NSButton) { guard let job = (sender as? JobPayloadButton)?.jobPayload else { return } if let rawURL = job.url, let url = URL(string: rawURL), !rawURL.isEmpty { NSWorkspace.shared.open(url) return } let allowed = CharacterSet.urlQueryAllowed let q = job.title.addingPercentEncoding(withAllowedCharacters: allowed) ?? "" guard let url = URL(string: "https://www.indeed.com/jobs?q=\(q)") else { return } NSWorkspace.shared.open(url) } @objc private func didTapJobSaved(_ sender: NSButton) { guard let job = (sender as? JobPayloadButton)?.jobPayload else { return } let willSave = !isJobSaved(job) applySavedState(willSave, for: job) sender.state = willSave ? .on : .off sender.title = willSave ? "Saved" : "Save" sender.image = NSImage(systemSymbolName: willSave ? "heart.fill" : "heart", accessibilityDescription: nil) styleJobSavedButton(sender) if isSavedJobsSidebarIndex(selectedSidebarIndex) { reloadSavedJobsListings() } } @objc private func didTapJobDismiss(_ sender: NSButton) { guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return } switch button.cardContext { case .homeSearchResults: removeJobCardFromChat(originating: button, job: job) case .savedJobsPage: applySavedState(false, for: job) reloadSavedJobsListings() } } /// Walks up from a dismiss button until it finds the enclosing chat job stack, then removes only the card that owns the button. Other chat history (older searches, the assistant summary text) is untouched. private func removeJobCardFromChat(originating button: NSView, job: JobListing) { var node: NSView? = button var card: NSView? var stack: ChatJobsStackView? while let v = node { if let parent = v.superview as? ChatJobsStackView { card = v stack = parent break } node = v.superview } guard let card, let stack else { return } stack.removeArrangedSubview(card) card.removeFromSuperview() lastSearchResults.removeAll { $0 == job } } private func configureSearchBar() { let pillCorner: CGFloat = 27 let barHeight: CGFloat = 54 searchBarShadowHost.translatesAutoresizingMaskIntoConstraints = false searchBarShadowHost.wantsLayer = true searchBarShadowHost.layer?.masksToBounds = false searchBarShadowHost.layer?.shadowColor = NSColor.black.withAlphaComponent(0.18).cgColor searchBarShadowHost.layer?.shadowOffset = CGSize(width: 0, height: 2) searchBarShadowHost.layer?.shadowRadius = 10 searchBarShadowHost.layer?.shadowOpacity = 1 searchBarShadowHost.setContentHuggingPriority(.defaultHigh, for: .vertical) searchCard.translatesAutoresizingMaskIntoConstraints = false searchCard.wantsLayer = true searchCard.layer?.backgroundColor = Theme.cardBackground.cgColor searchCard.layer?.cornerRadius = pillCorner searchCard.layer?.borderWidth = 1 searchCard.layer?.borderColor = Theme.searchBarBorder.cgColor searchCard.layer?.masksToBounds = true searchCard.hoverHandler = { [weak self] hovering in guard let self else { return } CATransaction.begin() CATransaction.setAnimationDuration(0.15) self.searchCard.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor self.searchCard.layer?.borderColor = (hovering ? Theme.searchBarBorderHover : Theme.searchBarBorder).cgColor self.searchBarShadowHost.layer?.shadowColor = NSColor.black.withAlphaComponent(hovering ? 0.24 : 0.18).cgColor self.searchBarShadowHost.layer?.shadowRadius = hovering ? 12 : 10 CATransaction.commit() } searchBarShadowHost.addSubview(searchCard) func configureField(_ field: NSTextField, placeholder: String) { field.translatesAutoresizingMaskIntoConstraints = false field.isBordered = false field.drawsBackground = false field.focusRingType = .none field.font = .systemFont(ofSize: 14, weight: .regular) field.textColor = Theme.primaryText field.delegate = self field.placeholderAttributedString = NSAttributedString( string: placeholder, attributes: [ .foregroundColor: Theme.secondaryText, .font: NSFont.systemFont(ofSize: 14, weight: .regular) ] ) field.cell?.usesSingleLineMode = true field.cell?.wraps = false field.cell?.isScrollable = true field.target = self field.action = #selector(didSubmitSearch) } jobSearchIcon.translatesAutoresizingMaskIntoConstraints = false jobSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold) jobSearchIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Ask AI") jobSearchIcon.contentTintColor = Theme.brandBlue configureField(jobKeywordsField, placeholder: "Ask for roles, skills, salary, or job descriptions...") let ctaHeight: CGFloat = 42 let ctaCorner = ctaHeight / 2 findJobsCTAHost.translatesAutoresizingMaskIntoConstraints = false findJobsCTAHost.wantsLayer = true findJobsCTAHost.layer?.masksToBounds = false findJobsCTAHost.layer?.shadowColor = NSColor.black.cgColor findJobsCTAHost.layer?.shadowOpacity = 0.16 findJobsCTAHost.layer?.shadowOffset = CGSize(width: 0, height: 2) findJobsCTAHost.layer?.shadowRadius = 6 findJobsCTAChrome.translatesAutoresizingMaskIntoConstraints = false findJobsCTAChrome.wantsLayer = true findJobsCTAChrome.layer?.masksToBounds = true findJobsCTAChrome.layer?.cornerRadius = ctaCorner if #available(macOS 11.0, *) { findJobsCTAChrome.layer?.cornerCurve = .continuous } let gradient = CAGradientLayer() gradient.colors = [Theme.findJobsCTAHighlight.cgColor, Theme.brandBlue.cgColor] gradient.startPoint = CGPoint(x: 0.5, y: 1) gradient.endPoint = CGPoint(x: 0.5, y: 0) findJobsCTAChrome.layer?.addSublayer(gradient) findJobsCTAGradientLayer = gradient // Tracks hover over the full pill (the button only covers an inset area), so the gradient darkens whenever the mouse is anywhere over the CTA. findJobsCTAChrome.pointerCursor = true findJobsCTAChrome.hoverHandler = { [weak self] hovering in guard let layer = self?.findJobsCTAGradientLayer else { return } CATransaction.begin() CATransaction.setAnimationDuration(0.15) layer.colors = hovering ? [Theme.findJobsCTAHighlightHover.cgColor, Theme.brandBlueHover.cgColor] : [Theme.findJobsCTAHighlight.cgColor, Theme.brandBlue.cgColor] CATransaction.commit() } findJobsButton.translatesAutoresizingMaskIntoConstraints = false findJobsButton.title = "" findJobsButton.image = NSImage(systemSymbolName: "paperplane.fill", accessibilityDescription: nil) findJobsButton.imagePosition = .imageLeading findJobsButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold) findJobsButton.attributedTitle = NSAttributedString( string: " Send", attributes: [ .font: NSFont.systemFont(ofSize: 14, weight: .semibold), .foregroundColor: Theme.proCTAText, .kern: 0.35 ] ) findJobsButton.contentTintColor = Theme.proCTAText findJobsButton.isBordered = false findJobsButton.bezelStyle = .rounded findJobsButton.wantsLayer = true findJobsButton.layer?.backgroundColor = NSColor.clear.cgColor findJobsButton.focusRingType = .none findJobsButton.target = self findJobsButton.action = #selector(didSubmitSearch) findJobsButton.setContentHuggingPriority(.required, for: .horizontal) findJobsButton.setContentCompressionResistancePriority(.required, for: .horizontal) findJobsCTAHost.addSubview(findJobsCTAChrome) findJobsCTAHost.addSubview(findJobsButton) NSLayoutConstraint.activate([ findJobsCTAChrome.leadingAnchor.constraint(equalTo: findJobsCTAHost.leadingAnchor), findJobsCTAChrome.trailingAnchor.constraint(equalTo: findJobsCTAHost.trailingAnchor), findJobsCTAChrome.topAnchor.constraint(equalTo: findJobsCTAHost.topAnchor), findJobsCTAChrome.bottomAnchor.constraint(equalTo: findJobsCTAHost.bottomAnchor), findJobsButton.leadingAnchor.constraint(equalTo: findJobsCTAHost.leadingAnchor, constant: 14), findJobsButton.trailingAnchor.constraint(equalTo: findJobsCTAHost.trailingAnchor, constant: -14), findJobsButton.topAnchor.constraint(equalTo: findJobsCTAHost.topAnchor), findJobsButton.bottomAnchor.constraint(equalTo: findJobsCTAHost.bottomAnchor) ]) let keywordsStack = NSStackView(views: [jobSearchIcon, jobKeywordsField]) keywordsStack.orientation = .horizontal keywordsStack.spacing = 10 keywordsStack.alignment = .centerY keywordsStack.translatesAutoresizingMaskIntoConstraints = false keywordsStack.edgeInsets = NSEdgeInsets(top: 0, left: 18, bottom: 0, right: 10) keywordsStack.setContentHuggingPriority(.defaultLow, for: .horizontal) let row = NSStackView(views: [keywordsStack, findJobsCTAHost]) row.orientation = .horizontal row.spacing = 0 row.alignment = .centerY row.distribution = .fill row.translatesAutoresizingMaskIntoConstraints = false row.edgeInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 7) searchCard.addSubview(row) NSLayoutConstraint.activate([ searchCard.leadingAnchor.constraint(equalTo: searchBarShadowHost.leadingAnchor), searchCard.trailingAnchor.constraint(equalTo: searchBarShadowHost.trailingAnchor), searchCard.topAnchor.constraint(equalTo: searchBarShadowHost.topAnchor), searchCard.bottomAnchor.constraint(equalTo: searchBarShadowHost.bottomAnchor), searchBarShadowHost.heightAnchor.constraint(equalToConstant: barHeight), row.leadingAnchor.constraint(equalTo: searchCard.leadingAnchor), row.trailingAnchor.constraint(equalTo: searchCard.trailingAnchor), row.topAnchor.constraint(equalTo: searchCard.topAnchor), row.bottomAnchor.constraint(equalTo: searchCard.bottomAnchor), jobSearchIcon.widthAnchor.constraint(equalToConstant: 18), jobSearchIcon.heightAnchor.constraint(equalToConstant: 18), findJobsCTAHost.heightAnchor.constraint(equalToConstant: ctaHeight), findJobsCTAHost.widthAnchor.constraint(greaterThanOrEqualToConstant: 112) ]) searchCard.hoverHandler = nil } private func updateFindJobsCTAShadowPath() { guard findJobsCTAHost.bounds.width > 0, findJobsCTAHost.bounds.height > 0 else { return } let r = findJobsCTAHost.bounds let radius = min(r.height / 2, r.width / 2) findJobsCTAHost.layer?.shadowPath = CGPath( roundedRect: r, cornerWidth: radius, cornerHeight: radius, transform: nil ) } private func configureNonHomePlaceholder() { nonHomeHost.translatesAutoresizingMaskIntoConstraints = false nonHomeHost.wantsLayer = true nonHomeHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor nonHomeHost.isHidden = true nonHomeGenericContainer.translatesAutoresizingMaskIntoConstraints = false savedJobsPageContainer.translatesAutoresizingMaskIntoConstraints = false settingsPageContainer.translatesAutoresizingMaskIntoConstraints = false nonHomeHost.addSubview(nonHomeGenericContainer) nonHomeHost.addSubview(savedJobsPageContainer) nonHomeHost.addSubview(settingsPageContainer) NSLayoutConstraint.activate([ nonHomeGenericContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor), nonHomeGenericContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor), nonHomeGenericContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor), nonHomeGenericContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor), savedJobsPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor), savedJobsPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor), savedJobsPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor), savedJobsPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor), settingsPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor), settingsPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor), settingsPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor), settingsPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor) ]) nonHomeTitleLabel.font = .systemFont(ofSize: 22, weight: .bold) nonHomeTitleLabel.textColor = Theme.primaryText nonHomeTitleLabel.alignment = .center nonHomeTitleLabel.maximumNumberOfLines = 1 nonHomeSubtitleLabel.font = .systemFont(ofSize: 14, weight: .regular) nonHomeSubtitleLabel.textColor = Theme.secondaryText nonHomeSubtitleLabel.alignment = .center nonHomeSubtitleLabel.maximumNumberOfLines = 0 nonHomeSubtitleLabel.stringValue = "This area is not available in the preview build. Use Home to search jobs." let genericStack = NSStackView(views: [nonHomeTitleLabel, nonHomeSubtitleLabel]) genericStack.orientation = .vertical genericStack.spacing = 10 genericStack.alignment = .centerX genericStack.translatesAutoresizingMaskIntoConstraints = false nonHomeGenericContainer.addSubview(genericStack) NSLayoutConstraint.activate([ genericStack.centerXAnchor.constraint(equalTo: nonHomeGenericContainer.centerXAnchor), genericStack.centerYAnchor.constraint(equalTo: nonHomeGenericContainer.centerYAnchor), genericStack.leadingAnchor.constraint(greaterThanOrEqualTo: nonHomeGenericContainer.leadingAnchor, constant: 32), genericStack.trailingAnchor.constraint(lessThanOrEqualTo: nonHomeGenericContainer.trailingAnchor, constant: -32), nonHomeSubtitleLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 420) ]) savedJobsPageTitleLabel.font = .systemFont(ofSize: 22, weight: .bold) savedJobsPageTitleLabel.textColor = Theme.primaryText savedJobsPageTitleLabel.alignment = .left savedJobsPageTitleLabel.maximumNumberOfLines = 1 savedJobsPageSubtitleLabel.font = .systemFont(ofSize: 14, weight: .regular) savedJobsPageSubtitleLabel.textColor = Theme.secondaryText savedJobsPageSubtitleLabel.alignment = .left savedJobsPageSubtitleLabel.maximumNumberOfLines = 0 savedJobsDocumentView.translatesAutoresizingMaskIntoConstraints = false savedJobsStack.orientation = .vertical savedJobsStack.spacing = 14 savedJobsStack.alignment = .leading savedJobsStack.distribution = .fill savedJobsStack.translatesAutoresizingMaskIntoConstraints = false savedJobsStack.setContentHuggingPriority(.defaultHigh, for: .vertical) savedJobsStack.setHuggingPriority(.defaultLow, for: .horizontal) savedJobsDocumentView.addSubview(savedJobsStack) NSLayoutConstraint.activate([ savedJobsStack.leadingAnchor.constraint(equalTo: savedJobsDocumentView.leadingAnchor), savedJobsStack.trailingAnchor.constraint(equalTo: savedJobsDocumentView.trailingAnchor), savedJobsStack.topAnchor.constraint(equalTo: savedJobsDocumentView.topAnchor), savedJobsStack.bottomAnchor.constraint(equalTo: savedJobsDocumentView.bottomAnchor) ]) savedJobsScrollView.translatesAutoresizingMaskIntoConstraints = false savedJobsScrollView.hasVerticalScroller = true savedJobsScrollView.hasHorizontalScroller = false savedJobsScrollView.scrollerStyle = .legacy savedJobsScrollView.autohidesScrollers = true savedJobsScrollView.drawsBackground = false savedJobsScrollView.borderType = .noBorder savedJobsScrollView.documentView = savedJobsDocumentView savedJobsScrollView.setContentHuggingPriority(.defaultLow, for: .vertical) savedJobsScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) let savedHeaderStack = NSStackView(views: [savedJobsPageTitleLabel, savedJobsPageSubtitleLabel]) savedHeaderStack.orientation = .vertical savedHeaderStack.spacing = 6 savedHeaderStack.alignment = .leading savedHeaderStack.translatesAutoresizingMaskIntoConstraints = false let savedOuterStack = NSStackView(views: [savedHeaderStack, savedJobsScrollView]) savedOuterStack.orientation = .vertical savedOuterStack.spacing = 16 // Leading alignment plus explicit column width keeps the title and subtitle on the same edge as the cards. savedOuterStack.alignment = .leading savedOuterStack.translatesAutoresizingMaskIntoConstraints = false savedJobsPageContainer.userInterfaceLayoutDirection = .leftToRight savedJobsPageContainer.addSubview(savedOuterStack) NSLayoutConstraint.activate([ savedOuterStack.leadingAnchor.constraint(equalTo: savedJobsPageContainer.leadingAnchor, constant: 32), savedOuterStack.trailingAnchor.constraint(equalTo: savedJobsPageContainer.trailingAnchor, constant: -32), savedOuterStack.topAnchor.constraint(equalTo: savedJobsPageContainer.topAnchor, constant: 8), savedOuterStack.bottomAnchor.constraint(equalTo: savedJobsPageContainer.bottomAnchor), savedHeaderStack.widthAnchor.constraint(equalTo: savedOuterStack.widthAnchor), savedJobsScrollView.widthAnchor.constraint(equalTo: savedOuterStack.widthAnchor), savedJobsDocumentView.topAnchor.constraint(equalTo: savedJobsScrollView.contentView.topAnchor), savedJobsDocumentView.leadingAnchor.constraint(equalTo: savedJobsScrollView.contentView.leadingAnchor), savedJobsDocumentView.widthAnchor.constraint(equalTo: savedJobsScrollView.contentView.widthAnchor) ]) configureSettingsPage() } private func configureSettingsPage() { settingsPageContainer.wantsLayer = true settingsPageContainer.layer?.backgroundColor = Theme.settingsPageBackground.cgColor settingsPageContainer.isHidden = true let contentStack = NSStackView() contentStack.orientation = .vertical contentStack.spacing = 26 contentStack.alignment = .leading contentStack.translatesAutoresizingMaskIntoConstraints = false let settingsSection = makeSettingsSection(rows: [ makeSettingsRow(title: "Share App", systemImage: "square.and.arrow.up", accessory: nil), makeSettingsRow(title: "Theme", systemImage: "circle.lefthalf.filled", accessory: makeThemeControl()), makeSettingsRow(title: "More Apps", systemImage: "square.grid.2x2", accessory: nil) ]) let aboutTitle = NSTextField(labelWithString: "About") aboutTitle.font = .systemFont(ofSize: 12, weight: .semibold) aboutTitle.textColor = Theme.secondaryText aboutTitle.alignment = .left let aboutSection = makeSettingsSection(rows: [ makeSettingsRow(title: "Support", systemImage: "questionmark.circle", accessory: nil), makeSettingsRow(title: "Terms of Use", systemImage: "doc.text", accessory: nil), makeSettingsRow(title: "Privacy Policy", systemImage: "shield", accessory: nil) ]) let aboutStack = NSStackView(views: [aboutTitle, aboutSection]) aboutStack.orientation = .vertical aboutStack.spacing = 14 aboutStack.alignment = .leading aboutStack.translatesAutoresizingMaskIntoConstraints = false contentStack.addArrangedSubview(settingsSection) contentStack.addArrangedSubview(aboutStack) settingsPageContainer.addSubview(contentStack) NSLayoutConstraint.activate([ contentStack.leadingAnchor.constraint(equalTo: settingsPageContainer.leadingAnchor, constant: 42), contentStack.trailingAnchor.constraint(lessThanOrEqualTo: settingsPageContainer.trailingAnchor, constant: -42), contentStack.topAnchor.constraint(equalTo: settingsPageContainer.topAnchor, constant: 48), settingsSection.widthAnchor.constraint(equalTo: contentStack.widthAnchor), aboutStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor), aboutSection.widthAnchor.constraint(equalTo: aboutStack.widthAnchor), contentStack.widthAnchor.constraint(equalTo: settingsPageContainer.widthAnchor, constant: -84) ]) } private func makeThemeControl() -> NSSegmentedControl { themeControl.target = self themeControl.action = #selector(didChangeThemeSelection(_:)) themeControl.selectedSegment = 0 themeControl.segmentStyle = .rounded themeControl.controlSize = .large themeControl.font = .systemFont(ofSize: 13, weight: .semibold) themeControl.translatesAutoresizingMaskIntoConstraints = false themeControl.widthAnchor.constraint(equalToConstant: 204).isActive = true themeControl.heightAnchor.constraint(equalToConstant: 30).isActive = true return themeControl } private func makeSettingsSection(rows: [NSView]) -> NSView { let section = NSStackView() section.orientation = .vertical section.spacing = 0 section.alignment = .leading section.translatesAutoresizingMaskIntoConstraints = false section.wantsLayer = true section.layer?.backgroundColor = Theme.settingsGroupBackground.cgColor section.layer?.cornerRadius = 14 section.layer?.borderWidth = 1 section.layer?.borderColor = Theme.border.cgColor section.layer?.masksToBounds = true for (index, row) in rows.enumerated() { section.addArrangedSubview(row) row.widthAnchor.constraint(equalTo: section.widthAnchor).isActive = true if index < rows.count - 1 { let divider = NSView() divider.translatesAutoresizingMaskIntoConstraints = false divider.wantsLayer = true divider.layer?.backgroundColor = Theme.settingsDivider.cgColor section.addArrangedSubview(divider) NSLayoutConstraint.activate([ divider.heightAnchor.constraint(equalToConstant: 1), divider.leadingAnchor.constraint(equalTo: section.leadingAnchor), divider.trailingAnchor.constraint(equalTo: section.trailingAnchor) ]) } } return section } private func makeSettingsRow(title: String, systemImage: String, accessory: NSView?) -> NSView { let row = NSView() row.translatesAutoresizingMaskIntoConstraints = false row.wantsLayer = true let iconTile = NSView() iconTile.translatesAutoresizingMaskIntoConstraints = false iconTile.wantsLayer = true iconTile.layer?.backgroundColor = Theme.settingsIconBackground.cgColor iconTile.layer?.cornerRadius = 9 let icon = NSImageView() icon.translatesAutoresizingMaskIntoConstraints = false icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium) icon.image = NSImage(systemSymbolName: systemImage, accessibilityDescription: title) icon.contentTintColor = Theme.brandBlue let titleLabel = NSTextField(labelWithString: title) titleLabel.font = .systemFont(ofSize: 14, weight: .medium) titleLabel.textColor = Theme.secondaryText titleLabel.alignment = .left let rowStack = NSStackView() rowStack.orientation = .horizontal rowStack.spacing = 16 rowStack.alignment = .centerY rowStack.translatesAutoresizingMaskIntoConstraints = false let spacer = NSView() spacer.translatesAutoresizingMaskIntoConstraints = false spacer.setContentHuggingPriority(NSLayoutConstraint.Priority(1), for: .horizontal) iconTile.addSubview(icon) rowStack.addArrangedSubview(iconTile) rowStack.addArrangedSubview(titleLabel) rowStack.addArrangedSubview(spacer) if let accessory { rowStack.addArrangedSubview(accessory) } row.addSubview(rowStack) NSLayoutConstraint.activate([ row.heightAnchor.constraint(equalToConstant: 68), iconTile.widthAnchor.constraint(equalToConstant: 38), iconTile.heightAnchor.constraint(equalToConstant: 38), icon.centerXAnchor.constraint(equalTo: iconTile.centerXAnchor), icon.centerYAnchor.constraint(equalTo: iconTile.centerYAnchor), icon.widthAnchor.constraint(equalToConstant: 20), icon.heightAnchor.constraint(equalToConstant: 20), rowStack.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 16), rowStack.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -16), rowStack.topAnchor.constraint(equalTo: row.topAnchor), rowStack.bottomAnchor.constraint(equalTo: row.bottomAnchor) ]) return row } private func reloadSavedJobsListings() { savedJobsStack.arrangedSubviews.forEach { savedJobsStack.removeArrangedSubview($0) $0.removeFromSuperview() } if savedJobOrder.isEmpty { savedJobsPageSubtitleLabel.stringValue = "Save jobs from Home to see them here." let empty = NSTextField(wrappingLabelWithString: "No saved jobs yet. Search on Home, then tap Save on a listing.") empty.font = .systemFont(ofSize: 14, weight: .regular) empty.textColor = Theme.secondaryText empty.alignment = .left empty.maximumNumberOfLines = 0 empty.translatesAutoresizingMaskIntoConstraints = false savedJobsStack.addArrangedSubview(empty) empty.widthAnchor.constraint(equalTo: savedJobsStack.widthAnchor).isActive = true return } savedJobsPageSubtitleLabel.stringValue = "\(savedJobOrder.count) saved \(savedJobOrder.count == 1 ? "position" : "positions")" for job in savedJobOrder { let card = makeJobListingCard(job, context: .savedJobsPage) savedJobsStack.addArrangedSubview(card) card.widthAnchor.constraint(equalTo: savedJobsStack.widthAnchor).isActive = true } } private func isSavedJobsSidebarIndex(_ index: Int) -> Bool { guard index >= 0, index < currentSidebarItems.count else { return false } return currentSidebarItems[index].title == "Saved Jobs" } private func isHomeSidebarIndex(_ index: Int) -> Bool { guard index >= 0, index < currentSidebarItems.count else { return false } return currentSidebarItems[index].title == "Home" } private func isSettingsSidebarIndex(_ index: Int) -> Bool { guard index >= 0, index < currentSidebarItems.count else { return false } return currentSidebarItems[index].title == "Settings" } private func updateMainContentVisibility() { let home = isHomeSidebarIndex(selectedSidebarIndex) let savedJobs = isSavedJobsSidebarIndex(selectedSidebarIndex) let settings = isSettingsSidebarIndex(selectedSidebarIndex) mainOverlay.isHidden = !home nonHomeHost.isHidden = home nonHomeGenericContainer.isHidden = savedJobs || settings savedJobsPageContainer.isHidden = !savedJobs settingsPageContainer.isHidden = !settings if !home, selectedSidebarIndex < currentSidebarItems.count { if savedJobs { reloadSavedJobsListings() } else if settings { window?.makeFirstResponder(nil) } else { 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. private func applyHomeState() { jobKeywordsField.stringValue = "" resetChatState() window?.makeFirstResponder(nil) } private func updateSearchBarShadowPath() { guard searchBarShadowHost.bounds.width > 0, searchBarShadowHost.bounds.height > 0 else { return } let r = searchBarShadowHost.bounds let radius = min(r.height / 2, 27) searchBarShadowHost.layer?.shadowPath = CGPath( roundedRect: r, cornerWidth: radius, cornerHeight: radius, transform: nil ) } @objc private func didSubmitSearch() { let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !prompt.isEmpty, !isAwaitingResponse else { return } let isContinuation = isContinuationPrompt(prompt) let effectiveQuery = resolvedSearchQuery(for: prompt) appendChatBubble(text: prompt, isUser: true) chatMessages.append(ChatMessage(role: "user", content: prompt)) jobKeywordsField.stringValue = "" startJobSearchRequest(effectiveQuery: effectiveQuery, isContinuation: isContinuation) window?.makeFirstResponder(nil) } @objc private func didTapFeatureRole() { focusSearchField(seed: "Find roles similar to: ") } @objc private func didTapFeatureCompany() { focusSearchField(seed: "Find jobs at company: ") } @objc private func didTapFeatureSkill() { focusSearchField(seed: "Find jobs that require skill: ") } private func focusSearchField(seed: String) { jobKeywordsField.stringValue = seed window?.makeFirstResponder(jobKeywordsField) if let editor = jobKeywordsField.window?.fieldEditor(true, for: jobKeywordsField) as? NSTextView { editor.moveToEndOfDocument(nil) } } @objc private func didTapLoadMoreJobs() { let prompt = "Show more jobs" guard !isAwaitingResponse, isContinuationPrompt(prompt) else { return } if anchorUserJobQuery(excludingLatestUserMessage: prompt) == nil { return } appendChatBubble(text: prompt, isUser: true) chatMessages.append(ChatMessage(role: "user", content: prompt)) let effectiveQuery = resolvedSearchQuery(for: prompt) startJobSearchRequest(effectiveQuery: effectiveQuery, isContinuation: true) } private func startJobSearchRequest(effectiveQuery: String, isContinuation: Bool) { isAwaitingResponse = true setChatStatusLabel("Thinking...") setInputEnabled(false) let contextMessages = chatMessages let maxJobs = Self.clampedJobsPerRequest() jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages, maxJobs: maxJobs) { [weak self] result in DispatchQueue.main.async { guard let self else { return } self.isAwaitingResponse = false self.setInputEnabled(true) switch result { case .success(let output): let normalizedJobs = self.normalizedJobs(output.jobs) let freshJobs: [JobListing] if isContinuation { // Continuations append only the *new* matches; previous cards already live in their own assistant message above. let alreadySeen = Set(self.lastSearchResults) freshJobs = normalizedJobs.filter { !alreadySeen.contains($0) } } else { freshJobs = normalizedJobs } self.lastSearchResults.append(contentsOf: freshJobs) let reply = self.makeAssistantSearchReply( query: effectiveQuery, newJobsCount: freshJobs.count, isContinuation: isContinuation ) 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.") } } } } } private func normalizedJobs(_ jobs: [JobListing]) -> [JobListing] { let trimmed = jobs.map { JobListing( title: $0.title.trimmingCharacters(in: .whitespacesAndNewlines), description: $0.description.trimmingCharacters(in: .whitespacesAndNewlines), url: $0.url?.trimmingCharacters(in: .whitespacesAndNewlines) ) } return trimmed.filter { !$0.title.isEmpty && !$0.description.isEmpty } } private func makeAssistantSearchReply(query: String, newJobsCount: Int, isContinuation: Bool) -> String { if newJobsCount == 0 { if isContinuation { return "I couldn't find new matches for \u{201C}\(query)\u{201D}. Try a different angle or a more specific keyword." } return "No jobs found for \u{201C}\(query)\u{201D}. Try another title, skill, company, or location." } let plural = newJobsCount == 1 ? "match" : "matches" if isContinuation { return "Here are \(newJobsCount) more \(plural) for \u{201C}\(query)\u{201D}." } return "Found \(newJobsCount) \(plural) for \u{201C}\(query)\u{201D}. Tap Apply to open the listing or Save to revisit later." } private func resolvedSearchQuery(for prompt: String) -> String { let anchor = anchorUserJobQuery(excludingLatestUserMessage: prompt) if isContinuationPrompt(prompt), !isRefinementPrompt(prompt) { if let anchor { return anchor } return prompt } if isRefinementPrompt(prompt), let anchor { return "\(anchor). User follow-up (apply on top of the same search topic): \(prompt)" } return prompt } /// First prior user message that looks like an original job query (skips short continuations and refinements so follow-ups keep a stable topic anchor). private func anchorUserJobQuery(excludingLatestUserMessage latest: String) -> String? { let prior = Array(chatMessages.dropLast()) for message in prior.reversed() where message.role == "user" { let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines) guard !candidate.isEmpty, candidate != latest else { continue } if isContinuationPrompt(candidate) { continue } if isRefinementPrompt(candidate) { continue } return candidate } return nil } private func isContinuationPrompt(_ prompt: String) -> Bool { let normalized = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let continuationPhrases: Set = [ "more", "show more", "more jobs", "more results", "do more searches", "more searches", "search more", "continue", "next" ] if continuationPhrases.contains(normalized) { return true } return normalized.contains("more search") || normalized.contains("more job") } /// Follow-ups that narrow, re-rank, or re-frame results rather than starting a brand-new role search. /// Strong phrases always count. Single-word cues (e.g. "remote") only count after we already showed results, so first searches like "Senior iOS remote" stay anchored as primary queries. private func isRefinementPrompt(_ prompt: String) -> Bool { let n = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if n.isEmpty { return false } let strongPhrases = [ "higher pay", "high pay", "better pay", "more pay", "top pay", "best pay", "higher salary", "better salary", "more salary", "pay rate", "hourly rate", "paid more", "paying more", "earn more", "better paid", "paying better", "work from home", "in office", "in-office", "on-site only", "remote only", "hybrid only", "onsite only", "visa sponsorship", "h1b", "entry level", "entry-level", "mid level", "mid-level", "full time", "full-time", "part time", "part-time", "closer to", "nearer", "different city", "different state", "relocate", "filter", "only show", "just show", "exclude", "without", "sort by", "rank by", "cheaper", "lower pay", "less travel", "better benefits", "equity", "bonus", "overtime", "get me the jobs", "show me the jobs", "give me the jobs", "narrow", "refine" ] if strongPhrases.contains(where: { n.contains($0) }) { return true } if n.hasPrefix("only ") || n.hasPrefix("just ") { return true } guard !lastSearchResults.isEmpty, n.count <= 52 else { return false } let softAfterResults = [ "remote", "hybrid", "onsite", "on-site", "senior", "junior", "staff", "lead", "principal", "intern", "contract", "location" ] return softAfterResults.contains(where: { n.contains($0) }) } func controlTextDidBeginEditing(_ obj: Notification) { applySearchFieldInsertionPoint(obj.object) if (obj.object as? NSTextField) === jobKeywordsField { setChatStatusLabel("Opening the vault...") } } func controlTextDidChange(_ obj: Notification) { applySearchFieldInsertionPoint(obj.object) } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { guard control === jobKeywordsField, commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false } didSubmitSearch() return true } private func applySearchFieldInsertionPoint(_ object: Any?) { guard let field = object as? NSTextField, field === jobKeywordsField, let textView = field.window?.fieldEditor(true, for: field) as? NSTextView else { return } textView.insertionPointColor = Theme.primaryText } private func resetChatState() { trailingLoadMoreJobsRow = nil trailingLoadMoreJobsButton = nil chatMessages.removeAll() lastSearchResults.removeAll() chatStack.arrangedSubviews.forEach { chatStack.removeArrangedSubview($0) $0.removeFromSuperview() } 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) { let host = NSView() host.translatesAutoresizingMaskIntoConstraints = false if isUser { installUserBubble(text: text, into: host) } else { installAssistantBubble(text: text, jobs: jobs, into: host) } 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 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() } } private func installUserBubble(text: String, into host: NSView) { let bubble = makeChatBubbleContainer(text: text, isUser: true) host.addSubview(bubble) NSLayoutConstraint.activate([ bubble.topAnchor.constraint(equalTo: host.topAnchor), bubble.bottomAnchor.constraint(equalTo: host.bottomAnchor), bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor), bubble.leadingAnchor.constraint(greaterThanOrEqualTo: host.leadingAnchor, constant: 64), bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78) ]) } private func installAssistantBubble(text: String, jobs: [JobListing]?, into host: NSView) { let avatar = makeAssistantAvatarView() let nameLabel = NSTextField(labelWithString: "AI Job Finder") nameLabel.font = .systemFont(ofSize: 11, weight: .semibold) nameLabel.textColor = Theme.secondaryText nameLabel.translatesAutoresizingMaskIntoConstraints = false let bubble = makeChatBubbleContainer(text: text, isUser: false) let column = NSStackView(views: [nameLabel, bubble]) column.orientation = .vertical column.spacing = 6 column.alignment = .width column.translatesAutoresizingMaskIntoConstraints = false if let jobs, !jobs.isEmpty { trailingLoadMoreJobsRow?.removeFromSuperview() trailingLoadMoreJobsRow = nil trailingLoadMoreJobsButton = nil let jobsStack = makeChatJobsStackView(jobs: jobs) column.addArrangedSubview(jobsStack) jobsStack.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true let moreRow = makeLoadMoreJobsRowView() column.addArrangedSubview(moreRow) moreRow.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true trailingLoadMoreJobsRow = moreRow } host.addSubview(avatar) host.addSubview(column) NSLayoutConstraint.activate([ avatar.leadingAnchor.constraint(equalTo: host.leadingAnchor), avatar.topAnchor.constraint(equalTo: host.topAnchor), avatar.widthAnchor.constraint(equalToConstant: 36), avatar.heightAnchor.constraint(equalToConstant: 36), column.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 12), column.trailingAnchor.constraint(equalTo: host.trailingAnchor), column.topAnchor.constraint(equalTo: host.topAnchor), column.bottomAnchor.constraint(equalTo: host.bottomAnchor) ]) } private func makeChatBubbleContainer(text: String, isUser: Bool) -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false container.wantsLayer = true container.layer?.cornerRadius = 14 if #available(macOS 11.0, *) { container.layer?.cornerCurve = .continuous } container.layer?.masksToBounds = true if isUser { container.layer?.backgroundColor = Theme.brandBlue.cgColor } else { container.layer?.backgroundColor = Theme.chromeBackground.cgColor container.layer?.borderWidth = 1 container.layer?.borderColor = Theme.border.cgColor } let label = ChatBubbleLabel(wrappingLabelWithString: text) label.font = .systemFont(ofSize: 13.5, weight: .regular) label.textColor = isUser ? .white : Theme.primaryText label.maximumNumberOfLines = 0 label.lineBreakMode = .byWordWrapping label.alignment = .left label.translatesAutoresizingMaskIntoConstraints = false label.setContentHuggingPriority(.defaultLow, for: .horizontal) label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) container.addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14), label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14), label.topAnchor.constraint(equalTo: container.topAnchor, constant: 10), label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10) ]) return container } private func makeAssistantAvatarView() -> NSView { let view = NSView() view.translatesAutoresizingMaskIntoConstraints = false view.wantsLayer = true view.layer?.cornerRadius = 18 if #available(macOS 11.0, *) { view.layer?.cornerCurve = .continuous } view.layer?.backgroundColor = Theme.settingsIconBackground.cgColor view.layer?.borderWidth = 1 view.layer?.borderColor = Theme.proCardBorder.cgColor let icon = NSImageView() icon.translatesAutoresizingMaskIntoConstraints = false icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold) icon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "AI Job Finder") icon.contentTintColor = Theme.brandBlue view.addSubview(icon) NSLayoutConstraint.activate([ icon.centerXAnchor.constraint(equalTo: view.centerXAnchor), icon.centerYAnchor.constraint(equalTo: view.centerYAnchor) ]) return view } private func makeLoadMoreJobsRowView() -> NSView { let row = NSView() row.translatesAutoresizingMaskIntoConstraints = false let button = HoverableButton() button.pointerCursor = true button.title = "Show more jobs" button.font = .systemFont(ofSize: 12, weight: .semibold) button.bezelStyle = .rounded button.controlSize = .regular button.contentTintColor = Theme.brandBlue button.target = self button.action = #selector(didTapLoadMoreJobs) button.translatesAutoresizingMaskIntoConstraints = false trailingLoadMoreJobsButton = button row.addSubview(button) NSLayoutConstraint.activate([ button.leadingAnchor.constraint(equalTo: row.leadingAnchor), button.topAnchor.constraint(equalTo: row.topAnchor, constant: 2), button.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -2) ]) return row } private func makeChatJobsStackView(jobs: [JobListing]) -> ChatJobsStackView { let stack = ChatJobsStackView() stack.orientation = .vertical stack.spacing = 10 stack.alignment = .width stack.distribution = .fill stack.translatesAutoresizingMaskIntoConstraints = false for job in jobs { let card = makeJobListingCard(job, context: .homeSearchResults) stack.addArrangedSubview(card) card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } return stack } private func scrollChatToBottom() { let maxY = max(0, chatDocumentView.bounds.height - chatScrollView.contentView.bounds.height) chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY)) chatScrollView.reflectScrolledClipView(chatScrollView.contentView) } private func setInputEnabled(_ enabled: Bool) { jobKeywordsField.isEnabled = enabled findJobsButton.isEnabled = enabled findJobsButton.alphaValue = enabled ? 1 : 0.65 trailingLoadMoreJobsButton?.isEnabled = enabled trailingLoadMoreJobsButton?.alphaValue = enabled ? 1 : 0.65 } private func configureSidebar() { let items = currentSidebarItems sidebar.arrangedSubviews.forEach { sidebar.removeArrangedSubview($0) $0.removeFromSuperview() } let brand = NSTextField(labelWithString: "Indeed AI\nJob Finder") brand.font = .systemFont(ofSize: 18, weight: .bold) brand.textColor = Theme.brandBlue brand.alignment = .left brand.maximumNumberOfLines = 2 // Tight multiline height in the sidebar stack (zero width makes intrinsic height unreliable). brand.preferredMaxLayoutWidth = 194 sidebar.addArrangedSubview(brand) sidebar.setCustomSpacing(10, after: brand) items.enumerated().forEach { index, item in let isSelected = index == selectedSidebarIndex let rowHost = SidebarNavRowView { [weak self] in self?.selectSidebarItem(at: index) } rowHost.translatesAutoresizingMaskIntoConstraints = false rowHost.wantsLayer = true rowHost.layer?.cornerRadius = 8 rowHost.restingBackgroundColor = isSelected ? Theme.selectionFill : nil rowHost.hoverBackgroundColor = isSelected ? Theme.selectionFillHover : Theme.sidebarRowHoverFill rowHost.setAccessibilityLabel(item.title) rowHost.setAccessibilityRole(.button) rowHost.setAccessibilitySelected(isSelected) let row = NSStackView() row.orientation = .horizontal row.spacing = 8 row.alignment = .centerY row.translatesAutoresizingMaskIntoConstraints = false let icon = NSImageView() icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium) icon.image = NSImage(systemSymbolName: item.systemImage, accessibilityDescription: item.title) icon.contentTintColor = isSelected ? Theme.brandBlue : Theme.secondaryText let text = NSTextField(labelWithString: item.title) text.font = .systemFont(ofSize: 14, weight: .medium) text.textColor = isSelected ? Theme.brandBlue : Theme.secondaryText text.refusesFirstResponder = true row.addArrangedSubview(icon) row.addArrangedSubview(text) if let badge = item.badge { let badgeField = NSTextField(labelWithString: badge) badgeField.font = .systemFont(ofSize: 11, weight: .semibold) badgeField.textColor = Theme.primaryText badgeField.wantsLayer = true badgeField.layer?.backgroundColor = Theme.toggleBackground.cgColor badgeField.layer?.cornerRadius = 8 badgeField.alignment = .center badgeField.maximumNumberOfLines = 1 badgeField.lineBreakMode = .byClipping badgeField.refusesFirstResponder = true badgeField.translatesAutoresizingMaskIntoConstraints = false badgeField.widthAnchor.constraint(equalToConstant: 42).isActive = true row.addArrangedSubview(NSView()) row.addArrangedSubview(badgeField) } rowHost.addSubview(row) NSLayoutConstraint.activate([ row.leadingAnchor.constraint(equalTo: rowHost.leadingAnchor, constant: 10), row.trailingAnchor.constraint(equalTo: rowHost.trailingAnchor, constant: -10), row.topAnchor.constraint(equalTo: rowHost.topAnchor, constant: 8), row.bottomAnchor.constraint(equalTo: rowHost.bottomAnchor, constant: -8) ]) rowHost.setContentHuggingPriority(.defaultLow, for: .horizontal) sidebar.addArrangedSubview(rowHost) let sidebarHorizontalInset = sidebar.edgeInsets.left + sidebar.edgeInsets.right rowHost.widthAnchor.constraint(equalTo: sidebar.widthAnchor, constant: -sidebarHorizontalInset).isActive = true } let sidebarBottomSpacer = NSView() sidebarBottomSpacer.translatesAutoresizingMaskIntoConstraints = false sidebarBottomSpacer.setContentHuggingPriority(.defaultLow, for: .vertical) sidebarBottomSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) sidebar.addArrangedSubview(sidebarBottomSpacer) let upgradeCard = NSView() upgradeCard.translatesAutoresizingMaskIntoConstraints = false upgradeCard.wantsLayer = true upgradeCard.layer?.backgroundColor = Theme.proCardFill.cgColor upgradeCard.layer?.cornerRadius = 14 upgradeCard.layer?.borderWidth = 1 upgradeCard.layer?.borderColor = Theme.proCardBorder.cgColor upgradeCard.layer?.masksToBounds = true let accentBar = NSView() accentBar.translatesAutoresizingMaskIntoConstraints = false accentBar.wantsLayer = true accentBar.layer?.backgroundColor = Theme.proAccent.cgColor let inner = NSStackView() inner.translatesAutoresizingMaskIntoConstraints = false inner.orientation = .vertical inner.spacing = 10 inner.alignment = .centerX let proIcon = NSImageView() proIcon.translatesAutoresizingMaskIntoConstraints = false proIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold) proIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil) proIcon.contentTintColor = Theme.proAccent let proEyebrow = NSTextField(labelWithString: "Premium") proEyebrow.font = .systemFont(ofSize: 11, weight: .heavy) proEyebrow.textColor = Theme.proAccent proEyebrow.alignment = .center let eyebrowRow = NSStackView(views: [proIcon, proEyebrow]) eyebrowRow.orientation = .horizontal eyebrowRow.spacing = 6 eyebrowRow.alignment = .centerY let headline = NSTextField(labelWithString: "Upgrade to Pro") headline.font = .systemFont(ofSize: 16, weight: .bold) headline.textColor = Theme.primaryText headline.alignment = .center let upgradeDescription = NSTextField(wrappingLabelWithString: "Unlimited AI matches, smart alerts, and interview prep—all in one place.") upgradeDescription.font = .systemFont(ofSize: 12, weight: .regular) upgradeDescription.textColor = Theme.secondaryText upgradeDescription.alignment = .center // Sidebar content width is the fixed sidebar width minus horizontal edge insets; card must stay within that band. let cardWidth: CGFloat = 186 let innerContentWidth = cardWidth - 28 upgradeDescription.preferredMaxLayoutWidth = innerContentWidth let upgradeButton = HoverableButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro)) upgradeButton.isBordered = false upgradeButton.bezelStyle = .rounded upgradeButton.font = .systemFont(ofSize: 13, weight: .bold) upgradeButton.contentTintColor = Theme.proCTAText upgradeButton.alignment = .center upgradeButton.wantsLayer = true upgradeButton.layer?.backgroundColor = Theme.proCTABackground.cgColor upgradeButton.layer?.cornerRadius = 20 upgradeButton.translatesAutoresizingMaskIntoConstraints = false upgradeButton.heightAnchor.constraint(equalToConstant: 40).isActive = true upgradeButton.pointerCursor = true upgradeButton.hoverHandler = { [weak upgradeButton] hovering in upgradeButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.proCTABackground).cgColor } inner.addArrangedSubview(eyebrowRow) inner.addArrangedSubview(headline) inner.addArrangedSubview(upgradeDescription) inner.addArrangedSubview(upgradeButton) upgradeCard.addSubview(accentBar) upgradeCard.addSubview(inner) NSLayoutConstraint.activate([ upgradeCard.widthAnchor.constraint(equalToConstant: cardWidth), accentBar.topAnchor.constraint(equalTo: upgradeCard.topAnchor), accentBar.leadingAnchor.constraint(equalTo: upgradeCard.leadingAnchor), accentBar.trailingAnchor.constraint(equalTo: upgradeCard.trailingAnchor), accentBar.heightAnchor.constraint(equalToConstant: 2), inner.leadingAnchor.constraint(equalTo: upgradeCard.leadingAnchor, constant: 14), inner.trailingAnchor.constraint(equalTo: upgradeCard.trailingAnchor, constant: -14), inner.topAnchor.constraint(equalTo: accentBar.bottomAnchor, constant: 12), inner.bottomAnchor.constraint(equalTo: upgradeCard.bottomAnchor, constant: -14), upgradeButton.widthAnchor.constraint(equalTo: inner.widthAnchor) ]) sidebar.addArrangedSubview(upgradeCard) } @objc private func didTapUpgradeToPro() { guard let url = URL(string: "https://www.indeed.com") else { return } NSWorkspace.shared.open(url) } @objc private func didChangeThemeSelection(_ sender: NSSegmentedControl) { switch sender.selectedSegment { case 1: NSApp.appearance = NSAppearance(named: .aqua) case 2: NSApp.appearance = NSAppearance(named: .darkAqua) default: NSApp.appearance = nil } } private func selectSidebarItem(at index: Int) { guard index >= 0, index < currentSidebarItems.count else { return } let selectingHome = isHomeSidebarIndex(index) if index == selectedSidebarIndex { if selectingHome { applyHomeState() } return } selectedSidebarIndex = index configureSidebar() updateMainContentVisibility() if selectingHome { applyHomeState() } } } private struct ChatMessage: Codable { let role: String let content: String } private final class OpenAIJobSearchService { private let endpoint = URL(string: "https://api.openai.com/v1/responses")! private let session = URLSession(configuration: .ephemeral) func searchJobs(query: String, conversation: [ChatMessage], maxJobs: Int, completion: @escaping (Result) -> Void) { let jobLimit = max(1, min(maxJobs, 25)) let apiKey = OpenAIConfiguration.apiKey guard OpenAIConfiguration.hasAPIKey else { completion(.failure(NSError( domain: "OpenAIJobSearchService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing API key. Set OPENAI_API_KEY in Xcode Build Settings."] ))) return } var request = URLRequest(url: endpoint) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 45 let recentContext = conversation.suffix(8) .map { "\($0.role.uppercased()): \($0.content)" } .joined(separator: "\n") let contextBlock: String if recentContext.isEmpty { contextBlock = "No prior conversation context." } else { contextBlock = recentContext } let instructions = """ Continue this same job-search conversation. Use prior context when useful. The line "Latest user query" is the primary task; earlier USER/ASSISTANT lines are context (previous role, location, or results). Conversation context: \(contextBlock) Latest user query: "\(query)" Use web search to find currently posted jobs that satisfy this query. If the user refines pay, seniority, work location, or similar, run a new search that applies those constraints to the same career topic as in the context. CRITICAL OUTPUT RULES: - Reply with NOTHING except one JSON object. No markdown, no code fences, no prose before or after, no labels like "Here is the JSON". - The JSON must match exactly this shape (lowercase key "jobs"): {"jobs":[{"title":"...","description":"...","url":"https://..."}]} - If you find no suitable listings, return {"jobs":[]} — still valid JSON only. Return up to \(jobLimit) jobs in the jobs array (fewer only if the web has no additional distinct matches). Do not repeat titles or URLs already implied in the conversation context above. Keep each description to one sentence; prefer real listing URLs. """ let payload = OpenAIResponsesRequest( model: "gpt-4o-mini", input: instructions, tools: [OpenAIResponsesTool(type: "web_search_preview")] ) do { request.httpBody = try JSONEncoder().encode(payload) } catch { completion(.failure(error)) return } session.dataTask(with: request) { data, response, error in if let error { completion(.failure(error)) return } guard let data else { completion(.failure(NSError( domain: "OpenAIJobSearchService", code: 2, userInfo: [NSLocalizedDescriptionKey: "API returned an empty response."] ))) return } if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { if let apiError = try? JSONDecoder().decode(OpenAIAPIErrorResponse.self, from: data) { completion(.failure(NSError( domain: "OpenAIJobSearchService", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: apiError.error.message] ))) } else { completion(.failure(NSError( domain: "OpenAIJobSearchService", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: "Job search request failed with status \(http.statusCode)."] ))) } return } do { let modelText = try Self.extractModelTextFromResponsesBody(data) let trimmed = modelText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw NSError( domain: "OpenAIJobSearchService", code: 4, userInfo: [NSLocalizedDescriptionKey: "The API returned an empty text payload."] ) } let jobs = try Self.parseJobListings(fromModelText: trimmed) completion(.success(JobSearchOutput(jobs: jobs))) } catch { completion(.failure(error)) } }.resume() } /// Walks the `/v1/responses` JSON without strict Codable so tool calls, refusals, and future output item types do not break decoding. Collects only `output_text` segments from assistant `message` items (and any other output items that expose a `content` array). private static func extractModelTextFromResponsesBody(_ data: Data) throws -> String { let rootObject: Any do { rootObject = try JSONSerialization.jsonObject(with: data, options: []) } catch { throw NSError( domain: "OpenAIJobSearchService", code: 5, userInfo: [NSLocalizedDescriptionKey: "The job search service returned data that was not valid JSON."] ) } guard let root = rootObject as? [String: Any] else { throw NSError( domain: "OpenAIJobSearchService", code: 5, userInfo: [NSLocalizedDescriptionKey: "Unexpected response shape from the job search service."] ) } if let status = root["status"] as? String { if status == "failed" { let message = (root["error"] as? [String: Any])?["message"] as? String ?? "The search request failed." throw NSError(domain: "OpenAIJobSearchService", code: 7, userInfo: [NSLocalizedDescriptionKey: message]) } if status == "incomplete", let details = root["incomplete_details"] as? [String: Any], let reason = details["reason"] as? String { throw NSError( domain: "OpenAIJobSearchService", code: 8, userInfo: [NSLocalizedDescriptionKey: "Search stopped early (\(reason)). Try a simpler query or try again."] ) } } if let direct = root["output_text"] as? String { let trimmed = direct.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } } guard let output = root["output"] as? [Any] else { throw NSError( domain: "OpenAIJobSearchService", code: 9, userInfo: [NSLocalizedDescriptionKey: "The search service returned no assistant text. Try again in a moment."] ) } var segments: [String] = [] for case let item as [String: Any] in output where (item["type"] as? String) == "message" { collectOutputTextSegments(fromOutputItem: item, into: &segments) } if segments.isEmpty { for case let item as [String: Any] in output { collectOutputTextSegments(fromOutputItem: item, into: &segments) } } let combined = segments.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) if !combined.isEmpty { return combined } throw NSError( domain: "OpenAIJobSearchService", code: 9, userInfo: [NSLocalizedDescriptionKey: "The model did not return readable job-search text. Try again."] ) } private static func collectOutputTextSegments(fromOutputItem item: [String: Any], into segments: inout [String]) { guard let content = item["content"] as? [Any] else { return } for case let part as [String: Any] in content { guard (part["type"] as? String) == "output_text" else { continue } if let s = part["text"] as? String { segments.append(s) } else if let nested = part["text"] as? [String: Any], let value = nested["value"] as? String { segments.append(value) } } } private static func parseJobListings(fromModelText text: String) throws -> [JobListing] { let jsonString = extractJobJSONObjectString(from: text) ?? extractJSONObject(from: text) let jsonData = Data(jsonString.utf8) if let payload = try? JSONDecoder().decode(JobSearchResultsPayload.self, from: jsonData) { return payload.jobs } if let listings = try? JSONDecoder().decode([JobListing].self, from: jsonData) { return listings } if let obj = try? JSONSerialization.jsonObject(with: jsonData, options: []) { if let dict = obj as? [String: Any], let jobs = jobListings(fromFlexibleJSONObject: dict) { return jobs } if let arr = obj as? [[String: Any]], let jobs = jobListings(fromFlexibleJobArray: arr) { return jobs } } throw NSError( domain: "OpenAIJobSearchService", code: 10, userInfo: [NSLocalizedDescriptionKey: "The assistant reply did not include job listings in the expected JSON format. Try your search again."] ) } private static func jobListings(fromFlexibleJSONObject dict: [String: Any]) -> [JobListing]? { for (key, value) in dict { guard key.caseInsensitiveCompare("jobs") == .orderedSame, let arr = value as? [[String: Any]] else { continue } if let jobs = jobListings(fromFlexibleJobArray: arr) { return jobs } } for wrapKey in ["data", "result", "results", "payload"] { if let inner = dict[wrapKey] as? [String: Any], let nested = jobListings(fromFlexibleJSONObject: inner) { return nested } } return nil } private static func jobListings(fromFlexibleJobArray jobs: [[String: Any]]) -> [JobListing]? { var out: [JobListing] = [] for item in jobs { guard let title = firstString(valuesForKeys: ["title", "job_title", "name", "position"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty, let desc = firstString(valuesForKeys: ["description", "snippet", "summary", "desc"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines), !desc.isEmpty else { continue } let urlRaw = firstString(valuesForKeys: ["url", "link", "apply_url", "job_url"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines) let url: String? = (urlRaw?.isEmpty == true) ? nil : urlRaw out.append(JobListing(title: title, description: desc, url: url)) } return out.isEmpty ? nil : out } private static func firstString(valuesForKeys keys: [String], in dict: [String: Any]) -> String? { for wanted in keys { for (dk, dv) in dict { guard dk.caseInsensitiveCompare(wanted) == .orderedSame, let s = dv as? String else { continue } return s } } return nil } private static func stripMarkdownCodeFence(_ text: String) -> String { var s = text.trimmingCharacters(in: .whitespacesAndNewlines) guard s.hasPrefix("```") else { return s } s.removeFirst(3) if s.lowercased().hasPrefix("json") { s.removeFirst(4) } s = s.trimmingCharacters(in: .whitespacesAndNewlines) if let fence = s.range(of: "```", options: .backwards) { s = String(s[.. String? { var depth = 0 var inString = false var escaped = false var i = openBrace while i < s.endIndex { let ch = s[i] if inString { if escaped { escaped = false } else if ch == "\\" { escaped = true } else if ch == "\"" { inString = false } } else { switch ch { case "\"": inString = true case "{": depth += 1 case "}": depth -= 1 if depth == 0 { return String(s[openBrace...i]) } default: break } } i = s.index(after: i) } return nil } /// Prefers the JSON object that contains a `"jobs"` key so prose before/after the payload does not confuse the decoder. private static func extractJobJSONObjectString(from text: String) -> String? { let s = stripMarkdownCodeFence(text) guard let jobsRange = s.range(of: "\"jobs\"", options: .caseInsensitive) else { return nil } let head = s[.. String { if let extracted = extractJobJSONObjectString(from: text) { return extracted } let stripped = stripMarkdownCodeFence(text) if let first = stripped.firstIndex(of: "{"), let balanced = balancedJSONObject(from: first, in: stripped) { return balanced } if let range = text.range(of: "\\{[\\s\\S]*\\}", options: .regularExpression) { return String(text[range]) } return text } } private struct OpenAIResponsesRequest: Codable { let model: String let input: String let tools: [OpenAIResponsesTool] } private struct OpenAIResponsesTool: Codable { let type: String } private struct JobSearchResultsPayload: Codable { let jobs: [JobListing] } private struct JobSearchOutput { let jobs: [JobListing] } private struct OpenAIAPIErrorResponse: Codable { let error: APIError struct APIError: Codable { let message: String } } /// Home welcome row: three tappable shortcuts that seed the main search field (reference: white cards, pastel icon well, arrow at bottom trailing). private final class FeatureShortcutCardView: NSView { private static let cardCornerRadius: CGFloat = 14 private weak var actionTarget: AnyObject? private var actionSelector: Selector init(symbolName: String, title: String, subtitle: String, target: AnyObject?, action: Selector) { self.actionTarget = target self.actionSelector = action super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false wantsLayer = true layer?.cornerRadius = Self.cardCornerRadius if #available(macOS 11.0, *) { layer?.cornerCurve = .continuous } layer?.backgroundColor = NSColor.white.cgColor layer?.masksToBounds = false layer?.borderWidth = 1 // `#EDF2F7` — light card stroke. layer?.borderColor = NSColor(srgbRed: 237 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1).cgColor layer?.shadowColor = NSColor.black.withAlphaComponent(0.06).cgColor layer?.shadowOffset = CGSize(width: 0, height: 2) layer?.shadowRadius = 12 layer?.shadowOpacity = 1 // `#0047AB` — primary title / icons / arrow. let primaryBlue = NSColor(srgbRed: 0 / 255, green: 71 / 255, blue: 171 / 255, alpha: 1) // `#EBF2FF` — circular icon well. let iconWellColor = NSColor(srgbRed: 235 / 255, green: 242 / 255, blue: 255 / 255, alpha: 1) // `#5D6D7E` — muted description. let secondary = NSColor(srgbRed: 93 / 255, green: 109 / 255, blue: 126 / 255, alpha: 1) let iconSize: CGFloat = 48 let iconHost = NSView() iconHost.translatesAutoresizingMaskIntoConstraints = false iconHost.wantsLayer = true iconHost.layer?.backgroundColor = iconWellColor.cgColor iconHost.layer?.cornerRadius = iconSize / 2 let icon = NSImageView() icon.translatesAutoresizingMaskIntoConstraints = false icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .regular) icon.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) icon.contentTintColor = primaryBlue iconHost.addSubview(icon) let titleField = NSTextField(wrappingLabelWithString: title) titleField.font = .systemFont(ofSize: 15, weight: .bold) titleField.textColor = primaryBlue titleField.maximumNumberOfLines = 1 titleField.isEditable = false titleField.isBordered = false titleField.drawsBackground = false titleField.alignment = .left let subtitleField = NSTextField(wrappingLabelWithString: subtitle) subtitleField.font = .systemFont(ofSize: 12, weight: .regular) subtitleField.textColor = secondary subtitleField.maximumNumberOfLines = 2 subtitleField.isEditable = false subtitleField.isBordered = false subtitleField.drawsBackground = false subtitleField.alignment = .left subtitleField.lineBreakMode = .byWordWrapping subtitleField.setContentHuggingPriority(.defaultLow, for: .horizontal) subtitleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let chevron = NSImageView() chevron.translatesAutoresizingMaskIntoConstraints = false chevron.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) chevron.image = NSImage(systemSymbolName: "arrow.right", accessibilityDescription: nil) chevron.contentTintColor = primaryBlue chevron.setContentHuggingPriority(.required, for: .horizontal) chevron.setContentCompressionResistancePriority(.required, for: .horizontal) let subtitleRow = NSStackView(views: [subtitleField, chevron]) subtitleRow.orientation = .horizontal subtitleRow.spacing = 10 subtitleRow.alignment = .bottom subtitleRow.distribution = .fill subtitleRow.translatesAutoresizingMaskIntoConstraints = false let textColumn = NSStackView(views: [titleField, subtitleRow]) textColumn.orientation = .vertical textColumn.spacing = 6 textColumn.alignment = .leading textColumn.translatesAutoresizingMaskIntoConstraints = false textColumn.setContentHuggingPriority(.defaultLow, for: .horizontal) textColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) iconHost.setContentHuggingPriority(.required, for: .horizontal) iconHost.setContentCompressionResistancePriority(.required, for: .horizontal) let row = NSStackView(views: [iconHost, textColumn]) row.orientation = .horizontal row.spacing = 16 row.alignment = .centerY row.distribution = .fill row.translatesAutoresizingMaskIntoConstraints = false addSubview(row) let inset: CGFloat = 22 NSLayoutConstraint.activate([ row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), row.topAnchor.constraint(equalTo: topAnchor, constant: inset), row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset), iconHost.widthAnchor.constraint(equalToConstant: iconSize), iconHost.heightAnchor.constraint(equalToConstant: iconSize), icon.centerXAnchor.constraint(equalTo: iconHost.centerXAnchor), icon.centerYAnchor.constraint(equalTo: iconHost.centerYAnchor) ]) setAccessibilityElement(true) setAccessibilityRole(.button) setAccessibilityLabel("\(title). \(subtitle)") } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layout() { super.layout() guard let layer = layer, bounds.width > 0, bounds.height > 0 else { return } let r = bounds let cr = Self.cardCornerRadius layer.shadowPath = CGPath(roundedRect: r, cornerWidth: cr, cornerHeight: cr, transform: nil) } override func mouseDown(with event: NSEvent) { if let target = actionTarget { _ = target.perform(actionSelector, with: nil) } } override func resetCursorRects() { super.resetCursorRects() addCursorRect(bounds, cursor: .pointingHand) } } /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target). private class JobPayloadButton: HoverableButton { var jobPayload: JobListing? var cardContext: JobListingCardContext = .homeSearchResults } /// Insets image + title so the Save pill matches the reference (balanced padding, not flush to the stroke). private final class SaveJobButtonCell: NSButtonCell { private let horizontalInset: CGFloat = 10 private let verticalInset: CGFloat = 3 private let imageTitleGap: CGFloat = 5 override func imageRect(forBounds rect: NSRect) -> NSRect { super.imageRect(forBounds: rect.insetBy(dx: horizontalInset, dy: verticalInset)) } override func titleRect(forBounds rect: NSRect) -> NSRect { let padded = rect.insetBy(dx: horizontalInset, dy: verticalInset) var t = super.titleRect(forBounds: padded) t.origin.x += imageTitleGap t.size.width = max(0, t.size.width - imageTitleGap) return t } } private final class SaveJobPayloadButton: JobPayloadButton { override class var cellClass: AnyClass? { get { SaveJobButtonCell.self } set { } } } /// `NSButton` with a tracking area that reports hover transitions and (optionally) swaps in a pointing-hand cursor while hovered. private class HoverableButton: NSButton { var hoverHandler: ((Bool) -> Void)? var pointerCursor: Bool = false private(set) var isHovering: Bool = false private var trackingArea: NSTrackingArea? private var didPushCursor: Bool = false override func updateTrackingAreas() { super.updateTrackingAreas() if let area = trackingArea { removeTrackingArea(area) } let area = NSTrackingArea( rect: bounds, options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil ) addTrackingArea(area) trackingArea = area } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) isHovering = true hoverHandler?(true) if pointerCursor, !didPushCursor { NSCursor.pointingHand.push() didPushCursor = true } } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) isHovering = false hoverHandler?(false) if didPushCursor { NSCursor.pop() didPushCursor = false } } override func viewWillMove(toWindow newWindow: NSWindow?) { super.viewWillMove(toWindow: newWindow) // Guard against an unbalanced cursor stack if the button is removed mid-hover (e.g. job card replaced after a search). if newWindow == nil, didPushCursor { NSCursor.pop() didPushCursor = false isHovering = false } } } /// `NSView` companion to `HoverableButton`: emits hover transitions and can manage a pointing-hand cursor. Used to track hover over composite controls like the gradient "Find jobs" pill. private class HoverableView: NSView { var hoverHandler: ((Bool) -> Void)? var pointerCursor: Bool = false private(set) var isHovering: Bool = false private var trackingArea: NSTrackingArea? private var didPushCursor: Bool = false override func updateTrackingAreas() { super.updateTrackingAreas() if let area = trackingArea { removeTrackingArea(area) } let area = NSTrackingArea( rect: bounds, options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil ) addTrackingArea(area) trackingArea = area } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) isHovering = true hoverHandler?(true) if pointerCursor, !didPushCursor { NSCursor.pointingHand.push() didPushCursor = true } } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) isHovering = false hoverHandler?(false) if didPushCursor { NSCursor.pop() didPushCursor = false } } override func viewWillMove(toWindow newWindow: NSWindow?) { super.viewWillMove(toWindow: newWindow) if newWindow == nil, didPushCursor { NSCursor.pop() didPushCursor = false isHovering = false } } } /// 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). private final class JobListingsDocumentView: NSView { override var isFlipped: Bool { true } } /// Marker subclass for the per-assistant-message job stack embedded inside a chat bubble. `NSView.tag` is read-only on macOS, so a typed subclass is the cleanest way to identify these stacks during dismiss and layout passes. private final class ChatJobsStackView: NSStackView {} /// Marker subclass for the wrapping label inside a chat bubble. Lets the layout pass find each bubble label to update its `preferredMaxLayoutWidth` when the chat width changes. private final class ChatBubbleLabel: NSTextField {} /// Captures clicks for the full sidebar pill so icon, label, and padding behave as one tab. Manages its own hover background so non-selected rows highlight subtly on hover without disturbing the selected-row fill. private final class SidebarNavRowView: NSView { private let onSelect: () -> Void var restingBackgroundColor: NSColor? { didSet { applyBackground() } } var hoverBackgroundColor: NSColor? private var isHovering: Bool = false private var didPushCursor: Bool = false init(onSelect: @escaping () -> Void) { self.onSelect = onSelect super.init(frame: .zero) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func hitTest(_ point: NSPoint) -> NSView? { guard let superview else { return super.hitTest(point) } let local = convert(point, from: superview) return bounds.contains(local) ? self : nil } override func mouseDown(with event: NSEvent) { onSelect() } override func updateTrackingAreas() { super.updateTrackingAreas() trackingAreas.forEach { removeTrackingArea($0) } addTrackingArea(NSTrackingArea( rect: bounds, options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil )) } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) isHovering = true applyBackground() if !didPushCursor { NSCursor.pointingHand.push() didPushCursor = true } } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) isHovering = false applyBackground() if didPushCursor { NSCursor.pop() didPushCursor = false } } override func viewWillMove(toWindow newWindow: NSWindow?) { super.viewWillMove(toWindow: newWindow) if newWindow == nil, didPushCursor { NSCursor.pop() didPushCursor = false isHovering = false } } private func applyBackground() { let color = isHovering ? (hoverBackgroundColor ?? restingBackgroundColor) : restingBackgroundColor layer?.backgroundColor = color?.cgColor } }