// // DashboardView.swift // App for Indeed // import Cocoa import QuartzCore import Security private enum JobListingCardContext { case homeSearchResults case savedJobsPage } final class DashboardView: NSView, NSTextFieldDelegate { /// Indeed.com-inspired neutrals and brand blue (white surfaces, `#2557a7` accent, `#2d2d2d` / `#767676` text, `#d4d2d0` borders). private enum Theme { static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) static let chromeBackground = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1) static let sidebarBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) static let mainHostBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) /// Subtitle on the welcome hero: readable blue-gray aligned with brand. static let welcomeSubtitleText = NSColor(srgbRed: 52 / 255, green: 92 / 255, blue: 142 / 255, alpha: 1) static let selectionFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.12) static let cardBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) static let toggleBackground = NSColor(srgbRed: 232 / 255, green: 232 / 255, blue: 232 / 255, alpha: 1) static let primaryText = NSColor(srgbRed: 45 / 255, green: 45 / 255, blue: 45 / 255, alpha: 1) static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1) static let tertiaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1) static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1) /// Job search bar outer stroke (charcoal). static let searchBarBorder = NSColor(srgbRed: 58 / 255, green: 58 / 255, blue: 58 / 255, alpha: 1) /// Search bar border on hover (brand-tinted, matches focus affordance elsewhere). static let searchBarBorderHover = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.45) static let proCardFill = NSColor(srgbRed: 239 / 255, green: 244 / 255, blue: 252 / 255, alpha: 1) static let proCardBorder = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1) static let proAccent = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) static let proCTABackground = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) static let proCTAText = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) /// Slightly lighter blue for the top of the search-bar “Find jobs” pill (reads less flat than a solid fill). static let findJobsCTAHighlight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1) /// Hover states: darker brand blue, deeper gradient top, stronger tints, and subtle neutral fills used across CTAs, toggles, and the sidebar. static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1) static let findJobsCTAHighlightHover = NSColor(srgbRed: 44 / 255, green: 94 / 255, blue: 178 / 255, alpha: 1) static let selectionFillHover = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.2) static let neutralHoverFill = NSColor(srgbRed: 240 / 255, green: 240 / 255, blue: 240 / 255, alpha: 1) static let sidebarRowHoverFill = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.04) static let settingsPageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) static let settingsGroupBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) static let settingsIconBackground = NSColor(srgbRed: 239 / 255, green: 244 / 255, blue: 252 / 255, alpha: 1) static let settingsDivider = NSColor(srgbRed: 228 / 255, green: 228 / 255, blue: 228 / 255, alpha: 1) } /// Multiline `NSTextField` often ignores `alignment` for wrapped runs; explicit paragraph alignment matches the title. private static func jobListingDescriptionAttributedString(_ plain: String) -> NSAttributedString { let paragraph = NSMutableParagraphStyle() paragraph.alignment = .left paragraph.lineBreakMode = .byWordWrapping paragraph.baseWritingDirection = .leftToRight let font = NSFont.systemFont(ofSize: 13, weight: .regular) return NSAttributedString(string: plain, attributes: [ .font: font, .foregroundColor: Theme.secondaryText, .paragraphStyle: paragraph ]) } private let contentStack = NSStackView() private let chromeContainer = NSView() private let sidebar = NSStackView() private let mainHost = NSView() private let mainOverlay = NSStackView() private let greetingLabel = NSTextField(labelWithString: "") private let subtitleLabel = NSTextField(labelWithString: "") private let searchBarShadowHost = NSView() private let searchCard = HoverableView() private let jobSearchIcon = NSImageView() private let jobKeywordsField = NSTextField() private let findJobsButton = HoverableButton() private let findJobsCTAHost = NSView() private let findJobsCTAChrome = HoverableView() private var findJobsCTAGradientLayer: CAGradientLayer? private let chatStatusStack = NSStackView() private let chatStatusIcon = NSImageView() private let chatStatusLabel = NSTextField(labelWithString: "Opening the vault...") private let chatScrollView = NSScrollView() private let chatDocumentView = JobListingsDocumentView() private let chatStack = NSStackView() /// Shown when a sidebar item other than Home is selected. private let nonHomeHost = NSView() private let nonHomeGenericContainer = NSView() private let nonHomeTitleLabel = NSTextField(labelWithString: "") private let nonHomeSubtitleLabel = NSTextField(wrappingLabelWithString: "") private let savedJobsPageContainer = NSView() private let savedJobsPageTitleLabel = NSTextField(labelWithString: "Saved Jobs") private let savedJobsPageSubtitleLabel = NSTextField(wrappingLabelWithString: "") private let savedJobsScrollView = NSScrollView() private let savedJobsDocumentView = JobListingsDocumentView() private let savedJobsStack = NSStackView() private let settingsPageContainer = NSView() private let themeControl = NSSegmentedControl(labels: ["System", "Light", "Dark"], trackingMode: .selectOne, target: nil, action: nil) private var currentSidebarItems: [SidebarItem] = [] private var selectedSidebarIndex: Int = 0 /// All jobs that have been shown in the current chat session, oldest first. Used to deduplicate continuation searches so "show more" doesn't re-display the same listings. private var lastSearchResults: [JobListing] = [] /// Most recently saved jobs appear first; persisted across launches. private var savedJobOrder: [JobListing] = [] private var chatMessages: [ChatMessage] = [] private var isAwaitingResponse = false private let jobSearchService = OpenAIJobSearchService() override init(frame frameRect: NSRect) { super.init(frame: frameRect) setupLayout() } required init?(coder: NSCoder) { super.init(coder: coder) setupLayout() } override func layout() { super.layout() updateSearchBarShadowPath() findJobsCTAGradientLayer?.frame = findJobsCTAChrome.bounds updateFindJobsCTAShadowPath() updateJobListingDescriptionWidths() updateChatBubbleWidths() } func render(_ data: DashboardData) { greetingLabel.stringValue = "Welcome" subtitleLabel.stringValue = data.subtitle currentSidebarItems = data.sidebarItems if selectedSidebarIndex >= currentSidebarItems.count { selectedSidebarIndex = max(0, currentSidebarItems.count - 1) } configureSidebar() savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load()) resetChatState() updateMainContentVisibility() } private func setupLayout() { wantsLayer = true layer?.backgroundColor = Theme.pageBackground.cgColor contentStack.orientation = .horizontal contentStack.spacing = 10 contentStack.distribution = .fill contentStack.translatesAutoresizingMaskIntoConstraints = false contentStack.alignment = .height // Tighter chrome insets so panels sit closer to the window edges (especially leading / top under the title bar). contentStack.edgeInsets = NSEdgeInsets(top: 10, left: 12, bottom: 20, right: 20) chromeContainer.translatesAutoresizingMaskIntoConstraints = false chromeContainer.wantsLayer = true chromeContainer.layer?.backgroundColor = Theme.chromeBackground.cgColor chromeContainer.layer?.cornerRadius = 0 addSubview(chromeContainer) chromeContainer.addSubview(contentStack) sidebar.orientation = .vertical sidebar.spacing = 10 sidebar.distribution = .fill sidebar.alignment = .leading sidebar.wantsLayer = true sidebar.layer?.backgroundColor = Theme.sidebarBackground.cgColor sidebar.layer?.cornerRadius = 16 sidebar.edgeInsets = NSEdgeInsets(top: 16, left: 12, bottom: 16, right: 12) sidebar.translatesAutoresizingMaskIntoConstraints = false mainHost.translatesAutoresizingMaskIntoConstraints = false mainHost.wantsLayer = true mainHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor mainHost.layer?.cornerRadius = 16 mainHost.layer?.masksToBounds = true sidebar.setContentHuggingPriority(.required, for: .horizontal) mainHost.setContentHuggingPriority(.defaultLow, for: .horizontal) mainHost.addSubview(mainOverlay) configureNonHomePlaceholder() mainHost.addSubview(nonHomeHost) mainOverlay.orientation = .vertical mainOverlay.spacing = 0 mainOverlay.alignment = .centerX mainOverlay.distribution = .fill mainOverlay.translatesAutoresizingMaskIntoConstraints = false mainOverlay.setContentHuggingPriority(.defaultLow, for: .vertical) greetingLabel.font = .systemFont(ofSize: 32, weight: .bold) greetingLabel.textColor = Theme.brandBlue greetingLabel.alignment = .center greetingLabel.maximumNumberOfLines = 1 subtitleLabel.font = .systemFont(ofSize: 15, weight: .regular) subtitleLabel.textColor = Theme.welcomeSubtitleText subtitleLabel.alignment = .center subtitleLabel.maximumNumberOfLines = 2 let topInset = NSView() topInset.translatesAutoresizingMaskIntoConstraints = false topInset.heightAnchor.constraint(equalToConstant: 18).isActive = true configureSearchBar() configureChatViews() let titleBlock = NSStackView(views: [greetingLabel, subtitleLabel]) titleBlock.orientation = .vertical titleBlock.spacing = 10 titleBlock.alignment = .centerX let midSpacer = NSView() midSpacer.translatesAutoresizingMaskIntoConstraints = false midSpacer.heightAnchor.constraint(equalToConstant: 18).isActive = true let chatTopSpacer = NSView() chatTopSpacer.translatesAutoresizingMaskIntoConstraints = false chatTopSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true let chatBottomSpacer = NSView() chatBottomSpacer.translatesAutoresizingMaskIntoConstraints = false chatBottomSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true mainOverlay.addArrangedSubview(topInset) mainOverlay.addArrangedSubview(titleBlock) mainOverlay.addArrangedSubview(midSpacer) mainOverlay.addArrangedSubview(chatStatusStack) mainOverlay.addArrangedSubview(chatTopSpacer) mainOverlay.addArrangedSubview(chatScrollView) mainOverlay.addArrangedSubview(chatBottomSpacer) mainOverlay.addArrangedSubview(searchBarShadowHost) contentStack.addArrangedSubview(sidebar) contentStack.addArrangedSubview(mainHost) NSLayoutConstraint.activate([ chromeContainer.leadingAnchor.constraint(equalTo: leadingAnchor), chromeContainer.trailingAnchor.constraint(equalTo: trailingAnchor), chromeContainer.topAnchor.constraint(equalTo: topAnchor), chromeContainer.bottomAnchor.constraint(equalTo: bottomAnchor), contentStack.leadingAnchor.constraint(equalTo: chromeContainer.leadingAnchor), contentStack.trailingAnchor.constraint(equalTo: chromeContainer.trailingAnchor), contentStack.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), contentStack.bottomAnchor.constraint(equalTo: chromeContainer.bottomAnchor), sidebar.widthAnchor.constraint(equalToConstant: 218), mainHost.widthAnchor.constraint(greaterThanOrEqualToConstant: 720), mainOverlay.leadingAnchor.constraint(equalTo: mainHost.leadingAnchor), mainOverlay.trailingAnchor.constraint(equalTo: mainHost.trailingAnchor), mainOverlay.topAnchor.constraint(equalTo: mainHost.topAnchor), mainOverlay.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24), nonHomeHost.leadingAnchor.constraint(equalTo: mainHost.leadingAnchor), nonHomeHost.trailingAnchor.constraint(equalTo: mainHost.trailingAnchor), nonHomeHost.topAnchor.constraint(equalTo: mainHost.topAnchor), nonHomeHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24), searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92), chatStatusStack.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92), chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92), greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 16), greetingLabel.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor, constant: -16), subtitleLabel.leadingAnchor.constraint(equalTo: greetingLabel.leadingAnchor), subtitleLabel.trailingAnchor.constraint(equalTo: greetingLabel.trailingAnchor) ]) } private func configureChatViews() { chatStatusStack.orientation = .vertical chatStatusStack.spacing = 6 chatStatusStack.alignment = .centerX chatStatusStack.translatesAutoresizingMaskIntoConstraints = false chatStatusIcon.translatesAutoresizingMaskIntoConstraints = false chatStatusIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 36, weight: .regular) chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status") chatStatusIcon.contentTintColor = Theme.brandBlue chatStatusLabel.font = .systemFont(ofSize: 20, weight: .semibold) chatStatusLabel.textColor = Theme.primaryText chatStatusLabel.alignment = .center chatStatusLabel.maximumNumberOfLines = 1 chatStatusStack.addArrangedSubview(chatStatusIcon) chatStatusStack.addArrangedSubview(chatStatusLabel) 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 chatScrollView.autohidesScrollers = true chatScrollView.drawsBackground = false chatScrollView.borderType = .noBorder chatScrollView.documentView = chatDocumentView chatScrollView.setContentHuggingPriority(.defaultLow, for: .vertical) chatScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true } 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 makeJobListingCard(_ job: JobListing, context: JobListingCardContext) -> NSView { let card = NSView() card.translatesAutoresizingMaskIntoConstraints = false card.wantsLayer = true card.layer?.backgroundColor = Theme.cardBackground.cgColor card.layer?.cornerRadius = 12 card.layer?.borderWidth = 1 card.layer?.borderColor = Theme.border.cgColor card.layer?.masksToBounds = true let titleField = NSTextField(labelWithString: job.title) titleField.font = .systemFont(ofSize: 16, weight: .semibold) titleField.textColor = Theme.primaryText titleField.maximumNumberOfLines = 2 titleField.lineBreakMode = .byWordWrapping titleField.alignment = .left titleField.translatesAutoresizingMaskIntoConstraints = false let descriptionField = NSTextField(wrappingLabelWithString: job.description) descriptionField.font = .systemFont(ofSize: 13, weight: .regular) descriptionField.textColor = Theme.secondaryText descriptionField.maximumNumberOfLines = 0 descriptionField.alignment = .left descriptionField.lineBreakMode = .byWordWrapping descriptionField.baseWritingDirection = .leftToRight descriptionField.attributedStringValue = Self.jobListingDescriptionAttributedString(job.description) if let cell = descriptionField.cell as? NSTextFieldCell { cell.alignment = .left cell.wraps = true } descriptionField.setContentHuggingPriority(.defaultLow, for: .horizontal) descriptionField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) descriptionField.tag = 502 descriptionField.translatesAutoresizingMaskIntoConstraints = false let applyButton = JobPayloadButton(title: "Apply", target: self, action: #selector(didTapJobApply(_:))) applyButton.jobPayload = job applyButton.cardContext = context applyButton.isBordered = false applyButton.bezelStyle = .rounded applyButton.font = .systemFont(ofSize: 13, weight: .semibold) applyButton.wantsLayer = true applyButton.layer?.cornerRadius = 6 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 = JobPayloadButton(title: savedOn ? "Saved" : "Save", target: self, action: #selector(didTapJobSaved(_:))) savedButton.jobPayload = job savedButton.cardContext = context savedButton.setButtonType(.toggle) savedButton.isBordered = false savedButton.bezelStyle = .rounded savedButton.font = .systemFont(ofSize: 13, weight: .semibold) savedButton.focusRingType = .none savedButton.state = savedOn ? .on : .off savedButton.pointerCursor = true savedButton.hoverHandler = { [weak self, weak savedButton] _ in guard let savedButton = savedButton else { return } self?.styleJobSavedButton(savedButton) } styleJobSavedButton(savedButton) savedButton.setContentHuggingPriority(.required, for: .horizontal) savedButton.setContentCompressionResistancePriority(.required, for: .horizontal) let dismissButton = JobPayloadButton() dismissButton.jobPayload = job dismissButton.cardContext = context dismissButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Dismiss") dismissButton.imagePosition = .imageOnly dismissButton.imageScaling = .scaleProportionallyDown dismissButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold) dismissButton.isBordered = false dismissButton.bezelStyle = .rounded dismissButton.contentTintColor = Theme.secondaryText dismissButton.target = self dismissButton.action = #selector(didTapJobDismiss(_:)) dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss" dismissButton.focusRingType = .none dismissButton.wantsLayer = true dismissButton.layer?.cornerRadius = 14 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 = .centerY buttonRow.translatesAutoresizingMaskIntoConstraints = false buttonRow.setContentHuggingPriority(.required, for: .horizontal) buttonRow.setContentCompressionResistancePriority(.required, for: .horizontal) // Title hugs the leading edge; a low–hugging-priority spacer absorbs remaining width so buttons stay trailing. titleField.setContentHuggingPriority(.defaultHigh, for: .horizontal) titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let titleRowSpacer = NSView() titleRowSpacer.translatesAutoresizingMaskIntoConstraints = false titleRowSpacer.setContentHuggingPriority(NSLayoutConstraint.Priority(1), for: .horizontal) titleRowSpacer.setContentCompressionResistancePriority(NSLayoutConstraint.Priority(1), for: .horizontal) let titleAndActionsRow = NSStackView(views: [titleField, titleRowSpacer, buttonRow]) titleAndActionsRow.orientation = .horizontal titleAndActionsRow.spacing = 0 titleAndActionsRow.setCustomSpacing(14, after: titleRowSpacer) titleAndActionsRow.alignment = .centerY titleAndActionsRow.distribution = .fill titleAndActionsRow.userInterfaceLayoutDirection = .leftToRight titleAndActionsRow.translatesAutoresizingMaskIntoConstraints = false let contentColumn = NSStackView(views: [titleAndActionsRow, descriptionField]) contentColumn.orientation = .vertical contentColumn.spacing = 6 contentColumn.alignment = .width contentColumn.translatesAutoresizingMaskIntoConstraints = false card.addSubview(contentColumn) NSLayoutConstraint.activate([ contentColumn.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), contentColumn.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16), contentColumn.topAnchor.constraint(equalTo: card.topAnchor, constant: 14), contentColumn.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14), applyButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 72), applyButton.heightAnchor.constraint(equalToConstant: 28), savedButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 72), savedButton.heightAnchor.constraint(equalToConstant: 28), dismissButton.widthAnchor.constraint(equalToConstant: 28), dismissButton.heightAnchor.constraint(equalToConstant: 28), descriptionField.widthAnchor.constraint(equalTo: contentColumn.widthAnchor) ]) return card } private func styleJobSavedButton(_ button: NSButton) { button.wantsLayer = true button.layer?.cornerRadius = 6 let on = button.state == .on let hovering = (button as? HoverableButton)?.isHovering ?? false if on { button.layer?.backgroundColor = (hovering ? Theme.selectionFillHover : Theme.selectionFill).cgColor button.layer?.borderWidth = 1 button.layer?.borderColor = Theme.brandBlue.cgColor button.contentTintColor = Theme.brandBlue } else { button.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor button.layer?.borderWidth = 1 button.layer?.borderColor = Theme.border.cgColor button.contentTintColor = Theme.primaryText } } @objc private func didTapJobApply(_ sender: NSButton) { guard let job = (sender as? JobPayloadButton)?.jobPayload else { return } if let rawURL = job.url, let url = URL(string: rawURL), !rawURL.isEmpty { NSWorkspace.shared.open(url) return } let allowed = CharacterSet.urlQueryAllowed let q = job.title.addingPercentEncoding(withAllowedCharacters: allowed) ?? "" guard let url = URL(string: "https://www.indeed.com/jobs?q=\(q)") else { return } NSWorkspace.shared.open(url) } @objc private func didTapJobSaved(_ sender: NSButton) { guard let job = (sender as? JobPayloadButton)?.jobPayload else { return } let willSave = !isJobSaved(job) applySavedState(willSave, for: job) sender.state = willSave ? .on : .off sender.title = willSave ? "Saved" : "Save" styleJobSavedButton(sender) if isSavedJobsSidebarIndex(selectedSidebarIndex) { reloadSavedJobsListings() } } @objc private func didTapJobDismiss(_ sender: NSButton) { guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return } switch button.cardContext { case .homeSearchResults: removeJobCardFromChat(originating: button, job: job) case .savedJobsPage: applySavedState(false, for: job) reloadSavedJobsListings() } } /// Walks up from a dismiss button until it finds the enclosing chat job stack, then removes only the card that owns the button. Other chat history (older searches, the assistant summary text) is untouched. private func removeJobCardFromChat(originating button: NSView, job: JobListing) { var node: NSView? = button var card: NSView? var stack: ChatJobsStackView? while let v = node { if let parent = v.superview as? ChatJobsStackView { card = v stack = parent break } node = v.superview } guard let card, let stack else { return } stack.removeArrangedSubview(card) card.removeFromSuperview() lastSearchResults.removeAll { $0 == job } } private func configureSearchBar() { let pillCorner: CGFloat = 27 let barHeight: CGFloat = 54 searchBarShadowHost.translatesAutoresizingMaskIntoConstraints = false searchBarShadowHost.wantsLayer = true searchBarShadowHost.layer?.masksToBounds = false searchBarShadowHost.layer?.shadowColor = NSColor.black.withAlphaComponent(0.18).cgColor searchBarShadowHost.layer?.shadowOffset = CGSize(width: 0, height: 2) searchBarShadowHost.layer?.shadowRadius = 10 searchBarShadowHost.layer?.shadowOpacity = 1 searchBarShadowHost.setContentHuggingPriority(.defaultHigh, for: .vertical) searchCard.translatesAutoresizingMaskIntoConstraints = false searchCard.wantsLayer = true searchCard.layer?.backgroundColor = Theme.cardBackground.cgColor searchCard.layer?.cornerRadius = pillCorner searchCard.layer?.borderWidth = 1 searchCard.layer?.borderColor = Theme.searchBarBorder.cgColor searchCard.layer?.masksToBounds = true searchCard.hoverHandler = { [weak self] hovering in guard let self else { return } CATransaction.begin() CATransaction.setAnimationDuration(0.15) self.searchCard.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor self.searchCard.layer?.borderColor = (hovering ? Theme.searchBarBorderHover : Theme.searchBarBorder).cgColor self.searchBarShadowHost.layer?.shadowColor = NSColor.black.withAlphaComponent(hovering ? 0.24 : 0.18).cgColor self.searchBarShadowHost.layer?.shadowRadius = hovering ? 12 : 10 CATransaction.commit() } searchBarShadowHost.addSubview(searchCard) func configureField(_ field: NSTextField, placeholder: String) { field.translatesAutoresizingMaskIntoConstraints = false field.isBordered = false field.drawsBackground = false field.focusRingType = .none field.font = .systemFont(ofSize: 14, weight: .regular) field.textColor = Theme.primaryText field.delegate = self field.placeholderAttributedString = NSAttributedString( string: placeholder, attributes: [ .foregroundColor: Theme.secondaryText, .font: NSFont.systemFont(ofSize: 14, weight: .regular) ] ) field.cell?.usesSingleLineMode = true field.cell?.wraps = false field.cell?.isScrollable = true field.target = self field.action = #selector(didSubmitSearch) } jobSearchIcon.translatesAutoresizingMaskIntoConstraints = false jobSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium) jobSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Job search") jobSearchIcon.contentTintColor = Theme.primaryText configureField(jobKeywordsField, placeholder: "Ask for roles, skills, salary, or job descriptions...") let ctaHeight: CGFloat = 42 let ctaCorner = ctaHeight / 2 findJobsCTAHost.translatesAutoresizingMaskIntoConstraints = false findJobsCTAHost.wantsLayer = true findJobsCTAHost.layer?.masksToBounds = false findJobsCTAHost.layer?.shadowColor = NSColor.black.cgColor findJobsCTAHost.layer?.shadowOpacity = 0.16 findJobsCTAHost.layer?.shadowOffset = CGSize(width: 0, height: 2) findJobsCTAHost.layer?.shadowRadius = 6 findJobsCTAChrome.translatesAutoresizingMaskIntoConstraints = false findJobsCTAChrome.wantsLayer = true findJobsCTAChrome.layer?.masksToBounds = true findJobsCTAChrome.layer?.cornerRadius = ctaCorner if #available(macOS 11.0, *) { findJobsCTAChrome.layer?.cornerCurve = .continuous } let gradient = CAGradientLayer() gradient.colors = [Theme.findJobsCTAHighlight.cgColor, Theme.brandBlue.cgColor] gradient.startPoint = CGPoint(x: 0.5, y: 1) gradient.endPoint = CGPoint(x: 0.5, y: 0) findJobsCTAChrome.layer?.addSublayer(gradient) findJobsCTAGradientLayer = gradient // Tracks hover over the full pill (the button only covers an inset area), so the gradient darkens whenever the mouse is anywhere over the CTA. findJobsCTAChrome.pointerCursor = true findJobsCTAChrome.hoverHandler = { [weak self] hovering in guard let layer = self?.findJobsCTAGradientLayer else { return } CATransaction.begin() CATransaction.setAnimationDuration(0.15) layer.colors = hovering ? [Theme.findJobsCTAHighlightHover.cgColor, Theme.brandBlueHover.cgColor] : [Theme.findJobsCTAHighlight.cgColor, Theme.brandBlue.cgColor] CATransaction.commit() } findJobsButton.translatesAutoresizingMaskIntoConstraints = false findJobsButton.title = "" findJobsButton.attributedTitle = NSAttributedString( string: "Send", attributes: [ .font: NSFont.systemFont(ofSize: 14, weight: .semibold), .foregroundColor: Theme.proCTAText, .kern: 0.35 ] ) findJobsButton.isBordered = false findJobsButton.bezelStyle = .rounded findJobsButton.wantsLayer = true findJobsButton.layer?.backgroundColor = NSColor.clear.cgColor findJobsButton.focusRingType = .none findJobsButton.target = self findJobsButton.action = #selector(didSubmitSearch) findJobsButton.setContentHuggingPriority(.required, for: .horizontal) findJobsButton.setContentCompressionResistancePriority(.required, for: .horizontal) findJobsCTAHost.addSubview(findJobsCTAChrome) findJobsCTAHost.addSubview(findJobsButton) NSLayoutConstraint.activate([ findJobsCTAChrome.leadingAnchor.constraint(equalTo: findJobsCTAHost.leadingAnchor), findJobsCTAChrome.trailingAnchor.constraint(equalTo: findJobsCTAHost.trailingAnchor), findJobsCTAChrome.topAnchor.constraint(equalTo: findJobsCTAHost.topAnchor), findJobsCTAChrome.bottomAnchor.constraint(equalTo: findJobsCTAHost.bottomAnchor), findJobsButton.leadingAnchor.constraint(equalTo: findJobsCTAHost.leadingAnchor, constant: 14), findJobsButton.trailingAnchor.constraint(equalTo: findJobsCTAHost.trailingAnchor, constant: -14), findJobsButton.topAnchor.constraint(equalTo: findJobsCTAHost.topAnchor), findJobsButton.bottomAnchor.constraint(equalTo: findJobsCTAHost.bottomAnchor) ]) let keywordsStack = NSStackView(views: [jobSearchIcon, jobKeywordsField]) keywordsStack.orientation = .horizontal keywordsStack.spacing = 10 keywordsStack.alignment = .centerY keywordsStack.translatesAutoresizingMaskIntoConstraints = false keywordsStack.edgeInsets = NSEdgeInsets(top: 0, left: 18, bottom: 0, right: 10) keywordsStack.setContentHuggingPriority(.defaultLow, for: .horizontal) let row = NSStackView(views: [keywordsStack, findJobsCTAHost]) row.orientation = .horizontal row.spacing = 0 row.alignment = .centerY row.distribution = .fill row.translatesAutoresizingMaskIntoConstraints = false row.edgeInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 7) searchCard.addSubview(row) NSLayoutConstraint.activate([ searchCard.leadingAnchor.constraint(equalTo: searchBarShadowHost.leadingAnchor), searchCard.trailingAnchor.constraint(equalTo: searchBarShadowHost.trailingAnchor), searchCard.topAnchor.constraint(equalTo: searchBarShadowHost.topAnchor), searchCard.bottomAnchor.constraint(equalTo: searchBarShadowHost.bottomAnchor), searchBarShadowHost.heightAnchor.constraint(equalToConstant: barHeight), row.leadingAnchor.constraint(equalTo: searchCard.leadingAnchor), row.trailingAnchor.constraint(equalTo: searchCard.trailingAnchor), row.topAnchor.constraint(equalTo: searchCard.topAnchor), row.bottomAnchor.constraint(equalTo: searchCard.bottomAnchor), jobSearchIcon.widthAnchor.constraint(equalToConstant: 18), jobSearchIcon.heightAnchor.constraint(equalToConstant: 18), findJobsCTAHost.heightAnchor.constraint(equalToConstant: ctaHeight), findJobsCTAHost.widthAnchor.constraint(greaterThanOrEqualToConstant: 112) ]) searchCard.hoverHandler = nil } private func updateFindJobsCTAShadowPath() { guard findJobsCTAHost.bounds.width > 0, findJobsCTAHost.bounds.height > 0 else { return } let r = findJobsCTAHost.bounds let radius = min(r.height / 2, r.width / 2) findJobsCTAHost.layer?.shadowPath = CGPath( roundedRect: r, cornerWidth: radius, cornerHeight: radius, transform: nil ) } private func configureNonHomePlaceholder() { nonHomeHost.translatesAutoresizingMaskIntoConstraints = false nonHomeHost.wantsLayer = true nonHomeHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor nonHomeHost.isHidden = true nonHomeGenericContainer.translatesAutoresizingMaskIntoConstraints = false savedJobsPageContainer.translatesAutoresizingMaskIntoConstraints = false settingsPageContainer.translatesAutoresizingMaskIntoConstraints = false nonHomeHost.addSubview(nonHomeGenericContainer) nonHomeHost.addSubview(savedJobsPageContainer) nonHomeHost.addSubview(settingsPageContainer) NSLayoutConstraint.activate([ nonHomeGenericContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor), nonHomeGenericContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor), nonHomeGenericContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor), nonHomeGenericContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor), savedJobsPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor), savedJobsPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor), savedJobsPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor), savedJobsPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor), settingsPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor), settingsPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor), settingsPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor), settingsPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor) ]) nonHomeTitleLabel.font = .systemFont(ofSize: 22, weight: .bold) nonHomeTitleLabel.textColor = Theme.primaryText nonHomeTitleLabel.alignment = .center nonHomeTitleLabel.maximumNumberOfLines = 1 nonHomeSubtitleLabel.font = .systemFont(ofSize: 14, weight: .regular) nonHomeSubtitleLabel.textColor = Theme.secondaryText nonHomeSubtitleLabel.alignment = .center nonHomeSubtitleLabel.maximumNumberOfLines = 0 nonHomeSubtitleLabel.stringValue = "This area is not available in the preview build. Use Home to search jobs." let genericStack = NSStackView(views: [nonHomeTitleLabel, nonHomeSubtitleLabel]) genericStack.orientation = .vertical genericStack.spacing = 10 genericStack.alignment = .centerX genericStack.translatesAutoresizingMaskIntoConstraints = false nonHomeGenericContainer.addSubview(genericStack) NSLayoutConstraint.activate([ genericStack.centerXAnchor.constraint(equalTo: nonHomeGenericContainer.centerXAnchor), genericStack.centerYAnchor.constraint(equalTo: nonHomeGenericContainer.centerYAnchor), genericStack.leadingAnchor.constraint(greaterThanOrEqualTo: nonHomeGenericContainer.leadingAnchor, constant: 32), genericStack.trailingAnchor.constraint(lessThanOrEqualTo: nonHomeGenericContainer.trailingAnchor, constant: -32), nonHomeSubtitleLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 420) ]) savedJobsPageTitleLabel.font = .systemFont(ofSize: 22, weight: .bold) savedJobsPageTitleLabel.textColor = Theme.primaryText savedJobsPageTitleLabel.alignment = .left savedJobsPageTitleLabel.maximumNumberOfLines = 1 savedJobsPageSubtitleLabel.font = .systemFont(ofSize: 14, weight: .regular) savedJobsPageSubtitleLabel.textColor = Theme.secondaryText savedJobsPageSubtitleLabel.alignment = .left savedJobsPageSubtitleLabel.maximumNumberOfLines = 0 savedJobsDocumentView.translatesAutoresizingMaskIntoConstraints = false savedJobsStack.orientation = .vertical savedJobsStack.spacing = 14 savedJobsStack.alignment = .leading savedJobsStack.distribution = .fill savedJobsStack.translatesAutoresizingMaskIntoConstraints = false savedJobsStack.setContentHuggingPriority(.defaultHigh, for: .vertical) savedJobsStack.setHuggingPriority(.defaultLow, for: .horizontal) savedJobsDocumentView.addSubview(savedJobsStack) NSLayoutConstraint.activate([ savedJobsStack.leadingAnchor.constraint(equalTo: savedJobsDocumentView.leadingAnchor), savedJobsStack.trailingAnchor.constraint(equalTo: savedJobsDocumentView.trailingAnchor), savedJobsStack.topAnchor.constraint(equalTo: savedJobsDocumentView.topAnchor), savedJobsStack.bottomAnchor.constraint(equalTo: savedJobsDocumentView.bottomAnchor) ]) savedJobsScrollView.translatesAutoresizingMaskIntoConstraints = false savedJobsScrollView.hasVerticalScroller = true savedJobsScrollView.hasHorizontalScroller = false savedJobsScrollView.autohidesScrollers = true savedJobsScrollView.drawsBackground = false savedJobsScrollView.borderType = .noBorder savedJobsScrollView.documentView = savedJobsDocumentView savedJobsScrollView.setContentHuggingPriority(.defaultLow, for: .vertical) savedJobsScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) let savedHeaderStack = NSStackView(views: [savedJobsPageTitleLabel, savedJobsPageSubtitleLabel]) savedHeaderStack.orientation = .vertical savedHeaderStack.spacing = 6 savedHeaderStack.alignment = .leading savedHeaderStack.translatesAutoresizingMaskIntoConstraints = false let savedOuterStack = NSStackView(views: [savedHeaderStack, savedJobsScrollView]) savedOuterStack.orientation = .vertical savedOuterStack.spacing = 16 // Leading alignment plus explicit column width keeps the title and subtitle on the same edge as the cards. savedOuterStack.alignment = .leading savedOuterStack.translatesAutoresizingMaskIntoConstraints = false savedJobsPageContainer.userInterfaceLayoutDirection = .leftToRight savedJobsPageContainer.addSubview(savedOuterStack) NSLayoutConstraint.activate([ savedOuterStack.leadingAnchor.constraint(equalTo: savedJobsPageContainer.leadingAnchor, constant: 32), savedOuterStack.trailingAnchor.constraint(equalTo: savedJobsPageContainer.trailingAnchor, constant: -32), savedOuterStack.topAnchor.constraint(equalTo: savedJobsPageContainer.topAnchor, constant: 8), savedOuterStack.bottomAnchor.constraint(equalTo: savedJobsPageContainer.bottomAnchor), savedHeaderStack.widthAnchor.constraint(equalTo: savedOuterStack.widthAnchor), savedJobsScrollView.widthAnchor.constraint(equalTo: savedOuterStack.widthAnchor), savedJobsDocumentView.topAnchor.constraint(equalTo: savedJobsScrollView.contentView.topAnchor), savedJobsDocumentView.leadingAnchor.constraint(equalTo: savedJobsScrollView.contentView.leadingAnchor), savedJobsDocumentView.widthAnchor.constraint(equalTo: savedJobsScrollView.contentView.widthAnchor) ]) configureSettingsPage() } private func configureSettingsPage() { settingsPageContainer.wantsLayer = true settingsPageContainer.layer?.backgroundColor = Theme.settingsPageBackground.cgColor settingsPageContainer.isHidden = true let contentStack = NSStackView() contentStack.orientation = .vertical contentStack.spacing = 26 contentStack.alignment = .leading contentStack.translatesAutoresizingMaskIntoConstraints = false let settingsSection = makeSettingsSection(rows: [ makeSettingsRow(title: "Share App", systemImage: "square.and.arrow.up", accessory: nil), makeSettingsRow(title: "Theme", systemImage: "circle.lefthalf.filled", accessory: makeThemeControl()), makeSettingsRow(title: "More Apps", systemImage: "square.grid.2x2", accessory: nil) ]) let aboutTitle = NSTextField(labelWithString: "About") aboutTitle.font = .systemFont(ofSize: 12, weight: .semibold) aboutTitle.textColor = Theme.secondaryText aboutTitle.alignment = .left let aboutSection = makeSettingsSection(rows: [ makeSettingsRow(title: "Support", systemImage: "questionmark.circle", accessory: nil), makeSettingsRow(title: "Terms of Use", systemImage: "doc.text", accessory: nil), makeSettingsRow(title: "Privacy Policy", systemImage: "shield", accessory: nil) ]) let aboutStack = NSStackView(views: [aboutTitle, aboutSection]) aboutStack.orientation = .vertical aboutStack.spacing = 14 aboutStack.alignment = .leading aboutStack.translatesAutoresizingMaskIntoConstraints = false contentStack.addArrangedSubview(settingsSection) contentStack.addArrangedSubview(aboutStack) settingsPageContainer.addSubview(contentStack) NSLayoutConstraint.activate([ contentStack.leadingAnchor.constraint(equalTo: settingsPageContainer.leadingAnchor, constant: 42), contentStack.trailingAnchor.constraint(lessThanOrEqualTo: settingsPageContainer.trailingAnchor, constant: -42), contentStack.topAnchor.constraint(equalTo: settingsPageContainer.topAnchor, constant: 48), settingsSection.widthAnchor.constraint(equalTo: contentStack.widthAnchor), aboutStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor), aboutSection.widthAnchor.constraint(equalTo: aboutStack.widthAnchor), contentStack.widthAnchor.constraint(equalTo: settingsPageContainer.widthAnchor, constant: -84) ]) } private func makeThemeControl() -> NSSegmentedControl { themeControl.target = self themeControl.action = #selector(didChangeThemeSelection(_:)) themeControl.selectedSegment = 0 themeControl.segmentStyle = .rounded themeControl.controlSize = .large themeControl.font = .systemFont(ofSize: 13, weight: .semibold) themeControl.translatesAutoresizingMaskIntoConstraints = false themeControl.widthAnchor.constraint(equalToConstant: 204).isActive = true themeControl.heightAnchor.constraint(equalToConstant: 30).isActive = true return themeControl } private func makeSettingsSection(rows: [NSView]) -> NSView { let section = NSStackView() section.orientation = .vertical section.spacing = 0 section.alignment = .leading section.translatesAutoresizingMaskIntoConstraints = false section.wantsLayer = true section.layer?.backgroundColor = Theme.settingsGroupBackground.cgColor section.layer?.cornerRadius = 14 section.layer?.borderWidth = 1 section.layer?.borderColor = Theme.border.cgColor section.layer?.masksToBounds = true for (index, row) in rows.enumerated() { section.addArrangedSubview(row) row.widthAnchor.constraint(equalTo: section.widthAnchor).isActive = true if index < rows.count - 1 { let divider = NSView() divider.translatesAutoresizingMaskIntoConstraints = false divider.wantsLayer = true divider.layer?.backgroundColor = Theme.settingsDivider.cgColor section.addArrangedSubview(divider) NSLayoutConstraint.activate([ divider.heightAnchor.constraint(equalToConstant: 1), divider.leadingAnchor.constraint(equalTo: section.leadingAnchor), divider.trailingAnchor.constraint(equalTo: section.trailingAnchor) ]) } } return section } private func makeSettingsRow(title: String, systemImage: String, accessory: NSView?) -> NSView { let row = NSView() row.translatesAutoresizingMaskIntoConstraints = false row.wantsLayer = true let iconTile = NSView() iconTile.translatesAutoresizingMaskIntoConstraints = false iconTile.wantsLayer = true iconTile.layer?.backgroundColor = Theme.settingsIconBackground.cgColor iconTile.layer?.cornerRadius = 9 let icon = NSImageView() icon.translatesAutoresizingMaskIntoConstraints = false icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium) icon.image = NSImage(systemSymbolName: systemImage, accessibilityDescription: title) icon.contentTintColor = Theme.brandBlue let titleLabel = NSTextField(labelWithString: title) titleLabel.font = .systemFont(ofSize: 14, weight: .medium) titleLabel.textColor = Theme.secondaryText titleLabel.alignment = .left let rowStack = NSStackView() rowStack.orientation = .horizontal rowStack.spacing = 16 rowStack.alignment = .centerY rowStack.translatesAutoresizingMaskIntoConstraints = false let spacer = NSView() spacer.translatesAutoresizingMaskIntoConstraints = false spacer.setContentHuggingPriority(NSLayoutConstraint.Priority(1), for: .horizontal) iconTile.addSubview(icon) rowStack.addArrangedSubview(iconTile) rowStack.addArrangedSubview(titleLabel) rowStack.addArrangedSubview(spacer) if let accessory { rowStack.addArrangedSubview(accessory) } row.addSubview(rowStack) NSLayoutConstraint.activate([ row.heightAnchor.constraint(equalToConstant: 68), iconTile.widthAnchor.constraint(equalToConstant: 38), iconTile.heightAnchor.constraint(equalToConstant: 38), icon.centerXAnchor.constraint(equalTo: iconTile.centerXAnchor), icon.centerYAnchor.constraint(equalTo: iconTile.centerYAnchor), icon.widthAnchor.constraint(equalToConstant: 20), icon.heightAnchor.constraint(equalToConstant: 20), rowStack.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 16), rowStack.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -16), rowStack.topAnchor.constraint(equalTo: row.topAnchor), rowStack.bottomAnchor.constraint(equalTo: row.bottomAnchor) ]) return row } private func reloadSavedJobsListings() { savedJobsStack.arrangedSubviews.forEach { savedJobsStack.removeArrangedSubview($0) $0.removeFromSuperview() } if savedJobOrder.isEmpty { savedJobsPageSubtitleLabel.stringValue = "Save jobs from Home to see them here." let empty = NSTextField(wrappingLabelWithString: "No saved jobs yet. Search on Home, then tap Save on a listing.") empty.font = .systemFont(ofSize: 14, weight: .regular) empty.textColor = Theme.secondaryText empty.alignment = .left empty.maximumNumberOfLines = 0 empty.translatesAutoresizingMaskIntoConstraints = false savedJobsStack.addArrangedSubview(empty) empty.widthAnchor.constraint(equalTo: savedJobsStack.widthAnchor).isActive = true return } savedJobsPageSubtitleLabel.stringValue = "\(savedJobOrder.count) saved \(savedJobOrder.count == 1 ? "position" : "positions")" for job in savedJobOrder { let card = makeJobListingCard(job, context: .savedJobsPage) savedJobsStack.addArrangedSubview(card) card.widthAnchor.constraint(equalTo: savedJobsStack.widthAnchor).isActive = true } } private func isSavedJobsSidebarIndex(_ index: Int) -> Bool { guard index >= 0, index < currentSidebarItems.count else { return false } return currentSidebarItems[index].title == "Saved Jobs" } private func isHomeSidebarIndex(_ index: Int) -> Bool { guard index >= 0, index < currentSidebarItems.count else { return false } return currentSidebarItems[index].title == "Home" } private func isSettingsSidebarIndex(_ index: Int) -> Bool { guard index >= 0, index < currentSidebarItems.count else { return false } return currentSidebarItems[index].title == "Settings" } private func updateMainContentVisibility() { let home = isHomeSidebarIndex(selectedSidebarIndex) let savedJobs = isSavedJobsSidebarIndex(selectedSidebarIndex) let settings = isSettingsSidebarIndex(selectedSidebarIndex) mainOverlay.isHidden = !home nonHomeHost.isHidden = home nonHomeGenericContainer.isHidden = savedJobs || settings savedJobsPageContainer.isHidden = !savedJobs settingsPageContainer.isHidden = !settings if !home, selectedSidebarIndex < currentSidebarItems.count { if savedJobs { reloadSavedJobsListings() } else if settings { window?.makeFirstResponder(nil) } else { nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title } } } /// Restores the main job-search experience: cleared query and a fresh chat history. private func applyHomeState() { jobKeywordsField.stringValue = "" resetChatState() window?.makeFirstResponder(nil) } private func updateSearchBarShadowPath() { guard searchBarShadowHost.bounds.width > 0, searchBarShadowHost.bounds.height > 0 else { return } let r = searchBarShadowHost.bounds let radius = min(r.height / 2, 27) searchBarShadowHost.layer?.shadowPath = CGPath( roundedRect: r, cornerWidth: radius, cornerHeight: radius, transform: nil ) } @objc private func didSubmitSearch() { let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !prompt.isEmpty, !isAwaitingResponse else { return } let isContinuation = isContinuationPrompt(prompt) let effectiveQuery = resolvedSearchQuery(for: prompt) appendChatBubble(text: prompt, isUser: true) chatMessages.append(ChatMessage(role: "user", content: prompt)) jobKeywordsField.stringValue = "" isAwaitingResponse = true chatStatusLabel.stringValue = "Thinking..." setInputEnabled(false) let contextMessages = chatMessages jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages) { [weak self] result in DispatchQueue.main.async { guard let self else { return } self.isAwaitingResponse = false self.setInputEnabled(true) switch result { case .success(let output): let normalizedJobs = self.normalizedJobs(output.jobs) let freshJobs: [JobListing] if isContinuation { // Continuations append only the *new* matches; previous cards already live in their own assistant message above. let alreadySeen = Set(self.lastSearchResults) freshJobs = normalizedJobs.filter { !alreadySeen.contains($0) } } else { freshJobs = normalizedJobs } self.lastSearchResults.append(contentsOf: freshJobs) let reply = self.makeAssistantSearchReply( query: effectiveQuery, newJobsCount: freshJobs.count, isContinuation: isContinuation ) self.chatMessages.append(ChatMessage(role: "assistant", content: reply)) self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs) self.chatStatusLabel.stringValue = "Ask for another role, company, or skill match" case .failure(let error): self.appendChatBubble(text: error.localizedDescription, isUser: false) self.chatStatusLabel.stringValue = "Could not reach API. Try again." } } } window?.makeFirstResponder(nil) } private func normalizedJobs(_ jobs: [JobListing]) -> [JobListing] { let trimmed = jobs.map { JobListing( title: $0.title.trimmingCharacters(in: .whitespacesAndNewlines), description: $0.description.trimmingCharacters(in: .whitespacesAndNewlines), url: $0.url?.trimmingCharacters(in: .whitespacesAndNewlines) ) } return trimmed.filter { !$0.title.isEmpty && !$0.description.isEmpty } } private func makeAssistantSearchReply(query: String, newJobsCount: Int, isContinuation: Bool) -> String { if newJobsCount == 0 { if isContinuation { return "I couldn't find new matches for \u{201C}\(query)\u{201D}. Try a different angle or a more specific keyword." } return "No jobs found for \u{201C}\(query)\u{201D}. Try another title, skill, company, or location." } let plural = newJobsCount == 1 ? "match" : "matches" if isContinuation { return "Here are \(newJobsCount) more \(plural) for \u{201C}\(query)\u{201D}." } return "Found \(newJobsCount) \(plural) for \u{201C}\(query)\u{201D}. Tap Apply to open the listing or Save to revisit later." } private func resolvedSearchQuery(for prompt: String) -> String { guard isContinuationPrompt(prompt) else { return prompt } for message in chatMessages.reversed() where message.role == "user" { let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines) if !candidate.isEmpty, !isContinuationPrompt(candidate) { return candidate } } return prompt } private func isContinuationPrompt(_ prompt: String) -> Bool { let normalized = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let continuationPhrases: Set = [ "more", "show more", "more jobs", "more results", "do more searches", "more searches", "search more", "continue", "next" ] if continuationPhrases.contains(normalized) { return true } return normalized.contains("more search") || normalized.contains("more job") } func controlTextDidBeginEditing(_ obj: Notification) { applySearchFieldInsertionPoint(obj.object) if (obj.object as? NSTextField) === jobKeywordsField { chatStatusLabel.stringValue = "Opening the vault..." } } func controlTextDidChange(_ obj: Notification) { applySearchFieldInsertionPoint(obj.object) } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { guard control === jobKeywordsField, commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false } didSubmitSearch() return true } private func applySearchFieldInsertionPoint(_ object: Any?) { guard let field = object as? NSTextField, field === jobKeywordsField, let textView = field.window?.fieldEditor(true, for: field) as? NSTextView else { return } textView.insertionPointColor = Theme.primaryText } private func resetChatState() { chatMessages.removeAll() lastSearchResults.removeAll() chatStack.arrangedSubviews.forEach { chatStack.removeArrangedSubview($0) $0.removeFromSuperview() } let welcome = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary." chatMessages.append(ChatMessage(role: "assistant", content: welcome)) appendChatBubble(text: welcome, isUser: false) chatStatusLabel.stringValue = "Ask me to find jobs" } private func appendChatBubble(text: String, isUser: Bool, jobs: [JobListing]? = nil) { let host = NSView() host.translatesAutoresizingMaskIntoConstraints = false if isUser { installUserBubble(text: text, into: host) } else { installAssistantBubble(text: text, jobs: jobs, into: host) } chatStack.addArrangedSubview(host) host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true DispatchQueue.main.async { [weak self] in self?.updateChatBubbleWidths() self?.scrollChatToBottom() } } private func installUserBubble(text: String, into host: NSView) { let bubble = makeChatBubbleContainer(text: text, isUser: true) host.addSubview(bubble) NSLayoutConstraint.activate([ bubble.topAnchor.constraint(equalTo: host.topAnchor), bubble.bottomAnchor.constraint(equalTo: host.bottomAnchor), bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor), bubble.leadingAnchor.constraint(greaterThanOrEqualTo: host.leadingAnchor, constant: 64), bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78) ]) } private func installAssistantBubble(text: String, jobs: [JobListing]?, into host: NSView) { let avatar = makeAssistantAvatarView() let nameLabel = NSTextField(labelWithString: "AI Job Finder") nameLabel.font = .systemFont(ofSize: 11, weight: .semibold) nameLabel.textColor = Theme.secondaryText nameLabel.translatesAutoresizingMaskIntoConstraints = false let bubble = makeChatBubbleContainer(text: text, isUser: false) let column = NSStackView(views: [nameLabel, bubble]) column.orientation = .vertical column.spacing = 6 column.alignment = .leading column.translatesAutoresizingMaskIntoConstraints = false if let jobs, !jobs.isEmpty { let jobsStack = makeChatJobsStackView(jobs: jobs) column.addArrangedSubview(jobsStack) jobsStack.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true } host.addSubview(avatar) host.addSubview(column) NSLayoutConstraint.activate([ avatar.leadingAnchor.constraint(equalTo: host.leadingAnchor), avatar.topAnchor.constraint(equalTo: host.topAnchor), avatar.widthAnchor.constraint(equalToConstant: 36), avatar.heightAnchor.constraint(equalToConstant: 36), column.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 12), column.trailingAnchor.constraint(equalTo: host.trailingAnchor), column.topAnchor.constraint(equalTo: host.topAnchor), column.bottomAnchor.constraint(equalTo: host.bottomAnchor) ]) } private func makeChatBubbleContainer(text: String, isUser: Bool) -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false container.wantsLayer = true container.layer?.cornerRadius = 14 if #available(macOS 11.0, *) { container.layer?.cornerCurve = .continuous } container.layer?.masksToBounds = true if isUser { container.layer?.backgroundColor = Theme.brandBlue.cgColor } else { container.layer?.backgroundColor = Theme.chromeBackground.cgColor container.layer?.borderWidth = 1 container.layer?.borderColor = Theme.border.cgColor } let label = ChatBubbleLabel(wrappingLabelWithString: text) label.font = .systemFont(ofSize: 13.5, weight: .regular) label.textColor = isUser ? .white : Theme.primaryText label.maximumNumberOfLines = 0 label.lineBreakMode = .byWordWrapping label.alignment = .left label.translatesAutoresizingMaskIntoConstraints = false label.setContentHuggingPriority(.defaultLow, for: .horizontal) label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) container.addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14), label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14), label.topAnchor.constraint(equalTo: container.topAnchor, constant: 10), label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10) ]) return container } private func makeAssistantAvatarView() -> NSView { let view = NSView() view.translatesAutoresizingMaskIntoConstraints = false view.wantsLayer = true view.layer?.cornerRadius = 18 if #available(macOS 11.0, *) { view.layer?.cornerCurve = .continuous } view.layer?.backgroundColor = Theme.settingsIconBackground.cgColor view.layer?.borderWidth = 1 view.layer?.borderColor = Theme.proCardBorder.cgColor let icon = NSImageView() icon.translatesAutoresizingMaskIntoConstraints = false icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold) icon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "AI Job Finder") icon.contentTintColor = Theme.brandBlue view.addSubview(icon) NSLayoutConstraint.activate([ icon.centerXAnchor.constraint(equalTo: view.centerXAnchor), icon.centerYAnchor.constraint(equalTo: view.centerYAnchor) ]) return view } private func makeChatJobsStackView(jobs: [JobListing]) -> ChatJobsStackView { let stack = ChatJobsStackView() stack.orientation = .vertical stack.spacing = 10 stack.alignment = .leading stack.distribution = .fill stack.translatesAutoresizingMaskIntoConstraints = false for job in jobs { let card = makeJobListingCard(job, context: .homeSearchResults) stack.addArrangedSubview(card) card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } return stack } private func scrollChatToBottom() { let maxY = max(0, chatDocumentView.bounds.height - chatScrollView.contentView.bounds.height) chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY)) chatScrollView.reflectScrolledClipView(chatScrollView.contentView) } private func setInputEnabled(_ enabled: Bool) { jobKeywordsField.isEnabled = enabled findJobsButton.isEnabled = enabled findJobsButton.alphaValue = enabled ? 1 : 0.65 } private func configureSidebar() { let items = currentSidebarItems sidebar.arrangedSubviews.forEach { sidebar.removeArrangedSubview($0) $0.removeFromSuperview() } let brand = NSTextField(labelWithString: "Indeed AI\nJob Finder") brand.font = .systemFont(ofSize: 18, weight: .bold) brand.textColor = Theme.brandBlue brand.alignment = .left brand.maximumNumberOfLines = 2 // Tight multiline height in the sidebar stack (zero width makes intrinsic height unreliable). brand.preferredMaxLayoutWidth = 194 sidebar.addArrangedSubview(brand) sidebar.setCustomSpacing(10, after: brand) items.enumerated().forEach { index, item in let isSelected = index == selectedSidebarIndex let rowHost = SidebarNavRowView { [weak self] in self?.selectSidebarItem(at: index) } rowHost.translatesAutoresizingMaskIntoConstraints = false rowHost.wantsLayer = true rowHost.layer?.cornerRadius = 8 rowHost.restingBackgroundColor = isSelected ? Theme.selectionFill : nil rowHost.hoverBackgroundColor = isSelected ? Theme.selectionFillHover : Theme.sidebarRowHoverFill rowHost.setAccessibilityLabel(item.title) rowHost.setAccessibilityRole(.button) rowHost.setAccessibilitySelected(isSelected) let row = NSStackView() row.orientation = .horizontal row.spacing = 8 row.alignment = .centerY row.translatesAutoresizingMaskIntoConstraints = false let icon = NSImageView() icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium) icon.image = NSImage(systemSymbolName: item.systemImage, accessibilityDescription: item.title) icon.contentTintColor = isSelected ? Theme.brandBlue : Theme.secondaryText let text = NSTextField(labelWithString: item.title) text.font = .systemFont(ofSize: 14, weight: .medium) text.textColor = isSelected ? Theme.brandBlue : Theme.secondaryText text.refusesFirstResponder = true row.addArrangedSubview(icon) row.addArrangedSubview(text) if let badge = item.badge { let badgeField = NSTextField(labelWithString: badge) badgeField.font = .systemFont(ofSize: 11, weight: .semibold) badgeField.textColor = Theme.primaryText badgeField.wantsLayer = true badgeField.layer?.backgroundColor = Theme.toggleBackground.cgColor badgeField.layer?.cornerRadius = 8 badgeField.alignment = .center badgeField.maximumNumberOfLines = 1 badgeField.lineBreakMode = .byClipping badgeField.refusesFirstResponder = true badgeField.translatesAutoresizingMaskIntoConstraints = false badgeField.widthAnchor.constraint(equalToConstant: 42).isActive = true row.addArrangedSubview(NSView()) row.addArrangedSubview(badgeField) } rowHost.addSubview(row) NSLayoutConstraint.activate([ row.leadingAnchor.constraint(equalTo: rowHost.leadingAnchor, constant: 10), row.trailingAnchor.constraint(equalTo: rowHost.trailingAnchor, constant: -10), row.topAnchor.constraint(equalTo: rowHost.topAnchor, constant: 8), row.bottomAnchor.constraint(equalTo: rowHost.bottomAnchor, constant: -8) ]) rowHost.setContentHuggingPriority(.defaultLow, for: .horizontal) sidebar.addArrangedSubview(rowHost) let sidebarHorizontalInset = sidebar.edgeInsets.left + sidebar.edgeInsets.right rowHost.widthAnchor.constraint(equalTo: sidebar.widthAnchor, constant: -sidebarHorizontalInset).isActive = true } let sidebarBottomSpacer = NSView() sidebarBottomSpacer.translatesAutoresizingMaskIntoConstraints = false sidebarBottomSpacer.setContentHuggingPriority(.defaultLow, for: .vertical) sidebarBottomSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) sidebar.addArrangedSubview(sidebarBottomSpacer) let upgradeCard = NSView() upgradeCard.translatesAutoresizingMaskIntoConstraints = false upgradeCard.wantsLayer = true upgradeCard.layer?.backgroundColor = Theme.proCardFill.cgColor upgradeCard.layer?.cornerRadius = 14 upgradeCard.layer?.borderWidth = 1 upgradeCard.layer?.borderColor = Theme.proCardBorder.cgColor upgradeCard.layer?.masksToBounds = true let accentBar = NSView() accentBar.translatesAutoresizingMaskIntoConstraints = false accentBar.wantsLayer = true accentBar.layer?.backgroundColor = Theme.proAccent.cgColor let inner = NSStackView() inner.translatesAutoresizingMaskIntoConstraints = false inner.orientation = .vertical inner.spacing = 10 inner.alignment = .centerX let proIcon = NSImageView() proIcon.translatesAutoresizingMaskIntoConstraints = false proIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold) proIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil) proIcon.contentTintColor = Theme.proAccent let proEyebrow = NSTextField(labelWithString: "Premium") proEyebrow.font = .systemFont(ofSize: 11, weight: .heavy) proEyebrow.textColor = Theme.proAccent proEyebrow.alignment = .center let eyebrowRow = NSStackView(views: [proIcon, proEyebrow]) eyebrowRow.orientation = .horizontal eyebrowRow.spacing = 6 eyebrowRow.alignment = .centerY let headline = NSTextField(labelWithString: "Upgrade to Pro") headline.font = .systemFont(ofSize: 16, weight: .bold) headline.textColor = Theme.primaryText headline.alignment = .center let upgradeDescription = NSTextField(wrappingLabelWithString: "Unlimited AI matches, smart alerts, and interview prep—all in one place.") upgradeDescription.font = .systemFont(ofSize: 12, weight: .regular) upgradeDescription.textColor = Theme.secondaryText upgradeDescription.alignment = .center // Sidebar content width is the fixed sidebar width minus horizontal edge insets; card must stay within that band. let cardWidth: CGFloat = 186 let innerContentWidth = cardWidth - 28 upgradeDescription.preferredMaxLayoutWidth = innerContentWidth let upgradeButton = HoverableButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro)) upgradeButton.isBordered = false upgradeButton.bezelStyle = .rounded upgradeButton.font = .systemFont(ofSize: 13, weight: .bold) upgradeButton.contentTintColor = Theme.proCTAText upgradeButton.alignment = .center upgradeButton.wantsLayer = true upgradeButton.layer?.backgroundColor = Theme.proCTABackground.cgColor upgradeButton.layer?.cornerRadius = 20 upgradeButton.translatesAutoresizingMaskIntoConstraints = false upgradeButton.heightAnchor.constraint(equalToConstant: 40).isActive = true upgradeButton.pointerCursor = true upgradeButton.hoverHandler = { [weak upgradeButton] hovering in upgradeButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.proCTABackground).cgColor } inner.addArrangedSubview(eyebrowRow) inner.addArrangedSubview(headline) inner.addArrangedSubview(upgradeDescription) inner.addArrangedSubview(upgradeButton) upgradeCard.addSubview(accentBar) upgradeCard.addSubview(inner) NSLayoutConstraint.activate([ upgradeCard.widthAnchor.constraint(equalToConstant: cardWidth), accentBar.topAnchor.constraint(equalTo: upgradeCard.topAnchor), accentBar.leadingAnchor.constraint(equalTo: upgradeCard.leadingAnchor), accentBar.trailingAnchor.constraint(equalTo: upgradeCard.trailingAnchor), accentBar.heightAnchor.constraint(equalToConstant: 2), inner.leadingAnchor.constraint(equalTo: upgradeCard.leadingAnchor, constant: 14), inner.trailingAnchor.constraint(equalTo: upgradeCard.trailingAnchor, constant: -14), inner.topAnchor.constraint(equalTo: accentBar.bottomAnchor, constant: 12), inner.bottomAnchor.constraint(equalTo: upgradeCard.bottomAnchor, constant: -14), upgradeButton.widthAnchor.constraint(equalTo: inner.widthAnchor) ]) sidebar.addArrangedSubview(upgradeCard) } @objc private func didTapUpgradeToPro() { guard let url = URL(string: "https://www.indeed.com") else { return } NSWorkspace.shared.open(url) } @objc private func didChangeThemeSelection(_ sender: NSSegmentedControl) { switch sender.selectedSegment { case 1: NSApp.appearance = NSAppearance(named: .aqua) case 2: NSApp.appearance = NSAppearance(named: .darkAqua) default: NSApp.appearance = nil } } private func selectSidebarItem(at index: Int) { guard index >= 0, index < currentSidebarItems.count else { return } let selectingHome = isHomeSidebarIndex(index) if index == selectedSidebarIndex { if selectingHome { applyHomeState() } return } selectedSidebarIndex = index configureSidebar() updateMainContentVisibility() if selectingHome { applyHomeState() } } } private struct ChatMessage: Codable { let role: String let content: String } private final class OpenAIJobSearchService { private let endpoint = URL(string: "https://api.openai.com/v1/responses")! private let session = URLSession(configuration: .ephemeral) func searchJobs(query: String, conversation: [ChatMessage], completion: @escaping (Result) -> Void) { let apiKey = OpenAIConfiguration.apiKey guard OpenAIConfiguration.hasAPIKey else { completion(.failure(NSError( domain: "OpenAIJobSearchService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing API key. Set OPENAI_API_KEY in Xcode Build Settings."] ))) return } var request = URLRequest(url: endpoint) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 45 let recentContext = conversation.suffix(8) .map { "\($0.role.uppercased()): \($0.content)" } .joined(separator: "\n") let contextBlock: String if recentContext.isEmpty { contextBlock = "No prior conversation context." } else { contextBlock = recentContext } let instructions = """ Continue this same job-search conversation. Use prior context when useful, and prioritize the latest user query. Conversation context: \(contextBlock) Latest user query: "\(query)" Search the web for currently available jobs related to the latest query (and relevant context above). Return ONLY strict JSON that matches this schema: { "jobs": [ { "title": "string", "description": "string", "url": "https://..." } ] } Keep descriptions concise (1 sentence), include real apply/listing URLs when available, and return up to 8 jobs. """ let payload = OpenAIResponsesRequest( model: "gpt-4o-mini", input: instructions, tools: [OpenAIResponsesTool(type: "web_search_preview")] ) do { request.httpBody = try JSONEncoder().encode(payload) } catch { completion(.failure(error)) return } session.dataTask(with: request) { data, response, error in if let error { completion(.failure(error)) return } guard let data else { completion(.failure(NSError( domain: "OpenAIJobSearchService", code: 2, userInfo: [NSLocalizedDescriptionKey: "API returned an empty response."] ))) return } if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { if let apiError = try? JSONDecoder().decode(OpenAIAPIErrorResponse.self, from: data) { completion(.failure(NSError( domain: "OpenAIJobSearchService", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: apiError.error.message] ))) } else { completion(.failure(NSError( domain: "OpenAIJobSearchService", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: "Job search request failed with status \(http.statusCode)."] ))) } return } do { let decoded = try JSONDecoder().decode(OpenAIResponsesResponse.self, from: data) let text = decoded.bestText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { throw NSError( domain: "OpenAIJobSearchService", code: 4, userInfo: [NSLocalizedDescriptionKey: "The API returned an empty text payload."] ) } let cleanedText = Self.extractJSONObject(from: text) let jsonData = Data(cleanedText.utf8) let jobsPayload = try JSONDecoder().decode(JobSearchResultsPayload.self, from: jsonData) completion(.success(JobSearchOutput(jobs: jobsPayload.jobs))) } catch { let rawBody = String(data: data, encoding: .utf8) ?? "" let message = "The API response could not be parsed as job JSON. Raw response: \(rawBody.prefix(600))" completion(.failure(NSError( domain: "OpenAIJobSearchService", code: 5, userInfo: [NSLocalizedDescriptionKey: message] ))) } }.resume() } private static func extractJSONObject(from text: String) -> String { if let range = text.range(of: "\\{[\\s\\S]*\\}", options: .regularExpression) { return String(text[range]) } return text } } private struct OpenAIResponsesRequest: Codable { let model: String let input: String let tools: [OpenAIResponsesTool] } private struct OpenAIResponsesTool: Codable { let type: String } private struct OpenAIResponsesResponse: Codable { let outputText: String? let output: [OpenAIOutputItem]? enum CodingKeys: String, CodingKey { case outputText = "output_text" case output } var bestText: String { if let outputText, !outputText.isEmpty { return outputText } let collected = (output ?? []) .flatMap { $0.content ?? [] } .compactMap { chunk in switch chunk { case .outputText(let value): return value.text case .inputText: return nil } } .joined(separator: "\n") return collected } } private struct OpenAIOutputItem: Codable { let content: [OpenAIOutputContent]? } private enum OpenAIOutputContent: Codable { case outputText(OpenAITextChunk) case inputText(OpenAITextChunk) enum CodingKeys: String, CodingKey { case type case text } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(String.self, forKey: .type) let text = try container.decode(String.self, forKey: .text) let payload = OpenAITextChunk(text: text) if type == "output_text" { self = .outputText(payload) } else { self = .inputText(payload) } } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .outputText(let chunk): try container.encode("output_text", forKey: .type) try container.encode(chunk.text, forKey: .text) case .inputText(let chunk): try container.encode("input_text", forKey: .type) try container.encode(chunk.text, forKey: .text) } } } private struct OpenAITextChunk: Codable { let text: 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 } } /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target). private final class JobPayloadButton: HoverableButton { var jobPayload: JobListing? var cardContext: JobListingCardContext = .homeSearchResults } /// `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 } }