// // DashboardView.swift // App for Indeed // import Cocoa import QuartzCore import Security private enum JobListingCardContext { case homeSearchResults case savedJobsPage } private enum PremiumSheetLayout { /// Grow the sheet past the host content rect on each side to hide compositing hairlines. static let overscanPerEdge: CGFloat = 2 /// Additional growth on the top edge only (pt). static let overscanExtraTop: CGFloat = 0.5 } /// Free-tier cap for Home AI job search (user messages only; Pro is unlimited). private enum FreeTierJobSearchQuota { static let maxUserMessages = 2 private static let userDefaultsKey = "com.appforindeed.freeJobSearchUserMessageCount" static var userMessageCount: Int { get { UserDefaults.standard.integer(forKey: userDefaultsKey) } set { UserDefaults.standard.set(newValue, forKey: userDefaultsKey) } } static func canSendAnotherMessage(isProActive: Bool) -> Bool { isProActive || userMessageCount < maxUserMessages } static func recordUserMessageSent(isProActive: Bool) { guard !isProActive else { return } userMessageCount += 1 } static func remainingUserMessages(isProActive: Bool) -> Int { guard !isProActive else { return maxUserMessages } return max(0, maxUserMessages - userMessageCount) } } private enum SettingsAppearanceID { static let section = "dashboard.settings.section" static let sectionHeader = "dashboard.settings.sectionHeader" static let rowTitle = "dashboard.settings.rowTitle" static let iconTile = "dashboard.settings.iconTile" static let divider = "dashboard.settings.divider" } final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDelegate, NSSharingServiceDelegate { /// Indeed.com-inspired neutrals and brand blue; values follow light / dark / system appearance. private enum Theme { static var brandBlue: NSColor { AppDashboardTheme.brandBlue } static var pageBackground: NSColor { AppDashboardTheme.pageBackground } static var chromeBackground: NSColor { AppDashboardTheme.chromeBackground } static var sidebarBackground: NSColor { AppDashboardTheme.sidebarBackground } static var mainHostBackground: NSColor { AppDashboardTheme.mainHostBackground } static var welcomeHeroHeadingBlue: NSColor { AppDashboardTheme.welcomeHeroHeadingBlue } static var welcomeHeroSubtitleText: NSColor { AppDashboardTheme.welcomeHeroSubtitleText } static var welcomeHeroIconWell: NSColor { AppDashboardTheme.welcomeHeroIconWell } static var welcomeHeroWaveTint: NSColor { AppDashboardTheme.welcomeHeroWaveTint } static var welcomeSubtitleText: NSColor { AppDashboardTheme.welcomeSubtitleText } static var selectionFill: NSColor { AppDashboardTheme.selectionFill } static var cardBackground: NSColor { AppDashboardTheme.cardBackground } static var toggleBackground: NSColor { AppDashboardTheme.toggleBackground } static var primaryText: NSColor { AppDashboardTheme.primaryText } static var secondaryText: NSColor { AppDashboardTheme.secondaryText } static var tertiaryText: NSColor { AppDashboardTheme.tertiaryText } static var border: NSColor { AppDashboardTheme.border } static var searchBarBorder: NSColor { AppDashboardTheme.searchBarBorder } static var searchBarBorderHover: NSColor { AppDashboardTheme.searchBarBorderHover } static var proCardFill: NSColor { AppDashboardTheme.proCardFill } static var proCardBorder: NSColor { AppDashboardTheme.proCardBorder } static var proAccent: NSColor { AppDashboardTheme.proAccent } static var proCTABackground: NSColor { AppDashboardTheme.proCTABackground } static var proCTAText: NSColor { AppDashboardTheme.proCTAText } static var brandBlueHover: NSColor { AppDashboardTheme.brandBlueHover } static var selectionFillHover: NSColor { AppDashboardTheme.selectionFillHover } static var neutralHoverFill: NSColor { AppDashboardTheme.neutralHoverFill } static var sidebarRowHoverFill: NSColor { AppDashboardTheme.sidebarRowHoverFill } static var settingsPageBackground: NSColor { AppDashboardTheme.settingsPageBackground } static var settingsGroupBackground: NSColor { AppDashboardTheme.settingsGroupBackground } static var settingsIconBackground: NSColor { AppDashboardTheme.settingsIconBackground } static var settingsDivider: NSColor { AppDashboardTheme.settingsDivider } } /// 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 ]) } /// Horizontal row for sidebar + main; plain view + constraints keep both panels top/bottom aligned (stack view height alignment was inconsistent). private let panelsRow = NSView() 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 searchBarColumn = NSStackView() private let searchBarShadowHost = NSView() private let freeJobSearchQuotaLabel = NSTextField(labelWithString: "") private let searchCard = HoverableView() private let jobSearchIcon = NSImageView() private let jobKeywordsField = NSTextField() private let findJobsButton = HoverableButton() private let findJobsCTAPill = HoverableView() private let sendIconView = NSImageView() private let sendLabel = NSTextField(labelWithString: L("Send")) private let sendContentStack = NSStackView() private let findJobsCTAHost = NSView() private let welcomeHeroHost = NSView() private let welcomeHeroBackgroundView = WelcomeHeroBackgroundView() private let welcomeLogoWell = NSView() private let welcomeLogoView = IndeedLogoView(displayHeight: 40, variant: .compact) private let featureCardsRow = NSStackView() private enum FeatureShortcut: Int { case role = 0, company = 1, skill = 2 } private let clearChatButton = NSButton(title: L("Clear chat"), target: nil, action: nil) 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() /// Full-bleed Indeed apply / listing web view inside the main panel (same window as the dashboard). private let indeedJobBrowserHost = NSView() private let nonHomeGenericContainer = NSView() private let nonHomeTitleLabel = NSTextField(labelWithString: "") private let nonHomeSubtitleLabel = NSTextField(wrappingLabelWithString: "") private let savedJobsPageContainer = NSView() private let savedJobsPageTitleLabel = NSTextField(labelWithString: L("Saved Jobs")) private let savedJobsPageSubtitleLabel = NSTextField(wrappingLabelWithString: "") private let savedJobsScrollView = NSScrollView() private let savedJobsDocumentView = JobListingsDocumentView() private let savedJobsStack = NSStackView() private let settingsPageContainer = NSView() private weak var appearanceModeSegment: NSSegmentedControl? private weak var languagePopUp: NSPopUpButton? private let cvMakerPageContainer = NSView() private lazy var cvMakerPageView: CVMakerPageView = { CVMakerPageView() }() private let profilePageContainer = NSView() private lazy var profilesListPageView: ProfilesListPageView = { ProfilesListPageView() }() private lazy var myProfilePageView: MyProfilePageView = { MyProfilePageView() }() /// When true, `myProfilePageView` is visible instead of the profiles list. private var isProfileEditorPresented = false /// When true, the merged CV preview is visible instead of the profiles list or editor. private var isCVDocumentPreviewPresented = false /// Exact template chosen in CV Maker until the user leaves Profile or starts a new CV Maker hand-off (avoids re-resolving by id and picking a different row). private var pendingCVTemplate: CVTemplate? private let cvFilledPreviewPageView = CVFilledPreviewPageView() private var currentSidebarItems: [SidebarItem] = [] private var selectedSidebarIndex: Int = 0 /// When true, the **Indeed** sidebar row is highlighted instead of `selectedSidebarIndex`. private var isIndeedSidebarSelected = false /// 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 /// Shown under the latest user message while a job search request is in flight. private var chatThinkingRowHost: NSView? private let jobSearchService = OpenAIJobSearchService() private var premiumPlansWindowController: PremiumPlansWindowController? private var isPreparingPremiumPlansSheet = false private var indeedJobBrowserViewController: IndeedJobBrowserViewController? private var isIndeedJobBrowserPresented = false private weak var sidebarUpgradeCard: NSView? private weak var sidebarUpgradeHeadline: NSTextField? private weak var sidebarUpgradeDescription: NSTextField? private weak var sidebarUpgradeButton: HoverableButton? private var subscriptionObserver: NSObjectProtocol? private var appearanceObserver: NSObjectProtocol? private var languageObserver: NSObjectProtocol? /// Retains the system share picker until the user picks a destination or dismisses the menu. private var appSharePicker: NSSharingServicePicker? /// 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 /// Matches SF Symbol nav icon footprint in sidebar rows. private static let sidebarNavIconSize: CGFloat = 18 private static func clampedJobsPerRequest(_ requested: Int = jobsPerSearchDefault) -> Int { min(jobsPerSearchMaxCap, max(jobsPerSearchMin, requested)) } override init(frame frameRect: NSRect) { super.init(frame: frameRect) setupLayout() registerAppearanceObservers() } required init?(coder: NSCoder) { super.init(coder: coder) setupLayout() registerAppearanceObservers() } deinit { if let subscriptionObserver { NotificationCenter.default.removeObserver(subscriptionObserver) } if let appearanceObserver { NotificationCenter.default.removeObserver(appearanceObserver) } if let languageObserver { NotificationCenter.default.removeObserver(languageObserver) } } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() applyCurrentAppearance() } private func registerAppearanceObservers() { appearanceObserver = NotificationCenter.default.addObserver( forName: AppAppearanceManager.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in self?.applyCurrentAppearance() } languageObserver = NotificationCenter.default.addObserver( forName: AppLanguageManager.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in self?.refreshLocalizedStrings() } } private func refreshLocalizedStrings() { greetingLabel.stringValue = L("Welcome") subtitleLabel.stringValue = L("Find your perfect job with the power of AI.") sendLabel.stringValue = L("Send") clearChatButton.title = L("Clear chat") clearChatButton.toolTip = L("Remove all messages and start a new conversation") savedJobsPageTitleLabel.stringValue = L("Saved Jobs") nonHomeSubtitleLabel.stringValue = L("This area is not available in the preview build. Use Home to search jobs.") jobKeywordsField.placeholderAttributedString = NSAttributedString( string: L("Ask for roles, skills, salary, or job descriptions..."), attributes: [ .foregroundColor: Theme.secondaryText, .font: NSFont.systemFont(ofSize: 14, weight: .regular) ] ) jobSearchIcon.setAccessibilityLabel(L("Ask AI")) findJobsButton.setAccessibilityLabel(L("Send")) appearanceModeSegment?.setLabel(L("System"), forSegment: 0) appearanceModeSegment?.setLabel(L("Light"), forSegment: 1) appearanceModeSegment?.setLabel(L("Dark"), forSegment: 2) refreshLanguagePopUp() refreshSettingsLocalizedLabels() refreshSidebarItemTitles() updateFreeJobSearchQuotaLabel() applyProSubscriptionToSidebar() configureSidebar() reloadSavedJobsListings() rebuildFeatureShortcutCards() trailingLoadMoreJobsButton?.title = L("Show more jobs") refreshWelcomeChatMessageForCurrentLanguage() } /// Updates the default assistant greeting when the user changes language before starting a conversation. private func refreshWelcomeChatMessageForCurrentLanguage() { guard chatMessages.count == 1, chatMessages[0].role == "assistant", chatMessages[0].attachedJobs == nil else { return } let welcome = L(Self.welcomeChatMessageKey) guard chatMessages[0].content != welcome else { return } chatMessages[0] = ChatMessage(role: "assistant", content: welcome) rebuildChatUI() } private func refreshSidebarItemTitles() { currentSidebarItems = currentSidebarItems.map { item in SidebarItem( title: localizedSidebarTitle(forSystemImage: item.systemImage), systemImage: item.systemImage, badge: item.badge ) } } private func localizedSidebarTitle(forSystemImage systemImage: String) -> String { switch systemImage { case "house.fill": return L("Home") case "heart": return L("Saved Jobs") case "doc.text": return L("CV Maker") case "person": return L("Profile") case "gearshape": return L("Settings") default: return L("Home") } } private func refreshLanguagePopUp() { guard let popup = languagePopUp else { return } let selectedCode = AppLanguageManager.shared.current.localeIdentifier popup.removeAllItems() for language in AppLanguage.allCases { popup.addItem(withTitle: language.localizedDisplayName) popup.lastItem?.representedObject = language.localeIdentifier } if let index = AppLanguage.allCases.firstIndex(where: { $0.localeIdentifier == selectedCode }) { popup.selectItem(at: index) } popup.isEnabled = !AppLanguage.allCases.isEmpty } private func refreshSettingsLocalizedLabels() { for view in settingsPageContainer.subviewsRecursive() { guard let rawID = view.identifier?.rawValue else { continue } let sectionPrefix = SettingsAppearanceID.sectionHeader + "." let rowPrefix = SettingsAppearanceID.rowTitle + "." if rawID.hasPrefix(sectionPrefix) { let key = String(rawID.dropFirst(sectionPrefix.count)) (view as? NSTextField)?.stringValue = L(key) } else if rawID.hasPrefix(rowPrefix) { let key = String(rawID.dropFirst(rowPrefix.count)) (view as? NSTextField)?.stringValue = L(key) } } } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() guard window != nil else { return } Task { @MainActor in await SubscriptionStore.shared.refreshEntitlements(deep: true) self.applyProSubscriptionToSidebar() } } override func layout() { super.layout() updateSearchBarShadowPath() updateFindJobsCTAShadowPath() updateJobListingDescriptionWidths() updateChatBubbleWidths() } func render(_ data: DashboardData) { dismissIndeedJobBrowserEmbedded() greetingLabel.stringValue = L("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() applyCurrentAppearance() } private func applyCurrentAppearance() { window?.backgroundColor = AppAppearanceManager.shared.windowChromeColor layer?.backgroundColor = Theme.chromeBackground.cgColor chromeContainer.layer?.backgroundColor = Theme.chromeBackground.cgColor sidebar.layer?.backgroundColor = Theme.sidebarBackground.cgColor mainHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor indeedJobBrowserHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor nonHomeHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor cvMakerPageContainer.layer?.backgroundColor = Theme.mainHostBackground.cgColor profilePageContainer.layer?.backgroundColor = Theme.mainHostBackground.cgColor settingsPageContainer.layer?.backgroundColor = Theme.settingsPageBackground.cgColor greetingLabel.textColor = Theme.welcomeHeroHeadingBlue subtitleLabel.textColor = Theme.welcomeHeroSubtitleText welcomeHeroBackgroundView.waveTint = Theme.welcomeHeroWaveTint welcomeLogoWell.layer?.backgroundColor = Theme.welcomeHeroIconWell.cgColor nonHomeTitleLabel.textColor = Theme.primaryText nonHomeSubtitleLabel.textColor = Theme.secondaryText savedJobsPageTitleLabel.textColor = Theme.primaryText savedJobsPageSubtitleLabel.textColor = Theme.secondaryText let searchHovering = searchCard.isHovering searchCard.layer?.backgroundColor = (searchHovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor searchCard.layer?.borderColor = (searchHovering ? Theme.searchBarBorderHover : Theme.searchBarBorder).cgColor jobKeywordsField.textColor = Theme.primaryText jobKeywordsField.placeholderAttributedString = NSAttributedString( string: L("Ask for roles, skills, salary, or job descriptions..."), attributes: [ .foregroundColor: Theme.secondaryText, .font: NSFont.systemFont(ofSize: 14, weight: .regular) ] ) jobSearchIcon.contentTintColor = Theme.brandBlue let ctaHovering = findJobsButton.isHovering findJobsCTAPill.layer?.backgroundColor = (ctaHovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor sendIconView.contentTintColor = Theme.proCTAText sendLabel.textColor = Theme.proCTAText clearChatButton.layer?.backgroundColor = Theme.brandBlue.cgColor clearChatButton.contentTintColor = Theme.proCTAText freeJobSearchQuotaLabel.textColor = Theme.secondaryText appearanceModeSegment?.selectedSegment = AppAppearanceManager.shared.mode.segmentIndex if let langPopUp = languagePopUp { let saved = AppLanguageManager.shared.current.localeIdentifier if let index = AppLanguage.allCases.firstIndex(where: { $0.localeIdentifier == saved }) { langPopUp.selectItem(at: index) } langPopUp.isEnabled = !AppLanguage.allCases.isEmpty } cvMakerPageView.applyCurrentAppearance() profilesListPageView.applyCurrentAppearance() myProfilePageView.applyCurrentAppearance() cvFilledPreviewPageView.applyCurrentAppearance() refreshSettingsPageAppearance(in: settingsPageContainer) rebuildFeatureShortcutCards() configureSidebar() reloadSavedJobsListings() rebuildChatUI() applyProSubscriptionToSidebar() updateFreeJobSearchQuotaLabel() needsLayout = true } private func refreshSettingsPageAppearance(in root: NSView) { for view in root.subviewsRecursive() { guard let rawID = view.identifier?.rawValue else { continue } switch rawID { case SettingsAppearanceID.section: view.layer?.backgroundColor = Theme.settingsGroupBackground.cgColor view.layer?.borderColor = Theme.border.cgColor case SettingsAppearanceID.divider: view.layer?.backgroundColor = Theme.settingsDivider.cgColor case SettingsAppearanceID.iconTile: view.layer?.backgroundColor = Theme.settingsIconBackground.cgColor for case let icon as NSImageView in view.subviews { icon.contentTintColor = Theme.brandBlue } default: if rawID.hasPrefix(SettingsAppearanceID.sectionHeader + ".") { (view as? NSTextField)?.textColor = Theme.secondaryText } else if rawID.hasPrefix(SettingsAppearanceID.rowTitle + ".") { (view as? NSTextField)?.textColor = Theme.primaryText } } } } private func rebuildFeatureShortcutCards() { let selectedIndex = featureCardsRow.arrangedSubviews.firstIndex { ($0 as? FeatureShortcutCardView)?.isSelected == true } featureCardsRow.arrangedSubviews.forEach { featureCardsRow.removeArrangedSubview($0) $0.removeFromSuperview() } configureFeatureShortcutCards() if let selectedIndex, let shortcut = FeatureShortcut(rawValue: selectedIndex) { selectFeatureShortcut(shortcut) } } private func setupLayout() { wantsLayer = true // Match chrome so the outer margin (inset chrome container) is grey, not an extra white ring. layer?.backgroundColor = Theme.chromeBackground.cgColor panelsRow.translatesAutoresizingMaskIntoConstraints = false chromeContainer.translatesAutoresizingMaskIntoConstraints = false chromeContainer.wantsLayer = true chromeContainer.layer?.backgroundColor = Theme.chromeBackground.cgColor chromeContainer.layer?.cornerRadius = 18 chromeContainer.layer?.masksToBounds = true addSubview(chromeContainer) chromeContainer.addSubview(panelsRow) 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: 20, left: 6, bottom: 6, right: 6) 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) indeedJobBrowserHost.translatesAutoresizingMaskIntoConstraints = false indeedJobBrowserHost.wantsLayer = true indeedJobBrowserHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor indeedJobBrowserHost.isHidden = true mainHost.addSubview(indeedJobBrowserHost) mainOverlay.orientation = .vertical mainOverlay.spacing = 0 mainOverlay.alignment = .centerX mainOverlay.distribution = .fill mainOverlay.translatesAutoresizingMaskIntoConstraints = false mainOverlay.setContentHuggingPriority(.defaultLow, for: .vertical) greetingLabel.font = .systemFont(ofSize: 28, weight: .bold) greetingLabel.textColor = Theme.welcomeHeroHeadingBlue greetingLabel.alignment = .center greetingLabel.maximumNumberOfLines = 1 subtitleLabel.font = .systemFont(ofSize: 14, weight: .regular) subtitleLabel.textColor = Theme.welcomeHeroSubtitleText subtitleLabel.alignment = .center subtitleLabel.maximumNumberOfLines = 2 let topInset = NSView() topInset.translatesAutoresizingMaskIntoConstraints = false topInset.heightAnchor.constraint(equalToConstant: 12).isActive = true configureSearchBar() configureChatViews() welcomeHeroHost.translatesAutoresizingMaskIntoConstraints = false welcomeHeroBackgroundView.translatesAutoresizingMaskIntoConstraints = false welcomeHeroBackgroundView.waveTint = Theme.welcomeHeroWaveTint welcomeLogoWell.translatesAutoresizingMaskIntoConstraints = false welcomeLogoWell.wantsLayer = true welcomeLogoWell.layer?.backgroundColor = Theme.welcomeHeroIconWell.cgColor welcomeLogoWell.layer?.cornerRadius = 28 if #available(macOS 11.0, *) { welcomeLogoWell.layer?.cornerCurve = .continuous } welcomeLogoView.translatesAutoresizingMaskIntoConstraints = false welcomeLogoWell.addSubview(welcomeLogoView) let welcomeHeroContent = NSStackView(views: [welcomeLogoWell, greetingLabel, subtitleLabel]) welcomeHeroContent.orientation = .vertical welcomeHeroContent.spacing = 8 welcomeHeroContent.alignment = .centerX welcomeHeroContent.translatesAutoresizingMaskIntoConstraints = false welcomeHeroHost.addSubview(welcomeHeroBackgroundView) welcomeHeroHost.addSubview(welcomeHeroContent) welcomeHeroHost.setContentHuggingPriority(.defaultHigh, for: .vertical) NSLayoutConstraint.activate([ welcomeHeroBackgroundView.leadingAnchor.constraint(equalTo: welcomeHeroHost.leadingAnchor), welcomeHeroBackgroundView.trailingAnchor.constraint(equalTo: welcomeHeroHost.trailingAnchor), welcomeHeroBackgroundView.topAnchor.constraint(equalTo: welcomeHeroHost.topAnchor), welcomeHeroBackgroundView.bottomAnchor.constraint(equalTo: welcomeHeroHost.bottomAnchor), welcomeHeroContent.centerXAnchor.constraint(equalTo: welcomeHeroHost.centerXAnchor), welcomeHeroContent.leadingAnchor.constraint(greaterThanOrEqualTo: welcomeHeroHost.leadingAnchor, constant: 16), welcomeHeroContent.trailingAnchor.constraint(lessThanOrEqualTo: welcomeHeroHost.trailingAnchor, constant: -16), welcomeHeroContent.topAnchor.constraint(equalTo: welcomeHeroHost.topAnchor, constant: 4), welcomeHeroContent.bottomAnchor.constraint(equalTo: welcomeHeroHost.bottomAnchor, constant: -2), welcomeLogoWell.widthAnchor.constraint(equalToConstant: 56), welcomeLogoWell.heightAnchor.constraint(equalToConstant: 56), welcomeLogoView.centerXAnchor.constraint(equalTo: welcomeLogoWell.centerXAnchor), welcomeLogoView.centerYAnchor.constraint(equalTo: welcomeLogoWell.centerYAnchor) ]) configureFeatureShortcutCards() featureCardsRow.setContentHuggingPriority(.defaultHigh, for: .vertical) let heroCardsSpacer = NSView() heroCardsSpacer.translatesAutoresizingMaskIntoConstraints = false heroCardsSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true let midSpacer = NSView() midSpacer.translatesAutoresizingMaskIntoConstraints = false midSpacer.heightAnchor.constraint(equalToConstant: 6).isActive = true let chatTopSpacer = NSView() chatTopSpacer.translatesAutoresizingMaskIntoConstraints = false chatTopSpacer.heightAnchor.constraint(equalToConstant: 8).isActive = true let chatBottomSpacer = NSView() chatBottomSpacer.translatesAutoresizingMaskIntoConstraints = false chatBottomSpacer.heightAnchor.constraint(equalToConstant: 8).isActive = true mainOverlay.addArrangedSubview(topInset) mainOverlay.addArrangedSubview(welcomeHeroHost) mainOverlay.addArrangedSubview(heroCardsSpacer) mainOverlay.addArrangedSubview(featureCardsRow) mainOverlay.addArrangedSubview(midSpacer) mainOverlay.addArrangedSubview(chatTopSpacer) let chatHeaderRow = NSStackView() chatHeaderRow.orientation = .horizontal chatHeaderRow.spacing = 8 chatHeaderRow.alignment = .centerY chatHeaderRow.distribution = .fill chatHeaderRow.translatesAutoresizingMaskIntoConstraints = false let chatHeaderLeadingSpacer = NSView() chatHeaderLeadingSpacer.translatesAutoresizingMaskIntoConstraints = false chatHeaderLeadingSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) clearChatButton.translatesAutoresizingMaskIntoConstraints = false clearChatButton.bezelStyle = .rounded clearChatButton.controlSize = .regular clearChatButton.font = .systemFont(ofSize: 13, weight: .medium) clearChatButton.isBordered = false clearChatButton.wantsLayer = true clearChatButton.layer?.cornerRadius = 8 clearChatButton.layer?.backgroundColor = Theme.brandBlue.cgColor clearChatButton.contentTintColor = Theme.proCTAText clearChatButton.focusRingType = .none clearChatButton.target = self clearChatButton.action = #selector(didTapClearChat) clearChatButton.toolTip = L("Remove all messages and start a new conversation") clearChatButton.setContentHuggingPriority(.required, for: .horizontal) let clearChatHorizontalPad: CGFloat = 12 let clearChatMinWidth = clearChatButton.intrinsicContentSize.width + clearChatHorizontalPad * 2 NSLayoutConstraint.activate([ clearChatButton.heightAnchor.constraint(equalToConstant: 30), clearChatButton.widthAnchor.constraint(greaterThanOrEqualToConstant: clearChatMinWidth) ]) chatHeaderRow.addArrangedSubview(chatHeaderLeadingSpacer) chatHeaderRow.addArrangedSubview(clearChatButton) mainOverlay.addArrangedSubview(chatHeaderRow) mainOverlay.addArrangedSubview(chatScrollView) mainOverlay.addArrangedSubview(chatBottomSpacer) mainOverlay.addArrangedSubview(searchBarColumn) panelsRow.addSubview(sidebar) panelsRow.addSubview(mainHost) NSLayoutConstraint.activate([ chromeContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6), chromeContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6), chromeContainer.topAnchor.constraint(equalTo: topAnchor, constant: 6), chromeContainer.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6), panelsRow.leadingAnchor.constraint(equalTo: chromeContainer.leadingAnchor, constant: 6), panelsRow.trailingAnchor.constraint(equalTo: chromeContainer.trailingAnchor, constant: -8), panelsRow.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 2), panelsRow.bottomAnchor.constraint(equalTo: chromeContainer.bottomAnchor, constant: -8), sidebar.leadingAnchor.constraint(equalTo: panelsRow.leadingAnchor), sidebar.topAnchor.constraint(equalTo: panelsRow.topAnchor), sidebar.bottomAnchor.constraint(equalTo: panelsRow.bottomAnchor), sidebar.widthAnchor.constraint(equalToConstant: 218), mainHost.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: 6), mainHost.trailingAnchor.constraint(equalTo: panelsRow.trailingAnchor), mainHost.topAnchor.constraint(equalTo: panelsRow.topAnchor), mainHost.bottomAnchor.constraint(equalTo: panelsRow.bottomAnchor), 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), indeedJobBrowserHost.leadingAnchor.constraint(equalTo: mainHost.leadingAnchor), indeedJobBrowserHost.trailingAnchor.constraint(equalTo: mainHost.trailingAnchor), indeedJobBrowserHost.topAnchor.constraint(equalTo: mainHost.topAnchor), indeedJobBrowserHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24), searchBarColumn.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92), featureCardsRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92), chatHeaderRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92), chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92), welcomeHeroHost.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor), welcomeHeroHost.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor), 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), welcomeHeroContent.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, constant: -32) ]) registerSubscriptionObserverOnce() refreshLocalizedStrings() } private func registerSubscriptionObserverOnce() { guard subscriptionObserver == nil else { return } subscriptionObserver = NotificationCenter.default.addObserver( forName: .subscriptionStatusDidChange, object: nil, queue: .main ) { [weak self] _ in self?.applyProSubscriptionToSidebar() } } private func applyProSubscriptionToSidebar() { let active = SubscriptionStore.shared.isProActive sidebarUpgradeCard?.isHidden = false guard let headline = sidebarUpgradeHeadline, let upgradeDescription = sidebarUpgradeDescription, let upgradeButton = sidebarUpgradeButton else { return } let descriptionWidth: CGFloat = 158 if active { headline.stringValue = L("You're on Pro") upgradeDescription.stringValue = L("Manage billing, renewals, and plans in Premium.") upgradeDescription.preferredMaxLayoutWidth = descriptionWidth upgradeButton.title = L("Manage Subscription") } else { headline.stringValue = L("Upgrade to Pro") upgradeDescription.stringValue = L("Unlimited AI matches, smart alerts, and interview prep—all in one place.") upgradeDescription.preferredMaxLayoutWidth = descriptionWidth upgradeButton.title = L("Try Pro") } updateFreeJobSearchQuotaLabel() } private func updateFreeJobSearchQuotaLabel() { let isPro = SubscriptionStore.shared.isProActive if isPro { freeJobSearchQuotaLabel.isHidden = true freeJobSearchQuotaLabel.stringValue = "" return } let remaining = FreeTierJobSearchQuota.remainingUserMessages(isProActive: false) freeJobSearchQuotaLabel.isHidden = false freeJobSearchQuotaLabel.stringValue = remaining == 1 ? L("1 reply left") : String(format: L("%d replies left"), remaining) } /// Returns `false` and presents the paywall when the user does not have an active Pro subscription. @discardableResult private func ensureProAccess() -> Bool { guard SubscriptionStore.shared.isProActive else { presentPremiumPlansSheet() return false } return true } /// Home AI job search: Pro is unlimited; free users may send up to `FreeTierJobSearchQuota.maxUserMessages` user messages. @discardableResult private func ensureProAccessForJobSearch() -> Bool { if SubscriptionStore.shared.isProActive { return true } guard FreeTierJobSearchQuota.canSendAnotherMessage(isProActive: false) else { presentPremiumPlansSheet() return false } return true } private func presentPremiumPlansSheet() { guard let hostWindow = window else { return } if isPreparingPremiumPlansSheet { return } isPreparingPremiumPlansSheet = true Task { @MainActor [weak self] in defer { self?.isPreparingPremiumPlansSheet = false } guard let self else { return } if self.premiumPlansWindowController == nil { self.premiumPlansWindowController = PremiumPlansWindowController() } guard let controller = self.premiumPlansWindowController else { return } await controller.prepareForPresentation() guard let paywallWindow = controller.window else { return } if hostWindow.attachedSheet === paywallWindow { return } paywallWindow.styleMask = [.borderless, .closable, .resizable] paywallWindow.isOpaque = true paywallWindow.backgroundColor = PremiumPlansWindowController.paywallSheetBackground let hostContentRect = hostWindow.contentRect(forFrameRect: hostWindow.frame) let overscan = PremiumSheetLayout.overscanPerEdge var expandedContentRect = hostContentRect.insetBy(dx: -overscan, dy: -overscan) expandedContentRect.size.height += PremiumSheetLayout.overscanExtraTop let paywallFrame = paywallWindow.frameRect(forContentRect: expandedContentRect) paywallWindow.setFrame(paywallFrame, display: false) let lockedSize = paywallWindow.frame.size paywallWindow.minSize = lockedSize paywallWindow.maxSize = lockedSize await hostWindow.beginSheet(paywallWindow) } } private func configureFeatureShortcutCards() { featureCardsRow.orientation = .horizontal featureCardsRow.spacing = 16 featureCardsRow.distribution = .fillEqually featureCardsRow.alignment = .top featureCardsRow.translatesAutoresizingMaskIntoConstraints = false let specs: [(symbol: String, titleKey: String, subtitleKey: 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: L(spec.titleKey), subtitle: L(spec.subtitleKey), target: self, action: spec.action ) featureCardsRow.addArrangedSubview(card) } } private func configureChatViews() { 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) // 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 var prefersReducedMotion: Bool { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion } 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 AppMarketingLinks.indeedBrandName } 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: L("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 ? L("Saved") : L("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: L("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 ? L("Remove from saved") : L("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 } presentIndeedJobBrowser(url: Self.resolvedIndeedApplyURL(for: job)) } /// Opens the listing’s Indeed URL when present (`/viewjob`, `/rc/clk`, `/jobs`, …). Falls back to `/jobs?q=` only when the URL is missing or not on Indeed. private static func resolvedIndeedApplyURL(for job: JobListing) -> URL { let title = job.title.trimmingCharacters(in: .whitespacesAndNewlines) let raw = job.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !raw.isEmpty, let directURL = URL(string: raw), let host = directURL.host?.lowercased(), isIndeedApplyHost(host) { return directURL } var preferredHost: String? if !raw.isEmpty, let host = URLComponents(string: raw)?.host { let lower = host.lowercased() if isIndeedApplyHost(lower) { preferredHost = host } } return indeedJobsSearchURL(title: title, preferredHost: preferredHost) } private static func indeedJobsSearchURL(title: String, preferredHost: String?) -> URL { let allowed = CharacterSet.urlQueryAllowed let q = title.addingPercentEncoding(withAllowedCharacters: allowed) ?? "" let host: String if let preferredHost, isIndeedApplyHost(preferredHost.lowercased()) { host = preferredHost } else { host = "www.indeed.com" } if let url = URL(string: "https://\(host)/jobs?q=\(q)") { return url } return URL(string: "https://www.indeed.com/jobs?q=\(q)")! } private static func isIndeedApplyHost(_ host: String) -> Bool { if host == "indeed.com" { return true } if host.hasPrefix("indeed.") { return true } return host.contains(".indeed.") } private static let indeedBrowseHomeURL = URL(string: "https://www.indeed.com/")! private static let externalIndeedAppURLSchemes = ["indeed://", "indeedjobs://"] private func selectIndeedSidebar() { isIndeedSidebarSelected = true if !isIndeedJobBrowserPresented { openIndeedFromSidebar() } configureSidebar() updateMainContentVisibility() } /// Opens the installed Indeed app when a handler is registered; otherwise loads Indeed in the embedded browser. private func openIndeedFromSidebar() { for scheme in Self.externalIndeedAppURLSchemes { guard let url = URL(string: scheme) else { continue } if NSWorkspace.shared.urlForApplication(toOpen: url) != nil { NSWorkspace.shared.open(url) return } } presentIndeedJobBrowser(url: Self.indeedBrowseHomeURL) } private func presentIndeedJobBrowser(url: URL) { guard let parentVC = hostingViewController else { return } if indeedJobBrowserViewController == nil { let vc = IndeedJobBrowserViewController() vc.onDismissEmbedded = { [weak self] in self?.dismissIndeedJobBrowserEmbedded() } vc.embed(in: indeedJobBrowserHost, parent: parentVC) indeedJobBrowserViewController = vc } indeedJobBrowserViewController?.loadPage(url) isIndeedJobBrowserPresented = true updateMainContentVisibility() } private func dismissIndeedJobBrowserEmbedded() { guard isIndeedJobBrowserPresented else { return } isIndeedJobBrowserPresented = false if isIndeedSidebarSelected { isIndeedSidebarSelected = false configureSidebar() } updateMainContentVisibility() } private var hostingViewController: NSViewController? { var responder: NSResponder? = self while let current = responder { if let viewController = current as? NSViewController { return viewController } responder = current.nextResponder } return nil } @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 ? L("Saved") : L("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 searchBarColumn.orientation = .vertical searchBarColumn.spacing = 6 searchBarColumn.alignment = .width searchBarColumn.distribution = .fill searchBarColumn.translatesAutoresizingMaskIntoConstraints = false searchBarColumn.setContentHuggingPriority(.defaultHigh, for: .vertical) freeJobSearchQuotaLabel.font = .systemFont(ofSize: 11, weight: .medium) freeJobSearchQuotaLabel.textColor = Theme.secondaryText freeJobSearchQuotaLabel.alignment = .center freeJobSearchQuotaLabel.lineBreakMode = .byTruncatingTail freeJobSearchQuotaLabel.maximumNumberOfLines = 1 freeJobSearchQuotaLabel.setContentHuggingPriority(.required, for: .vertical) freeJobSearchQuotaLabel.setContentCompressionResistancePriority(.required, for: .vertical) searchBarColumn.addArrangedSubview(searchBarShadowHost) searchBarColumn.addArrangedSubview(freeJobSearchQuotaLabel) 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: L("Ask AI")) jobSearchIcon.contentTintColor = Theme.brandBlue configureField(jobKeywordsField, placeholder: L("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 let sendContentPadding: CGFloat = 16 findJobsCTAPill.translatesAutoresizingMaskIntoConstraints = false findJobsCTAPill.wantsLayer = true findJobsCTAPill.layer?.cornerRadius = ctaCorner if #available(macOS 11.0, *) { findJobsCTAPill.layer?.cornerCurve = .continuous } findJobsCTAPill.layer?.masksToBounds = true findJobsCTAPill.layer?.backgroundColor = Theme.brandBlue.cgColor sendIconView.translatesAutoresizingMaskIntoConstraints = false sendIconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold) sendIconView.image = NSImage(systemSymbolName: "paperplane.fill", accessibilityDescription: nil) sendIconView.contentTintColor = Theme.proCTAText sendLabel.font = .systemFont(ofSize: 14, weight: .semibold) sendLabel.textColor = Theme.proCTAText sendLabel.alignment = .center sendContentStack.orientation = .horizontal sendContentStack.spacing = 6 sendContentStack.alignment = .centerY sendContentStack.translatesAutoresizingMaskIntoConstraints = false sendContentStack.addArrangedSubview(sendIconView) sendContentStack.addArrangedSubview(sendLabel) findJobsButton.translatesAutoresizingMaskIntoConstraints = false findJobsButton.title = "" findJobsButton.isBordered = false findJobsButton.bezelStyle = .inline findJobsButton.wantsLayer = true findJobsButton.layer?.backgroundColor = NSColor.clear.cgColor findJobsButton.focusRingType = .none findJobsButton.pointerCursor = true findJobsButton.target = self findJobsButton.action = #selector(didSubmitSearch) findJobsButton.setAccessibilityLabel(L("Send")) findJobsButton.hoverHandler = { [weak self] hovering in self?.findJobsCTAPill.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor } findJobsCTAHost.addSubview(findJobsCTAPill) findJobsCTAHost.addSubview(sendContentStack) findJobsCTAHost.addSubview(findJobsButton) findJobsCTAHost.setContentHuggingPriority(.required, for: .horizontal) findJobsCTAHost.setContentCompressionResistancePriority(.required, for: .horizontal) NSLayoutConstraint.activate([ findJobsCTAPill.leadingAnchor.constraint(equalTo: findJobsCTAHost.leadingAnchor), findJobsCTAPill.trailingAnchor.constraint(equalTo: findJobsCTAHost.trailingAnchor), findJobsCTAPill.topAnchor.constraint(equalTo: findJobsCTAHost.topAnchor), findJobsCTAPill.bottomAnchor.constraint(equalTo: findJobsCTAHost.bottomAnchor), sendContentStack.centerXAnchor.constraint(equalTo: findJobsCTAPill.centerXAnchor), sendContentStack.centerYAnchor.constraint(equalTo: findJobsCTAPill.centerYAnchor), sendContentStack.leadingAnchor.constraint(greaterThanOrEqualTo: findJobsCTAPill.leadingAnchor, constant: sendContentPadding), sendContentStack.trailingAnchor.constraint(lessThanOrEqualTo: findJobsCTAPill.trailingAnchor, constant: -sendContentPadding), sendIconView.widthAnchor.constraint(equalToConstant: 14), sendIconView.heightAnchor.constraint(equalToConstant: 14), findJobsButton.leadingAnchor.constraint(equalTo: findJobsCTAHost.leadingAnchor), findJobsButton.trailingAnchor.constraint(equalTo: findJobsCTAHost.trailingAnchor), 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 = 10 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(greaterThanOrEqualTo: sendContentStack.widthAnchor, constant: sendContentPadding * 2) ]) searchCard.hoverHandler = nil updateFreeJobSearchQuotaLabel() } 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 nonHomeHost.userInterfaceLayoutDirection = .leftToRight nonHomeGenericContainer.translatesAutoresizingMaskIntoConstraints = false savedJobsPageContainer.translatesAutoresizingMaskIntoConstraints = false settingsPageContainer.translatesAutoresizingMaskIntoConstraints = false cvMakerPageContainer.translatesAutoresizingMaskIntoConstraints = false profilePageContainer.translatesAutoresizingMaskIntoConstraints = false nonHomeHost.addSubview(nonHomeGenericContainer) nonHomeHost.addSubview(savedJobsPageContainer) nonHomeHost.addSubview(settingsPageContainer) nonHomeHost.addSubview(cvMakerPageContainer) nonHomeHost.addSubview(profilePageContainer) 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), cvMakerPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor), cvMakerPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor), cvMakerPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor), cvMakerPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor), profilePageContainer.leftAnchor.constraint(equalTo: nonHomeHost.leftAnchor), profilePageContainer.rightAnchor.constraint(equalTo: nonHomeHost.rightAnchor), profilePageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor), profilePageContainer.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 = L("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() configureCVMakerPage() configureProfilePage() } private func configureCVMakerPage() { cvMakerPageContainer.wantsLayer = true cvMakerPageContainer.layer?.backgroundColor = Theme.mainHostBackground.cgColor cvMakerPageContainer.isHidden = true cvMakerPageView.translatesAutoresizingMaskIntoConstraints = false cvMakerPageContainer.addSubview(cvMakerPageView) NSLayoutConstraint.activate([ cvMakerPageView.leadingAnchor.constraint(equalTo: cvMakerPageContainer.leadingAnchor), cvMakerPageView.trailingAnchor.constraint(equalTo: cvMakerPageContainer.trailingAnchor), cvMakerPageView.topAnchor.constraint(equalTo: cvMakerPageContainer.topAnchor), cvMakerPageView.bottomAnchor.constraint(equalTo: cvMakerPageContainer.bottomAnchor) ]) cvMakerPageView.onContinueToProfileSelection = { [weak self] template in guard let self, self.ensureProAccess() else { return } self.pendingCVTemplate = template self.profilesListPageView.setPendingCVTemplateDisplayName(template.name) self.selectProfileSidebarForCVMakerFlow() } } /// Switches the main panel to **Profile** so the user can pick a saved CV profile after choosing a template in CV Maker. private func selectProfileSidebarForCVMakerFlow() { guard let index = currentSidebarItems.firstIndex(where: { $0.title == L("Profile") }) else { return } selectSidebarItem(at: index) } private func configureProfilePage() { profilePageContainer.wantsLayer = true profilePageContainer.layer?.backgroundColor = Theme.mainHostBackground.cgColor profilePageContainer.isHidden = true profilePageContainer.userInterfaceLayoutDirection = .leftToRight profilesListPageView.translatesAutoresizingMaskIntoConstraints = false myProfilePageView.translatesAutoresizingMaskIntoConstraints = false cvFilledPreviewPageView.translatesAutoresizingMaskIntoConstraints = false profilePageContainer.addSubview(profilesListPageView) profilePageContainer.addSubview(myProfilePageView) profilePageContainer.addSubview(cvFilledPreviewPageView) NSLayoutConstraint.activate([ profilesListPageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor), profilesListPageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor), profilesListPageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor), profilesListPageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor), myProfilePageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor), myProfilePageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor), myProfilePageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor), myProfilePageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor), cvFilledPreviewPageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor), cvFilledPreviewPageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor), cvFilledPreviewPageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor), cvFilledPreviewPageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor) ]) profilesListPageView.onAddProfile = { [weak self] in self?.presentProfileEditor(existingID: nil) } profilesListPageView.onEditProfile = { [weak self] id in self?.presentProfileEditor(existingID: id) } profilesListPageView.onDeleteProfile = { [weak self] id in self?.confirmDeleteProfile(id: id) } profilesListPageView.onBuildCVWithProfile = { [weak self] profileID in guard let self, let template = self.pendingCVTemplate, let profile = SavedProfilesStore.profile(id: profileID) else { return } self.presentCVDocumentPreview(profile: profile, template: template) } cvFilledPreviewPageView.onDismiss = { [weak self] in self?.dismissCVDocumentPreview() } myProfilePageView.onDismiss = { [weak self] in self?.dismissProfileEditor() } isProfileEditorPresented = false isCVDocumentPreviewPresented = false profilesListPageView.isHidden = false myProfilePageView.isHidden = true cvFilledPreviewPageView.isHidden = true profilesListPageView.reloadFromStore() } private func presentCVDocumentPreview(profile: SavedProfile, template: CVTemplate) { guard ensureProAccess() else { return } isCVDocumentPreviewPresented = true cvFilledPreviewPageView.configure(profile: profile, template: template) cvFilledPreviewPageView.isHidden = false profilesListPageView.isHidden = true myProfilePageView.isHidden = true } private func dismissCVDocumentPreview() { isCVDocumentPreviewPresented = false cvFilledPreviewPageView.isHidden = true profilesListPageView.reloadFromStore() profilesListPageView.isHidden = false myProfilePageView.isHidden = true } private func presentProfileEditor(existingID: UUID?) { guard ensureProAccess() else { return } if isCVDocumentPreviewPresented { dismissCVDocumentPreview() } isProfileEditorPresented = true if let id = existingID, let profile = SavedProfilesStore.profile(id: id) { myProfilePageView.loadSavedProfile(profile) } else { myProfilePageView.prepareNewProfile() } profilesListPageView.isHidden = true myProfilePageView.isHidden = false } private func dismissProfileEditor() { isProfileEditorPresented = false profilesListPageView.reloadFromStore() profilesListPageView.isHidden = false myProfilePageView.isHidden = true } private func confirmDeleteProfile(id: UUID) { let displayName = SavedProfilesStore.profile(id: id)?.profileDisplayName ?? "" let alert = NSAlert() alert.messageText = L("Delete this profile?") alert.informativeText = displayName.isEmpty ? L("This profile will be removed from this Mac.") : String(format: L("“%@” will be removed from this Mac."), displayName) alert.alertStyle = .warning alert.addButton(withTitle: L("Cancel")) alert.addButton(withTitle: L("Delete")) guard let window = window else { let response = alert.runModal() if response == .alertSecondButtonReturn { SavedProfilesStore.delete(id: id) profilesListPageView.reloadFromStore() } return } alert.beginSheetModal(for: window) { [weak self] response in guard let self else { return } if response == .alertSecondButtonReturn { SavedProfilesStore.delete(id: id) if self.isProfileEditorPresented { self.dismissProfileEditor() } else { self.profilesListPageView.reloadFromStore() } } } } 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 appearanceTitle = NSTextField(labelWithString: L("Appearance")) appearanceTitle.font = .systemFont(ofSize: 12, weight: .semibold) appearanceTitle.textColor = Theme.secondaryText appearanceTitle.alignment = .left appearanceTitle.identifier = NSUserInterfaceItemIdentifier("\(SettingsAppearanceID.sectionHeader).Appearance") let themeSegment = makeAppearanceModeSegment() appearanceModeSegment = themeSegment let langPopUp = makeLanguagePopUp() languagePopUp = langPopUp let appearanceSection = makeSettingsSection(rows: [ makeSettingsRow(localizationKey: "Theme", systemImage: "circle.lefthalf.filled", accessory: themeSegment, tapAction: nil), makeSettingsRow(localizationKey: "Language", systemImage: "character.bubble", accessory: langPopUp, tapAction: nil) ]) let appearanceStack = NSStackView(views: [appearanceTitle, appearanceSection]) appearanceStack.orientation = .vertical appearanceStack.spacing = 14 appearanceStack.alignment = .leading appearanceStack.translatesAutoresizingMaskIntoConstraints = false let settingsSection = makeSettingsSection(rows: [ makeSettingsRow(localizationKey: "Share App", systemImage: "square.and.arrow.up", accessory: nil, tapAction: #selector(didTapShareApp)), makeSettingsRow(localizationKey: "More Apps", systemImage: "square.grid.2x2", accessory: nil, tapAction: #selector(didTapMoreApps)) ]) let aboutTitle = NSTextField(labelWithString: L("About")) aboutTitle.font = .systemFont(ofSize: 12, weight: .semibold) aboutTitle.textColor = Theme.secondaryText aboutTitle.alignment = .left aboutTitle.identifier = NSUserInterfaceItemIdentifier("\(SettingsAppearanceID.sectionHeader).About") let aboutSection = makeSettingsSection(rows: [ makeSettingsRow(localizationKey: "Website", systemImage: "globe", accessory: nil, tapAction: #selector(didTapWebsite)), makeSettingsRow(localizationKey: "Support", systemImage: "questionmark.circle", accessory: nil, tapAction: #selector(didTapSupport)), makeSettingsRow(localizationKey: "Terms of Use", systemImage: "doc.text", accessory: nil, tapAction: #selector(didTapTermsOfUse)), makeSettingsRow(localizationKey: "Privacy Policy", systemImage: "shield", accessory: nil, tapAction: #selector(didTapPrivacyPolicy)) ]) let aboutStack = NSStackView(views: [aboutTitle, aboutSection]) aboutStack.orientation = .vertical aboutStack.spacing = 14 aboutStack.alignment = .leading aboutStack.translatesAutoresizingMaskIntoConstraints = false contentStack.addArrangedSubview(appearanceStack) 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), appearanceStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor), appearanceSection.widthAnchor.constraint(equalTo: appearanceStack.widthAnchor), 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 makeAppearanceModeSegment() -> NSSegmentedControl { let segment = NSSegmentedControl( labels: [L("System"), L("Light"), L("Dark")], trackingMode: .selectOne, target: self, action: #selector(appearanceModeChanged(_:)) ) segment.translatesAutoresizingMaskIntoConstraints = false segment.segmentStyle = .automatic segment.selectedSegment = AppAppearanceManager.shared.mode.segmentIndex segment.setContentHuggingPriority(.required, for: .horizontal) segment.setContentCompressionResistancePriority(.required, for: .horizontal) return segment } @objc private func appearanceModeChanged(_ sender: NSSegmentedControl) { guard let mode = AppAppearanceManager.Mode(segmentIndex: sender.selectedSegment) else { return } AppAppearanceManager.shared.mode = mode } private func makeLanguagePopUp() -> NSPopUpButton { let popup = NSPopUpButton(frame: .zero, pullsDown: false) popup.translatesAutoresizingMaskIntoConstraints = false popup.removeAllItems() for language in AppLanguage.allCases { popup.addItem(withTitle: language.localizedDisplayName) popup.lastItem?.representedObject = language.localeIdentifier } let currentCode = AppLanguageManager.shared.current.localeIdentifier if let index = AppLanguage.allCases.firstIndex(where: { $0.localeIdentifier == currentCode }) { popup.selectItem(at: index) } popup.target = self popup.action = #selector(languageChanged(_:)) popup.isEnabled = !AppLanguage.allCases.isEmpty popup.setContentHuggingPriority(.required, for: .horizontal) popup.setContentCompressionResistancePriority(.required, for: .horizontal) return popup } @objc private func languageChanged(_ sender: NSPopUpButton) { guard let code = sender.selectedItem?.representedObject as? String else { return } AppLanguageManager.shared.setLanguage(code: code) } 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.identifier = NSUserInterfaceItemIdentifier(SettingsAppearanceID.section) 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.identifier = NSUserInterfaceItemIdentifier(SettingsAppearanceID.divider) 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(localizationKey: String, systemImage: String, accessory: NSView?, tapAction: Selector? = nil) -> NSView { let row = NSView() row.translatesAutoresizingMaskIntoConstraints = false row.wantsLayer = true let iconTile = NSView() iconTile.translatesAutoresizingMaskIntoConstraints = false iconTile.wantsLayer = true iconTile.identifier = NSUserInterfaceItemIdentifier(SettingsAppearanceID.iconTile) 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: L(localizationKey)) icon.contentTintColor = Theme.brandBlue let titleLabel = NSTextField(labelWithString: L(localizationKey)) titleLabel.font = .systemFont(ofSize: 14, weight: .medium) titleLabel.textColor = Theme.primaryText titleLabel.alignment = .left titleLabel.identifier = NSUserInterfaceItemIdentifier("\(SettingsAppearanceID.rowTitle).\(localizationKey)") 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) ]) if let tapAction { let rowButton = NSButton(title: "", target: self, action: tapAction) rowButton.translatesAutoresizingMaskIntoConstraints = false rowButton.isBordered = false rowButton.bezelStyle = .regularSquare rowButton.setButtonType(.momentaryChange) rowButton.focusRingType = .none rowButton.wantsLayer = true rowButton.appearance = NSApp.appearance rowButton.layer?.backgroundColor = .clear row.addSubview(rowButton) NSLayoutConstraint.activate([ rowButton.leadingAnchor.constraint(equalTo: row.leadingAnchor), rowButton.trailingAnchor.constraint(equalTo: row.trailingAnchor), rowButton.topAnchor.constraint(equalTo: row.topAnchor), rowButton.bottomAnchor.constraint(equalTo: row.bottomAnchor) ]) } return row } private func reloadSavedJobsListings() { savedJobsStack.arrangedSubviews.forEach { savedJobsStack.removeArrangedSubview($0) $0.removeFromSuperview() } if savedJobOrder.isEmpty { savedJobsPageSubtitleLabel.stringValue = L("Save jobs from Home to see them here.") let empty = NSTextField(wrappingLabelWithString: L("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 == 1 ? L("1 saved position") : String(format: L("%d saved positions"), savedJobOrder.count) 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 == L("Saved Jobs") } private func isHomeSidebarIndex(_ index: Int) -> Bool { guard index >= 0, index < currentSidebarItems.count else { return false } return currentSidebarItems[index].title == L("Home") } private func isSettingsSidebarIndex(_ index: Int) -> Bool { guard index >= 0, index < currentSidebarItems.count else { return false } return currentSidebarItems[index].title == L("Settings") } private func isCVMakerSidebarIndex(_ index: Int) -> Bool { guard index >= 0, index < currentSidebarItems.count else { return false } return currentSidebarItems[index].title == L("CV Maker") } private func isProfileSidebarIndex(_ index: Int) -> Bool { guard index >= 0, index < currentSidebarItems.count else { return false } return currentSidebarItems[index].title == L("Profile") } private func updateMainContentVisibility() { if isIndeedJobBrowserPresented { mainOverlay.isHidden = true nonHomeHost.isHidden = true indeedJobBrowserHost.isHidden = false return } indeedJobBrowserHost.isHidden = true let home = isHomeSidebarIndex(selectedSidebarIndex) let savedJobs = isSavedJobsSidebarIndex(selectedSidebarIndex) let settings = isSettingsSidebarIndex(selectedSidebarIndex) let cvMaker = isCVMakerSidebarIndex(selectedSidebarIndex) let profile = isProfileSidebarIndex(selectedSidebarIndex) mainOverlay.isHidden = !home nonHomeHost.isHidden = home nonHomeGenericContainer.isHidden = savedJobs || settings || cvMaker || profile savedJobsPageContainer.isHidden = !savedJobs settingsPageContainer.isHidden = !settings cvMakerPageContainer.isHidden = !cvMaker profilePageContainer.isHidden = !profile if !profile { isProfileEditorPresented = false isCVDocumentPreviewPresented = false pendingCVTemplate = nil profilesListPageView.setPendingCVTemplateDisplayName(nil) cvFilledPreviewPageView.isHidden = true profilesListPageView.isHidden = false myProfilePageView.isHidden = true } if profile, !isProfileEditorPresented { if isCVDocumentPreviewPresented { profilesListPageView.isHidden = true myProfilePageView.isHidden = true cvFilledPreviewPageView.isHidden = false } else { profilesListPageView.reloadFromStore() profilesListPageView.isHidden = false myProfilePageView.isHidden = true cvFilledPreviewPageView.isHidden = true } } if !home, selectedSidebarIndex < currentSidebarItems.count { if savedJobs { reloadSavedJobsListings() } else if settings || cvMaker || profile { window?.makeFirstResponder(nil) } else { nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title } } } /// Restores the main job-search field when returning to Home; chat history is kept until the user chooses **Clear chat**. private func applyHomeState() { jobKeywordsField.stringValue = "" window?.makeFirstResponder(nil) } @objc private func didTapClearChat() { guard !isAwaitingResponse else { return } 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() { guard ensureProAccessForJobSearch() else { return } 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() { selectFeatureShortcut(.role) focusSearchField(seed: L("Find roles similar to: ")) } @objc private func didTapFeatureCompany() { selectFeatureShortcut(.company) focusSearchField(seed: L("Find jobs at company: ")) } @objc private func didTapFeatureSkill() { selectFeatureShortcut(.skill) focusSearchField(seed: L("Find jobs that require skill: ")) } private func selectFeatureShortcut(_ shortcut: FeatureShortcut) { for (index, view) in featureCardsRow.arrangedSubviews.enumerated() { guard let card = view as? FeatureShortcutCardView else { continue } card.isSelected = (index == shortcut.rawValue) } } @objc private func didTapShareApp(_ sender: NSButton) { presentAppShareMenu(anchoredTo: sender) } /// Shows the macOS share menu (Mail, Messages, AirDrop, Copy Link, etc.) with the app link. private func presentAppShareMenu(anchoredTo sender: NSButton) { guard let row = sender.superview else { return } let items = AppMarketingLinks.shareItems guard !items.isEmpty else { return } let picker = NSSharingServicePicker(items: items) picker.delegate = self appSharePicker = picker // Match `makeSettingsRow` layout: 16pt leading inset + 38pt icon tile — anchor the // popover beside the share icon, not the horizontal center of the full-width row. let iconTileInset: CGFloat = 16 let iconTileSize: CGFloat = 38 let anchorRect = NSRect( x: iconTileInset, y: row.bounds.minY + 6, width: iconTileSize, height: max(row.bounds.height - 12, 1) ) picker.show(relativeTo: anchorRect, of: row, preferredEdge: .minY) } func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, delegateFor sharingService: NSSharingService) -> Any? { self } func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose sharingService: NSSharingService?) { appSharePicker = nil } func sharingService(_ sharingService: NSSharingService, willShareItems items: [Any]) -> [Any] { if sharingService == NSSharingService(named: .composeEmail) { sharingService.subject = AppMarketingLinks.shareEmailSubject } return items } @objc private func didTapMoreApps() { guard let url = AppMarketingLinks.developerAppsURL else { presentAppMarketingConfigurationAlert(feature: L("More Apps")) return } NSWorkspace.shared.open(url) } private func presentAppMarketingConfigurationAlert(feature: String) { let alert = NSAlert() alert.messageText = String(format: L("%@ isn’t available yet"), feature) alert.informativeText = L("Add your Mac App Store IDs in the target’s build settings:\n• AppStoreAppID — numeric app ID from App Store Connect\n• AppStoreDeveloperID — numeric developer ID (for your other apps page)") alert.alertStyle = .informational alert.addButton(withTitle: L("OK")) if let window { alert.beginSheetModal(for: window) } else { alert.runModal() } } @objc private func didTapWebsite() { AppLegalURLs.openInSafari(AppLegalURLs.marketingHome) } @objc private func didTapSupport() { AppLegalURLs.openInSafari(AppLegalURLs.support) } @objc private func didTapTermsOfUse() { AppLegalURLs.openInSafari(AppLegalURLs.termsOfUse) } @objc private func didTapPrivacyPolicy() { AppLegalURLs.openInSafari(AppLegalURLs.privacyPolicy) } private func focusSearchField(seed: String) { guard ensureProAccessForJobSearch() else { return } jobKeywordsField.stringValue = seed window?.makeFirstResponder(jobKeywordsField) if let editor = jobKeywordsField.window?.fieldEditor(true, for: jobKeywordsField) as? NSTextView { editor.moveToEndOfDocument(nil) } } @objc private func didTapLoadMoreJobs() { guard ensureProAccessForJobSearch() else { return } let prompt = L("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) { FreeTierJobSearchQuota.recordUserMessageSent(isProActive: SubscriptionStore.shared.isProActive) updateFreeJobSearchQuotaLabel() isAwaitingResponse = true addInlineChatThinkingRow() 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.removeInlineChatThinkingRow() 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, attachedJobs: freshJobs.isEmpty ? nil : freshJobs)) self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs) case .failure(let error): self.appendChatBubble(text: UserFacingErrorMessage.jobSearchFailure(error), isUser: false) } } } } 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 String(format: L("I couldn't find new matches for “%@”. Try a different angle or a more specific keyword."), query) } return String(format: L("No jobs found for “%@”. Try another title, skill, company, or location."), query) } let matchWord = newJobsCount == 1 ? L("match") : L("matches") if isContinuation { return String(format: L("Here are %d more %@ for “%@”."), newJobsCount, matchWord, query) } return String(format: L("Found %d %@ for “%@”. Tap Apply to open the listing or Save to revisit later."), newJobsCount, matchWord, query) } 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 showMoreJobs = L("Show more jobs").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if normalized == showMoreJobs { return true } let continuationPhrases: Set<String> = [ "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) } 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 static let welcomeChatMessageKey = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary." private func resetChatState() { removeInlineChatThinkingRow() trailingLoadMoreJobsRow = nil trailingLoadMoreJobsButton = nil chatMessages.removeAll() lastSearchResults.removeAll() clearChatStack() let welcome = L(Self.welcomeChatMessageKey) chatMessages.append(ChatMessage(role: "assistant", content: welcome)) appendChatBubble(text: welcome, isUser: false) } private func clearChatStack() { chatStack.arrangedSubviews.forEach { chatStack.removeArrangedSubview($0) $0.removeFromSuperview() } } private func rebuildChatUI() { guard !chatMessages.isEmpty else { return } removeInlineChatThinkingRow() trailingLoadMoreJobsRow = nil trailingLoadMoreJobsButton = nil clearChatStack() for message in chatMessages { let isUser = message.role == "user" appendChatBubble(text: message.content, isUser: isUser, jobs: message.attachedJobs) } for host in chatStack.arrangedSubviews { host.alphaValue = 1 } updateChatBubbleWidths() } 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 } let scroll: () -> Void = { if isUser { self.scrollChatToBottom() } else { self.scrollChatToShowTopOfView(host) } } if self.prefersReducedMotion { self.updateChatBubbleWidths() scroll() return } NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.3 ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) host.animator().alphaValue = 1 } self.updateChatBubbleWidths() scroll() } } 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: AppMarketingLinks.appDisplayName) nameLabel.font = .systemFont(ofSize: 11, weight: .semibold) nameLabel.textColor = Theme.secondaryText nameLabel.alignment = .left nameLabel.translatesAutoresizingMaskIntoConstraints = false let bubble = makeChatBubbleContainer(text: text, isUser: false) let column = NSStackView(views: [nameLabel, bubble]) column.orientation = .vertical column.spacing = 6 // Leading keeps the assistant label, text bubble, and job cards hugging the left (after the avatar); `.width` was letting narrow intrinsic widths sit on the trailing side so AI read like a second “user” column. column.alignment = .leading 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) host.userInterfaceLayoutDirection = .leftToRight 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), bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78) ]) } 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: AppMarketingLinks.appDisplayName) 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 = L("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) } /// Scrolls so the top of `view` sits at the top of the chat clip (flipped document coords). Long assistant replies stay readable from the first line instead of jumping to the end. private func scrollChatToShowTopOfView(_ view: NSView) { chatDocumentView.layoutSubtreeIfNeeded() view.layoutSubtreeIfNeeded() let doc = chatDocumentView let visibleHeight = chatScrollView.contentView.bounds.height let docHeight = doc.bounds.height guard docHeight > 0, visibleHeight > 0 else { scrollChatToBottom() return } let rectInDoc = view.convert(view.bounds, to: doc) let maxY = max(0, docHeight - visibleHeight) let targetY = min(max(0, rectInDoc.minY), maxY) chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: targetY)) chatScrollView.reflectScrolledClipView(chatScrollView.contentView) } private func addInlineChatThinkingRow() { removeInlineChatThinkingRow() let host = NSView() host.translatesAutoresizingMaskIntoConstraints = false let indicator = ChatThinkingIndicatorView(compact: false) host.addSubview(indicator) NSLayoutConstraint.activate([ indicator.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 8), indicator.topAnchor.constraint(equalTo: host.topAnchor), indicator.bottomAnchor.constraint(equalTo: host.bottomAnchor, constant: -2) ]) chatThinkingRowHost = host chatStack.addArrangedSubview(host) host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true indicator.startAnimatingIfNeeded() DispatchQueue.main.async { [weak self] in self?.updateChatBubbleWidths() self?.scrollChatToBottom() } } private func removeInlineChatThinkingRow() { guard let host = chatThinkingRowHost else { return } for sub in host.subviews { (sub as? ChatThinkingIndicatorView)?.stopAnimating() } chatStack.removeArrangedSubview(host) host.removeFromSuperview() chatThinkingRowHost = nil } private func setInputEnabled(_ enabled: Bool) { jobKeywordsField.isEnabled = enabled findJobsButton.isEnabled = enabled findJobsCTAHost.alphaValue = enabled ? 1 : 0.65 clearChatButton.isEnabled = enabled clearChatButton.alphaValue = enabled ? 1 : 0.65 trailingLoadMoreJobsButton?.isEnabled = enabled trailingLoadMoreJobsButton?.alphaValue = enabled ? 1 : 0.65 } private func addIndeedSidebarLaunchRow() { let isSelected = isIndeedSidebarSelected let rowHost = SidebarNavRowView { [weak self] in self?.selectIndeedSidebar() } 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(AppMarketingLinks.indeedBrandName) rowHost.setAccessibilityRole(.button) rowHost.setAccessibilitySelected(isSelected) rowHost.setAccessibilityHelp(L("Open Indeed to search and apply for jobs")) let row = NSStackView() row.orientation = .horizontal row.spacing = 8 row.alignment = .centerY row.translatesAutoresizingMaskIntoConstraints = false let icon = NSImageView() icon.translatesAutoresizingMaskIntoConstraints = false icon.image = IndeedSidebarNavIcon.image(filled: isSelected) icon.imageScaling = .scaleProportionallyUpOrDown icon.contentTintColor = isSelected ? Theme.brandBlue : Theme.secondaryText icon.widthAnchor.constraint(equalToConstant: Self.sidebarNavIconSize).isActive = true icon.heightAnchor.constraint(equalToConstant: Self.sidebarNavIconSize).isActive = true let text = NSTextField(labelWithString: AppMarketingLinks.indeedBrandName) text.font = .systemFont(ofSize: 14, weight: .medium) text.textColor = isSelected ? Theme.brandBlue : Theme.secondaryText text.refusesFirstResponder = true row.addArrangedSubview(icon) row.addArrangedSubview(text) 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 sidebar.setCustomSpacing(10, after: rowHost) } private func configureSidebar() { let items = currentSidebarItems sidebar.arrangedSubviews.forEach { sidebar.removeArrangedSubview($0) $0.removeFromSuperview() } let logo = IndeedLogoView(displayHeight: 34, variant: .compact) logo.translatesAutoresizingMaskIntoConstraints = false let brand = NSTextField(labelWithString: AppMarketingLinks.appDisplayName) brand.font = .systemFont(ofSize: 14, weight: .semibold) brand.textColor = Theme.brandBlue brand.alignment = .left brand.maximumNumberOfLines = 2 brand.preferredMaxLayoutWidth = 194 let brandHeader = NSStackView(views: [logo, brand]) brandHeader.orientation = .vertical brandHeader.alignment = .leading brandHeader.spacing = 6 brandHeader.translatesAutoresizingMaskIntoConstraints = false sidebar.addArrangedSubview(brandHeader) sidebar.setCustomSpacing(22, after: brandHeader) items.enumerated().forEach { index, item in let isSelected = index == selectedSidebarIndex && !isIndeedSidebarSelected 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: L("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: L("Upgrade to Pro")) headline.font = .systemFont(ofSize: 16, weight: .bold) headline.textColor = Theme.primaryText headline.alignment = .center let upgradeDescription = NSTextField(wrappingLabelWithString: L("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: L("Try 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 = 8 upgradeButton.translatesAutoresizingMaskIntoConstraints = false upgradeButton.heightAnchor.constraint(equalToConstant: 32).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) ]) let upgradeCardHost = NSView() upgradeCardHost.translatesAutoresizingMaskIntoConstraints = false upgradeCardHost.addSubview(upgradeCard) NSLayoutConstraint.activate([ upgradeCard.centerXAnchor.constraint(equalTo: upgradeCardHost.centerXAnchor), upgradeCard.topAnchor.constraint(equalTo: upgradeCardHost.topAnchor), upgradeCard.bottomAnchor.constraint(equalTo: upgradeCardHost.bottomAnchor), upgradeCard.leadingAnchor.constraint(greaterThanOrEqualTo: upgradeCardHost.leadingAnchor), upgradeCard.trailingAnchor.constraint(lessThanOrEqualTo: upgradeCardHost.trailingAnchor) ]) sidebar.addArrangedSubview(upgradeCardHost) let upgradeCardHorizontalInset = sidebar.edgeInsets.left + sidebar.edgeInsets.right upgradeCardHost.widthAnchor.constraint(equalTo: sidebar.widthAnchor, constant: -upgradeCardHorizontalInset).isActive = true sidebarUpgradeCard = upgradeCard sidebarUpgradeHeadline = headline sidebarUpgradeDescription = upgradeDescription sidebarUpgradeButton = upgradeButton applyProSubscriptionToSidebar() } @objc private func didTapUpgradeToPro() { Task { @MainActor in await SubscriptionStore.shared.refreshEntitlements(deep: true) applyProSubscriptionToSidebar() presentPremiumPlansSheet() } } private func selectSidebarItem(at index: Int) { let leavingIndeedSelection = isIndeedSidebarSelected isIndeedSidebarSelected = false dismissIndeedJobBrowserEmbedded() guard index >= 0, index < currentSidebarItems.count else { return } let selectingHome = isHomeSidebarIndex(index) if index == selectedSidebarIndex, !leavingIndeedSelection { if selectingHome { applyHomeState() } return } selectedSidebarIndex = index configureSidebar() updateMainContentVisibility() if selectingHome { applyHomeState() } } } private struct ChatMessage: Codable { let role: String let content: String /// Job cards shown under this assistant message (not sent to the API). var attachedJobs: [JobListing]? enum CodingKeys: String, CodingKey { case role case content } init(role: String, content: String, attachedJobs: [JobListing]? = nil) { self.role = role self.content = content self.attachedJobs = attachedJobs } } private extension NSView { func subviewsRecursive() -> [NSView] { var result: [NSView] = [] var stack: [NSView] = [self] while let view = stack.popLast() { result.append(view) stack.append(contentsOf: view.subviews) } return result } } 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<JobSearchOutput, Error>) -> 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: L("Job search is unavailable.")] ))) 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 developerInstructions = """ You are the job-search backend for an Indeed-focused desktop app. Always use web search, but only to discover roles that are listed on Indeed (indeed.com and regional Indeed sites such as indeed.co.uk, ca.indeed.com, etc.). Do not include jobs sourced only from LinkedIn, Glassdoor, company career pages (unless the same opening is clearly on Indeed with an Indeed URL), Google Jobs aggregates, or other job boards. Your final assistant message must be JSON that strictly matches the configured response schema (one object with a "jobs" array). Do not add markdown, code fences, or conversational prose outside that JSON. Each job entry needs a title, a single-sentence description, and a "url" string. Prefer a stable Indeed **search results** URL on the correct regional domain (path `/jobs` with a `q=` query built from the listing title, company, and location—e.g. `https://www.indeed.com/jobs?q=…&l=…`). Never invent or guess job keys (`jk=`), click IDs, or viewjob URLs you did not copy exactly from live search results—wrong permalinks show 404 inside the app. Use an empty string for "url" only when you cannot construct any Indeed URL (omit the listing if it is not on Indeed). Return at most \(jobLimit) distinct listings. If the conversation context already lists jobs, do not repeat the same titles or URLs when the user asks for more—it is fine to return fewer than \(jobLimit) new results. Full sentences such as "looking for an AI developer job" are still job queries: always populate "jobs" from Indeed-oriented web search rather than answering with chatty text alone. """ let userInput = """ 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)" If the user refines pay, seniority, work location, or similar, run a new Indeed-focused search that applies those constraints to the same career topic as in the context. """ let payload = OpenAIJobSearchAPIRequest.jobSearchPayload( model: "gpt-4o-mini", instructions: developerInstructions, input: userInput, tools: [OpenAIResponsesTool(type: "web_search_preview")], jobLimit: jobLimit ) 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) { _ = try? JSONDecoder().decode(OpenAIAPIErrorResponse.self, from: data) completion(.failure(NSError( domain: "OpenAIJobSearchService", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: "Job search request failed."] ))) 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) .filter(Self.jobListingUsesIndeedOrEmptyURL) .map(Self.normalizedJobListing) 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" { throw NSError( domain: "OpenAIJobSearchService", code: 7, userInfo: [NSLocalizedDescriptionKey: "The search request failed."] ) } 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 normalizedJobListing(_ job: JobListing) -> JobListing { let trimmedURL = job.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmedURL.isEmpty { return JobListing(title: job.title, description: job.description, url: nil) } return JobListing(title: job.title, description: job.description, url: trimmedURL) } /// Drops listings whose `url` points at non-Indeed sites (e.g. LinkedIn) when the model ignores instructions. private static func jobListingUsesIndeedOrEmptyURL(_ job: JobListing) -> Bool { let trimmed = job.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { return true } return isIndeedJobURL(trimmed) } /// Host looks like an official Indeed property (`indeed.com`, `www.indeed.co.uk`, `ca.indeed.com`, …), not `notindeed.com`. private static func isIndeedJobURL(_ string: String) -> Bool { guard let host = URL(string: string)?.host?.lowercased() else { return false } if host == "indeed.com" { return true } if host.hasPrefix("indeed.") { return true } return host.contains(".indeed.") } private static func parseJobListings(fromModelText text: String) throws -> [JobListing] { let stripped = text.trimmingCharacters(in: .whitespacesAndNewlines) if stripped.hasPrefix("{"), let directData = stripped.data(using: .utf8), let payload = try? JSONDecoder().decode(JobSearchResultsPayload.self, from: directData) { return payload.jobs } 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[..<fence.lowerBound]) } return s.trimmingCharacters(in: .whitespacesAndNewlines) } private static func balancedJSONObject(from openBrace: String.Index, in s: String) -> 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[..<jobsRange.lowerBound] guard let open = head.lastIndex(of: "{") else { return nil } return balancedJSONObject(from: open, in: s) } private static func extractJSONObject(from text: String) -> 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 } } /// Responses API request with structured JSON output so web-search replies cannot omit the `jobs` schema. private struct OpenAIJobSearchAPIRequest: Encodable { let model: String let instructions: String let input: String let tools: [OpenAIResponsesTool] let text: OpenAITextOutputConfig static func jobSearchPayload( model: String, instructions: String, input: String, tools: [OpenAIResponsesTool], jobLimit: Int ) -> OpenAIJobSearchAPIRequest { let itemProperties = OpenAIJobSearchJobItemProperties( title: OpenAIJSONSchemaStringField(type: "string", description: "Job title as shown on the Indeed listing."), description: OpenAIJSONSchemaStringField(type: "string", description: "One concise sentence summarizing the role from the Indeed posting."), url: OpenAIJSONSchemaStringField(type: "string", description: "Indeed search URL (https, path /jobs, query q=…) on indeed.com or the correct regional Indeed host; never fabricate viewjob/jk links. Empty string only if no Indeed URL exists—never use LinkedIn, Glassdoor, or other boards.") ) let itemSchema = OpenAIJobSearchJobItemSchema( type: "object", properties: itemProperties, required: ["title", "description", "url"], additionalProperties: false ) let jobsProperty = OpenAIJobSearchJobsArrayProperty( type: "array", description: "Up to \(jobLimit) jobs found on Indeed via web search; use an empty array if none are found. Do not include listings that only exist off Indeed.", items: itemSchema ) let rootProperties = OpenAIJobSearchRootProperties(jobs: jobsProperty) let rootSchema = OpenAIJobSearchRootSchema( type: "object", properties: rootProperties, required: ["jobs"], additionalProperties: false ) let format = OpenAIJobSearchResponseJSONSchemaFormat( type: "json_schema", name: "job_search_results", strict: true, schema: rootSchema ) return OpenAIJobSearchAPIRequest( model: model, instructions: instructions, input: input, tools: tools, text: OpenAITextOutputConfig(format: format) ) } } private struct OpenAITextOutputConfig: Encodable { let format: OpenAIJobSearchResponseJSONSchemaFormat } private struct OpenAIJobSearchResponseJSONSchemaFormat: Encodable { let type: String let name: String let strict: Bool let schema: OpenAIJobSearchRootSchema } private struct OpenAIJobSearchRootSchema: Encodable { let type: String let properties: OpenAIJobSearchRootProperties let required: [String] let additionalProperties: Bool } private struct OpenAIJobSearchRootProperties: Encodable { let jobs: OpenAIJobSearchJobsArrayProperty } private struct OpenAIJobSearchJobsArrayProperty: Encodable { let type: String let description: String let items: OpenAIJobSearchJobItemSchema } private struct OpenAIJobSearchJobItemSchema: Encodable { let type: String let properties: OpenAIJobSearchJobItemProperties let required: [String] let additionalProperties: Bool } private struct OpenAIJobSearchJobItemProperties: Encodable { let title: OpenAIJSONSchemaStringField let description: OpenAIJSONSchemaStringField let url: OpenAIJSONSchemaStringField } private struct OpenAIJSONSchemaStringField: Encodable { let type: String let description: String } 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 } } /// Decorative waves and faint sparkles behind the welcome hero (reference layout). private final class WelcomeHeroBackgroundView: NSView { /// Stroke color for side waves (pastel blue). var waveTint = NSColor(srgbRed: 186 / 255, green: 210 / 255, blue: 253 / 255, alpha: 1) override var isFlipped: Bool { true } override func draw(_ dirtyRect: NSRect) { NSColor.clear.setFill() bounds.fill() guard bounds.width > 24, bounds.height > 24 else { return } drawSideWaves(in: bounds, isLeft: true) drawSideWaves(in: bounds, isLeft: false) drawAmbientSparkles(in: bounds) } private func drawSideWaves(in bounds: NSRect, isLeft: Bool) { for i in 0..<9 { let path = NSBezierPath() path.lineWidth = 1 path.lineCapStyle = .round let phase = CGFloat(i) * 0.88 let base = CGFloat(i + 1) * 11 + 4 var first = true for y in stride(from: CGFloat(0), through: bounds.height, by: 2.8) { let wobble = sin(y * 0.048 + phase) * (4 + CGFloat(i % 5)) let x = isLeft ? (base + wobble) : (bounds.width - base - wobble) let point = NSPoint(x: x, y: y) if first { path.move(to: point) first = false } else { path.line(to: point) } } let fade = 1 - CGFloat(i) / 10 waveTint.withAlphaComponent((0.09 + CGFloat(i % 3) * 0.022) * fade).setStroke() path.stroke() } } private func drawAmbientSparkles(in bounds: NSRect) { let accent = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1) let specs: [(CGFloat, CGFloat, CGFloat, CGFloat)] = [ (0.06, 0.14, 9, 0.28), (0.94, 0.12, 12, 0.34), (0.18, 0.42, 5, 0.18), (0.86, 0.44, 6, 0.2), (0.5, 0.06, 7, 0.15) ] for (nx, ny, size, a) in specs { let center = NSPoint(x: bounds.width * nx, y: bounds.height * ny) fillFourPointStar(center: center, radius: size, color: accent.withAlphaComponent(a)) } } private func fillFourPointStar(center: NSPoint, radius: CGFloat, color: NSColor) { let path = NSBezierPath() for i in 0..<4 { let angle = CGFloat(i) * .pi / 2 - .pi / 2 let x = center.x + cos(angle) * radius let y = center.y + sin(angle) * radius let point = NSPoint(x: x, y: y) if i == 0 { path.move(to: point) } else { path.line(to: point) } } path.close() color.setFill() path.fill() } } /// 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 var isSelected = false { didSet { updateSelectionAppearance() } } private weak var titleField: NSTextField? private weak var subtitleField: NSTextField? private weak var iconHost: NSView? private weak var iconView: NSImageView? private weak var chevronView: NSImageView? 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 = AppDashboardTheme.featureCardBackground.cgColor layer?.masksToBounds = false layer?.shadowColor = NSColor.black.withAlphaComponent(AppDashboardTheme.isDark ? 0.35 : 0.06).cgColor layer?.shadowOffset = CGSize(width: 0, height: 2) layer?.shadowRadius = 12 layer?.shadowOpacity = 1 updateSelectionAppearance() let iconSize: CGFloat = 40 let iconHost = NSView() self.iconHost = iconHost iconHost.translatesAutoresizingMaskIntoConstraints = false iconHost.wantsLayer = true iconHost.layer?.cornerRadius = iconSize / 2 let icon = NSImageView() self.iconView = icon icon.translatesAutoresizingMaskIntoConstraints = false icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular) icon.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) iconHost.addSubview(icon) let titleField = NSTextField(wrappingLabelWithString: title) self.titleField = titleField titleField.font = .systemFont(ofSize: 15, weight: .bold) titleField.maximumNumberOfLines = 1 titleField.isEditable = false titleField.isBordered = false titleField.drawsBackground = false titleField.alignment = .left let subtitleField = NSTextField(wrappingLabelWithString: subtitle) self.subtitleField = subtitleField subtitleField.font = .systemFont(ofSize: 12, weight: .regular) 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() self.chevronView = chevron chevron.translatesAutoresizingMaskIntoConstraints = false chevron.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) chevron.image = NSImage(systemSymbolName: "arrow.right", accessibilityDescription: nil) 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 = 16 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)") applyCardAppearance() } func applyCardAppearance() { layer?.backgroundColor = AppDashboardTheme.featureCardBackground.cgColor layer?.shadowColor = NSColor.black.withAlphaComponent(AppDashboardTheme.isDark ? 0.35 : 0.06).cgColor iconHost?.layer?.backgroundColor = AppDashboardTheme.featureIconWell.cgColor let accent = AppDashboardTheme.featurePrimaryBlue iconView?.contentTintColor = accent titleField?.textColor = accent subtitleField?.textColor = AppDashboardTheme.featureSecondaryText chevronView?.contentTintColor = accent updateSelectionAppearance() } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() applyCardAppearance() } private func updateSelectionAppearance() { guard let layer else { return } let accent = AppDashboardTheme.featurePrimaryBlue if isSelected { layer.borderWidth = 2 layer.borderColor = accent.cgColor layer.shadowOpacity = AppDashboardTheme.isDark ? 0.2 : 0.1 } else { layer.borderWidth = 1 layer.borderColor = AppDashboardTheme.featureCardBorder.cgColor layer.shadowOpacity = 1 } setAccessibilitySelected(isSelected) } @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 } }