| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563 |
- //
- // 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
- }
- final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDelegate, NSSharingServiceDelegate {
- /// 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)
- /// Welcome hero (matches reference: `#0052CC` heading, `#334155` subline, `#EFF6FF` icon well).
- static let welcomeHeroHeadingBlue = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1)
- static let welcomeHeroSubtitleText = NSColor(srgbRed: 51 / 255, green: 65 / 255, blue: 85 / 255, alpha: 1)
- static let welcomeHeroIconWell = NSColor(srgbRed: 239 / 255, green: 246 / 255, blue: 255 / 255, alpha: 1)
- /// Light decorative strokes / sparkles behind the welcome hero.
- static let welcomeHeroWaveTint = NSColor(srgbRed: 186 / 255, green: 210 / 255, blue: 253 / 255, alpha: 1)
- /// Subtitle on the welcome hero: dark neutral gray to match the reference layout.
- static let welcomeSubtitleText = NSColor(srgbRed: 64 / 255, green: 64 / 255, blue: 64 / 255, alpha: 1)
- static let selectionFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.12)
- static let cardBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
- static let toggleBackground = NSColor(srgbRed: 232 / 255, green: 232 / 255, blue: 232 / 255, alpha: 1)
- static let primaryText = NSColor(srgbRed: 45 / 255, green: 45 / 255, blue: 45 / 255, alpha: 1)
- static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
- static let tertiaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
- static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
- static let featureIconWell = NSColor(srgbRed: 220 / 255, green: 235 / 255, blue: 252 / 255, alpha: 1)
- /// Job search bar outer stroke (soft blue-gray, pill field in the reference UI).
- static let searchBarBorder = NSColor(srgbRed: 180 / 255, green: 200 / 255, blue: 228 / 255, alpha: 1)
- /// Search bar border on hover (brand-tinted, matches focus affordance elsewhere).
- static let searchBarBorderHover = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55)
- static let proCardFill = NSColor(srgbRed: 239 / 255, green: 244 / 255, blue: 252 / 255, alpha: 1)
- static let proCardBorder = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
- static let proAccent = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
- static let proCTABackground = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
- static let proCTAText = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
- /// Slightly lighter blue for the top of the search-bar “Find jobs” pill (reads less flat than a solid fill).
- static let findJobsCTAHighlight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
- /// Hover states: darker brand blue, deeper gradient top, stronger tints, and subtle neutral fills used across CTAs, toggles, and the sidebar.
- static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
- static let findJobsCTAHighlightHover = NSColor(srgbRed: 44 / 255, green: 94 / 255, blue: 178 / 255, alpha: 1)
- static let selectionFillHover = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.2)
- static let neutralHoverFill = NSColor(srgbRed: 240 / 255, green: 240 / 255, blue: 240 / 255, alpha: 1)
- static let sidebarRowHoverFill = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.04)
- static let settingsPageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
- static let settingsGroupBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
- static let settingsIconBackground = NSColor(srgbRed: 239 / 255, green: 244 / 255, blue: 252 / 255, alpha: 1)
- static let settingsDivider = NSColor(srgbRed: 228 / 255, green: 228 / 255, blue: 228 / 255, alpha: 1)
- }
- /// Multiline `NSTextField` often ignores `alignment` for wrapped runs; explicit paragraph alignment matches the title.
- private static func jobListingDescriptionAttributedString(_ plain: String) -> NSAttributedString {
- let paragraph = NSMutableParagraphStyle()
- paragraph.alignment = .left
- paragraph.lineBreakMode = .byWordWrapping
- paragraph.baseWritingDirection = .leftToRight
- let font = NSFont.systemFont(ofSize: 13, weight: .regular)
- return NSAttributedString(string: plain, attributes: [
- .font: font,
- .foregroundColor: Theme.secondaryText,
- .paragraphStyle: paragraph
- ])
- }
- /// 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 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 welcomeHeroHost = NSView()
- private let welcomeHeroBackgroundView = WelcomeHeroBackgroundView()
- private lazy var welcomeSparkleCluster: WelcomeSparkleClusterView = {
- WelcomeSparkleClusterView(iconWell: Theme.welcomeHeroIconWell, tint: Theme.welcomeHeroHeadingBlue)
- }()
- private let featureCardsRow = NSStackView()
- private let clearChatButton = NSButton(title: "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: "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 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
- /// 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 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?
- /// 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
- private static func clampedJobsPerRequest(_ requested: Int = jobsPerSearchDefault) -> Int {
- min(jobsPerSearchMaxCap, max(jobsPerSearchMin, requested))
- }
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- setupLayout()
- }
- required init?(coder: NSCoder) {
- super.init(coder: coder)
- setupLayout()
- }
- deinit {
- if let subscriptionObserver {
- NotificationCenter.default.removeObserver(subscriptionObserver)
- }
- }
- 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()
- findJobsCTAGradientLayer?.frame = findJobsCTAChrome.bounds
- updateFindJobsCTAShadowPath()
- updateJobListingDescriptionWidths()
- updateChatBubbleWidths()
- }
- func render(_ data: DashboardData) {
- dismissIndeedJobBrowserEmbedded()
- 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
- // 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: 6, 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
- let welcomeHeroContent = NSStackView(views: [welcomeSparkleCluster, 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)
- ])
- 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.font = .systemFont(ofSize: 12, weight: .medium)
- clearChatButton.target = self
- clearChatButton.action = #selector(didTapClearChat)
- clearChatButton.toolTip = "Remove all messages and start a new conversation"
- clearChatButton.setContentHuggingPriority(.required, for: .horizontal)
- chatHeaderRow.addArrangedSubview(chatHeaderLeadingSpacer)
- chatHeaderRow.addArrangedSubview(clearChatButton)
- mainOverlay.addArrangedSubview(chatHeaderRow)
- mainOverlay.addArrangedSubview(chatScrollView)
- mainOverlay.addArrangedSubview(chatBottomSpacer)
- mainOverlay.addArrangedSubview(searchBarShadowHost)
- 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),
- searchBarShadowHost.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()
- }
- 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 = "You're on Pro"
- upgradeDescription.stringValue = "Manage billing, renewals, and plans in Premium."
- upgradeDescription.preferredMaxLayoutWidth = descriptionWidth
- upgradeButton.title = "Manage Subscription"
- } else {
- headline.stringValue = "Upgrade to Pro"
- upgradeDescription.stringValue = "Unlimited AI matches, smart alerts, and interview prep—all in one place."
- upgradeDescription.preferredMaxLayoutWidth = descriptionWidth
- upgradeButton.title = "Try Pro"
- }
- }
- private func presentPremiumPlansSheet() {
- guard let hostWindow = window else { return }
- if premiumPlansWindowController == nil {
- premiumPlansWindowController = PremiumPlansWindowController()
- }
- guard let paywallWindow = premiumPlansWindowController?.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
- 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, title: String, subtitle: String, action: Selector)] = [
- ("briefcase", "Role", "Explore similar or better job roles", #selector(didTapFeatureRole)),
- ("building.2", "Company", "Find opportunities at other companies", #selector(didTapFeatureCompany)),
- ("chevron.left.forwardslash.chevron.right", "Skill", "Match jobs that fit your skills", #selector(didTapFeatureSkill))
- ]
- for spec in specs {
- let card = FeatureShortcutCardView(
- symbolName: spec.symbol,
- title: spec.title,
- subtitle: spec.subtitle,
- target: self,
- action: spec.action
- )
- featureCardsRow.addArrangedSubview(card)
- }
- }
- private func configureChatViews() {
- 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<JobListing>()
- var out: [JobListing] = []
- for job in jobs where seen.insert(job).inserted {
- out.append(job)
- }
- return out
- }
- private func isJobSaved(_ job: JobListing) -> Bool {
- savedJobOrder.contains(job)
- }
- private func persistSavedJobs() {
- SavedJobsStore.save(savedJobOrder)
- }
- private func applySavedState(_ saved: Bool, for job: JobListing) {
- if saved {
- savedJobOrder.removeAll { $0 == job }
- savedJobOrder.insert(job, at: 0)
- } else {
- savedJobOrder.removeAll { $0 == job }
- }
- persistSavedJobs()
- }
- private func jobListingHostSubtitle(_ job: JobListing) -> String {
- guard let raw = job.url, let url = URL(string: raw), let host = url.host?.lowercased() else {
- return "Indeed"
- }
- if host.hasPrefix("www.") {
- return String(host.dropFirst(4))
- }
- return host
- }
- private func jobListingCategorySymbol(for job: JobListing) -> String {
- let blob = (job.title + " " + job.description).lowercased()
- if blob.contains("machine learning") || blob.contains("deep learning") || blob.contains(" ml ") {
- return "brain.head.profile"
- }
- if blob.contains("audio") || blob.contains(" sound ") || blob.contains("dsp") {
- return "waveform"
- }
- if blob.contains("ios") || blob.contains("swift") || blob.contains("mobile") {
- return "iphone"
- }
- if blob.contains("design") || blob.contains(" ux") || blob.contains("figma") {
- return "paintpalette.fill"
- }
- if blob.contains("data ") || blob.contains("analytics") {
- return "chart.bar.fill"
- }
- if blob.contains("ai") || blob.contains("llm") || blob.contains("nlp") {
- return "cpu"
- }
- return "briefcase.fill"
- }
- private func makeJobListingCard(_ job: JobListing, context: JobListingCardContext) -> NSView {
- let card = NSView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.backgroundColor = Theme.cardBackground.cgColor
- card.layer?.cornerRadius = 14
- card.layer?.borderWidth = 1
- card.layer?.borderColor = Theme.border.cgColor
- card.layer?.masksToBounds = true
- let iconBox = NSView()
- iconBox.translatesAutoresizingMaskIntoConstraints = false
- iconBox.wantsLayer = true
- iconBox.layer?.backgroundColor = Theme.brandBlue.cgColor
- iconBox.layer?.cornerRadius = 12
- if #available(macOS 11.0, *) {
- iconBox.layer?.cornerCurve = .continuous
- }
- let categoryIcon = NSImageView()
- categoryIcon.translatesAutoresizingMaskIntoConstraints = false
- categoryIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 22, weight: .medium)
- categoryIcon.image = NSImage(systemSymbolName: jobListingCategorySymbol(for: job), accessibilityDescription: nil)
- categoryIcon.contentTintColor = .white
- iconBox.addSubview(categoryIcon)
- let titleField = NSTextField(labelWithString: job.title)
- titleField.font = .systemFont(ofSize: 16, weight: .semibold)
- titleField.textColor = Theme.brandBlue
- titleField.maximumNumberOfLines = 2
- titleField.lineBreakMode = .byWordWrapping
- titleField.alignment = .left
- titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- titleField.translatesAutoresizingMaskIntoConstraints = false
- let buildingIcon = NSImageView()
- buildingIcon.translatesAutoresizingMaskIntoConstraints = false
- buildingIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .medium)
- buildingIcon.image = NSImage(systemSymbolName: "building.2.fill", accessibilityDescription: nil)
- buildingIcon.contentTintColor = Theme.welcomeSubtitleText
- let companyLabel = NSTextField(labelWithString: jobListingHostSubtitle(job))
- companyLabel.font = .systemFont(ofSize: 12, weight: .medium)
- companyLabel.textColor = Theme.welcomeSubtitleText
- companyLabel.maximumNumberOfLines = 1
- companyLabel.lineBreakMode = .byTruncatingTail
- companyLabel.translatesAutoresizingMaskIntoConstraints = false
- let companyRow = NSStackView(views: [buildingIcon, companyLabel])
- companyRow.orientation = .horizontal
- companyRow.spacing = 5
- companyRow.alignment = .centerY
- companyRow.translatesAutoresizingMaskIntoConstraints = false
- let descriptionField = NSTextField(wrappingLabelWithString: job.description)
- descriptionField.font = .systemFont(ofSize: 13, weight: .regular)
- descriptionField.textColor = Theme.secondaryText
- descriptionField.maximumNumberOfLines = 2
- descriptionField.lineBreakMode = .byWordWrapping
- descriptionField.alignment = .left
- descriptionField.baseWritingDirection = .leftToRight
- descriptionField.attributedStringValue = Self.jobListingDescriptionAttributedString(job.description)
- if let cell = descriptionField.cell as? NSTextFieldCell {
- cell.alignment = .left
- cell.wraps = true
- }
- descriptionField.setContentHuggingPriority(.defaultLow, for: .horizontal)
- descriptionField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- descriptionField.tag = 502
- descriptionField.translatesAutoresizingMaskIntoConstraints = false
- let applyButton = JobPayloadButton(title: "Apply", target: self, action: #selector(didTapJobApply(_:)))
- applyButton.jobPayload = job
- applyButton.cardContext = context
- applyButton.isBordered = false
- applyButton.bezelStyle = .rounded
- applyButton.font = .systemFont(ofSize: 13, weight: .semibold)
- applyButton.wantsLayer = true
- applyButton.layer?.cornerRadius = 8
- applyButton.layer?.backgroundColor = Theme.brandBlue.cgColor
- applyButton.contentTintColor = Theme.proCTAText
- applyButton.focusRingType = .none
- applyButton.pointerCursor = true
- applyButton.hoverHandler = { [weak applyButton] hovering in
- applyButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor
- }
- applyButton.setContentHuggingPriority(.required, for: .horizontal)
- applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
- let savedOn = isJobSaved(job)
- let savedButton = SaveJobPayloadButton(title: savedOn ? "Saved" : "Save", target: self, action: #selector(didTapJobSaved(_:)))
- savedButton.jobPayload = job
- savedButton.cardContext = context
- savedButton.setButtonType(.toggle)
- savedButton.isBordered = false
- savedButton.bezelStyle = .rounded
- savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
- savedButton.image = NSImage(systemSymbolName: savedOn ? "heart.fill" : "heart", accessibilityDescription: nil)
- savedButton.imagePosition = .imageLeading
- savedButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- savedButton.focusRingType = .none
- savedButton.state = savedOn ? .on : .off
- savedButton.pointerCursor = true
- savedButton.hoverHandler = { [weak self, weak savedButton] _ in
- guard let savedButton = savedButton else { return }
- self?.styleJobSavedButton(savedButton)
- }
- styleJobSavedButton(savedButton)
- savedButton.setContentHuggingPriority(.required, for: .horizontal)
- savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
- let dismissButton = JobPayloadButton()
- dismissButton.jobPayload = job
- dismissButton.cardContext = context
- dismissButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Dismiss")
- dismissButton.imagePosition = .imageOnly
- dismissButton.imageScaling = .scaleProportionallyDown
- dismissButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
- dismissButton.isBordered = false
- dismissButton.bezelStyle = .rounded
- dismissButton.contentTintColor = Theme.secondaryText
- dismissButton.target = self
- dismissButton.action = #selector(didTapJobDismiss(_:))
- dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss"
- dismissButton.focusRingType = .none
- dismissButton.wantsLayer = true
- dismissButton.layer?.cornerRadius = 8
- dismissButton.layer?.backgroundColor = NSColor.clear.cgColor
- dismissButton.pointerCursor = true
- dismissButton.hoverHandler = { [weak dismissButton] hovering in
- dismissButton?.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : NSColor.clear).cgColor
- dismissButton?.contentTintColor = hovering ? Theme.primaryText : Theme.secondaryText
- }
- dismissButton.setContentHuggingPriority(.required, for: .horizontal)
- let buttonRow = NSStackView(views: [applyButton, savedButton, dismissButton])
- buttonRow.orientation = .horizontal
- buttonRow.spacing = 8
- buttonRow.alignment = .top
- buttonRow.translatesAutoresizingMaskIntoConstraints = false
- buttonRow.setContentHuggingPriority(.required, for: .horizontal)
- buttonRow.setContentCompressionResistancePriority(.required, for: .horizontal)
- buttonRow.setContentHuggingPriority(.required, for: .vertical)
- buttonRow.setContentCompressionResistancePriority(.required, for: .vertical)
- applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
- savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
- dismissButton.setContentCompressionResistancePriority(.required, for: .horizontal)
- let middleColumn = NSStackView(views: [titleField, companyRow, descriptionField])
- middleColumn.orientation = .vertical
- middleColumn.spacing = 5
- middleColumn.alignment = .leading
- middleColumn.translatesAutoresizingMaskIntoConstraints = false
- middleColumn.setContentHuggingPriority(.defaultLow, for: .horizontal)
- middleColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- let contentRow = NSStackView(views: [iconBox, middleColumn])
- contentRow.orientation = .horizontal
- contentRow.spacing = 14
- contentRow.alignment = .top
- contentRow.distribution = .fill
- contentRow.translatesAutoresizingMaskIntoConstraints = false
- card.addSubview(contentRow)
- card.addSubview(buttonRow)
- let actionCornerInset: CGFloat = 8
- let contentToActionsGap: CGFloat = 12
- let bodyTrailingInset: CGFloat = 16
- NSLayoutConstraint.activate([
- contentRow.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
- contentRow.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -bodyTrailingInset),
- contentRow.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
- contentRow.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14),
- buttonRow.topAnchor.constraint(equalTo: card.topAnchor, constant: actionCornerInset),
- buttonRow.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -actionCornerInset),
- middleColumn.trailingAnchor.constraint(lessThanOrEqualTo: buttonRow.leadingAnchor, constant: -contentToActionsGap),
- iconBox.widthAnchor.constraint(equalToConstant: 58),
- iconBox.heightAnchor.constraint(equalToConstant: 58),
- categoryIcon.centerXAnchor.constraint(equalTo: iconBox.centerXAnchor),
- categoryIcon.centerYAnchor.constraint(equalTo: iconBox.centerYAnchor),
- buildingIcon.widthAnchor.constraint(equalToConstant: 14),
- buildingIcon.heightAnchor.constraint(equalToConstant: 14),
- applyButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76),
- applyButton.heightAnchor.constraint(equalToConstant: 32),
- savedButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 84),
- savedButton.heightAnchor.constraint(equalToConstant: 32),
- dismissButton.widthAnchor.constraint(equalToConstant: 32),
- dismissButton.heightAnchor.constraint(equalToConstant: 32),
- descriptionField.widthAnchor.constraint(equalTo: middleColumn.widthAnchor)
- ])
- return card
- }
- private func styleJobSavedButton(_ button: NSButton) {
- button.wantsLayer = true
- button.layer?.cornerRadius = 10
- let hovering = (button as? HoverableButton)?.isHovering ?? false
- // Reference: white surface, soft blue outline, brand blue icon + label (no tinted fill on hover).
- button.layer?.backgroundColor = Theme.cardBackground.cgColor
- button.layer?.borderWidth = 1
- button.layer?.borderColor = (hovering ? Theme.searchBarBorderHover : Theme.searchBarBorder).cgColor
- button.contentTintColor = Theme.brandBlue
- }
- @objc private func didTapJobApply(_ sender: NSButton) {
- guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
- presentIndeedJobBrowser(url: Self.resolvedIndeedApplyURL(for: job))
- }
- /// Apply always loads a live Indeed page. Single-job URLs (`/viewjob`, `/pagead`, `/rc/clk`, …) are often expired or incorrectly synthesized by the model and show Indeed’s 404; we only trust **search** URLs whose path is `/jobs`. Otherwise we open a fresh `/jobs?q=<title>` (same regional host when the model provided one).
- private static func resolvedIndeedApplyURL(for job: JobListing) -> URL {
- let title = job.title.trimmingCharacters(in: .whitespacesAndNewlines)
- let raw = job.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- var preferredHost: String?
- if !raw.isEmpty, let components = URLComponents(string: raw), let host = components.host {
- let lower = host.lowercased()
- if isIndeedApplyHost(lower) {
- preferredHost = host
- let pathLower = components.path.lowercased()
- let isJobsSearchPath = pathLower == "/jobs" || pathLower.hasSuffix("/jobs")
- if isJobsSearchPath, let url = components.url {
- return url
- }
- }
- }
- 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 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
- 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 ? "Saved" : "Save"
- sender.image = NSImage(systemSymbolName: willSave ? "heart.fill" : "heart", accessibilityDescription: nil)
- styleJobSavedButton(sender)
- if isSavedJobsSidebarIndex(selectedSidebarIndex) {
- reloadSavedJobsListings()
- }
- }
- @objc private func didTapJobDismiss(_ sender: NSButton) {
- guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return }
- switch button.cardContext {
- case .homeSearchResults:
- removeJobCardFromChat(originating: button, job: job)
- case .savedJobsPage:
- applySavedState(false, for: job)
- reloadSavedJobsListings()
- }
- }
- /// Walks up from a dismiss button until it finds the enclosing chat job stack, then removes only the card that owns the button. Other chat history (older searches, the assistant summary text) is untouched.
- private func removeJobCardFromChat(originating button: NSView, job: JobListing) {
- var node: NSView? = button
- var card: NSView?
- var stack: ChatJobsStackView?
- while let v = node {
- if let parent = v.superview as? ChatJobsStackView {
- card = v
- stack = parent
- break
- }
- node = v.superview
- }
- guard let card, let stack else { return }
- stack.removeArrangedSubview(card)
- card.removeFromSuperview()
- lastSearchResults.removeAll { $0 == job }
- }
- private func configureSearchBar() {
- let pillCorner: CGFloat = 27
- let barHeight: CGFloat = 54
- searchBarShadowHost.translatesAutoresizingMaskIntoConstraints = false
- searchBarShadowHost.wantsLayer = true
- searchBarShadowHost.layer?.masksToBounds = false
- searchBarShadowHost.layer?.shadowColor = NSColor.black.withAlphaComponent(0.18).cgColor
- searchBarShadowHost.layer?.shadowOffset = CGSize(width: 0, height: 2)
- searchBarShadowHost.layer?.shadowRadius = 10
- searchBarShadowHost.layer?.shadowOpacity = 1
- searchBarShadowHost.setContentHuggingPriority(.defaultHigh, for: .vertical)
- searchCard.translatesAutoresizingMaskIntoConstraints = false
- searchCard.wantsLayer = true
- searchCard.layer?.backgroundColor = Theme.cardBackground.cgColor
- searchCard.layer?.cornerRadius = pillCorner
- searchCard.layer?.borderWidth = 1
- searchCard.layer?.borderColor = Theme.searchBarBorder.cgColor
- searchCard.layer?.masksToBounds = true
- searchCard.hoverHandler = { [weak self] hovering in
- guard let self else { return }
- CATransaction.begin()
- CATransaction.setAnimationDuration(0.15)
- self.searchCard.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor
- self.searchCard.layer?.borderColor = (hovering ? Theme.searchBarBorderHover : Theme.searchBarBorder).cgColor
- self.searchBarShadowHost.layer?.shadowColor = NSColor.black.withAlphaComponent(hovering ? 0.24 : 0.18).cgColor
- self.searchBarShadowHost.layer?.shadowRadius = hovering ? 12 : 10
- CATransaction.commit()
- }
- searchBarShadowHost.addSubview(searchCard)
- func configureField(_ field: NSTextField, placeholder: String) {
- field.translatesAutoresizingMaskIntoConstraints = false
- field.isBordered = false
- field.drawsBackground = false
- field.focusRingType = .none
- field.font = .systemFont(ofSize: 14, weight: .regular)
- field.textColor = Theme.primaryText
- field.delegate = self
- field.placeholderAttributedString = NSAttributedString(
- string: placeholder,
- attributes: [
- .foregroundColor: Theme.secondaryText,
- .font: NSFont.systemFont(ofSize: 14, weight: .regular)
- ]
- )
- field.cell?.usesSingleLineMode = true
- field.cell?.wraps = false
- field.cell?.isScrollable = true
- field.target = self
- field.action = #selector(didSubmitSearch)
- }
- jobSearchIcon.translatesAutoresizingMaskIntoConstraints = false
- jobSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
- jobSearchIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Ask AI")
- jobSearchIcon.contentTintColor = Theme.brandBlue
- configureField(jobKeywordsField, placeholder: "Ask for roles, skills, salary, or job descriptions...")
- let ctaHeight: CGFloat = 42
- let ctaCorner = ctaHeight / 2
- findJobsCTAHost.translatesAutoresizingMaskIntoConstraints = false
- findJobsCTAHost.wantsLayer = true
- findJobsCTAHost.layer?.masksToBounds = false
- findJobsCTAHost.layer?.shadowColor = NSColor.black.cgColor
- findJobsCTAHost.layer?.shadowOpacity = 0.16
- findJobsCTAHost.layer?.shadowOffset = CGSize(width: 0, height: 2)
- findJobsCTAHost.layer?.shadowRadius = 6
- findJobsCTAChrome.translatesAutoresizingMaskIntoConstraints = false
- findJobsCTAChrome.wantsLayer = true
- findJobsCTAChrome.layer?.masksToBounds = true
- findJobsCTAChrome.layer?.cornerRadius = ctaCorner
- if #available(macOS 11.0, *) {
- findJobsCTAChrome.layer?.cornerCurve = .continuous
- }
- let gradient = CAGradientLayer()
- gradient.colors = [Theme.findJobsCTAHighlight.cgColor, Theme.brandBlue.cgColor]
- gradient.startPoint = CGPoint(x: 0.5, y: 1)
- gradient.endPoint = CGPoint(x: 0.5, y: 0)
- findJobsCTAChrome.layer?.addSublayer(gradient)
- findJobsCTAGradientLayer = gradient
- // Tracks hover over the full pill (the button only covers an inset area), so the gradient darkens whenever the mouse is anywhere over the CTA.
- findJobsCTAChrome.pointerCursor = true
- findJobsCTAChrome.hoverHandler = { [weak self] hovering in
- guard let layer = self?.findJobsCTAGradientLayer else { return }
- CATransaction.begin()
- CATransaction.setAnimationDuration(0.15)
- layer.colors = hovering
- ? [Theme.findJobsCTAHighlightHover.cgColor, Theme.brandBlueHover.cgColor]
- : [Theme.findJobsCTAHighlight.cgColor, Theme.brandBlue.cgColor]
- CATransaction.commit()
- }
- findJobsButton.translatesAutoresizingMaskIntoConstraints = false
- findJobsButton.title = ""
- findJobsButton.image = NSImage(systemSymbolName: "paperplane.fill", accessibilityDescription: nil)
- findJobsButton.imagePosition = .imageLeading
- findJobsButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- findJobsButton.attributedTitle = NSAttributedString(
- string: " Send",
- attributes: [
- .font: NSFont.systemFont(ofSize: 14, weight: .semibold),
- .foregroundColor: Theme.proCTAText,
- .kern: 0.35
- ]
- )
- findJobsButton.contentTintColor = Theme.proCTAText
- findJobsButton.isBordered = false
- findJobsButton.bezelStyle = .rounded
- findJobsButton.wantsLayer = true
- findJobsButton.layer?.backgroundColor = NSColor.clear.cgColor
- findJobsButton.focusRingType = .none
- findJobsButton.target = self
- findJobsButton.action = #selector(didSubmitSearch)
- findJobsButton.setContentHuggingPriority(.required, for: .horizontal)
- findJobsButton.setContentCompressionResistancePriority(.required, for: .horizontal)
- findJobsCTAHost.addSubview(findJobsCTAChrome)
- findJobsCTAHost.addSubview(findJobsButton)
- NSLayoutConstraint.activate([
- findJobsCTAChrome.leadingAnchor.constraint(equalTo: findJobsCTAHost.leadingAnchor),
- findJobsCTAChrome.trailingAnchor.constraint(equalTo: findJobsCTAHost.trailingAnchor),
- findJobsCTAChrome.topAnchor.constraint(equalTo: findJobsCTAHost.topAnchor),
- findJobsCTAChrome.bottomAnchor.constraint(equalTo: findJobsCTAHost.bottomAnchor),
- findJobsButton.leadingAnchor.constraint(equalTo: findJobsCTAHost.leadingAnchor, constant: 14),
- findJobsButton.trailingAnchor.constraint(equalTo: findJobsCTAHost.trailingAnchor, constant: -14),
- findJobsButton.topAnchor.constraint(equalTo: findJobsCTAHost.topAnchor),
- findJobsButton.bottomAnchor.constraint(equalTo: findJobsCTAHost.bottomAnchor)
- ])
- let keywordsStack = NSStackView(views: [jobSearchIcon, jobKeywordsField])
- keywordsStack.orientation = .horizontal
- keywordsStack.spacing = 10
- keywordsStack.alignment = .centerY
- keywordsStack.translatesAutoresizingMaskIntoConstraints = false
- keywordsStack.edgeInsets = NSEdgeInsets(top: 0, left: 18, bottom: 0, right: 10)
- keywordsStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
- let row = NSStackView(views: [keywordsStack, findJobsCTAHost])
- row.orientation = .horizontal
- row.spacing = 0
- row.alignment = .centerY
- row.distribution = .fill
- row.translatesAutoresizingMaskIntoConstraints = false
- row.edgeInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 7)
- searchCard.addSubview(row)
- NSLayoutConstraint.activate([
- searchCard.leadingAnchor.constraint(equalTo: searchBarShadowHost.leadingAnchor),
- searchCard.trailingAnchor.constraint(equalTo: searchBarShadowHost.trailingAnchor),
- searchCard.topAnchor.constraint(equalTo: searchBarShadowHost.topAnchor),
- searchCard.bottomAnchor.constraint(equalTo: searchBarShadowHost.bottomAnchor),
- searchBarShadowHost.heightAnchor.constraint(equalToConstant: barHeight),
- row.leadingAnchor.constraint(equalTo: searchCard.leadingAnchor),
- row.trailingAnchor.constraint(equalTo: searchCard.trailingAnchor),
- row.topAnchor.constraint(equalTo: searchCard.topAnchor),
- row.bottomAnchor.constraint(equalTo: searchCard.bottomAnchor),
- jobSearchIcon.widthAnchor.constraint(equalToConstant: 18),
- jobSearchIcon.heightAnchor.constraint(equalToConstant: 18),
- findJobsCTAHost.heightAnchor.constraint(equalToConstant: ctaHeight),
- findJobsCTAHost.widthAnchor.constraint(greaterThanOrEqualToConstant: 112)
- ])
- searchCard.hoverHandler = nil
- }
- private func updateFindJobsCTAShadowPath() {
- guard findJobsCTAHost.bounds.width > 0, findJobsCTAHost.bounds.height > 0 else { return }
- let r = findJobsCTAHost.bounds
- let radius = min(r.height / 2, r.width / 2)
- findJobsCTAHost.layer?.shadowPath = CGPath(
- roundedRect: r,
- cornerWidth: radius,
- cornerHeight: radius,
- transform: nil
- )
- }
- private func configureNonHomePlaceholder() {
- nonHomeHost.translatesAutoresizingMaskIntoConstraints = false
- nonHomeHost.wantsLayer = true
- nonHomeHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
- nonHomeHost.isHidden = true
- 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 = "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 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 == "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) {
- 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?) {
- 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 = "Delete this profile?"
- alert.informativeText = displayName.isEmpty
- ? "This profile will be removed from this Mac."
- : "“\(displayName)” will be removed from this Mac."
- alert.alertStyle = .warning
- alert.addButton(withTitle: "Cancel")
- alert.addButton(withTitle: "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 settingsSection = makeSettingsSection(rows: [
- makeSettingsRow(title: "Share App", systemImage: "square.and.arrow.up", accessory: nil, tapAction: #selector(didTapShareApp)),
- makeSettingsRow(title: "More Apps", systemImage: "square.grid.2x2", accessory: nil, tapAction: #selector(didTapMoreApps))
- ])
- 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, tapAction: #selector(didTapSupport)),
- makeSettingsRow(title: "Terms of Use", systemImage: "doc.text", accessory: nil, tapAction: #selector(didTapTermsOfUse)),
- makeSettingsRow(title: "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(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 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?, tapAction: Selector? = nil) -> 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)
- ])
- 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.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 = "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 isCVMakerSidebarIndex(_ index: Int) -> Bool {
- guard index >= 0, index < currentSidebarItems.count else { return false }
- return currentSidebarItems[index].title == "CV Maker"
- }
- private func isProfileSidebarIndex(_ index: Int) -> Bool {
- guard index >= 0, index < currentSidebarItems.count else { return false }
- return currentSidebarItems[index].title == "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 SubscriptionStore.shared.isProActive else {
- presentPremiumPlansSheet()
- 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() {
- focusSearchField(seed: "Find roles similar to: ")
- }
- @objc private func didTapFeatureCompany() {
- focusSearchField(seed: "Find jobs at company: ")
- }
- @objc private func didTapFeatureSkill() {
- focusSearchField(seed: "Find jobs that require skill: ")
- }
- @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: "More Apps")
- return
- }
- NSWorkspace.shared.open(url)
- }
- private func presentAppMarketingConfigurationAlert(feature: String) {
- let alert = NSAlert()
- alert.messageText = "\(feature) isn’t available yet"
- alert.informativeText = """
- Add your Mac App Store IDs in the target’s build settings:
- • AppStoreAppID — numeric app ID from App Store Connect
- • AppStoreDeveloperID — numeric developer ID (for your other apps page)
- """
- alert.alertStyle = .informational
- alert.addButton(withTitle: "OK")
- if let window {
- alert.beginSheetModal(for: window)
- } else {
- alert.runModal()
- }
- }
- @objc private func didTapSupport() {
- presentSettingsPlaceholderAlert(for: "Support")
- }
- @objc private func didTapTermsOfUse() {
- presentSettingsPlaceholderAlert(for: "Terms of Use")
- }
- @objc private func didTapPrivacyPolicy() {
- presentSettingsPlaceholderAlert(for: "Privacy Policy")
- }
- private func presentSettingsPlaceholderAlert(for featureName: String) {
- let alert = NSAlert()
- alert.messageText = "\(featureName) coming soon"
- alert.informativeText = "This section is a placeholder for now."
- alert.alertStyle = .informational
- alert.addButton(withTitle: "OK")
- if let window {
- alert.beginSheetModal(for: window)
- } else {
- alert.runModal()
- }
- }
- private func focusSearchField(seed: String) {
- jobKeywordsField.stringValue = seed
- window?.makeFirstResponder(jobKeywordsField)
- if let editor = jobKeywordsField.window?.fieldEditor(true, for: jobKeywordsField) as? NSTextView {
- editor.moveToEndOfDocument(nil)
- }
- }
- @objc private func didTapLoadMoreJobs() {
- guard SubscriptionStore.shared.isProActive else {
- presentPremiumPlansSheet()
- return
- }
- let prompt = "Show more jobs"
- guard !isAwaitingResponse, isContinuationPrompt(prompt) else { return }
- if anchorUserJobQuery(excludingLatestUserMessage: prompt) == nil { return }
- appendChatBubble(text: prompt, isUser: true)
- chatMessages.append(ChatMessage(role: "user", content: prompt))
- let effectiveQuery = resolvedSearchQuery(for: prompt)
- startJobSearchRequest(effectiveQuery: effectiveQuery, isContinuation: true)
- }
- private func startJobSearchRequest(effectiveQuery: String, isContinuation: Bool) {
- isAwaitingResponse = true
- 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))
- self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
- case .failure(let error):
- self.appendChatBubble(text: error.localizedDescription, 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 "I couldn't find new matches for \u{201C}\(query)\u{201D}. Try a different angle or a more specific keyword."
- }
- return "No jobs found for \u{201C}\(query)\u{201D}. Try another title, skill, company, or location."
- }
- let plural = newJobsCount == 1 ? "match" : "matches"
- if isContinuation {
- return "Here are \(newJobsCount) more \(plural) for \u{201C}\(query)\u{201D}."
- }
- return "Found \(newJobsCount) \(plural) for \u{201C}\(query)\u{201D}. Tap Apply to open the listing or Save to revisit later."
- }
- private func resolvedSearchQuery(for prompt: String) -> String {
- let anchor = anchorUserJobQuery(excludingLatestUserMessage: prompt)
- if isContinuationPrompt(prompt), !isRefinementPrompt(prompt) {
- if let anchor { return anchor }
- return prompt
- }
- if isRefinementPrompt(prompt), let anchor {
- return "\(anchor). User follow-up (apply on top of the same search topic): \(prompt)"
- }
- return prompt
- }
- /// First prior user message that looks like an original job query (skips short continuations and refinements so follow-ups keep a stable topic anchor).
- private func anchorUserJobQuery(excludingLatestUserMessage latest: String) -> String? {
- let prior = Array(chatMessages.dropLast())
- for message in prior.reversed() where message.role == "user" {
- let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !candidate.isEmpty, candidate != latest else { continue }
- if isContinuationPrompt(candidate) { continue }
- if isRefinementPrompt(candidate) { continue }
- return candidate
- }
- return nil
- }
- private func isContinuationPrompt(_ prompt: String) -> Bool {
- let normalized = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- let continuationPhrases: Set<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 func resetChatState() {
- removeInlineChatThinkingRow()
- trailingLoadMoreJobsRow = nil
- trailingLoadMoreJobsButton = nil
- chatMessages.removeAll()
- lastSearchResults.removeAll()
- chatStack.arrangedSubviews.forEach {
- chatStack.removeArrangedSubview($0)
- $0.removeFromSuperview()
- }
- let welcome = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary."
- chatMessages.append(ChatMessage(role: "assistant", content: welcome))
- appendChatBubble(text: welcome, isUser: false)
- }
- 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: "AI Job Finder")
- 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: "AI Job Finder")
- icon.contentTintColor = Theme.brandBlue
- view.addSubview(icon)
- NSLayoutConstraint.activate([
- icon.centerXAnchor.constraint(equalTo: view.centerXAnchor),
- icon.centerYAnchor.constraint(equalTo: view.centerYAnchor)
- ])
- return view
- }
- private func makeLoadMoreJobsRowView() -> NSView {
- let row = NSView()
- row.translatesAutoresizingMaskIntoConstraints = false
- let button = HoverableButton()
- button.pointerCursor = true
- button.title = "Show more jobs"
- button.font = .systemFont(ofSize: 12, weight: .semibold)
- button.bezelStyle = .rounded
- button.controlSize = .regular
- button.contentTintColor = Theme.brandBlue
- button.target = self
- button.action = #selector(didTapLoadMoreJobs)
- button.translatesAutoresizingMaskIntoConstraints = false
- trailingLoadMoreJobsButton = button
- row.addSubview(button)
- NSLayoutConstraint.activate([
- button.leadingAnchor.constraint(equalTo: row.leadingAnchor),
- button.topAnchor.constraint(equalTo: row.topAnchor, constant: 2),
- button.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -2)
- ])
- return row
- }
- private func makeChatJobsStackView(jobs: [JobListing]) -> ChatJobsStackView {
- let stack = ChatJobsStackView()
- stack.orientation = .vertical
- stack.spacing = 10
- stack.alignment = .width
- stack.distribution = .fill
- stack.translatesAutoresizingMaskIntoConstraints = false
- for job in jobs {
- let card = makeJobListingCard(job, context: .homeSearchResults)
- stack.addArrangedSubview(card)
- card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- return stack
- }
- private func scrollChatToBottom() {
- let maxY = max(0, chatDocumentView.bounds.height - chatScrollView.contentView.bounds.height)
- chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY))
- chatScrollView.reflectScrolledClipView(chatScrollView.contentView)
- }
- /// 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
- findJobsButton.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 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: "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) {
- dismissIndeedJobBrowserEmbedded()
- guard index >= 0, index < currentSidebarItems.count else { return }
- let selectingHome = isHomeSidebarIndex(index)
- if index == selectedSidebarIndex {
- if selectingHome {
- applyHomeState()
- }
- return
- }
- selectedSidebarIndex = index
- configureSidebar()
- updateMainContentVisibility()
- if selectingHome {
- applyHomeState()
- }
- }
- }
- private struct ChatMessage: Codable {
- let role: String
- let content: String
- }
- private final class OpenAIJobSearchService {
- private let endpoint = URL(string: "https://api.openai.com/v1/responses")!
- private let session = URLSession(configuration: .ephemeral)
- func searchJobs(query: String, conversation: [ChatMessage], maxJobs: Int, completion: @escaping (Result<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: "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 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) {
- if let apiError = try? JSONDecoder().decode(OpenAIAPIErrorResponse.self, from: data) {
- completion(.failure(NSError(
- domain: "OpenAIJobSearchService",
- code: http.statusCode,
- userInfo: [NSLocalizedDescriptionKey: apiError.error.message]
- )))
- } else {
- completion(.failure(NSError(
- domain: "OpenAIJobSearchService",
- code: http.statusCode,
- userInfo: [NSLocalizedDescriptionKey: "Job search request failed with status \(http.statusCode)."]
- )))
- }
- return
- }
- do {
- let modelText = try Self.extractModelTextFromResponsesBody(data)
- let trimmed = modelText.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else {
- throw NSError(
- domain: "OpenAIJobSearchService",
- code: 4,
- userInfo: [NSLocalizedDescriptionKey: "The API returned an empty text payload."]
- )
- }
- let jobs = try Self.parseJobListings(fromModelText: trimmed)
- .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" {
- let message = (root["error"] as? [String: Any])?["message"] as? String ?? "The search request failed."
- throw NSError(domain: "OpenAIJobSearchService", code: 7, userInfo: [NSLocalizedDescriptionKey: message])
- }
- if status == "incomplete",
- let details = root["incomplete_details"] as? [String: Any],
- let reason = details["reason"] as? String {
- throw NSError(
- domain: "OpenAIJobSearchService",
- code: 8,
- userInfo: [NSLocalizedDescriptionKey: "Search stopped early (\(reason)). Try a simpler query or try again."]
- )
- }
- }
- if let direct = root["output_text"] as? String {
- let trimmed = direct.trimmingCharacters(in: .whitespacesAndNewlines)
- if !trimmed.isEmpty { return trimmed }
- }
- guard let output = root["output"] as? [Any] else {
- throw NSError(
- domain: "OpenAIJobSearchService",
- code: 9,
- userInfo: [NSLocalizedDescriptionKey: "The search service returned no assistant text. Try again in a moment."]
- )
- }
- var segments: [String] = []
- for case let item as [String: Any] in output where (item["type"] as? String) == "message" {
- collectOutputTextSegments(fromOutputItem: item, into: &segments)
- }
- if segments.isEmpty {
- for case let item as [String: Any] in output {
- collectOutputTextSegments(fromOutputItem: item, into: &segments)
- }
- }
- let combined = segments.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
- if !combined.isEmpty {
- return combined
- }
- throw NSError(
- domain: "OpenAIJobSearchService",
- code: 9,
- userInfo: [NSLocalizedDescriptionKey: "The model did not return readable job-search text. Try again."]
- )
- }
- private static func collectOutputTextSegments(fromOutputItem item: [String: Any], into segments: inout [String]) {
- guard let content = item["content"] as? [Any] else { return }
- for case let part as [String: Any] in content {
- guard (part["type"] as? String) == "output_text" else { continue }
- if let s = part["text"] as? String {
- segments.append(s)
- } else if let nested = part["text"] as? [String: Any], let value = nested["value"] as? String {
- segments.append(value)
- }
- }
- }
- private static func 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()
- }
- }
- /// Circular pastel well with three sparkle symbols (reference: layered stars of different sizes).
- private final class WelcomeSparkleClusterView: NSView {
- private let diameter: CGFloat = 56
- override var intrinsicContentSize: NSSize {
- NSSize(width: diameter, height: diameter)
- }
- init(iconWell: NSColor, tint: NSColor) {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = diameter / 2
- if #available(macOS 11.0, *) {
- layer?.cornerCurve = .continuous
- }
- layer?.backgroundColor = iconWell.cgColor
- let configs: [(CGFloat, NSFont.Weight, CGFloat, CGFloat)] = [
- (17, .medium, 0, 0),
- (11, .regular, -11, -9),
- (9, .regular, 12, 10)
- ]
- let symbolName = Self.sparkleSymbolName()
- for (size, weight, ox, oy) in configs {
- let iv = NSImageView()
- iv.translatesAutoresizingMaskIntoConstraints = false
- iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: size, weight: weight)
- iv.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
- iv.contentTintColor = tint
- addSubview(iv)
- NSLayoutConstraint.activate([
- iv.centerXAnchor.constraint(equalTo: centerXAnchor, constant: ox),
- iv.centerYAnchor.constraint(equalTo: centerYAnchor, constant: oy)
- ])
- }
- }
- private static func sparkleSymbolName() -> String {
- if NSImage(systemSymbolName: "sparkle", accessibilityDescription: nil) != nil {
- return "sparkle"
- }
- return "sparkles"
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- }
- /// Home welcome row: three tappable shortcuts that seed the main search field (reference: white cards, pastel icon well, arrow at bottom trailing).
- private final class FeatureShortcutCardView: NSView {
- private static let cardCornerRadius: CGFloat = 14
- private weak var actionTarget: AnyObject?
- private var actionSelector: Selector
- init(symbolName: String, title: String, subtitle: String, target: AnyObject?, action: Selector) {
- self.actionTarget = target
- self.actionSelector = action
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = Self.cardCornerRadius
- if #available(macOS 11.0, *) {
- layer?.cornerCurve = .continuous
- }
- layer?.backgroundColor = NSColor.white.cgColor
- layer?.masksToBounds = false
- layer?.borderWidth = 1
- // `#EDF2F7` — light card stroke.
- layer?.borderColor = NSColor(srgbRed: 237 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1).cgColor
- layer?.shadowColor = NSColor.black.withAlphaComponent(0.06).cgColor
- layer?.shadowOffset = CGSize(width: 0, height: 2)
- layer?.shadowRadius = 12
- layer?.shadowOpacity = 1
- // `#0052CC` — primary title / icons / arrow (matches welcome hero reference).
- let primaryBlue = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1)
- // `#EBF2FF` — circular icon well.
- let iconWellColor = NSColor(srgbRed: 235 / 255, green: 242 / 255, blue: 255 / 255, alpha: 1)
- // `#5D6D7E` — muted description.
- let secondary = NSColor(srgbRed: 93 / 255, green: 109 / 255, blue: 126 / 255, alpha: 1)
- let iconSize: CGFloat = 40
- let iconHost = NSView()
- iconHost.translatesAutoresizingMaskIntoConstraints = false
- iconHost.wantsLayer = true
- iconHost.layer?.backgroundColor = iconWellColor.cgColor
- iconHost.layer?.cornerRadius = iconSize / 2
- let icon = NSImageView()
- icon.translatesAutoresizingMaskIntoConstraints = false
- icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
- icon.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
- icon.contentTintColor = primaryBlue
- iconHost.addSubview(icon)
- let titleField = NSTextField(wrappingLabelWithString: title)
- titleField.font = .systemFont(ofSize: 15, weight: .bold)
- titleField.textColor = primaryBlue
- titleField.maximumNumberOfLines = 1
- titleField.isEditable = false
- titleField.isBordered = false
- titleField.drawsBackground = false
- titleField.alignment = .left
- let subtitleField = NSTextField(wrappingLabelWithString: subtitle)
- subtitleField.font = .systemFont(ofSize: 12, weight: .regular)
- subtitleField.textColor = secondary
- subtitleField.maximumNumberOfLines = 2
- subtitleField.isEditable = false
- subtitleField.isBordered = false
- subtitleField.drawsBackground = false
- subtitleField.alignment = .left
- subtitleField.lineBreakMode = .byWordWrapping
- subtitleField.setContentHuggingPriority(.defaultLow, for: .horizontal)
- subtitleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- let chevron = NSImageView()
- chevron.translatesAutoresizingMaskIntoConstraints = false
- chevron.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
- chevron.image = NSImage(systemSymbolName: "arrow.right", accessibilityDescription: nil)
- chevron.contentTintColor = primaryBlue
- chevron.setContentHuggingPriority(.required, for: .horizontal)
- chevron.setContentCompressionResistancePriority(.required, for: .horizontal)
- let subtitleRow = NSStackView(views: [subtitleField, chevron])
- subtitleRow.orientation = .horizontal
- subtitleRow.spacing = 10
- subtitleRow.alignment = .bottom
- subtitleRow.distribution = .fill
- subtitleRow.translatesAutoresizingMaskIntoConstraints = false
- let textColumn = NSStackView(views: [titleField, subtitleRow])
- textColumn.orientation = .vertical
- textColumn.spacing = 6
- textColumn.alignment = .leading
- textColumn.translatesAutoresizingMaskIntoConstraints = false
- textColumn.setContentHuggingPriority(.defaultLow, for: .horizontal)
- textColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- iconHost.setContentHuggingPriority(.required, for: .horizontal)
- iconHost.setContentCompressionResistancePriority(.required, for: .horizontal)
- let row = NSStackView(views: [iconHost, textColumn])
- row.orientation = .horizontal
- row.spacing = 16
- row.alignment = .centerY
- row.distribution = .fill
- row.translatesAutoresizingMaskIntoConstraints = false
- addSubview(row)
- let inset: CGFloat = 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)")
- }
- @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
- }
- }
|