DashboardView.swift 194 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285
  1. //
  2. // DashboardView.swift
  3. // App for Indeed
  4. //
  5. import Cocoa
  6. import QuartzCore
  7. import Security
  8. private enum JobListingCardContext {
  9. case homeSearchResults
  10. case savedJobsPage
  11. }
  12. private enum PremiumSheetLayout {
  13. /// Grow the sheet past the host content rect on each side to hide compositing hairlines.
  14. static let overscanPerEdge: CGFloat = 2
  15. /// Additional growth on the top edge only (pt).
  16. static let overscanExtraTop: CGFloat = 0.5
  17. }
  18. /// Free-tier cap for Home AI job search (user messages only; Pro is unlimited).
  19. private enum FreeTierJobSearchQuota {
  20. static let maxUserMessages = 2
  21. private static let userDefaultsKey = "com.appforindeed.freeJobSearchUserMessageCount"
  22. static var userMessageCount: Int {
  23. get { UserDefaults.standard.integer(forKey: userDefaultsKey) }
  24. set { UserDefaults.standard.set(newValue, forKey: userDefaultsKey) }
  25. }
  26. static func canSendAnotherMessage(isProActive: Bool) -> Bool {
  27. isProActive || userMessageCount < maxUserMessages
  28. }
  29. static func recordUserMessageSent(isProActive: Bool) {
  30. guard !isProActive else { return }
  31. userMessageCount += 1
  32. }
  33. static func remainingUserMessages(isProActive: Bool) -> Int {
  34. guard !isProActive else { return maxUserMessages }
  35. return max(0, maxUserMessages - userMessageCount)
  36. }
  37. }
  38. private enum SettingsAppearanceID {
  39. static let section = "dashboard.settings.section"
  40. static let sectionHeader = "dashboard.settings.sectionHeader"
  41. static let rowTitle = "dashboard.settings.rowTitle"
  42. static let iconTile = "dashboard.settings.iconTile"
  43. static let divider = "dashboard.settings.divider"
  44. }
  45. final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDelegate, NSSharingServiceDelegate {
  46. /// Indeed.com-inspired neutrals and brand blue; values follow light / dark / system appearance.
  47. private enum Theme {
  48. static var brandBlue: NSColor { AppDashboardTheme.brandBlue }
  49. static var pageBackground: NSColor { AppDashboardTheme.pageBackground }
  50. static var chromeBackground: NSColor { AppDashboardTheme.chromeBackground }
  51. static var sidebarBackground: NSColor { AppDashboardTheme.sidebarBackground }
  52. static var mainHostBackground: NSColor { AppDashboardTheme.mainHostBackground }
  53. static var welcomeHeroHeadingBlue: NSColor { AppDashboardTheme.welcomeHeroHeadingBlue }
  54. static var welcomeHeroSubtitleText: NSColor { AppDashboardTheme.welcomeHeroSubtitleText }
  55. static var welcomeHeroIconWell: NSColor { AppDashboardTheme.welcomeHeroIconWell }
  56. static var welcomeHeroWaveTint: NSColor { AppDashboardTheme.welcomeHeroWaveTint }
  57. static var welcomeSubtitleText: NSColor { AppDashboardTheme.welcomeSubtitleText }
  58. static var selectionFill: NSColor { AppDashboardTheme.selectionFill }
  59. static var cardBackground: NSColor { AppDashboardTheme.cardBackground }
  60. static var toggleBackground: NSColor { AppDashboardTheme.toggleBackground }
  61. static var primaryText: NSColor { AppDashboardTheme.primaryText }
  62. static var secondaryText: NSColor { AppDashboardTheme.secondaryText }
  63. static var tertiaryText: NSColor { AppDashboardTheme.tertiaryText }
  64. static var border: NSColor { AppDashboardTheme.border }
  65. static var searchBarBorder: NSColor { AppDashboardTheme.searchBarBorder }
  66. static var searchBarBorderHover: NSColor { AppDashboardTheme.searchBarBorderHover }
  67. static var proCardFill: NSColor { AppDashboardTheme.proCardFill }
  68. static var proCardBorder: NSColor { AppDashboardTheme.proCardBorder }
  69. static var proAccent: NSColor { AppDashboardTheme.proAccent }
  70. static var proCTABackground: NSColor { AppDashboardTheme.proCTABackground }
  71. static var proCTAText: NSColor { AppDashboardTheme.proCTAText }
  72. static var brandBlueHover: NSColor { AppDashboardTheme.brandBlueHover }
  73. static var selectionFillHover: NSColor { AppDashboardTheme.selectionFillHover }
  74. static var neutralHoverFill: NSColor { AppDashboardTheme.neutralHoverFill }
  75. static var sidebarRowHoverFill: NSColor { AppDashboardTheme.sidebarRowHoverFill }
  76. static var settingsPageBackground: NSColor { AppDashboardTheme.settingsPageBackground }
  77. static var settingsGroupBackground: NSColor { AppDashboardTheme.settingsGroupBackground }
  78. static var settingsIconBackground: NSColor { AppDashboardTheme.settingsIconBackground }
  79. static var settingsDivider: NSColor { AppDashboardTheme.settingsDivider }
  80. }
  81. /// Sidebar content band (218pt sidebar minus 6pt edge insets on each side).
  82. private static let sidebarProCardWidth: CGFloat = 206
  83. private static let sidebarProCardInnerPadding: CGFloat = 14
  84. private static var sidebarProCardInnerContentWidth: CGFloat {
  85. sidebarProCardWidth - sidebarProCardInnerPadding * 2
  86. }
  87. /// Multiline `NSTextField` often ignores `alignment` for wrapped runs; explicit paragraph alignment matches the title.
  88. private static func jobListingDescriptionAttributedString(
  89. _ plain: String,
  90. forceLeftToRight: Bool = false
  91. ) -> NSAttributedString {
  92. if forceLeftToRight {
  93. let paragraph = NSMutableParagraphStyle()
  94. paragraph.alignment = .left
  95. paragraph.lineBreakMode = .byWordWrapping
  96. paragraph.baseWritingDirection = .leftToRight
  97. return NSAttributedString(string: plain, attributes: [
  98. .font: NSFont.systemFont(ofSize: 13, weight: .regular),
  99. .foregroundColor: Theme.secondaryText,
  100. .paragraphStyle: paragraph
  101. ])
  102. }
  103. return AppLayoutDirection.attributedString(
  104. plain,
  105. font: NSFont.systemFont(ofSize: 13, weight: .regular),
  106. color: Theme.secondaryText
  107. )
  108. }
  109. private static func chatBubbleAttributedString(_ text: String, color: NSColor, font: NSFont) -> NSAttributedString {
  110. AppLayoutDirection.attributedString(text, font: font, color: color)
  111. }
  112. private func searchFieldPlaceholder() -> NSAttributedString {
  113. NSAttributedString(
  114. string: L("Ask for roles, skills, salary, or job descriptions..."),
  115. attributes: AppLayoutDirection.placeholderAttributes(
  116. font: .systemFont(ofSize: 14, weight: .regular),
  117. color: Theme.secondaryText
  118. )
  119. )
  120. }
  121. /// Horizontal row for sidebar + main; plain view + constraints keep both panels top/bottom aligned (stack view height alignment was inconsistent).
  122. private let panelsRow = NSView()
  123. private let chromeContainer = NSView()
  124. private let sidebar = NSStackView()
  125. private let mainHost = NSView()
  126. private let mainOverlay = NSStackView()
  127. private let greetingLabel = NSTextField(labelWithString: "")
  128. private let subtitleLabel = NSTextField(labelWithString: "")
  129. private let searchBarColumn = NSStackView()
  130. private let searchBarShadowHost = NSView()
  131. private let freeJobSearchQuotaLabel = NSTextField(labelWithString: "")
  132. private let searchCard = HoverableView()
  133. private let jobSearchIcon = NSImageView()
  134. private let jobKeywordsField = NSTextField()
  135. private let findJobsButton = HoverableButton()
  136. private let findJobsCTAPill = HoverableView()
  137. private let sendIconView = NSImageView()
  138. private let sendLabel = NSTextField(labelWithString: L("Send"))
  139. private let sendContentStack = NSStackView()
  140. private let findJobsCTAHost = NSView()
  141. private let welcomeHeroHost = NSView()
  142. private let welcomeHeroBackgroundView = WelcomeHeroBackgroundView()
  143. private let welcomeLogoWell = NSView()
  144. private let welcomeLogoView = IndeedLogoView(displayHeight: 40, variant: .compact)
  145. private let featureCardsRow = NSStackView()
  146. private enum FeatureShortcut: Int { case role = 0, company = 1, skill = 2 }
  147. private let clearChatButton = NSButton(title: L("Clear chat"), target: nil, action: nil)
  148. private let chatScrollView = NSScrollView()
  149. private let chatDocumentView = JobListingsDocumentView()
  150. private let chatStack = NSStackView()
  151. /// Shown when a sidebar item other than Home is selected.
  152. private let nonHomeHost = NSView()
  153. /// Full-bleed Indeed apply / listing web view inside the main panel (same window as the dashboard).
  154. private let indeedJobBrowserHost = NSView()
  155. private let nonHomeGenericContainer = NSView()
  156. private let nonHomeTitleLabel = NSTextField(labelWithString: "")
  157. private let nonHomeSubtitleLabel = NSTextField(wrappingLabelWithString: "")
  158. private let savedJobsPageContainer = NSView()
  159. private let savedJobsPageTitleLabel = NSTextField(labelWithString: L("Saved Jobs"))
  160. private let savedJobsPageSubtitleLabel = NSTextField(wrappingLabelWithString: "")
  161. private let savedJobsScrollView = NSScrollView()
  162. private let savedJobsDocumentView = JobListingsDocumentView()
  163. private let savedJobsStack = NSStackView()
  164. private let settingsPageContainer = NSView()
  165. private weak var appearanceModeSegment: NSSegmentedControl?
  166. private weak var languagePopUp: NSPopUpButton?
  167. private let cvMakerPageContainer = NSView()
  168. private lazy var cvMakerPageView: CVMakerPageView = {
  169. CVMakerPageView()
  170. }()
  171. private let profilePageContainer = NSView()
  172. private lazy var profilesListPageView: ProfilesListPageView = {
  173. ProfilesListPageView()
  174. }()
  175. private lazy var myProfilePageView: MyProfilePageView = {
  176. MyProfilePageView()
  177. }()
  178. /// When true, `myProfilePageView` is visible instead of the profiles list.
  179. private var isProfileEditorPresented = false
  180. /// When true, the merged CV preview is visible instead of the profiles list or editor.
  181. private var isCVDocumentPreviewPresented = false
  182. /// 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).
  183. private var pendingCVTemplate: CVTemplate?
  184. private let cvFilledPreviewPageView = CVFilledPreviewPageView()
  185. private var currentSidebarItems: [SidebarItem] = []
  186. private var selectedSidebarIndex: Int = 0
  187. /// When true, the **Indeed** sidebar row is highlighted instead of `selectedSidebarIndex`.
  188. private var isIndeedSidebarSelected = false
  189. /// 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.
  190. private var lastSearchResults: [JobListing] = []
  191. /// "Show more jobs" row under the latest assistant message that listed jobs; removed when a newer listing block replaces it.
  192. private var trailingLoadMoreJobsRow: NSView?
  193. private weak var trailingLoadMoreJobsButton: HoverableButton?
  194. /// Most recently saved jobs appear first; persisted across launches.
  195. private var savedJobOrder: [JobListing] = []
  196. private var chatMessages: [ChatMessage] = []
  197. private var isAwaitingResponse = false
  198. /// Shown under the latest user message while a job search request is in flight.
  199. private var chatThinkingRowHost: NSView?
  200. private let jobSearchService = OpenAIJobSearchService()
  201. private var premiumPlansWindowController: PremiumPlansWindowController?
  202. private var isPreparingPremiumPlansSheet = false
  203. private var indeedJobBrowserViewController: IndeedJobBrowserViewController?
  204. private var isIndeedJobBrowserPresented = false
  205. private weak var sidebarUpgradeCard: NSView?
  206. private weak var sidebarUpgradeHeadline: NSTextField?
  207. private weak var sidebarUpgradeDescription: NSTextField?
  208. private weak var sidebarUpgradeButton: HoverableButton?
  209. private var subscriptionObserver: NSObjectProtocol?
  210. private var appearanceObserver: NSObjectProtocol?
  211. private var languageObserver: NSObjectProtocol?
  212. /// Retains the system share picker until the user picks a destination or dismisses the menu.
  213. private var appSharePicker: NSSharingServicePicker?
  214. /// Upper bound sent to the model per request (was fixed at 8 in the prompt). Clamped when calling the API.
  215. private static let jobsPerSearchDefault = 15
  216. private static let jobsPerSearchMin = 1
  217. private static let jobsPerSearchMaxCap = 25
  218. /// Matches SF Symbol nav icon footprint in sidebar rows.
  219. private static let sidebarNavIconSize: CGFloat = 18
  220. private static func clampedJobsPerRequest(_ requested: Int = jobsPerSearchDefault) -> Int {
  221. min(jobsPerSearchMaxCap, max(jobsPerSearchMin, requested))
  222. }
  223. override init(frame frameRect: NSRect) {
  224. super.init(frame: frameRect)
  225. setupLayout()
  226. registerAppearanceObservers()
  227. }
  228. required init?(coder: NSCoder) {
  229. super.init(coder: coder)
  230. setupLayout()
  231. registerAppearanceObservers()
  232. }
  233. deinit {
  234. if let subscriptionObserver {
  235. NotificationCenter.default.removeObserver(subscriptionObserver)
  236. }
  237. if let appearanceObserver {
  238. NotificationCenter.default.removeObserver(appearanceObserver)
  239. }
  240. if let languageObserver {
  241. NotificationCenter.default.removeObserver(languageObserver)
  242. }
  243. }
  244. override func viewDidChangeEffectiveAppearance() {
  245. super.viewDidChangeEffectiveAppearance()
  246. applyCurrentAppearance()
  247. }
  248. private func registerAppearanceObservers() {
  249. appearanceObserver = NotificationCenter.default.addObserver(
  250. forName: AppAppearanceManager.didChangeNotification,
  251. object: nil,
  252. queue: .main
  253. ) { [weak self] _ in
  254. self?.applyCurrentAppearance()
  255. }
  256. languageObserver = NotificationCenter.default.addObserver(
  257. forName: AppLanguageManager.didChangeNotification,
  258. object: nil,
  259. queue: .main
  260. ) { [weak self] _ in
  261. self?.refreshLocalizedStrings()
  262. }
  263. }
  264. private func refreshLocalizedStrings() {
  265. greetingLabel.stringValue = L("Welcome")
  266. subtitleLabel.stringValue = L("Find your perfect job with the power of AI.")
  267. sendLabel.stringValue = L("Send")
  268. clearChatButton.title = L("Clear chat")
  269. clearChatButton.toolTip = L("Remove all messages and start a new conversation")
  270. savedJobsPageTitleLabel.stringValue = L("Saved Jobs")
  271. nonHomeSubtitleLabel.stringValue = L("This area is not available in the preview build. Use Home to search jobs.")
  272. jobKeywordsField.placeholderAttributedString = searchFieldPlaceholder()
  273. jobSearchIcon.setAccessibilityLabel(L("Ask AI"))
  274. findJobsButton.setAccessibilityLabel(L("Send"))
  275. appearanceModeSegment?.setLabel(L("System"), forSegment: 0)
  276. appearanceModeSegment?.setLabel(L("Light"), forSegment: 1)
  277. appearanceModeSegment?.setLabel(L("Dark"), forSegment: 2)
  278. refreshLanguagePopUp()
  279. refreshSettingsLocalizedLabels()
  280. refreshSidebarItemTitles()
  281. updateFreeJobSearchQuotaLabel()
  282. applyProSubscriptionToSidebar()
  283. configureSidebar()
  284. reloadSavedJobsListings()
  285. rebuildFeatureShortcutCards()
  286. trailingLoadMoreJobsButton?.title = L("Show more jobs")
  287. refreshWelcomeChatMessageForCurrentLanguage()
  288. applyHomeLayoutDirection()
  289. AppMenuLocalizer.apply()
  290. }
  291. /// Updates the default assistant greeting when the user changes language before starting a conversation.
  292. private func refreshWelcomeChatMessageForCurrentLanguage() {
  293. guard chatMessages.count == 1,
  294. chatMessages[0].role == "assistant",
  295. chatMessages[0].attachedJobs == nil else { return }
  296. let welcome = L(Self.welcomeChatMessageKey)
  297. guard chatMessages[0].content != welcome else { return }
  298. chatMessages[0] = ChatMessage(role: "assistant", content: welcome)
  299. rebuildChatUI()
  300. }
  301. private func refreshSidebarItemTitles() {
  302. currentSidebarItems = currentSidebarItems.map { item in
  303. SidebarItem(
  304. title: localizedSidebarTitle(forSystemImage: item.systemImage),
  305. systemImage: item.systemImage,
  306. badge: item.badge
  307. )
  308. }
  309. }
  310. private func localizedSidebarTitle(forSystemImage systemImage: String) -> String {
  311. switch systemImage {
  312. case "house.fill":
  313. return L("Home")
  314. case "heart":
  315. return L("Saved Jobs")
  316. case "doc.text":
  317. return L("CV Maker")
  318. case "person":
  319. return L("Profile")
  320. case "gearshape":
  321. return L("Settings")
  322. default:
  323. return L("Home")
  324. }
  325. }
  326. private func refreshLanguagePopUp() {
  327. guard let popup = languagePopUp else { return }
  328. let selectedCode = AppLanguageManager.shared.current.localeIdentifier
  329. popup.removeAllItems()
  330. for language in AppLanguage.allCases {
  331. popup.addItem(withTitle: language.localizedDisplayName)
  332. popup.lastItem?.representedObject = language.localeIdentifier
  333. }
  334. if let index = AppLanguage.allCases.firstIndex(where: { $0.localeIdentifier == selectedCode }) {
  335. popup.selectItem(at: index)
  336. }
  337. popup.isEnabled = !AppLanguage.allCases.isEmpty
  338. }
  339. private func refreshSettingsLocalizedLabels() {
  340. for view in settingsPageContainer.subviewsRecursive() {
  341. guard let rawID = view.identifier?.rawValue else { continue }
  342. let sectionPrefix = SettingsAppearanceID.sectionHeader + "."
  343. let rowPrefix = SettingsAppearanceID.rowTitle + "."
  344. if rawID.hasPrefix(sectionPrefix) {
  345. let key = String(rawID.dropFirst(sectionPrefix.count))
  346. (view as? NSTextField)?.stringValue = L(key)
  347. } else if rawID.hasPrefix(rowPrefix) {
  348. let key = String(rawID.dropFirst(rowPrefix.count))
  349. (view as? NSTextField)?.stringValue = L(key)
  350. }
  351. }
  352. }
  353. override func viewDidMoveToWindow() {
  354. super.viewDidMoveToWindow()
  355. guard window != nil else { return }
  356. Task { @MainActor in
  357. await SubscriptionStore.shared.refreshEntitlements(deep: true)
  358. self.applyProSubscriptionToSidebar()
  359. }
  360. }
  361. override func layout() {
  362. super.layout()
  363. updateSearchBarShadowPath()
  364. updateFindJobsCTAShadowPath()
  365. updateJobListingDescriptionWidths()
  366. updateChatBubbleWidths()
  367. }
  368. func render(_ data: DashboardData) {
  369. dismissIndeedJobBrowserEmbedded()
  370. greetingLabel.stringValue = L("Welcome")
  371. subtitleLabel.stringValue = data.subtitle
  372. currentSidebarItems = data.sidebarItems
  373. if selectedSidebarIndex >= currentSidebarItems.count {
  374. selectedSidebarIndex = max(0, currentSidebarItems.count - 1)
  375. }
  376. configureSidebar()
  377. savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load())
  378. resetChatState()
  379. updateMainContentVisibility()
  380. applyCurrentAppearance()
  381. }
  382. private func applyCurrentAppearance() {
  383. window?.backgroundColor = AppAppearanceManager.shared.windowChromeColor
  384. layer?.backgroundColor = Theme.chromeBackground.cgColor
  385. chromeContainer.layer?.backgroundColor = Theme.chromeBackground.cgColor
  386. sidebar.layer?.backgroundColor = Theme.sidebarBackground.cgColor
  387. mainHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
  388. indeedJobBrowserHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
  389. nonHomeHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
  390. cvMakerPageContainer.layer?.backgroundColor = Theme.mainHostBackground.cgColor
  391. profilePageContainer.layer?.backgroundColor = Theme.mainHostBackground.cgColor
  392. settingsPageContainer.layer?.backgroundColor = Theme.settingsPageBackground.cgColor
  393. greetingLabel.textColor = Theme.welcomeHeroHeadingBlue
  394. subtitleLabel.textColor = Theme.welcomeHeroSubtitleText
  395. welcomeHeroBackgroundView.waveTint = Theme.welcomeHeroWaveTint
  396. welcomeLogoWell.layer?.backgroundColor = Theme.welcomeHeroIconWell.cgColor
  397. nonHomeTitleLabel.textColor = Theme.primaryText
  398. nonHomeSubtitleLabel.textColor = Theme.secondaryText
  399. savedJobsPageTitleLabel.textColor = Theme.primaryText
  400. savedJobsPageSubtitleLabel.textColor = Theme.secondaryText
  401. let searchHovering = searchCard.isHovering
  402. searchCard.layer?.backgroundColor = (searchHovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor
  403. searchCard.layer?.borderColor = (searchHovering ? Theme.searchBarBorderHover : Theme.searchBarBorder).cgColor
  404. jobKeywordsField.textColor = Theme.primaryText
  405. jobKeywordsField.placeholderAttributedString = searchFieldPlaceholder()
  406. jobSearchIcon.contentTintColor = Theme.brandBlue
  407. let ctaHovering = findJobsButton.isHovering
  408. findJobsCTAPill.layer?.backgroundColor = (ctaHovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor
  409. sendIconView.contentTintColor = Theme.proCTAText
  410. sendLabel.textColor = Theme.proCTAText
  411. clearChatButton.layer?.backgroundColor = Theme.brandBlue.cgColor
  412. clearChatButton.contentTintColor = Theme.proCTAText
  413. freeJobSearchQuotaLabel.textColor = Theme.secondaryText
  414. appearanceModeSegment?.selectedSegment = AppAppearanceManager.shared.mode.segmentIndex
  415. if let langPopUp = languagePopUp {
  416. let saved = AppLanguageManager.shared.current.localeIdentifier
  417. if let index = AppLanguage.allCases.firstIndex(where: { $0.localeIdentifier == saved }) {
  418. langPopUp.selectItem(at: index)
  419. }
  420. langPopUp.isEnabled = !AppLanguage.allCases.isEmpty
  421. }
  422. cvMakerPageView.applyCurrentAppearance()
  423. profilesListPageView.applyCurrentAppearance()
  424. myProfilePageView.applyCurrentAppearance()
  425. cvFilledPreviewPageView.applyCurrentAppearance()
  426. refreshSettingsPageAppearance(in: settingsPageContainer)
  427. rebuildFeatureShortcutCards()
  428. configureSidebar()
  429. reloadSavedJobsListings()
  430. rebuildChatUI()
  431. applyHomeLayoutDirection()
  432. applyProSubscriptionToSidebar()
  433. updateFreeJobSearchQuotaLabel()
  434. needsLayout = true
  435. }
  436. /// Home search, chat, and shell chrome follow the in-app language; CV/profile subtrees stay LTR.
  437. private func applyHomeLayoutDirection() {
  438. let direction = AppLayoutDirection.interface
  439. userInterfaceLayoutDirection = direction
  440. panelsRow.userInterfaceLayoutDirection = direction
  441. chromeContainer.userInterfaceLayoutDirection = direction
  442. sidebar.userInterfaceLayoutDirection = direction
  443. mainOverlay.userInterfaceLayoutDirection = direction
  444. searchBarColumn.userInterfaceLayoutDirection = direction
  445. searchCard.userInterfaceLayoutDirection = direction
  446. chatScrollView.userInterfaceLayoutDirection = direction
  447. chatDocumentView.userInterfaceLayoutDirection = direction
  448. chatStack.userInterfaceLayoutDirection = direction
  449. featureCardsRow.userInterfaceLayoutDirection = direction
  450. welcomeHeroHost.userInterfaceLayoutDirection = direction
  451. AppLayoutDirection.configureTextField(jobKeywordsField)
  452. jobKeywordsField.placeholderAttributedString = searchFieldPlaceholder()
  453. greetingLabel.baseWritingDirection = AppLayoutDirection.naturalWritingDirection
  454. subtitleLabel.baseWritingDirection = AppLayoutDirection.naturalWritingDirection
  455. for card in featureCardsRow.arrangedSubviews {
  456. (card as? FeatureShortcutCardView)?.applyLayoutDirection()
  457. }
  458. refreshChatBubbleTextDirection()
  459. nonHomeHost.userInterfaceLayoutDirection = .leftToRight
  460. }
  461. private func refreshChatBubbleTextDirection() {
  462. func walk(_ view: NSView) {
  463. if let label = view as? ChatBubbleLabel {
  464. let color = label.textColor ?? Theme.primaryText
  465. let font = label.font ?? .systemFont(ofSize: 13.5, weight: .regular)
  466. label.attributedStringValue = Self.chatBubbleAttributedString(label.stringValue, color: color, font: font)
  467. }
  468. for sub in view.subviews { walk(sub) }
  469. }
  470. walk(chatStack)
  471. }
  472. private func refreshSettingsPageAppearance(in root: NSView) {
  473. for view in root.subviewsRecursive() {
  474. guard let rawID = view.identifier?.rawValue else { continue }
  475. switch rawID {
  476. case SettingsAppearanceID.section:
  477. view.layer?.backgroundColor = Theme.settingsGroupBackground.cgColor
  478. view.layer?.borderColor = Theme.border.cgColor
  479. case SettingsAppearanceID.divider:
  480. view.layer?.backgroundColor = Theme.settingsDivider.cgColor
  481. case SettingsAppearanceID.iconTile:
  482. view.layer?.backgroundColor = Theme.settingsIconBackground.cgColor
  483. for case let icon as NSImageView in view.subviews {
  484. icon.contentTintColor = Theme.brandBlue
  485. }
  486. default:
  487. if rawID.hasPrefix(SettingsAppearanceID.sectionHeader + ".") {
  488. (view as? NSTextField)?.textColor = Theme.secondaryText
  489. } else if rawID.hasPrefix(SettingsAppearanceID.rowTitle + ".") {
  490. (view as? NSTextField)?.textColor = Theme.primaryText
  491. }
  492. }
  493. }
  494. }
  495. private func rebuildFeatureShortcutCards() {
  496. let selectedIndex = featureCardsRow.arrangedSubviews.firstIndex {
  497. ($0 as? FeatureShortcutCardView)?.isSelected == true
  498. }
  499. featureCardsRow.arrangedSubviews.forEach {
  500. featureCardsRow.removeArrangedSubview($0)
  501. $0.removeFromSuperview()
  502. }
  503. configureFeatureShortcutCards()
  504. if let selectedIndex, let shortcut = FeatureShortcut(rawValue: selectedIndex) {
  505. selectFeatureShortcut(shortcut)
  506. }
  507. }
  508. private func setupLayout() {
  509. wantsLayer = true
  510. // Match chrome so the outer margin (inset chrome container) is grey, not an extra white ring.
  511. layer?.backgroundColor = Theme.chromeBackground.cgColor
  512. panelsRow.translatesAutoresizingMaskIntoConstraints = false
  513. chromeContainer.translatesAutoresizingMaskIntoConstraints = false
  514. chromeContainer.wantsLayer = true
  515. chromeContainer.layer?.backgroundColor = Theme.chromeBackground.cgColor
  516. chromeContainer.layer?.cornerRadius = 18
  517. chromeContainer.layer?.masksToBounds = true
  518. addSubview(chromeContainer)
  519. chromeContainer.addSubview(panelsRow)
  520. sidebar.orientation = .vertical
  521. sidebar.spacing = 10
  522. sidebar.distribution = .fill
  523. sidebar.alignment = .leading
  524. sidebar.wantsLayer = true
  525. sidebar.layer?.backgroundColor = Theme.sidebarBackground.cgColor
  526. sidebar.layer?.cornerRadius = 16
  527. sidebar.edgeInsets = NSEdgeInsets(top: 20, left: 6, bottom: 6, right: 6)
  528. sidebar.translatesAutoresizingMaskIntoConstraints = false
  529. mainHost.translatesAutoresizingMaskIntoConstraints = false
  530. mainHost.wantsLayer = true
  531. mainHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
  532. mainHost.layer?.cornerRadius = 16
  533. mainHost.layer?.masksToBounds = true
  534. sidebar.setContentHuggingPriority(.required, for: .horizontal)
  535. mainHost.setContentHuggingPriority(.defaultLow, for: .horizontal)
  536. mainHost.addSubview(mainOverlay)
  537. configureNonHomePlaceholder()
  538. mainHost.addSubview(nonHomeHost)
  539. indeedJobBrowserHost.translatesAutoresizingMaskIntoConstraints = false
  540. indeedJobBrowserHost.wantsLayer = true
  541. indeedJobBrowserHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
  542. indeedJobBrowserHost.isHidden = true
  543. mainHost.addSubview(indeedJobBrowserHost)
  544. mainOverlay.orientation = .vertical
  545. mainOverlay.spacing = 0
  546. mainOverlay.alignment = .centerX
  547. mainOverlay.distribution = .fill
  548. mainOverlay.translatesAutoresizingMaskIntoConstraints = false
  549. mainOverlay.setContentHuggingPriority(.defaultLow, for: .vertical)
  550. greetingLabel.font = .systemFont(ofSize: 28, weight: .bold)
  551. greetingLabel.textColor = Theme.welcomeHeroHeadingBlue
  552. greetingLabel.alignment = .center
  553. greetingLabel.maximumNumberOfLines = 1
  554. subtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
  555. subtitleLabel.textColor = Theme.welcomeHeroSubtitleText
  556. subtitleLabel.alignment = .center
  557. subtitleLabel.maximumNumberOfLines = 2
  558. let topInset = NSView()
  559. topInset.translatesAutoresizingMaskIntoConstraints = false
  560. topInset.heightAnchor.constraint(equalToConstant: 12).isActive = true
  561. configureSearchBar()
  562. configureChatViews()
  563. welcomeHeroHost.translatesAutoresizingMaskIntoConstraints = false
  564. welcomeHeroBackgroundView.translatesAutoresizingMaskIntoConstraints = false
  565. welcomeHeroBackgroundView.waveTint = Theme.welcomeHeroWaveTint
  566. welcomeLogoWell.translatesAutoresizingMaskIntoConstraints = false
  567. welcomeLogoWell.wantsLayer = true
  568. welcomeLogoWell.layer?.backgroundColor = Theme.welcomeHeroIconWell.cgColor
  569. welcomeLogoWell.layer?.cornerRadius = 28
  570. if #available(macOS 11.0, *) {
  571. welcomeLogoWell.layer?.cornerCurve = .continuous
  572. }
  573. welcomeLogoView.translatesAutoresizingMaskIntoConstraints = false
  574. welcomeLogoWell.addSubview(welcomeLogoView)
  575. let welcomeHeroContent = NSStackView(views: [welcomeLogoWell, greetingLabel, subtitleLabel])
  576. welcomeHeroContent.orientation = .vertical
  577. welcomeHeroContent.spacing = 8
  578. welcomeHeroContent.alignment = .centerX
  579. welcomeHeroContent.translatesAutoresizingMaskIntoConstraints = false
  580. welcomeHeroHost.addSubview(welcomeHeroBackgroundView)
  581. welcomeHeroHost.addSubview(welcomeHeroContent)
  582. welcomeHeroHost.setContentHuggingPriority(.defaultHigh, for: .vertical)
  583. NSLayoutConstraint.activate([
  584. welcomeHeroBackgroundView.leadingAnchor.constraint(equalTo: welcomeHeroHost.leadingAnchor),
  585. welcomeHeroBackgroundView.trailingAnchor.constraint(equalTo: welcomeHeroHost.trailingAnchor),
  586. welcomeHeroBackgroundView.topAnchor.constraint(equalTo: welcomeHeroHost.topAnchor),
  587. welcomeHeroBackgroundView.bottomAnchor.constraint(equalTo: welcomeHeroHost.bottomAnchor),
  588. welcomeHeroContent.centerXAnchor.constraint(equalTo: welcomeHeroHost.centerXAnchor),
  589. welcomeHeroContent.leadingAnchor.constraint(greaterThanOrEqualTo: welcomeHeroHost.leadingAnchor, constant: 16),
  590. welcomeHeroContent.trailingAnchor.constraint(lessThanOrEqualTo: welcomeHeroHost.trailingAnchor, constant: -16),
  591. welcomeHeroContent.topAnchor.constraint(equalTo: welcomeHeroHost.topAnchor, constant: 4),
  592. welcomeHeroContent.bottomAnchor.constraint(equalTo: welcomeHeroHost.bottomAnchor, constant: -2),
  593. welcomeLogoWell.widthAnchor.constraint(equalToConstant: 56),
  594. welcomeLogoWell.heightAnchor.constraint(equalToConstant: 56),
  595. welcomeLogoView.centerXAnchor.constraint(equalTo: welcomeLogoWell.centerXAnchor),
  596. welcomeLogoView.centerYAnchor.constraint(equalTo: welcomeLogoWell.centerYAnchor)
  597. ])
  598. configureFeatureShortcutCards()
  599. featureCardsRow.setContentHuggingPriority(.defaultHigh, for: .vertical)
  600. let heroCardsSpacer = NSView()
  601. heroCardsSpacer.translatesAutoresizingMaskIntoConstraints = false
  602. heroCardsSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true
  603. let midSpacer = NSView()
  604. midSpacer.translatesAutoresizingMaskIntoConstraints = false
  605. midSpacer.heightAnchor.constraint(equalToConstant: 6).isActive = true
  606. let chatTopSpacer = NSView()
  607. chatTopSpacer.translatesAutoresizingMaskIntoConstraints = false
  608. chatTopSpacer.heightAnchor.constraint(equalToConstant: 8).isActive = true
  609. let chatBottomSpacer = NSView()
  610. chatBottomSpacer.translatesAutoresizingMaskIntoConstraints = false
  611. chatBottomSpacer.heightAnchor.constraint(equalToConstant: 8).isActive = true
  612. mainOverlay.addArrangedSubview(topInset)
  613. mainOverlay.addArrangedSubview(welcomeHeroHost)
  614. mainOverlay.addArrangedSubview(heroCardsSpacer)
  615. mainOverlay.addArrangedSubview(featureCardsRow)
  616. mainOverlay.addArrangedSubview(midSpacer)
  617. mainOverlay.addArrangedSubview(chatTopSpacer)
  618. let chatHeaderRow = NSStackView()
  619. chatHeaderRow.orientation = .horizontal
  620. chatHeaderRow.spacing = 8
  621. chatHeaderRow.alignment = .centerY
  622. chatHeaderRow.distribution = .fill
  623. chatHeaderRow.translatesAutoresizingMaskIntoConstraints = false
  624. let chatHeaderLeadingSpacer = NSView()
  625. chatHeaderLeadingSpacer.translatesAutoresizingMaskIntoConstraints = false
  626. chatHeaderLeadingSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  627. clearChatButton.translatesAutoresizingMaskIntoConstraints = false
  628. clearChatButton.bezelStyle = .rounded
  629. clearChatButton.controlSize = .regular
  630. clearChatButton.font = .systemFont(ofSize: 13, weight: .medium)
  631. clearChatButton.isBordered = false
  632. clearChatButton.wantsLayer = true
  633. clearChatButton.layer?.cornerRadius = 8
  634. clearChatButton.layer?.backgroundColor = Theme.brandBlue.cgColor
  635. clearChatButton.contentTintColor = Theme.proCTAText
  636. clearChatButton.focusRingType = .none
  637. clearChatButton.target = self
  638. clearChatButton.action = #selector(didTapClearChat)
  639. clearChatButton.toolTip = L("Remove all messages and start a new conversation")
  640. clearChatButton.setContentHuggingPriority(.required, for: .horizontal)
  641. let clearChatHorizontalPad: CGFloat = 12
  642. let clearChatMinWidth = clearChatButton.intrinsicContentSize.width + clearChatHorizontalPad * 2
  643. NSLayoutConstraint.activate([
  644. clearChatButton.heightAnchor.constraint(equalToConstant: 30),
  645. clearChatButton.widthAnchor.constraint(greaterThanOrEqualToConstant: clearChatMinWidth)
  646. ])
  647. chatHeaderRow.addArrangedSubview(chatHeaderLeadingSpacer)
  648. chatHeaderRow.addArrangedSubview(clearChatButton)
  649. mainOverlay.addArrangedSubview(chatHeaderRow)
  650. mainOverlay.addArrangedSubview(chatScrollView)
  651. mainOverlay.addArrangedSubview(chatBottomSpacer)
  652. mainOverlay.addArrangedSubview(searchBarColumn)
  653. panelsRow.addSubview(sidebar)
  654. panelsRow.addSubview(mainHost)
  655. NSLayoutConstraint.activate([
  656. chromeContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6),
  657. chromeContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6),
  658. chromeContainer.topAnchor.constraint(equalTo: topAnchor, constant: 6),
  659. chromeContainer.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6),
  660. panelsRow.leadingAnchor.constraint(equalTo: chromeContainer.leadingAnchor, constant: 6),
  661. panelsRow.trailingAnchor.constraint(equalTo: chromeContainer.trailingAnchor, constant: -8),
  662. panelsRow.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 2),
  663. panelsRow.bottomAnchor.constraint(equalTo: chromeContainer.bottomAnchor, constant: -8),
  664. sidebar.leadingAnchor.constraint(equalTo: panelsRow.leadingAnchor),
  665. sidebar.topAnchor.constraint(equalTo: panelsRow.topAnchor),
  666. sidebar.bottomAnchor.constraint(equalTo: panelsRow.bottomAnchor),
  667. sidebar.widthAnchor.constraint(equalToConstant: 218),
  668. mainHost.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: 6),
  669. mainHost.trailingAnchor.constraint(equalTo: panelsRow.trailingAnchor),
  670. mainHost.topAnchor.constraint(equalTo: panelsRow.topAnchor),
  671. mainHost.bottomAnchor.constraint(equalTo: panelsRow.bottomAnchor),
  672. mainHost.widthAnchor.constraint(greaterThanOrEqualToConstant: 720),
  673. mainOverlay.leadingAnchor.constraint(equalTo: mainHost.leadingAnchor),
  674. mainOverlay.trailingAnchor.constraint(equalTo: mainHost.trailingAnchor),
  675. mainOverlay.topAnchor.constraint(equalTo: mainHost.topAnchor),
  676. mainOverlay.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
  677. nonHomeHost.leadingAnchor.constraint(equalTo: mainHost.leadingAnchor),
  678. nonHomeHost.trailingAnchor.constraint(equalTo: mainHost.trailingAnchor),
  679. nonHomeHost.topAnchor.constraint(equalTo: mainHost.topAnchor),
  680. nonHomeHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
  681. indeedJobBrowserHost.leadingAnchor.constraint(equalTo: mainHost.leadingAnchor),
  682. indeedJobBrowserHost.trailingAnchor.constraint(equalTo: mainHost.trailingAnchor),
  683. indeedJobBrowserHost.topAnchor.constraint(equalTo: mainHost.topAnchor),
  684. indeedJobBrowserHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
  685. searchBarColumn.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
  686. featureCardsRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
  687. chatHeaderRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
  688. chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
  689. welcomeHeroHost.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor),
  690. welcomeHeroHost.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor),
  691. greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 16),
  692. greetingLabel.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor, constant: -16),
  693. subtitleLabel.leadingAnchor.constraint(equalTo: greetingLabel.leadingAnchor),
  694. subtitleLabel.trailingAnchor.constraint(equalTo: greetingLabel.trailingAnchor),
  695. welcomeHeroContent.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, constant: -32)
  696. ])
  697. registerSubscriptionObserverOnce()
  698. refreshLocalizedStrings()
  699. applyHomeLayoutDirection()
  700. }
  701. private func registerSubscriptionObserverOnce() {
  702. guard subscriptionObserver == nil else { return }
  703. subscriptionObserver = NotificationCenter.default.addObserver(
  704. forName: .subscriptionStatusDidChange,
  705. object: nil,
  706. queue: .main
  707. ) { [weak self] _ in
  708. self?.applyProSubscriptionToSidebar()
  709. }
  710. }
  711. private func applyProSubscriptionToSidebar() {
  712. let active = SubscriptionStore.shared.isProActive
  713. sidebarUpgradeCard?.isHidden = false
  714. guard let headline = sidebarUpgradeHeadline,
  715. let upgradeDescription = sidebarUpgradeDescription,
  716. let upgradeButton = sidebarUpgradeButton else { return }
  717. let descriptionWidth = Self.sidebarProCardInnerContentWidth
  718. if active {
  719. headline.stringValue = L("You're on Pro")
  720. upgradeDescription.stringValue = L("Manage billing, renewals, and plans in Premium.")
  721. upgradeDescription.preferredMaxLayoutWidth = descriptionWidth
  722. upgradeButton.title = L("Manage Subscription")
  723. } else {
  724. headline.stringValue = L("Upgrade to Pro")
  725. upgradeDescription.stringValue = L("Unlimited AI matches, smart alerts, and interview prep—all in one place.")
  726. upgradeDescription.preferredMaxLayoutWidth = descriptionWidth
  727. upgradeButton.title = L("Try Pro")
  728. }
  729. headline.preferredMaxLayoutWidth = descriptionWidth
  730. headline.invalidateIntrinsicContentSize()
  731. upgradeDescription.invalidateIntrinsicContentSize()
  732. upgradeButton.invalidateIntrinsicContentSize()
  733. sidebarUpgradeCard?.layoutSubtreeIfNeeded()
  734. updateFreeJobSearchQuotaLabel()
  735. }
  736. private func updateFreeJobSearchQuotaLabel() {
  737. let isPro = SubscriptionStore.shared.isProActive
  738. if isPro {
  739. freeJobSearchQuotaLabel.isHidden = true
  740. freeJobSearchQuotaLabel.stringValue = ""
  741. return
  742. }
  743. let remaining = FreeTierJobSearchQuota.remainingUserMessages(isProActive: false)
  744. freeJobSearchQuotaLabel.isHidden = false
  745. freeJobSearchQuotaLabel.stringValue = remaining == 1
  746. ? L("1 reply left")
  747. : String(format: L("%d replies left"), remaining)
  748. }
  749. /// Returns `false` and presents the paywall when the user does not have an active Pro subscription.
  750. @discardableResult
  751. private func ensureProAccess() -> Bool {
  752. guard SubscriptionStore.shared.isProActive else {
  753. presentPremiumPlansSheet()
  754. return false
  755. }
  756. return true
  757. }
  758. /// Home AI job search: Pro is unlimited; free users may send up to `FreeTierJobSearchQuota.maxUserMessages` user messages.
  759. @discardableResult
  760. private func ensureProAccessForJobSearch() -> Bool {
  761. if SubscriptionStore.shared.isProActive { return true }
  762. guard FreeTierJobSearchQuota.canSendAnotherMessage(isProActive: false) else {
  763. presentPremiumPlansSheet()
  764. return false
  765. }
  766. return true
  767. }
  768. private func presentPremiumPlansSheet() {
  769. guard let hostWindow = window else { return }
  770. if isPreparingPremiumPlansSheet { return }
  771. isPreparingPremiumPlansSheet = true
  772. Task { @MainActor [weak self] in
  773. defer { self?.isPreparingPremiumPlansSheet = false }
  774. guard let self else { return }
  775. if self.premiumPlansWindowController == nil {
  776. self.premiumPlansWindowController = PremiumPlansWindowController()
  777. }
  778. guard let controller = self.premiumPlansWindowController else { return }
  779. await controller.prepareForPresentation()
  780. guard let paywallWindow = controller.window else { return }
  781. if hostWindow.attachedSheet === paywallWindow {
  782. return
  783. }
  784. paywallWindow.styleMask = [.borderless, .closable, .resizable]
  785. paywallWindow.isOpaque = true
  786. paywallWindow.backgroundColor = PremiumPlansWindowController.paywallSheetBackground
  787. let hostContentRect = hostWindow.contentRect(forFrameRect: hostWindow.frame)
  788. let overscan = PremiumSheetLayout.overscanPerEdge
  789. var expandedContentRect = hostContentRect.insetBy(dx: -overscan, dy: -overscan)
  790. expandedContentRect.size.height += PremiumSheetLayout.overscanExtraTop
  791. let paywallFrame = paywallWindow.frameRect(forContentRect: expandedContentRect)
  792. paywallWindow.setFrame(paywallFrame, display: false)
  793. let lockedSize = paywallWindow.frame.size
  794. paywallWindow.minSize = lockedSize
  795. paywallWindow.maxSize = lockedSize
  796. await hostWindow.beginSheet(paywallWindow)
  797. }
  798. }
  799. private func configureFeatureShortcutCards() {
  800. featureCardsRow.orientation = .horizontal
  801. featureCardsRow.spacing = 16
  802. featureCardsRow.distribution = .fillEqually
  803. featureCardsRow.alignment = .top
  804. featureCardsRow.translatesAutoresizingMaskIntoConstraints = false
  805. let specs: [(symbol: String, titleKey: String, subtitleKey: String, action: Selector)] = [
  806. ("briefcase", "Role", "Explore similar or better job roles", #selector(didTapFeatureRole)),
  807. ("building.2", "Company", "Find opportunities at other companies", #selector(didTapFeatureCompany)),
  808. ("chevron.left.forwardslash.chevron.right", "Skill", "Match jobs that fit your skills", #selector(didTapFeatureSkill))
  809. ]
  810. for spec in specs {
  811. let card = FeatureShortcutCardView(
  812. symbolName: spec.symbol,
  813. title: L(spec.titleKey),
  814. subtitle: L(spec.subtitleKey),
  815. target: self,
  816. action: spec.action
  817. )
  818. card.applyLayoutDirection()
  819. featureCardsRow.addArrangedSubview(card)
  820. }
  821. }
  822. private func configureChatViews() {
  823. chatDocumentView.translatesAutoresizingMaskIntoConstraints = false
  824. chatStack.orientation = .vertical
  825. chatStack.spacing = 18
  826. chatStack.alignment = .width
  827. chatStack.distribution = .fill
  828. chatStack.translatesAutoresizingMaskIntoConstraints = false
  829. chatDocumentView.addSubview(chatStack)
  830. NSLayoutConstraint.activate([
  831. chatStack.leadingAnchor.constraint(equalTo: chatDocumentView.leadingAnchor, constant: 4),
  832. chatStack.trailingAnchor.constraint(equalTo: chatDocumentView.trailingAnchor, constant: -4),
  833. chatStack.topAnchor.constraint(equalTo: chatDocumentView.topAnchor, constant: 8),
  834. chatStack.bottomAnchor.constraint(equalTo: chatDocumentView.bottomAnchor, constant: -8)
  835. ])
  836. chatScrollView.translatesAutoresizingMaskIntoConstraints = false
  837. chatScrollView.hasVerticalScroller = true
  838. chatScrollView.hasHorizontalScroller = false
  839. // Legacy reserves a dedicated track to the right of the clip view so the thumb never sits on top of cards/buttons.
  840. chatScrollView.scrollerStyle = .legacy
  841. chatScrollView.autohidesScrollers = true
  842. chatScrollView.drawsBackground = false
  843. chatScrollView.borderType = .noBorder
  844. chatScrollView.documentView = chatDocumentView
  845. chatScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
  846. chatScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  847. // 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.
  848. NSLayoutConstraint.activate([
  849. chatDocumentView.topAnchor.constraint(equalTo: chatScrollView.contentView.topAnchor),
  850. chatDocumentView.leadingAnchor.constraint(equalTo: chatScrollView.contentView.leadingAnchor),
  851. chatDocumentView.widthAnchor.constraint(equalTo: chatScrollView.contentView.widthAnchor)
  852. ])
  853. }
  854. private var prefersReducedMotion: Bool {
  855. NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
  856. }
  857. private func updateJobListingDescriptionWidths() {
  858. updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
  859. walkChatJobStacks { stack in
  860. updateDescriptionColumnWidths(in: stack, containerWidth: stack.bounds.width)
  861. }
  862. }
  863. private func walkChatJobStacks(_ visitor: (ChatJobsStackView) -> Void) {
  864. func walk(_ view: NSView) {
  865. if let stack = view as? ChatJobsStackView {
  866. visitor(stack)
  867. }
  868. for sub in view.subviews { walk(sub) }
  869. }
  870. walk(chatStack)
  871. }
  872. /// 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.
  873. private func updateChatBubbleWidths() {
  874. func walk(_ view: NSView) {
  875. if let label = view as? ChatBubbleLabel,
  876. let bubble = label.superview, bubble.bounds.width > 1 {
  877. let target = max(40, bubble.bounds.width - 28)
  878. if abs(label.preferredMaxLayoutWidth - target) > 0.5 {
  879. label.preferredMaxLayoutWidth = target
  880. label.invalidateIntrinsicContentSize()
  881. }
  882. }
  883. for sub in view.subviews { walk(sub) }
  884. }
  885. walk(chatStack)
  886. }
  887. private func updateDescriptionColumnWidths(in stack: NSStackView, containerWidth: CGFloat) {
  888. guard containerWidth > 1 else { return }
  889. // 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.
  890. let contentHorizontalInset: CGFloat = 32
  891. var didChange = false
  892. for card in stack.arrangedSubviews {
  893. guard let desc = card.viewWithTag(502) as? NSTextField else { continue }
  894. let cardWidth = card.bounds.width > 1 ? card.bounds.width : containerWidth
  895. let fallbackColumn = max(1, cardWidth - contentHorizontalInset)
  896. let columnWidth: CGFloat
  897. if desc.bounds.width > 1 {
  898. columnWidth = desc.bounds.width
  899. } else if let column = desc.superview, column.bounds.width > 1 {
  900. columnWidth = column.bounds.width
  901. } else {
  902. columnWidth = fallbackColumn
  903. }
  904. if abs(desc.preferredMaxLayoutWidth - columnWidth) > 0.5 {
  905. desc.preferredMaxLayoutWidth = columnWidth
  906. desc.invalidateIntrinsicContentSize()
  907. didChange = true
  908. }
  909. }
  910. if didChange {
  911. stack.needsLayout = true
  912. }
  913. }
  914. private static func normalizedSavedJobs(_ jobs: [JobListing]) -> [JobListing] {
  915. var seen = Set<JobListing>()
  916. var out: [JobListing] = []
  917. for job in jobs where seen.insert(job).inserted {
  918. out.append(job)
  919. }
  920. return out
  921. }
  922. private func isJobSaved(_ job: JobListing) -> Bool {
  923. savedJobOrder.contains(job)
  924. }
  925. private func persistSavedJobs() {
  926. SavedJobsStore.save(savedJobOrder)
  927. }
  928. private func applySavedState(_ saved: Bool, for job: JobListing) {
  929. if saved {
  930. savedJobOrder.removeAll { $0 == job }
  931. savedJobOrder.insert(job, at: 0)
  932. } else {
  933. savedJobOrder.removeAll { $0 == job }
  934. }
  935. persistSavedJobs()
  936. }
  937. private func jobListingHostSubtitle(_ job: JobListing) -> String {
  938. guard let raw = job.url, let url = URL(string: raw), let host = url.host?.lowercased() else {
  939. return AppMarketingLinks.indeedBrandName
  940. }
  941. if host.hasPrefix("www.") {
  942. return String(host.dropFirst(4))
  943. }
  944. return host
  945. }
  946. private func jobListingCategorySymbol(for job: JobListing) -> String {
  947. let blob = (job.title + " " + job.description).lowercased()
  948. if blob.contains("machine learning") || blob.contains("deep learning") || blob.contains(" ml ") {
  949. return "brain.head.profile"
  950. }
  951. if blob.contains("audio") || blob.contains(" sound ") || blob.contains("dsp") {
  952. return "waveform"
  953. }
  954. if blob.contains("ios") || blob.contains("swift") || blob.contains("mobile") {
  955. return "iphone"
  956. }
  957. if blob.contains("design") || blob.contains(" ux") || blob.contains("figma") {
  958. return "paintpalette.fill"
  959. }
  960. if blob.contains("data ") || blob.contains("analytics") {
  961. return "chart.bar.fill"
  962. }
  963. if blob.contains("ai") || blob.contains("llm") || blob.contains("nlp") {
  964. return "cpu"
  965. }
  966. return "briefcase.fill"
  967. }
  968. private func makeJobListingCard(_ job: JobListing, context: JobListingCardContext) -> NSView {
  969. let card = NSView()
  970. card.translatesAutoresizingMaskIntoConstraints = false
  971. card.wantsLayer = true
  972. card.layer?.backgroundColor = Theme.cardBackground.cgColor
  973. card.layer?.cornerRadius = 14
  974. card.layer?.borderWidth = 1
  975. card.layer?.borderColor = Theme.border.cgColor
  976. card.layer?.masksToBounds = true
  977. let iconBox = NSView()
  978. iconBox.translatesAutoresizingMaskIntoConstraints = false
  979. iconBox.wantsLayer = true
  980. iconBox.layer?.backgroundColor = Theme.brandBlue.cgColor
  981. iconBox.layer?.cornerRadius = 12
  982. if #available(macOS 11.0, *) {
  983. iconBox.layer?.cornerCurve = .continuous
  984. }
  985. let categoryIcon = NSImageView()
  986. categoryIcon.translatesAutoresizingMaskIntoConstraints = false
  987. categoryIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 22, weight: .medium)
  988. categoryIcon.image = NSImage(systemSymbolName: jobListingCategorySymbol(for: job), accessibilityDescription: nil)
  989. categoryIcon.contentTintColor = .white
  990. iconBox.addSubview(categoryIcon)
  991. let titleField = NSTextField(labelWithString: job.title)
  992. titleField.font = .systemFont(ofSize: 16, weight: .semibold)
  993. titleField.textColor = Theme.brandBlue
  994. titleField.maximumNumberOfLines = 2
  995. titleField.lineBreakMode = .byWordWrapping
  996. titleField.alignment = context == .homeSearchResults ? AppLayoutDirection.naturalTextAlignment : .left
  997. titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  998. titleField.translatesAutoresizingMaskIntoConstraints = false
  999. let buildingIcon = NSImageView()
  1000. buildingIcon.translatesAutoresizingMaskIntoConstraints = false
  1001. buildingIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .medium)
  1002. buildingIcon.image = NSImage(systemSymbolName: "building.2.fill", accessibilityDescription: nil)
  1003. buildingIcon.contentTintColor = Theme.welcomeSubtitleText
  1004. let companyLabel = NSTextField(labelWithString: jobListingHostSubtitle(job))
  1005. companyLabel.font = .systemFont(ofSize: 12, weight: .medium)
  1006. companyLabel.textColor = Theme.welcomeSubtitleText
  1007. companyLabel.maximumNumberOfLines = 1
  1008. companyLabel.lineBreakMode = .byTruncatingTail
  1009. companyLabel.translatesAutoresizingMaskIntoConstraints = false
  1010. let companyRow = NSStackView(views: [buildingIcon, companyLabel])
  1011. companyRow.orientation = .horizontal
  1012. companyRow.spacing = 5
  1013. companyRow.alignment = .centerY
  1014. companyRow.translatesAutoresizingMaskIntoConstraints = false
  1015. let descriptionField = NSTextField(wrappingLabelWithString: job.description)
  1016. descriptionField.font = .systemFont(ofSize: 13, weight: .regular)
  1017. descriptionField.textColor = Theme.secondaryText
  1018. descriptionField.maximumNumberOfLines = 2
  1019. descriptionField.lineBreakMode = .byWordWrapping
  1020. descriptionField.alignment = context == .homeSearchResults ? AppLayoutDirection.naturalTextAlignment : .left
  1021. descriptionField.baseWritingDirection = context == .homeSearchResults
  1022. ? AppLayoutDirection.naturalWritingDirection
  1023. : .leftToRight
  1024. descriptionField.attributedStringValue = Self.jobListingDescriptionAttributedString(
  1025. job.description,
  1026. forceLeftToRight: context == .savedJobsPage
  1027. )
  1028. if let cell = descriptionField.cell as? NSTextFieldCell {
  1029. cell.alignment = descriptionField.alignment
  1030. cell.wraps = true
  1031. }
  1032. descriptionField.setContentHuggingPriority(.defaultLow, for: .horizontal)
  1033. descriptionField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  1034. descriptionField.tag = 502
  1035. descriptionField.translatesAutoresizingMaskIntoConstraints = false
  1036. let applyButton = JobPayloadButton(title: L("Apply"), target: self, action: #selector(didTapJobApply(_:)))
  1037. applyButton.jobPayload = job
  1038. applyButton.cardContext = context
  1039. applyButton.isBordered = false
  1040. applyButton.bezelStyle = .rounded
  1041. applyButton.font = .systemFont(ofSize: 13, weight: .semibold)
  1042. applyButton.wantsLayer = true
  1043. applyButton.layer?.cornerRadius = 8
  1044. applyButton.layer?.backgroundColor = Theme.brandBlue.cgColor
  1045. applyButton.contentTintColor = Theme.proCTAText
  1046. applyButton.focusRingType = .none
  1047. applyButton.pointerCursor = true
  1048. applyButton.hoverHandler = { [weak applyButton] hovering in
  1049. applyButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor
  1050. }
  1051. applyButton.setContentHuggingPriority(.required, for: .horizontal)
  1052. applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  1053. let savedOn = isJobSaved(job)
  1054. let savedButton = SaveJobPayloadButton(title: savedOn ? L("Saved") : L("Save"), target: self, action: #selector(didTapJobSaved(_:)))
  1055. savedButton.jobPayload = job
  1056. savedButton.cardContext = context
  1057. savedButton.setButtonType(.toggle)
  1058. savedButton.isBordered = false
  1059. savedButton.bezelStyle = .rounded
  1060. savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
  1061. savedButton.image = NSImage(systemSymbolName: savedOn ? "heart.fill" : "heart", accessibilityDescription: nil)
  1062. savedButton.imagePosition = .imageLeading
  1063. savedButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  1064. savedButton.focusRingType = .none
  1065. savedButton.state = savedOn ? .on : .off
  1066. savedButton.pointerCursor = true
  1067. savedButton.hoverHandler = { [weak self, weak savedButton] _ in
  1068. guard let savedButton = savedButton else { return }
  1069. self?.styleJobSavedButton(savedButton)
  1070. }
  1071. styleJobSavedButton(savedButton)
  1072. savedButton.setContentHuggingPriority(.required, for: .horizontal)
  1073. savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  1074. let dismissButton = JobPayloadButton()
  1075. dismissButton.jobPayload = job
  1076. dismissButton.cardContext = context
  1077. dismissButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: L("Dismiss"))
  1078. dismissButton.imagePosition = .imageOnly
  1079. dismissButton.imageScaling = .scaleProportionallyDown
  1080. dismissButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  1081. dismissButton.isBordered = false
  1082. dismissButton.bezelStyle = .rounded
  1083. dismissButton.contentTintColor = Theme.secondaryText
  1084. dismissButton.target = self
  1085. dismissButton.action = #selector(didTapJobDismiss(_:))
  1086. dismissButton.toolTip = context == .savedJobsPage ? L("Remove from saved") : L("Dismiss")
  1087. dismissButton.focusRingType = .none
  1088. dismissButton.wantsLayer = true
  1089. dismissButton.layer?.cornerRadius = 8
  1090. dismissButton.layer?.backgroundColor = NSColor.clear.cgColor
  1091. dismissButton.pointerCursor = true
  1092. dismissButton.hoverHandler = { [weak dismissButton] hovering in
  1093. dismissButton?.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : NSColor.clear).cgColor
  1094. dismissButton?.contentTintColor = hovering ? Theme.primaryText : Theme.secondaryText
  1095. }
  1096. dismissButton.setContentHuggingPriority(.required, for: .horizontal)
  1097. let buttonRow = NSStackView(views: [applyButton, savedButton, dismissButton])
  1098. buttonRow.orientation = .horizontal
  1099. buttonRow.spacing = 8
  1100. buttonRow.alignment = .top
  1101. buttonRow.translatesAutoresizingMaskIntoConstraints = false
  1102. buttonRow.setContentHuggingPriority(.required, for: .horizontal)
  1103. buttonRow.setContentCompressionResistancePriority(.required, for: .horizontal)
  1104. buttonRow.setContentHuggingPriority(.required, for: .vertical)
  1105. buttonRow.setContentCompressionResistancePriority(.required, for: .vertical)
  1106. applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  1107. savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  1108. dismissButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  1109. let middleColumn = NSStackView(views: [titleField, companyRow, descriptionField])
  1110. middleColumn.orientation = .vertical
  1111. middleColumn.spacing = 5
  1112. middleColumn.alignment = .leading
  1113. middleColumn.translatesAutoresizingMaskIntoConstraints = false
  1114. middleColumn.setContentHuggingPriority(.defaultLow, for: .horizontal)
  1115. middleColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  1116. let contentRow = NSStackView(views: [iconBox, middleColumn])
  1117. contentRow.orientation = .horizontal
  1118. contentRow.spacing = 14
  1119. contentRow.alignment = .top
  1120. contentRow.distribution = .fill
  1121. contentRow.translatesAutoresizingMaskIntoConstraints = false
  1122. card.addSubview(contentRow)
  1123. card.addSubview(buttonRow)
  1124. let actionCornerInset: CGFloat = 8
  1125. let contentToActionsGap: CGFloat = 12
  1126. let bodyTrailingInset: CGFloat = 16
  1127. NSLayoutConstraint.activate([
  1128. contentRow.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  1129. contentRow.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -bodyTrailingInset),
  1130. contentRow.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  1131. contentRow.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14),
  1132. buttonRow.topAnchor.constraint(equalTo: card.topAnchor, constant: actionCornerInset),
  1133. buttonRow.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -actionCornerInset),
  1134. middleColumn.trailingAnchor.constraint(lessThanOrEqualTo: buttonRow.leadingAnchor, constant: -contentToActionsGap),
  1135. iconBox.widthAnchor.constraint(equalToConstant: 58),
  1136. iconBox.heightAnchor.constraint(equalToConstant: 58),
  1137. categoryIcon.centerXAnchor.constraint(equalTo: iconBox.centerXAnchor),
  1138. categoryIcon.centerYAnchor.constraint(equalTo: iconBox.centerYAnchor),
  1139. buildingIcon.widthAnchor.constraint(equalToConstant: 14),
  1140. buildingIcon.heightAnchor.constraint(equalToConstant: 14),
  1141. applyButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76),
  1142. applyButton.heightAnchor.constraint(equalToConstant: 32),
  1143. savedButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 84),
  1144. savedButton.heightAnchor.constraint(equalToConstant: 32),
  1145. dismissButton.widthAnchor.constraint(equalToConstant: 32),
  1146. dismissButton.heightAnchor.constraint(equalToConstant: 32),
  1147. descriptionField.widthAnchor.constraint(equalTo: middleColumn.widthAnchor)
  1148. ])
  1149. return card
  1150. }
  1151. private func styleJobSavedButton(_ button: NSButton) {
  1152. button.wantsLayer = true
  1153. button.layer?.cornerRadius = 10
  1154. let hovering = (button as? HoverableButton)?.isHovering ?? false
  1155. // Reference: white surface, soft blue outline, brand blue icon + label (no tinted fill on hover).
  1156. button.layer?.backgroundColor = Theme.cardBackground.cgColor
  1157. button.layer?.borderWidth = 1
  1158. button.layer?.borderColor = (hovering ? Theme.searchBarBorderHover : Theme.searchBarBorder).cgColor
  1159. button.contentTintColor = Theme.brandBlue
  1160. }
  1161. @objc private func didTapJobApply(_ sender: NSButton) {
  1162. guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
  1163. presentIndeedJobBrowser(url: Self.resolvedIndeedApplyURL(for: job))
  1164. }
  1165. /// Opens the listing’s Indeed URL when present (`/viewjob`, `/rc/clk`, `/jobs`, …). Falls back to `/jobs?q=<title>` only when the URL is missing or not on Indeed.
  1166. private static func resolvedIndeedApplyURL(for job: JobListing) -> URL {
  1167. let title = job.title.trimmingCharacters(in: .whitespacesAndNewlines)
  1168. let raw = job.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1169. if !raw.isEmpty,
  1170. let directURL = URL(string: raw),
  1171. let host = directURL.host?.lowercased(),
  1172. isIndeedApplyHost(host) {
  1173. return directURL
  1174. }
  1175. var preferredHost: String?
  1176. if !raw.isEmpty, let host = URLComponents(string: raw)?.host {
  1177. let lower = host.lowercased()
  1178. if isIndeedApplyHost(lower) {
  1179. preferredHost = host
  1180. }
  1181. }
  1182. return indeedJobsSearchURL(title: title, preferredHost: preferredHost)
  1183. }
  1184. private static func indeedJobsSearchURL(title: String, preferredHost: String?) -> URL {
  1185. let allowed = CharacterSet.urlQueryAllowed
  1186. let q = title.addingPercentEncoding(withAllowedCharacters: allowed) ?? ""
  1187. let host: String
  1188. if let preferredHost, isIndeedApplyHost(preferredHost.lowercased()) {
  1189. host = preferredHost
  1190. } else {
  1191. host = "www.indeed.com"
  1192. }
  1193. if let url = URL(string: "https://\(host)/jobs?q=\(q)") {
  1194. return url
  1195. }
  1196. return URL(string: "https://www.indeed.com/jobs?q=\(q)")!
  1197. }
  1198. private static func isIndeedApplyHost(_ host: String) -> Bool {
  1199. if host == "indeed.com" { return true }
  1200. if host.hasPrefix("indeed.") { return true }
  1201. return host.contains(".indeed.")
  1202. }
  1203. private static let indeedBrowseHomeURL = URL(string: "https://www.indeed.com/")!
  1204. private static let externalIndeedAppURLSchemes = ["indeed://", "indeedjobs://"]
  1205. private func selectIndeedSidebar() {
  1206. isIndeedSidebarSelected = true
  1207. if !isIndeedJobBrowserPresented {
  1208. openIndeedFromSidebar()
  1209. }
  1210. configureSidebar()
  1211. updateMainContentVisibility()
  1212. }
  1213. /// Opens the installed Indeed app when a handler is registered; otherwise loads Indeed in the embedded browser.
  1214. private func openIndeedFromSidebar() {
  1215. for scheme in Self.externalIndeedAppURLSchemes {
  1216. guard let url = URL(string: scheme) else { continue }
  1217. if NSWorkspace.shared.urlForApplication(toOpen: url) != nil {
  1218. NSWorkspace.shared.open(url)
  1219. return
  1220. }
  1221. }
  1222. presentIndeedJobBrowser(url: Self.indeedBrowseHomeURL)
  1223. }
  1224. private func presentIndeedJobBrowser(url: URL) {
  1225. guard let parentVC = hostingViewController else { return }
  1226. if indeedJobBrowserViewController == nil {
  1227. let vc = IndeedJobBrowserViewController()
  1228. vc.homeURL = Self.indeedBrowseHomeURL
  1229. vc.embed(in: indeedJobBrowserHost, parent: parentVC)
  1230. indeedJobBrowserViewController = vc
  1231. }
  1232. indeedJobBrowserViewController?.loadPage(url)
  1233. isIndeedJobBrowserPresented = true
  1234. updateMainContentVisibility()
  1235. }
  1236. private func dismissIndeedJobBrowserEmbedded() {
  1237. guard isIndeedJobBrowserPresented else { return }
  1238. isIndeedJobBrowserPresented = false
  1239. if isIndeedSidebarSelected {
  1240. isIndeedSidebarSelected = false
  1241. configureSidebar()
  1242. }
  1243. updateMainContentVisibility()
  1244. }
  1245. private var hostingViewController: NSViewController? {
  1246. var responder: NSResponder? = self
  1247. while let current = responder {
  1248. if let viewController = current as? NSViewController {
  1249. return viewController
  1250. }
  1251. responder = current.nextResponder
  1252. }
  1253. return nil
  1254. }
  1255. @objc private func didTapJobSaved(_ sender: NSButton) {
  1256. guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
  1257. let willSave = !isJobSaved(job)
  1258. applySavedState(willSave, for: job)
  1259. sender.state = willSave ? .on : .off
  1260. sender.title = willSave ? L("Saved") : L("Save")
  1261. sender.image = NSImage(systemSymbolName: willSave ? "heart.fill" : "heart", accessibilityDescription: nil)
  1262. styleJobSavedButton(sender)
  1263. if isSavedJobsSidebarIndex(selectedSidebarIndex) {
  1264. reloadSavedJobsListings()
  1265. }
  1266. }
  1267. @objc private func didTapJobDismiss(_ sender: NSButton) {
  1268. guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return }
  1269. switch button.cardContext {
  1270. case .homeSearchResults:
  1271. removeJobCardFromChat(originating: button, job: job)
  1272. case .savedJobsPage:
  1273. applySavedState(false, for: job)
  1274. reloadSavedJobsListings()
  1275. }
  1276. }
  1277. /// 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.
  1278. private func removeJobCardFromChat(originating button: NSView, job: JobListing) {
  1279. var node: NSView? = button
  1280. var card: NSView?
  1281. var stack: ChatJobsStackView?
  1282. while let v = node {
  1283. if let parent = v.superview as? ChatJobsStackView {
  1284. card = v
  1285. stack = parent
  1286. break
  1287. }
  1288. node = v.superview
  1289. }
  1290. guard let card, let stack else { return }
  1291. stack.removeArrangedSubview(card)
  1292. card.removeFromSuperview()
  1293. lastSearchResults.removeAll { $0 == job }
  1294. }
  1295. private func configureSearchBar() {
  1296. let pillCorner: CGFloat = 27
  1297. let barHeight: CGFloat = 54
  1298. searchBarColumn.orientation = .vertical
  1299. searchBarColumn.spacing = 6
  1300. searchBarColumn.alignment = .width
  1301. searchBarColumn.distribution = .fill
  1302. searchBarColumn.translatesAutoresizingMaskIntoConstraints = false
  1303. searchBarColumn.setContentHuggingPriority(.defaultHigh, for: .vertical)
  1304. freeJobSearchQuotaLabel.font = .systemFont(ofSize: 11, weight: .medium)
  1305. freeJobSearchQuotaLabel.textColor = Theme.secondaryText
  1306. freeJobSearchQuotaLabel.alignment = .center
  1307. freeJobSearchQuotaLabel.lineBreakMode = .byTruncatingTail
  1308. freeJobSearchQuotaLabel.maximumNumberOfLines = 1
  1309. freeJobSearchQuotaLabel.setContentHuggingPriority(.required, for: .vertical)
  1310. freeJobSearchQuotaLabel.setContentCompressionResistancePriority(.required, for: .vertical)
  1311. searchBarColumn.addArrangedSubview(searchBarShadowHost)
  1312. searchBarColumn.addArrangedSubview(freeJobSearchQuotaLabel)
  1313. searchBarShadowHost.translatesAutoresizingMaskIntoConstraints = false
  1314. searchBarShadowHost.wantsLayer = true
  1315. searchBarShadowHost.layer?.masksToBounds = false
  1316. searchBarShadowHost.layer?.shadowColor = NSColor.black.withAlphaComponent(0.18).cgColor
  1317. searchBarShadowHost.layer?.shadowOffset = CGSize(width: 0, height: 2)
  1318. searchBarShadowHost.layer?.shadowRadius = 10
  1319. searchBarShadowHost.layer?.shadowOpacity = 1
  1320. searchBarShadowHost.setContentHuggingPriority(.defaultHigh, for: .vertical)
  1321. searchCard.translatesAutoresizingMaskIntoConstraints = false
  1322. searchCard.wantsLayer = true
  1323. searchCard.layer?.backgroundColor = Theme.cardBackground.cgColor
  1324. searchCard.layer?.cornerRadius = pillCorner
  1325. searchCard.layer?.borderWidth = 1
  1326. searchCard.layer?.borderColor = Theme.searchBarBorder.cgColor
  1327. searchCard.layer?.masksToBounds = true
  1328. searchCard.hoverHandler = { [weak self] hovering in
  1329. guard let self else { return }
  1330. CATransaction.begin()
  1331. CATransaction.setAnimationDuration(0.15)
  1332. self.searchCard.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor
  1333. self.searchCard.layer?.borderColor = (hovering ? Theme.searchBarBorderHover : Theme.searchBarBorder).cgColor
  1334. self.searchBarShadowHost.layer?.shadowColor = NSColor.black.withAlphaComponent(hovering ? 0.24 : 0.18).cgColor
  1335. self.searchBarShadowHost.layer?.shadowRadius = hovering ? 12 : 10
  1336. CATransaction.commit()
  1337. }
  1338. searchBarShadowHost.addSubview(searchCard)
  1339. func configureField(_ field: NSTextField, placeholder: String) {
  1340. field.translatesAutoresizingMaskIntoConstraints = false
  1341. field.isBordered = false
  1342. field.drawsBackground = false
  1343. field.focusRingType = .none
  1344. field.font = .systemFont(ofSize: 14, weight: .regular)
  1345. field.textColor = Theme.primaryText
  1346. field.delegate = self
  1347. field.placeholderAttributedString = NSAttributedString(
  1348. string: placeholder,
  1349. attributes: AppLayoutDirection.placeholderAttributes(
  1350. font: .systemFont(ofSize: 14, weight: .regular),
  1351. color: Theme.secondaryText
  1352. )
  1353. )
  1354. field.cell?.usesSingleLineMode = true
  1355. field.cell?.wraps = false
  1356. field.cell?.isScrollable = true
  1357. field.target = self
  1358. field.action = #selector(didSubmitSearch)
  1359. }
  1360. jobSearchIcon.translatesAutoresizingMaskIntoConstraints = false
  1361. jobSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
  1362. jobSearchIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: L("Ask AI"))
  1363. jobSearchIcon.contentTintColor = Theme.brandBlue
  1364. configureField(jobKeywordsField, placeholder: L("Ask for roles, skills, salary, or job descriptions..."))
  1365. AppLayoutDirection.configureTextField(jobKeywordsField)
  1366. let ctaHeight: CGFloat = 42
  1367. let ctaCorner = ctaHeight / 2
  1368. findJobsCTAHost.translatesAutoresizingMaskIntoConstraints = false
  1369. findJobsCTAHost.wantsLayer = true
  1370. findJobsCTAHost.layer?.masksToBounds = false
  1371. findJobsCTAHost.layer?.shadowColor = NSColor.black.cgColor
  1372. findJobsCTAHost.layer?.shadowOpacity = 0.16
  1373. findJobsCTAHost.layer?.shadowOffset = CGSize(width: 0, height: 2)
  1374. findJobsCTAHost.layer?.shadowRadius = 6
  1375. let sendContentPadding: CGFloat = 16
  1376. findJobsCTAPill.translatesAutoresizingMaskIntoConstraints = false
  1377. findJobsCTAPill.wantsLayer = true
  1378. findJobsCTAPill.layer?.cornerRadius = ctaCorner
  1379. if #available(macOS 11.0, *) {
  1380. findJobsCTAPill.layer?.cornerCurve = .continuous
  1381. }
  1382. findJobsCTAPill.layer?.masksToBounds = true
  1383. findJobsCTAPill.layer?.backgroundColor = Theme.brandBlue.cgColor
  1384. sendIconView.translatesAutoresizingMaskIntoConstraints = false
  1385. sendIconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  1386. sendIconView.image = NSImage(systemSymbolName: "paperplane.fill", accessibilityDescription: nil)
  1387. sendIconView.contentTintColor = Theme.proCTAText
  1388. sendLabel.font = .systemFont(ofSize: 14, weight: .semibold)
  1389. sendLabel.textColor = Theme.proCTAText
  1390. sendLabel.alignment = .center
  1391. sendContentStack.orientation = .horizontal
  1392. sendContentStack.spacing = 6
  1393. sendContentStack.alignment = .centerY
  1394. sendContentStack.translatesAutoresizingMaskIntoConstraints = false
  1395. sendContentStack.addArrangedSubview(sendIconView)
  1396. sendContentStack.addArrangedSubview(sendLabel)
  1397. findJobsButton.translatesAutoresizingMaskIntoConstraints = false
  1398. findJobsButton.title = ""
  1399. findJobsButton.isBordered = false
  1400. findJobsButton.bezelStyle = .inline
  1401. findJobsButton.wantsLayer = true
  1402. findJobsButton.layer?.backgroundColor = NSColor.clear.cgColor
  1403. findJobsButton.focusRingType = .none
  1404. findJobsButton.pointerCursor = true
  1405. findJobsButton.target = self
  1406. findJobsButton.action = #selector(didSubmitSearch)
  1407. findJobsButton.setAccessibilityLabel(L("Send"))
  1408. findJobsButton.hoverHandler = { [weak self] hovering in
  1409. self?.findJobsCTAPill.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor
  1410. }
  1411. findJobsCTAHost.addSubview(findJobsCTAPill)
  1412. findJobsCTAHost.addSubview(sendContentStack)
  1413. findJobsCTAHost.addSubview(findJobsButton)
  1414. findJobsCTAHost.setContentHuggingPriority(.required, for: .horizontal)
  1415. findJobsCTAHost.setContentCompressionResistancePriority(.required, for: .horizontal)
  1416. NSLayoutConstraint.activate([
  1417. findJobsCTAPill.leadingAnchor.constraint(equalTo: findJobsCTAHost.leadingAnchor),
  1418. findJobsCTAPill.trailingAnchor.constraint(equalTo: findJobsCTAHost.trailingAnchor),
  1419. findJobsCTAPill.topAnchor.constraint(equalTo: findJobsCTAHost.topAnchor),
  1420. findJobsCTAPill.bottomAnchor.constraint(equalTo: findJobsCTAHost.bottomAnchor),
  1421. sendContentStack.centerXAnchor.constraint(equalTo: findJobsCTAPill.centerXAnchor),
  1422. sendContentStack.centerYAnchor.constraint(equalTo: findJobsCTAPill.centerYAnchor),
  1423. sendContentStack.leadingAnchor.constraint(greaterThanOrEqualTo: findJobsCTAPill.leadingAnchor, constant: sendContentPadding),
  1424. sendContentStack.trailingAnchor.constraint(lessThanOrEqualTo: findJobsCTAPill.trailingAnchor, constant: -sendContentPadding),
  1425. sendIconView.widthAnchor.constraint(equalToConstant: 14),
  1426. sendIconView.heightAnchor.constraint(equalToConstant: 14),
  1427. findJobsButton.leadingAnchor.constraint(equalTo: findJobsCTAHost.leadingAnchor),
  1428. findJobsButton.trailingAnchor.constraint(equalTo: findJobsCTAHost.trailingAnchor),
  1429. findJobsButton.topAnchor.constraint(equalTo: findJobsCTAHost.topAnchor),
  1430. findJobsButton.bottomAnchor.constraint(equalTo: findJobsCTAHost.bottomAnchor)
  1431. ])
  1432. let keywordsStack = NSStackView(views: [jobSearchIcon, jobKeywordsField])
  1433. keywordsStack.orientation = .horizontal
  1434. keywordsStack.spacing = 10
  1435. keywordsStack.alignment = .centerY
  1436. keywordsStack.translatesAutoresizingMaskIntoConstraints = false
  1437. keywordsStack.edgeInsets = NSEdgeInsets(top: 0, left: 18, bottom: 0, right: 10)
  1438. keywordsStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  1439. let row = NSStackView(views: [keywordsStack, findJobsCTAHost])
  1440. row.orientation = .horizontal
  1441. row.spacing = 10
  1442. row.alignment = .centerY
  1443. row.distribution = .fill
  1444. row.translatesAutoresizingMaskIntoConstraints = false
  1445. row.edgeInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 7)
  1446. searchCard.addSubview(row)
  1447. NSLayoutConstraint.activate([
  1448. searchCard.leadingAnchor.constraint(equalTo: searchBarShadowHost.leadingAnchor),
  1449. searchCard.trailingAnchor.constraint(equalTo: searchBarShadowHost.trailingAnchor),
  1450. searchCard.topAnchor.constraint(equalTo: searchBarShadowHost.topAnchor),
  1451. searchCard.bottomAnchor.constraint(equalTo: searchBarShadowHost.bottomAnchor),
  1452. searchBarShadowHost.heightAnchor.constraint(equalToConstant: barHeight),
  1453. row.leadingAnchor.constraint(equalTo: searchCard.leadingAnchor),
  1454. row.trailingAnchor.constraint(equalTo: searchCard.trailingAnchor),
  1455. row.topAnchor.constraint(equalTo: searchCard.topAnchor),
  1456. row.bottomAnchor.constraint(equalTo: searchCard.bottomAnchor),
  1457. jobSearchIcon.widthAnchor.constraint(equalToConstant: 18),
  1458. jobSearchIcon.heightAnchor.constraint(equalToConstant: 18),
  1459. findJobsCTAHost.heightAnchor.constraint(equalToConstant: ctaHeight),
  1460. findJobsCTAHost.widthAnchor.constraint(greaterThanOrEqualTo: sendContentStack.widthAnchor, constant: sendContentPadding * 2)
  1461. ])
  1462. searchCard.hoverHandler = nil
  1463. updateFreeJobSearchQuotaLabel()
  1464. }
  1465. private func updateFindJobsCTAShadowPath() {
  1466. guard findJobsCTAHost.bounds.width > 0, findJobsCTAHost.bounds.height > 0 else { return }
  1467. let r = findJobsCTAHost.bounds
  1468. let radius = min(r.height / 2, r.width / 2)
  1469. findJobsCTAHost.layer?.shadowPath = CGPath(
  1470. roundedRect: r,
  1471. cornerWidth: radius,
  1472. cornerHeight: radius,
  1473. transform: nil
  1474. )
  1475. }
  1476. private func configureNonHomePlaceholder() {
  1477. nonHomeHost.translatesAutoresizingMaskIntoConstraints = false
  1478. nonHomeHost.wantsLayer = true
  1479. nonHomeHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
  1480. nonHomeHost.isHidden = true
  1481. nonHomeHost.userInterfaceLayoutDirection = .leftToRight
  1482. nonHomeGenericContainer.translatesAutoresizingMaskIntoConstraints = false
  1483. savedJobsPageContainer.translatesAutoresizingMaskIntoConstraints = false
  1484. settingsPageContainer.translatesAutoresizingMaskIntoConstraints = false
  1485. cvMakerPageContainer.translatesAutoresizingMaskIntoConstraints = false
  1486. profilePageContainer.translatesAutoresizingMaskIntoConstraints = false
  1487. nonHomeHost.addSubview(nonHomeGenericContainer)
  1488. nonHomeHost.addSubview(savedJobsPageContainer)
  1489. nonHomeHost.addSubview(settingsPageContainer)
  1490. nonHomeHost.addSubview(cvMakerPageContainer)
  1491. nonHomeHost.addSubview(profilePageContainer)
  1492. NSLayoutConstraint.activate([
  1493. nonHomeGenericContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
  1494. nonHomeGenericContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
  1495. nonHomeGenericContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
  1496. nonHomeGenericContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor),
  1497. savedJobsPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
  1498. savedJobsPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
  1499. savedJobsPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
  1500. savedJobsPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor),
  1501. settingsPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
  1502. settingsPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
  1503. settingsPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
  1504. settingsPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor),
  1505. cvMakerPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
  1506. cvMakerPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
  1507. cvMakerPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
  1508. cvMakerPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor),
  1509. profilePageContainer.leftAnchor.constraint(equalTo: nonHomeHost.leftAnchor),
  1510. profilePageContainer.rightAnchor.constraint(equalTo: nonHomeHost.rightAnchor),
  1511. profilePageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
  1512. profilePageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor)
  1513. ])
  1514. nonHomeTitleLabel.font = .systemFont(ofSize: 22, weight: .bold)
  1515. nonHomeTitleLabel.textColor = Theme.primaryText
  1516. nonHomeTitleLabel.alignment = .center
  1517. nonHomeTitleLabel.maximumNumberOfLines = 1
  1518. nonHomeSubtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
  1519. nonHomeSubtitleLabel.textColor = Theme.secondaryText
  1520. nonHomeSubtitleLabel.alignment = .center
  1521. nonHomeSubtitleLabel.maximumNumberOfLines = 0
  1522. nonHomeSubtitleLabel.stringValue = L("This area is not available in the preview build. Use Home to search jobs.")
  1523. let genericStack = NSStackView(views: [nonHomeTitleLabel, nonHomeSubtitleLabel])
  1524. genericStack.orientation = .vertical
  1525. genericStack.spacing = 10
  1526. genericStack.alignment = .centerX
  1527. genericStack.translatesAutoresizingMaskIntoConstraints = false
  1528. nonHomeGenericContainer.addSubview(genericStack)
  1529. NSLayoutConstraint.activate([
  1530. genericStack.centerXAnchor.constraint(equalTo: nonHomeGenericContainer.centerXAnchor),
  1531. genericStack.centerYAnchor.constraint(equalTo: nonHomeGenericContainer.centerYAnchor),
  1532. genericStack.leadingAnchor.constraint(greaterThanOrEqualTo: nonHomeGenericContainer.leadingAnchor, constant: 32),
  1533. genericStack.trailingAnchor.constraint(lessThanOrEqualTo: nonHomeGenericContainer.trailingAnchor, constant: -32),
  1534. nonHomeSubtitleLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 420)
  1535. ])
  1536. savedJobsPageTitleLabel.font = .systemFont(ofSize: 22, weight: .bold)
  1537. savedJobsPageTitleLabel.textColor = Theme.primaryText
  1538. savedJobsPageTitleLabel.alignment = .left
  1539. savedJobsPageTitleLabel.maximumNumberOfLines = 1
  1540. savedJobsPageSubtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
  1541. savedJobsPageSubtitleLabel.textColor = Theme.secondaryText
  1542. savedJobsPageSubtitleLabel.alignment = .left
  1543. savedJobsPageSubtitleLabel.maximumNumberOfLines = 0
  1544. savedJobsDocumentView.translatesAutoresizingMaskIntoConstraints = false
  1545. savedJobsStack.orientation = .vertical
  1546. savedJobsStack.spacing = 14
  1547. savedJobsStack.alignment = .leading
  1548. savedJobsStack.distribution = .fill
  1549. savedJobsStack.translatesAutoresizingMaskIntoConstraints = false
  1550. savedJobsStack.setContentHuggingPriority(.defaultHigh, for: .vertical)
  1551. savedJobsStack.setHuggingPriority(.defaultLow, for: .horizontal)
  1552. savedJobsDocumentView.addSubview(savedJobsStack)
  1553. NSLayoutConstraint.activate([
  1554. savedJobsStack.leadingAnchor.constraint(equalTo: savedJobsDocumentView.leadingAnchor),
  1555. savedJobsStack.trailingAnchor.constraint(equalTo: savedJobsDocumentView.trailingAnchor),
  1556. savedJobsStack.topAnchor.constraint(equalTo: savedJobsDocumentView.topAnchor),
  1557. savedJobsStack.bottomAnchor.constraint(equalTo: savedJobsDocumentView.bottomAnchor)
  1558. ])
  1559. savedJobsScrollView.translatesAutoresizingMaskIntoConstraints = false
  1560. savedJobsScrollView.hasVerticalScroller = true
  1561. savedJobsScrollView.hasHorizontalScroller = false
  1562. savedJobsScrollView.scrollerStyle = .legacy
  1563. savedJobsScrollView.autohidesScrollers = true
  1564. savedJobsScrollView.drawsBackground = false
  1565. savedJobsScrollView.borderType = .noBorder
  1566. savedJobsScrollView.documentView = savedJobsDocumentView
  1567. savedJobsScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
  1568. savedJobsScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  1569. let savedHeaderStack = NSStackView(views: [savedJobsPageTitleLabel, savedJobsPageSubtitleLabel])
  1570. savedHeaderStack.orientation = .vertical
  1571. savedHeaderStack.spacing = 6
  1572. savedHeaderStack.alignment = .leading
  1573. savedHeaderStack.translatesAutoresizingMaskIntoConstraints = false
  1574. let savedOuterStack = NSStackView(views: [savedHeaderStack, savedJobsScrollView])
  1575. savedOuterStack.orientation = .vertical
  1576. savedOuterStack.spacing = 16
  1577. // Leading alignment plus explicit column width keeps the title and subtitle on the same edge as the cards.
  1578. savedOuterStack.alignment = .leading
  1579. savedOuterStack.translatesAutoresizingMaskIntoConstraints = false
  1580. savedJobsPageContainer.userInterfaceLayoutDirection = .leftToRight
  1581. savedJobsPageContainer.addSubview(savedOuterStack)
  1582. NSLayoutConstraint.activate([
  1583. savedOuterStack.leadingAnchor.constraint(equalTo: savedJobsPageContainer.leadingAnchor, constant: 32),
  1584. savedOuterStack.trailingAnchor.constraint(equalTo: savedJobsPageContainer.trailingAnchor, constant: -32),
  1585. savedOuterStack.topAnchor.constraint(equalTo: savedJobsPageContainer.topAnchor, constant: 8),
  1586. savedOuterStack.bottomAnchor.constraint(equalTo: savedJobsPageContainer.bottomAnchor),
  1587. savedHeaderStack.widthAnchor.constraint(equalTo: savedOuterStack.widthAnchor),
  1588. savedJobsScrollView.widthAnchor.constraint(equalTo: savedOuterStack.widthAnchor),
  1589. savedJobsDocumentView.topAnchor.constraint(equalTo: savedJobsScrollView.contentView.topAnchor),
  1590. savedJobsDocumentView.leadingAnchor.constraint(equalTo: savedJobsScrollView.contentView.leadingAnchor),
  1591. savedJobsDocumentView.widthAnchor.constraint(equalTo: savedJobsScrollView.contentView.widthAnchor)
  1592. ])
  1593. configureSettingsPage()
  1594. configureCVMakerPage()
  1595. configureProfilePage()
  1596. }
  1597. private func configureCVMakerPage() {
  1598. cvMakerPageContainer.wantsLayer = true
  1599. cvMakerPageContainer.layer?.backgroundColor = Theme.mainHostBackground.cgColor
  1600. cvMakerPageContainer.isHidden = true
  1601. cvMakerPageView.translatesAutoresizingMaskIntoConstraints = false
  1602. cvMakerPageContainer.addSubview(cvMakerPageView)
  1603. NSLayoutConstraint.activate([
  1604. cvMakerPageView.leadingAnchor.constraint(equalTo: cvMakerPageContainer.leadingAnchor),
  1605. cvMakerPageView.trailingAnchor.constraint(equalTo: cvMakerPageContainer.trailingAnchor),
  1606. cvMakerPageView.topAnchor.constraint(equalTo: cvMakerPageContainer.topAnchor),
  1607. cvMakerPageView.bottomAnchor.constraint(equalTo: cvMakerPageContainer.bottomAnchor)
  1608. ])
  1609. cvMakerPageView.onContinueToProfileSelection = { [weak self] template in
  1610. guard let self, self.ensureProAccess() else { return }
  1611. self.pendingCVTemplate = template
  1612. self.profilesListPageView.setPendingCVTemplateDisplayName(template.name)
  1613. self.selectProfileSidebarForCVMakerFlow()
  1614. }
  1615. }
  1616. /// Sidebar index for **Profile** (`person` icon). Stable across language changes unlike title-only matching.
  1617. private func profileSidebarIndex() -> Int? {
  1618. if let index = currentSidebarItems.firstIndex(where: { $0.systemImage == "person" }) {
  1619. return index
  1620. }
  1621. return currentSidebarItems.firstIndex(where: { $0.title == L("Profile") })
  1622. }
  1623. /// Switches the main panel to **Profile** so the user can pick a saved CV profile after choosing a template in CV Maker.
  1624. private func selectProfileSidebarForCVMakerFlow() {
  1625. guard let index = profileSidebarIndex() else {
  1626. presentDashboardAlert(
  1627. title: L("Couldn't open Profile"),
  1628. message: L("The Profile section isn't available right now. Try restarting the app.")
  1629. )
  1630. return
  1631. }
  1632. applyProfilePageForCVMakerHandoff(selectingSidebarIndex: index)
  1633. }
  1634. /// Always shows the profiles list for the CV Maker hand-off (avoids `selectSidebarItem` no-op when Profile is already selected).
  1635. private func applyProfilePageForCVMakerHandoff(selectingSidebarIndex index: Int) {
  1636. isIndeedSidebarSelected = false
  1637. dismissIndeedJobBrowserEmbedded()
  1638. guard index >= 0, index < currentSidebarItems.count else { return }
  1639. isCVDocumentPreviewPresented = false
  1640. isProfileEditorPresented = false
  1641. selectedSidebarIndex = index
  1642. configureSidebar()
  1643. updateMainContentVisibility()
  1644. }
  1645. private func presentDashboardAlert(title: String, message: String) {
  1646. let alert = NSAlert()
  1647. alert.messageText = title
  1648. alert.informativeText = message
  1649. alert.alertStyle = .informational
  1650. alert.addButton(withTitle: L("OK"))
  1651. if let window {
  1652. alert.beginSheetModal(for: window)
  1653. } else {
  1654. alert.runModal()
  1655. }
  1656. }
  1657. private func configureProfilePage() {
  1658. profilePageContainer.wantsLayer = true
  1659. profilePageContainer.layer?.backgroundColor = Theme.mainHostBackground.cgColor
  1660. profilePageContainer.isHidden = true
  1661. profilePageContainer.userInterfaceLayoutDirection = .leftToRight
  1662. profilesListPageView.translatesAutoresizingMaskIntoConstraints = false
  1663. myProfilePageView.translatesAutoresizingMaskIntoConstraints = false
  1664. cvFilledPreviewPageView.translatesAutoresizingMaskIntoConstraints = false
  1665. profilePageContainer.addSubview(profilesListPageView)
  1666. profilePageContainer.addSubview(myProfilePageView)
  1667. profilePageContainer.addSubview(cvFilledPreviewPageView)
  1668. NSLayoutConstraint.activate([
  1669. profilesListPageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
  1670. profilesListPageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor),
  1671. profilesListPageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
  1672. profilesListPageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor),
  1673. myProfilePageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
  1674. myProfilePageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor),
  1675. myProfilePageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
  1676. myProfilePageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor),
  1677. cvFilledPreviewPageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
  1678. cvFilledPreviewPageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor),
  1679. cvFilledPreviewPageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
  1680. cvFilledPreviewPageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor)
  1681. ])
  1682. profilesListPageView.onAddProfile = { [weak self] in
  1683. self?.presentProfileEditor(existingID: nil)
  1684. }
  1685. profilesListPageView.onEditProfile = { [weak self] id in
  1686. self?.presentProfileEditor(existingID: id)
  1687. }
  1688. profilesListPageView.onDeleteProfile = { [weak self] id in
  1689. self?.confirmDeleteProfile(id: id)
  1690. }
  1691. profilesListPageView.onBuildCVWithProfile = { [weak self] profileID in
  1692. guard let self else { return }
  1693. guard let template = self.pendingCVTemplate else {
  1694. self.presentDashboardAlert(
  1695. title: L("No template selected"),
  1696. message: L("Choose a template in CV Maker, then tap Use Template & Select Profile before Build CV.")
  1697. )
  1698. return
  1699. }
  1700. guard let profile = SavedProfilesStore.profile(id: profileID) else { return }
  1701. self.presentCVDocumentPreview(profile: profile, template: template)
  1702. }
  1703. cvFilledPreviewPageView.onDismiss = { [weak self] in
  1704. self?.dismissCVDocumentPreview()
  1705. }
  1706. myProfilePageView.onDismiss = { [weak self] in
  1707. self?.dismissProfileEditor()
  1708. }
  1709. isProfileEditorPresented = false
  1710. isCVDocumentPreviewPresented = false
  1711. profilesListPageView.isHidden = false
  1712. myProfilePageView.isHidden = true
  1713. cvFilledPreviewPageView.isHidden = true
  1714. profilesListPageView.reloadFromStore()
  1715. }
  1716. private func presentCVDocumentPreview(profile: SavedProfile, template: CVTemplate) {
  1717. guard ensureProAccess() else { return }
  1718. isCVDocumentPreviewPresented = true
  1719. cvFilledPreviewPageView.configure(profile: profile, template: template)
  1720. cvFilledPreviewPageView.isHidden = false
  1721. profilesListPageView.isHidden = true
  1722. myProfilePageView.isHidden = true
  1723. }
  1724. private func dismissCVDocumentPreview() {
  1725. isCVDocumentPreviewPresented = false
  1726. cvFilledPreviewPageView.isHidden = true
  1727. profilesListPageView.reloadFromStore()
  1728. profilesListPageView.isHidden = false
  1729. myProfilePageView.isHidden = true
  1730. }
  1731. private func presentProfileEditor(existingID: UUID?) {
  1732. guard ensureProAccess() else { return }
  1733. if isCVDocumentPreviewPresented {
  1734. dismissCVDocumentPreview()
  1735. }
  1736. isProfileEditorPresented = true
  1737. if let id = existingID, let profile = SavedProfilesStore.profile(id: id) {
  1738. myProfilePageView.loadSavedProfile(profile)
  1739. } else {
  1740. myProfilePageView.prepareNewProfile()
  1741. }
  1742. profilesListPageView.isHidden = true
  1743. myProfilePageView.isHidden = false
  1744. }
  1745. private func dismissProfileEditor() {
  1746. isProfileEditorPresented = false
  1747. profilesListPageView.reloadFromStore()
  1748. profilesListPageView.isHidden = false
  1749. myProfilePageView.isHidden = true
  1750. }
  1751. private func confirmDeleteProfile(id: UUID) {
  1752. let displayName = SavedProfilesStore.profile(id: id)?.profileDisplayName ?? ""
  1753. let alert = NSAlert()
  1754. alert.messageText = L("Delete this profile?")
  1755. alert.informativeText = displayName.isEmpty
  1756. ? L("This profile will be removed from this Mac.")
  1757. : String(format: L("“%@” will be removed from this Mac."), displayName)
  1758. alert.alertStyle = .warning
  1759. alert.addButton(withTitle: L("Cancel"))
  1760. alert.addButton(withTitle: L("Delete"))
  1761. guard let window = window else {
  1762. let response = alert.runModal()
  1763. if response == .alertSecondButtonReturn {
  1764. SavedProfilesStore.delete(id: id)
  1765. profilesListPageView.reloadFromStore()
  1766. }
  1767. return
  1768. }
  1769. alert.beginSheetModal(for: window) { [weak self] response in
  1770. guard let self else { return }
  1771. if response == .alertSecondButtonReturn {
  1772. SavedProfilesStore.delete(id: id)
  1773. if self.isProfileEditorPresented {
  1774. self.dismissProfileEditor()
  1775. } else {
  1776. self.profilesListPageView.reloadFromStore()
  1777. }
  1778. }
  1779. }
  1780. }
  1781. private func configureSettingsPage() {
  1782. settingsPageContainer.wantsLayer = true
  1783. settingsPageContainer.layer?.backgroundColor = Theme.settingsPageBackground.cgColor
  1784. settingsPageContainer.isHidden = true
  1785. let contentStack = NSStackView()
  1786. contentStack.orientation = .vertical
  1787. contentStack.spacing = 26
  1788. contentStack.alignment = .leading
  1789. contentStack.translatesAutoresizingMaskIntoConstraints = false
  1790. let appearanceTitle = NSTextField(labelWithString: L("Appearance"))
  1791. appearanceTitle.font = .systemFont(ofSize: 12, weight: .semibold)
  1792. appearanceTitle.textColor = Theme.secondaryText
  1793. appearanceTitle.alignment = .left
  1794. appearanceTitle.identifier = NSUserInterfaceItemIdentifier("\(SettingsAppearanceID.sectionHeader).Appearance")
  1795. let themeSegment = makeAppearanceModeSegment()
  1796. appearanceModeSegment = themeSegment
  1797. let langPopUp = makeLanguagePopUp()
  1798. languagePopUp = langPopUp
  1799. let appearanceSection = makeSettingsSection(rows: [
  1800. makeSettingsRow(localizationKey: "Theme", systemImage: "circle.lefthalf.filled", accessory: themeSegment, tapAction: nil),
  1801. makeSettingsRow(localizationKey: "Language", systemImage: "character.bubble", accessory: langPopUp, tapAction: nil)
  1802. ])
  1803. let appearanceStack = NSStackView(views: [appearanceTitle, appearanceSection])
  1804. appearanceStack.orientation = .vertical
  1805. appearanceStack.spacing = 14
  1806. appearanceStack.alignment = .leading
  1807. appearanceStack.translatesAutoresizingMaskIntoConstraints = false
  1808. let settingsSection = makeSettingsSection(rows: [
  1809. makeSettingsRow(localizationKey: "Share App", systemImage: "square.and.arrow.up", accessory: nil, tapAction: #selector(didTapShareApp)),
  1810. makeSettingsRow(localizationKey: "More Apps", systemImage: "square.grid.2x2", accessory: nil, tapAction: #selector(didTapMoreApps))
  1811. ])
  1812. let aboutTitle = NSTextField(labelWithString: L("About"))
  1813. aboutTitle.font = .systemFont(ofSize: 12, weight: .semibold)
  1814. aboutTitle.textColor = Theme.secondaryText
  1815. aboutTitle.alignment = .left
  1816. aboutTitle.identifier = NSUserInterfaceItemIdentifier("\(SettingsAppearanceID.sectionHeader).About")
  1817. let aboutSection = makeSettingsSection(rows: [
  1818. makeSettingsRow(localizationKey: "Website", systemImage: "globe", accessory: nil, tapAction: #selector(didTapWebsite)),
  1819. makeSettingsRow(localizationKey: "Support", systemImage: "questionmark.circle", accessory: nil, tapAction: #selector(didTapSupport)),
  1820. makeSettingsRow(localizationKey: "Terms of Use", systemImage: "doc.text", accessory: nil, tapAction: #selector(didTapTermsOfUse)),
  1821. makeSettingsRow(localizationKey: "Privacy Policy", systemImage: "shield", accessory: nil, tapAction: #selector(didTapPrivacyPolicy))
  1822. ])
  1823. let aboutStack = NSStackView(views: [aboutTitle, aboutSection])
  1824. aboutStack.orientation = .vertical
  1825. aboutStack.spacing = 14
  1826. aboutStack.alignment = .leading
  1827. aboutStack.translatesAutoresizingMaskIntoConstraints = false
  1828. contentStack.addArrangedSubview(appearanceStack)
  1829. contentStack.addArrangedSubview(settingsSection)
  1830. contentStack.addArrangedSubview(aboutStack)
  1831. settingsPageContainer.addSubview(contentStack)
  1832. NSLayoutConstraint.activate([
  1833. contentStack.leadingAnchor.constraint(equalTo: settingsPageContainer.leadingAnchor, constant: 42),
  1834. contentStack.trailingAnchor.constraint(lessThanOrEqualTo: settingsPageContainer.trailingAnchor, constant: -42),
  1835. contentStack.topAnchor.constraint(equalTo: settingsPageContainer.topAnchor, constant: 48),
  1836. appearanceStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  1837. appearanceSection.widthAnchor.constraint(equalTo: appearanceStack.widthAnchor),
  1838. settingsSection.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  1839. aboutStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  1840. aboutSection.widthAnchor.constraint(equalTo: aboutStack.widthAnchor),
  1841. contentStack.widthAnchor.constraint(equalTo: settingsPageContainer.widthAnchor, constant: -84)
  1842. ])
  1843. }
  1844. private func makeAppearanceModeSegment() -> NSSegmentedControl {
  1845. let segment = NSSegmentedControl(
  1846. labels: [L("System"), L("Light"), L("Dark")],
  1847. trackingMode: .selectOne,
  1848. target: self,
  1849. action: #selector(appearanceModeChanged(_:))
  1850. )
  1851. segment.translatesAutoresizingMaskIntoConstraints = false
  1852. segment.segmentStyle = .automatic
  1853. segment.selectedSegment = AppAppearanceManager.shared.mode.segmentIndex
  1854. segment.setContentHuggingPriority(.required, for: .horizontal)
  1855. segment.setContentCompressionResistancePriority(.required, for: .horizontal)
  1856. return segment
  1857. }
  1858. @objc private func appearanceModeChanged(_ sender: NSSegmentedControl) {
  1859. guard let mode = AppAppearanceManager.Mode(segmentIndex: sender.selectedSegment) else { return }
  1860. AppAppearanceManager.shared.mode = mode
  1861. }
  1862. private func makeLanguagePopUp() -> NSPopUpButton {
  1863. let popup = NSPopUpButton(frame: .zero, pullsDown: false)
  1864. popup.translatesAutoresizingMaskIntoConstraints = false
  1865. popup.removeAllItems()
  1866. for language in AppLanguage.allCases {
  1867. popup.addItem(withTitle: language.localizedDisplayName)
  1868. popup.lastItem?.representedObject = language.localeIdentifier
  1869. }
  1870. let currentCode = AppLanguageManager.shared.current.localeIdentifier
  1871. if let index = AppLanguage.allCases.firstIndex(where: { $0.localeIdentifier == currentCode }) {
  1872. popup.selectItem(at: index)
  1873. }
  1874. popup.target = self
  1875. popup.action = #selector(languageChanged(_:))
  1876. popup.isEnabled = !AppLanguage.allCases.isEmpty
  1877. popup.setContentHuggingPriority(.required, for: .horizontal)
  1878. popup.setContentCompressionResistancePriority(.required, for: .horizontal)
  1879. return popup
  1880. }
  1881. @objc private func languageChanged(_ sender: NSPopUpButton) {
  1882. guard let code = sender.selectedItem?.representedObject as? String else { return }
  1883. AppLanguageManager.shared.setLanguage(code: code)
  1884. }
  1885. private func makeSettingsSection(rows: [NSView]) -> NSView {
  1886. let section = NSStackView()
  1887. section.orientation = .vertical
  1888. section.spacing = 0
  1889. section.alignment = .leading
  1890. section.translatesAutoresizingMaskIntoConstraints = false
  1891. section.wantsLayer = true
  1892. section.identifier = NSUserInterfaceItemIdentifier(SettingsAppearanceID.section)
  1893. section.layer?.backgroundColor = Theme.settingsGroupBackground.cgColor
  1894. section.layer?.cornerRadius = 14
  1895. section.layer?.borderWidth = 1
  1896. section.layer?.borderColor = Theme.border.cgColor
  1897. section.layer?.masksToBounds = true
  1898. for (index, row) in rows.enumerated() {
  1899. section.addArrangedSubview(row)
  1900. row.widthAnchor.constraint(equalTo: section.widthAnchor).isActive = true
  1901. if index < rows.count - 1 {
  1902. let divider = NSView()
  1903. divider.translatesAutoresizingMaskIntoConstraints = false
  1904. divider.wantsLayer = true
  1905. divider.identifier = NSUserInterfaceItemIdentifier(SettingsAppearanceID.divider)
  1906. divider.layer?.backgroundColor = Theme.settingsDivider.cgColor
  1907. section.addArrangedSubview(divider)
  1908. NSLayoutConstraint.activate([
  1909. divider.heightAnchor.constraint(equalToConstant: 1),
  1910. divider.leadingAnchor.constraint(equalTo: section.leadingAnchor),
  1911. divider.trailingAnchor.constraint(equalTo: section.trailingAnchor)
  1912. ])
  1913. }
  1914. }
  1915. return section
  1916. }
  1917. private func makeSettingsRow(localizationKey: String, systemImage: String, accessory: NSView?, tapAction: Selector? = nil) -> NSView {
  1918. let row = NSView()
  1919. row.translatesAutoresizingMaskIntoConstraints = false
  1920. row.wantsLayer = true
  1921. let iconTile = NSView()
  1922. iconTile.translatesAutoresizingMaskIntoConstraints = false
  1923. iconTile.wantsLayer = true
  1924. iconTile.identifier = NSUserInterfaceItemIdentifier(SettingsAppearanceID.iconTile)
  1925. iconTile.layer?.backgroundColor = Theme.settingsIconBackground.cgColor
  1926. iconTile.layer?.cornerRadius = 9
  1927. let icon = NSImageView()
  1928. icon.translatesAutoresizingMaskIntoConstraints = false
  1929. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium)
  1930. icon.image = NSImage(systemSymbolName: systemImage, accessibilityDescription: L(localizationKey))
  1931. icon.contentTintColor = Theme.brandBlue
  1932. let titleLabel = NSTextField(labelWithString: L(localizationKey))
  1933. titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
  1934. titleLabel.textColor = Theme.primaryText
  1935. titleLabel.alignment = .left
  1936. titleLabel.identifier = NSUserInterfaceItemIdentifier("\(SettingsAppearanceID.rowTitle).\(localizationKey)")
  1937. let rowStack = NSStackView()
  1938. rowStack.orientation = .horizontal
  1939. rowStack.spacing = 16
  1940. rowStack.alignment = .centerY
  1941. rowStack.translatesAutoresizingMaskIntoConstraints = false
  1942. let spacer = NSView()
  1943. spacer.translatesAutoresizingMaskIntoConstraints = false
  1944. spacer.setContentHuggingPriority(NSLayoutConstraint.Priority(1), for: .horizontal)
  1945. iconTile.addSubview(icon)
  1946. rowStack.addArrangedSubview(iconTile)
  1947. rowStack.addArrangedSubview(titleLabel)
  1948. rowStack.addArrangedSubview(spacer)
  1949. if let accessory {
  1950. rowStack.addArrangedSubview(accessory)
  1951. }
  1952. row.addSubview(rowStack)
  1953. NSLayoutConstraint.activate([
  1954. row.heightAnchor.constraint(equalToConstant: 68),
  1955. iconTile.widthAnchor.constraint(equalToConstant: 38),
  1956. iconTile.heightAnchor.constraint(equalToConstant: 38),
  1957. icon.centerXAnchor.constraint(equalTo: iconTile.centerXAnchor),
  1958. icon.centerYAnchor.constraint(equalTo: iconTile.centerYAnchor),
  1959. icon.widthAnchor.constraint(equalToConstant: 20),
  1960. icon.heightAnchor.constraint(equalToConstant: 20),
  1961. rowStack.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 16),
  1962. rowStack.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -16),
  1963. rowStack.topAnchor.constraint(equalTo: row.topAnchor),
  1964. rowStack.bottomAnchor.constraint(equalTo: row.bottomAnchor)
  1965. ])
  1966. if let tapAction {
  1967. let rowButton = NSButton(title: "", target: self, action: tapAction)
  1968. rowButton.translatesAutoresizingMaskIntoConstraints = false
  1969. rowButton.isBordered = false
  1970. rowButton.bezelStyle = .regularSquare
  1971. rowButton.setButtonType(.momentaryChange)
  1972. rowButton.focusRingType = .none
  1973. rowButton.wantsLayer = true
  1974. rowButton.appearance = NSApp.appearance
  1975. rowButton.layer?.backgroundColor = .clear
  1976. row.addSubview(rowButton)
  1977. NSLayoutConstraint.activate([
  1978. rowButton.leadingAnchor.constraint(equalTo: row.leadingAnchor),
  1979. rowButton.trailingAnchor.constraint(equalTo: row.trailingAnchor),
  1980. rowButton.topAnchor.constraint(equalTo: row.topAnchor),
  1981. rowButton.bottomAnchor.constraint(equalTo: row.bottomAnchor)
  1982. ])
  1983. }
  1984. return row
  1985. }
  1986. private func reloadSavedJobsListings() {
  1987. savedJobsStack.arrangedSubviews.forEach {
  1988. savedJobsStack.removeArrangedSubview($0)
  1989. $0.removeFromSuperview()
  1990. }
  1991. if savedJobOrder.isEmpty {
  1992. savedJobsPageSubtitleLabel.stringValue = L("Save jobs from Home to see them here.")
  1993. let empty = NSTextField(wrappingLabelWithString: L("No saved jobs yet. Search on Home, then tap Save on a listing."))
  1994. empty.font = .systemFont(ofSize: 14, weight: .regular)
  1995. empty.textColor = Theme.secondaryText
  1996. empty.alignment = .left
  1997. empty.maximumNumberOfLines = 0
  1998. empty.translatesAutoresizingMaskIntoConstraints = false
  1999. savedJobsStack.addArrangedSubview(empty)
  2000. empty.widthAnchor.constraint(equalTo: savedJobsStack.widthAnchor).isActive = true
  2001. return
  2002. }
  2003. savedJobsPageSubtitleLabel.stringValue = savedJobOrder.count == 1
  2004. ? L("1 saved position")
  2005. : String(format: L("%d saved positions"), savedJobOrder.count)
  2006. for job in savedJobOrder {
  2007. let card = makeJobListingCard(job, context: .savedJobsPage)
  2008. savedJobsStack.addArrangedSubview(card)
  2009. card.widthAnchor.constraint(equalTo: savedJobsStack.widthAnchor).isActive = true
  2010. }
  2011. }
  2012. private func isSavedJobsSidebarIndex(_ index: Int) -> Bool {
  2013. guard index >= 0, index < currentSidebarItems.count else { return false }
  2014. return currentSidebarItems[index].title == L("Saved Jobs")
  2015. }
  2016. private func isHomeSidebarIndex(_ index: Int) -> Bool {
  2017. guard index >= 0, index < currentSidebarItems.count else { return false }
  2018. return currentSidebarItems[index].title == L("Home")
  2019. }
  2020. private func isSettingsSidebarIndex(_ index: Int) -> Bool {
  2021. guard index >= 0, index < currentSidebarItems.count else { return false }
  2022. return currentSidebarItems[index].title == L("Settings")
  2023. }
  2024. private func isCVMakerSidebarIndex(_ index: Int) -> Bool {
  2025. guard index >= 0, index < currentSidebarItems.count else { return false }
  2026. return currentSidebarItems[index].title == L("CV Maker")
  2027. }
  2028. private func isProfileSidebarIndex(_ index: Int) -> Bool {
  2029. guard index >= 0, index < currentSidebarItems.count else { return false }
  2030. return currentSidebarItems[index].title == L("Profile")
  2031. }
  2032. private func updateMainContentVisibility() {
  2033. if isIndeedJobBrowserPresented {
  2034. mainOverlay.isHidden = true
  2035. nonHomeHost.isHidden = true
  2036. indeedJobBrowserHost.isHidden = false
  2037. return
  2038. }
  2039. indeedJobBrowserHost.isHidden = true
  2040. let home = isHomeSidebarIndex(selectedSidebarIndex)
  2041. let savedJobs = isSavedJobsSidebarIndex(selectedSidebarIndex)
  2042. let settings = isSettingsSidebarIndex(selectedSidebarIndex)
  2043. let cvMaker = isCVMakerSidebarIndex(selectedSidebarIndex)
  2044. let profile = isProfileSidebarIndex(selectedSidebarIndex)
  2045. mainOverlay.isHidden = !home
  2046. nonHomeHost.isHidden = home
  2047. nonHomeGenericContainer.isHidden = savedJobs || settings || cvMaker || profile
  2048. savedJobsPageContainer.isHidden = !savedJobs
  2049. settingsPageContainer.isHidden = !settings
  2050. cvMakerPageContainer.isHidden = !cvMaker
  2051. profilePageContainer.isHidden = !profile
  2052. if !profile {
  2053. isProfileEditorPresented = false
  2054. isCVDocumentPreviewPresented = false
  2055. pendingCVTemplate = nil
  2056. profilesListPageView.setPendingCVTemplateDisplayName(nil)
  2057. cvFilledPreviewPageView.isHidden = true
  2058. profilesListPageView.isHidden = false
  2059. myProfilePageView.isHidden = true
  2060. }
  2061. if profile, !isProfileEditorPresented {
  2062. if isCVDocumentPreviewPresented {
  2063. profilesListPageView.isHidden = true
  2064. myProfilePageView.isHidden = true
  2065. cvFilledPreviewPageView.isHidden = false
  2066. } else {
  2067. profilesListPageView.reloadFromStore()
  2068. profilesListPageView.isHidden = false
  2069. myProfilePageView.isHidden = true
  2070. cvFilledPreviewPageView.isHidden = true
  2071. }
  2072. }
  2073. if !home, selectedSidebarIndex < currentSidebarItems.count {
  2074. if savedJobs {
  2075. reloadSavedJobsListings()
  2076. } else if settings || cvMaker || profile {
  2077. window?.makeFirstResponder(nil)
  2078. } else {
  2079. nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title
  2080. }
  2081. }
  2082. }
  2083. /// Restores the main job-search field when returning to Home; chat history is kept until the user chooses **Clear chat**.
  2084. private func applyHomeState() {
  2085. jobKeywordsField.stringValue = ""
  2086. window?.makeFirstResponder(nil)
  2087. }
  2088. @objc private func didTapClearChat() {
  2089. guard !isAwaitingResponse else { return }
  2090. resetChatState()
  2091. window?.makeFirstResponder(nil)
  2092. }
  2093. private func updateSearchBarShadowPath() {
  2094. guard searchBarShadowHost.bounds.width > 0, searchBarShadowHost.bounds.height > 0 else { return }
  2095. let r = searchBarShadowHost.bounds
  2096. let radius = min(r.height / 2, 27)
  2097. searchBarShadowHost.layer?.shadowPath = CGPath(
  2098. roundedRect: r,
  2099. cornerWidth: radius,
  2100. cornerHeight: radius,
  2101. transform: nil
  2102. )
  2103. }
  2104. @objc private func didSubmitSearch() {
  2105. guard ensureProAccessForJobSearch() else { return }
  2106. let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  2107. guard !prompt.isEmpty, !isAwaitingResponse else { return }
  2108. let isContinuation = isContinuationPrompt(prompt)
  2109. let effectiveQuery = resolvedSearchQuery(for: prompt)
  2110. appendChatBubble(text: prompt, isUser: true)
  2111. chatMessages.append(ChatMessage(role: "user", content: prompt))
  2112. jobKeywordsField.stringValue = ""
  2113. startJobSearchRequest(effectiveQuery: effectiveQuery, isContinuation: isContinuation)
  2114. window?.makeFirstResponder(nil)
  2115. }
  2116. @objc private func didTapFeatureRole() {
  2117. selectFeatureShortcut(.role)
  2118. focusSearchField(seed: L("Find roles similar to: "))
  2119. }
  2120. @objc private func didTapFeatureCompany() {
  2121. selectFeatureShortcut(.company)
  2122. focusSearchField(seed: L("Find jobs at company: "))
  2123. }
  2124. @objc private func didTapFeatureSkill() {
  2125. selectFeatureShortcut(.skill)
  2126. focusSearchField(seed: L("Find jobs that require skill: "))
  2127. }
  2128. private func selectFeatureShortcut(_ shortcut: FeatureShortcut) {
  2129. for (index, view) in featureCardsRow.arrangedSubviews.enumerated() {
  2130. guard let card = view as? FeatureShortcutCardView else { continue }
  2131. card.isSelected = (index == shortcut.rawValue)
  2132. }
  2133. }
  2134. @objc private func didTapShareApp(_ sender: NSButton) {
  2135. presentAppShareMenu(anchoredTo: sender)
  2136. }
  2137. /// Shows the macOS share menu (Mail, Messages, AirDrop, Copy Link, etc.) with the app link.
  2138. private func presentAppShareMenu(anchoredTo sender: NSButton) {
  2139. guard let row = sender.superview else { return }
  2140. let items = AppMarketingLinks.shareItems
  2141. guard !items.isEmpty else { return }
  2142. let picker = NSSharingServicePicker(items: items)
  2143. picker.delegate = self
  2144. appSharePicker = picker
  2145. // Match `makeSettingsRow` layout: 16pt leading inset + 38pt icon tile — anchor the
  2146. // popover beside the share icon, not the horizontal center of the full-width row.
  2147. let iconTileInset: CGFloat = 16
  2148. let iconTileSize: CGFloat = 38
  2149. let anchorRect = NSRect(
  2150. x: iconTileInset,
  2151. y: row.bounds.minY + 6,
  2152. width: iconTileSize,
  2153. height: max(row.bounds.height - 12, 1)
  2154. )
  2155. picker.show(relativeTo: anchorRect, of: row, preferredEdge: .minY)
  2156. }
  2157. func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, delegateFor sharingService: NSSharingService) -> Any? {
  2158. self
  2159. }
  2160. func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose sharingService: NSSharingService?) {
  2161. appSharePicker = nil
  2162. }
  2163. func sharingService(_ sharingService: NSSharingService, willShareItems items: [Any]) -> [Any] {
  2164. if sharingService == NSSharingService(named: .composeEmail) {
  2165. sharingService.subject = AppMarketingLinks.shareEmailSubject
  2166. }
  2167. return items
  2168. }
  2169. @objc private func didTapMoreApps() {
  2170. guard let url = AppMarketingLinks.developerAppsURL else {
  2171. presentAppMarketingConfigurationAlert(feature: L("More Apps"))
  2172. return
  2173. }
  2174. NSWorkspace.shared.open(url)
  2175. }
  2176. private func presentAppMarketingConfigurationAlert(feature: String) {
  2177. let alert = NSAlert()
  2178. alert.messageText = String(format: L("%@ isn’t available yet"), feature)
  2179. alert.informativeText = L("Add your Mac App Store IDs in the target’s build settings:\n• AppStoreAppID — numeric app ID from App Store Connect\n• AppStoreDeveloperID — numeric developer ID (for your other apps page)")
  2180. alert.alertStyle = .informational
  2181. alert.addButton(withTitle: L("OK"))
  2182. if let window {
  2183. alert.beginSheetModal(for: window)
  2184. } else {
  2185. alert.runModal()
  2186. }
  2187. }
  2188. @objc private func didTapWebsite() {
  2189. AppLegalURLs.openInSafari(AppLegalURLs.marketingHome)
  2190. }
  2191. @objc private func didTapSupport() {
  2192. AppLegalURLs.openInSafari(AppLegalURLs.support)
  2193. }
  2194. @objc private func didTapTermsOfUse() {
  2195. AppLegalURLs.openInSafari(AppLegalURLs.termsOfUse)
  2196. }
  2197. @objc private func didTapPrivacyPolicy() {
  2198. AppLegalURLs.openInSafari(AppLegalURLs.privacyPolicy)
  2199. }
  2200. private func focusSearchField(seed: String) {
  2201. guard ensureProAccessForJobSearch() else { return }
  2202. jobKeywordsField.stringValue = seed
  2203. window?.makeFirstResponder(jobKeywordsField)
  2204. if let editor = jobKeywordsField.window?.fieldEditor(true, for: jobKeywordsField) as? NSTextView {
  2205. editor.moveToEndOfDocument(nil)
  2206. }
  2207. }
  2208. @objc private func didTapLoadMoreJobs() {
  2209. guard ensureProAccessForJobSearch() else { return }
  2210. let prompt = L("Show more jobs")
  2211. guard !isAwaitingResponse, isContinuationPrompt(prompt) else { return }
  2212. if anchorUserJobQuery(excludingLatestUserMessage: prompt) == nil { return }
  2213. appendChatBubble(text: prompt, isUser: true)
  2214. chatMessages.append(ChatMessage(role: "user", content: prompt))
  2215. let effectiveQuery = resolvedSearchQuery(for: prompt)
  2216. startJobSearchRequest(effectiveQuery: effectiveQuery, isContinuation: true)
  2217. }
  2218. private func startJobSearchRequest(effectiveQuery: String, isContinuation: Bool) {
  2219. FreeTierJobSearchQuota.recordUserMessageSent(isProActive: SubscriptionStore.shared.isProActive)
  2220. updateFreeJobSearchQuotaLabel()
  2221. isAwaitingResponse = true
  2222. addInlineChatThinkingRow()
  2223. setInputEnabled(false)
  2224. let contextMessages = chatMessages
  2225. let maxJobs = Self.clampedJobsPerRequest()
  2226. jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages, maxJobs: maxJobs) { [weak self] result in
  2227. DispatchQueue.main.async {
  2228. guard let self else { return }
  2229. self.removeInlineChatThinkingRow()
  2230. self.isAwaitingResponse = false
  2231. self.setInputEnabled(true)
  2232. switch result {
  2233. case .success(let output):
  2234. let normalizedJobs = self.normalizedJobs(output.jobs)
  2235. let freshJobs: [JobListing]
  2236. if isContinuation {
  2237. // Continuations append only the *new* matches; previous cards already live in their own assistant message above.
  2238. let alreadySeen = Set(self.lastSearchResults)
  2239. freshJobs = normalizedJobs.filter { !alreadySeen.contains($0) }
  2240. } else {
  2241. freshJobs = normalizedJobs
  2242. }
  2243. self.lastSearchResults.append(contentsOf: freshJobs)
  2244. let reply = self.makeAssistantSearchReply(
  2245. query: effectiveQuery,
  2246. newJobsCount: freshJobs.count,
  2247. isContinuation: isContinuation
  2248. )
  2249. self.chatMessages.append(ChatMessage(role: "assistant", content: reply, attachedJobs: freshJobs.isEmpty ? nil : freshJobs))
  2250. self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
  2251. case .failure(let error):
  2252. self.appendChatBubble(text: UserFacingErrorMessage.jobSearchFailure(error), isUser: false)
  2253. }
  2254. }
  2255. }
  2256. }
  2257. private func normalizedJobs(_ jobs: [JobListing]) -> [JobListing] {
  2258. let trimmed = jobs.map {
  2259. JobListing(
  2260. title: $0.title.trimmingCharacters(in: .whitespacesAndNewlines),
  2261. description: $0.description.trimmingCharacters(in: .whitespacesAndNewlines),
  2262. url: $0.url?.trimmingCharacters(in: .whitespacesAndNewlines)
  2263. )
  2264. }
  2265. return trimmed.filter { !$0.title.isEmpty && !$0.description.isEmpty }
  2266. }
  2267. private func makeAssistantSearchReply(query: String, newJobsCount: Int, isContinuation: Bool) -> String {
  2268. if newJobsCount == 0 {
  2269. if isContinuation {
  2270. return String(format: L("I couldn't find new matches for “%@”. Try a different angle or a more specific keyword."), query)
  2271. }
  2272. return String(format: L("No jobs found for “%@”. Try another title, skill, company, or location."), query)
  2273. }
  2274. let matchWord = newJobsCount == 1 ? L("match") : L("matches")
  2275. if isContinuation {
  2276. return String(format: L("Here are %d more %@ for “%@”."), newJobsCount, matchWord, query)
  2277. }
  2278. return String(format: L("Found %d %@ for “%@”. Tap Apply to open the listing or Save to revisit later."), newJobsCount, matchWord, query)
  2279. }
  2280. private func resolvedSearchQuery(for prompt: String) -> String {
  2281. let anchor = anchorUserJobQuery(excludingLatestUserMessage: prompt)
  2282. if isContinuationPrompt(prompt), !isRefinementPrompt(prompt) {
  2283. if let anchor { return anchor }
  2284. return prompt
  2285. }
  2286. if isRefinementPrompt(prompt), let anchor {
  2287. return "\(anchor). User follow-up (apply on top of the same search topic): \(prompt)"
  2288. }
  2289. return prompt
  2290. }
  2291. /// First prior user message that looks like an original job query (skips short continuations and refinements so follow-ups keep a stable topic anchor).
  2292. private func anchorUserJobQuery(excludingLatestUserMessage latest: String) -> String? {
  2293. let prior = Array(chatMessages.dropLast())
  2294. for message in prior.reversed() where message.role == "user" {
  2295. let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
  2296. guard !candidate.isEmpty, candidate != latest else { continue }
  2297. if isContinuationPrompt(candidate) { continue }
  2298. if isRefinementPrompt(candidate) { continue }
  2299. return candidate
  2300. }
  2301. return nil
  2302. }
  2303. private func isContinuationPrompt(_ prompt: String) -> Bool {
  2304. let normalized = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  2305. let showMoreJobs = L("Show more jobs").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  2306. if normalized == showMoreJobs {
  2307. return true
  2308. }
  2309. let continuationPhrases: Set<String> = [
  2310. "more",
  2311. "show more",
  2312. "more jobs",
  2313. "more results",
  2314. "do more searches",
  2315. "more searches",
  2316. "search more",
  2317. "continue",
  2318. "next"
  2319. ]
  2320. if continuationPhrases.contains(normalized) {
  2321. return true
  2322. }
  2323. return normalized.contains("more search") || normalized.contains("more job")
  2324. }
  2325. /// Follow-ups that narrow, re-rank, or re-frame results rather than starting a brand-new role search.
  2326. /// 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.
  2327. private func isRefinementPrompt(_ prompt: String) -> Bool {
  2328. let n = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  2329. if n.isEmpty { return false }
  2330. let strongPhrases = [
  2331. "higher pay", "high pay", "better pay", "more pay", "top pay", "best pay",
  2332. "higher salary", "better salary", "more salary", "pay rate", "hourly rate",
  2333. "paid more", "paying more", "earn more", "better paid", "paying better",
  2334. "work from home", "in office", "in-office", "on-site only", "remote only",
  2335. "hybrid only", "onsite only", "visa sponsorship", "h1b",
  2336. "entry level", "entry-level", "mid level", "mid-level", "full time", "full-time",
  2337. "part time", "part-time",
  2338. "closer to", "nearer", "different city", "different state", "relocate",
  2339. "filter", "only show", "just show", "exclude", "without", "sort by", "rank by",
  2340. "cheaper", "lower pay", "less travel",
  2341. "better benefits", "equity", "bonus", "overtime",
  2342. "get me the jobs", "show me the jobs", "give me the jobs", "narrow", "refine"
  2343. ]
  2344. if strongPhrases.contains(where: { n.contains($0) }) { return true }
  2345. if n.hasPrefix("only ") || n.hasPrefix("just ") { return true }
  2346. guard !lastSearchResults.isEmpty, n.count <= 52 else { return false }
  2347. let softAfterResults = [
  2348. "remote", "hybrid", "onsite", "on-site", "senior", "junior", "staff", "lead",
  2349. "principal", "intern", "contract", "location"
  2350. ]
  2351. return softAfterResults.contains(where: { n.contains($0) })
  2352. }
  2353. func controlTextDidBeginEditing(_ obj: Notification) {
  2354. applySearchFieldInsertionPoint(obj.object)
  2355. }
  2356. func controlTextDidChange(_ obj: Notification) {
  2357. applySearchFieldInsertionPoint(obj.object)
  2358. }
  2359. func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
  2360. guard control === jobKeywordsField, commandSelector == #selector(NSResponder.insertNewline(_:)) else {
  2361. return false
  2362. }
  2363. didSubmitSearch()
  2364. return true
  2365. }
  2366. private func applySearchFieldInsertionPoint(_ object: Any?) {
  2367. guard let field = object as? NSTextField,
  2368. field === jobKeywordsField,
  2369. let textView = field.window?.fieldEditor(true, for: field) as? NSTextView else { return }
  2370. textView.insertionPointColor = Theme.primaryText
  2371. }
  2372. private static let welcomeChatMessageKey =
  2373. "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary."
  2374. private func resetChatState() {
  2375. removeInlineChatThinkingRow()
  2376. trailingLoadMoreJobsRow = nil
  2377. trailingLoadMoreJobsButton = nil
  2378. chatMessages.removeAll()
  2379. lastSearchResults.removeAll()
  2380. clearChatStack()
  2381. let welcome = L(Self.welcomeChatMessageKey)
  2382. chatMessages.append(ChatMessage(role: "assistant", content: welcome))
  2383. appendChatBubble(text: welcome, isUser: false)
  2384. }
  2385. private func clearChatStack() {
  2386. chatStack.arrangedSubviews.forEach {
  2387. chatStack.removeArrangedSubview($0)
  2388. $0.removeFromSuperview()
  2389. }
  2390. }
  2391. private func rebuildChatUI() {
  2392. guard !chatMessages.isEmpty else { return }
  2393. removeInlineChatThinkingRow()
  2394. trailingLoadMoreJobsRow = nil
  2395. trailingLoadMoreJobsButton = nil
  2396. clearChatStack()
  2397. for message in chatMessages {
  2398. let isUser = message.role == "user"
  2399. appendChatBubble(text: message.content, isUser: isUser, jobs: message.attachedJobs)
  2400. }
  2401. for host in chatStack.arrangedSubviews {
  2402. host.alphaValue = 1
  2403. }
  2404. updateChatBubbleWidths()
  2405. }
  2406. private func appendChatBubble(text: String, isUser: Bool, jobs: [JobListing]? = nil) {
  2407. let host = NSView()
  2408. host.translatesAutoresizingMaskIntoConstraints = false
  2409. if isUser {
  2410. installUserBubble(text: text, into: host)
  2411. } else {
  2412. installAssistantBubble(text: text, jobs: jobs, into: host)
  2413. }
  2414. chatStack.addArrangedSubview(host)
  2415. host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
  2416. if prefersReducedMotion {
  2417. host.alphaValue = 1
  2418. } else {
  2419. host.alphaValue = 0
  2420. }
  2421. DispatchQueue.main.async { [weak self] in
  2422. guard let self else { return }
  2423. let scroll: () -> Void = {
  2424. if isUser {
  2425. self.scrollChatToBottom()
  2426. } else {
  2427. self.scrollChatToShowTopOfView(host)
  2428. }
  2429. }
  2430. if self.prefersReducedMotion {
  2431. self.updateChatBubbleWidths()
  2432. scroll()
  2433. return
  2434. }
  2435. NSAnimationContext.runAnimationGroup { ctx in
  2436. ctx.duration = 0.3
  2437. ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
  2438. host.animator().alphaValue = 1
  2439. }
  2440. self.updateChatBubbleWidths()
  2441. scroll()
  2442. }
  2443. }
  2444. private func installUserBubble(text: String, into host: NSView) {
  2445. let bubble = makeChatBubbleContainer(text: text, isUser: true)
  2446. host.addSubview(bubble)
  2447. NSLayoutConstraint.activate([
  2448. bubble.topAnchor.constraint(equalTo: host.topAnchor),
  2449. bubble.bottomAnchor.constraint(equalTo: host.bottomAnchor),
  2450. bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor),
  2451. bubble.leadingAnchor.constraint(greaterThanOrEqualTo: host.leadingAnchor, constant: 64),
  2452. bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78)
  2453. ])
  2454. }
  2455. private func installAssistantBubble(text: String, jobs: [JobListing]?, into host: NSView) {
  2456. let avatar = makeAssistantAvatarView()
  2457. let nameLabel = NSTextField(labelWithString: AppMarketingLinks.appDisplayName)
  2458. nameLabel.font = .systemFont(ofSize: 11, weight: .semibold)
  2459. nameLabel.textColor = Theme.secondaryText
  2460. nameLabel.alignment = AppLayoutDirection.naturalTextAlignment
  2461. nameLabel.translatesAutoresizingMaskIntoConstraints = false
  2462. let bubble = makeChatBubbleContainer(text: text, isUser: false)
  2463. let column = NSStackView(views: [nameLabel, bubble])
  2464. column.orientation = .vertical
  2465. column.spacing = 6
  2466. // 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.
  2467. column.alignment = .leading
  2468. column.translatesAutoresizingMaskIntoConstraints = false
  2469. if let jobs, !jobs.isEmpty {
  2470. trailingLoadMoreJobsRow?.removeFromSuperview()
  2471. trailingLoadMoreJobsRow = nil
  2472. trailingLoadMoreJobsButton = nil
  2473. let jobsStack = makeChatJobsStackView(jobs: jobs)
  2474. column.addArrangedSubview(jobsStack)
  2475. jobsStack.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true
  2476. let moreRow = makeLoadMoreJobsRowView()
  2477. column.addArrangedSubview(moreRow)
  2478. moreRow.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true
  2479. trailingLoadMoreJobsRow = moreRow
  2480. }
  2481. host.addSubview(avatar)
  2482. host.addSubview(column)
  2483. NSLayoutConstraint.activate([
  2484. avatar.leadingAnchor.constraint(equalTo: host.leadingAnchor),
  2485. avatar.topAnchor.constraint(equalTo: host.topAnchor),
  2486. avatar.widthAnchor.constraint(equalToConstant: 36),
  2487. avatar.heightAnchor.constraint(equalToConstant: 36),
  2488. column.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 12),
  2489. column.trailingAnchor.constraint(equalTo: host.trailingAnchor),
  2490. column.topAnchor.constraint(equalTo: host.topAnchor),
  2491. column.bottomAnchor.constraint(equalTo: host.bottomAnchor),
  2492. bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78)
  2493. ])
  2494. }
  2495. private func makeChatBubbleContainer(text: String, isUser: Bool) -> NSView {
  2496. let container = NSView()
  2497. container.translatesAutoresizingMaskIntoConstraints = false
  2498. container.wantsLayer = true
  2499. container.layer?.cornerRadius = 14
  2500. if #available(macOS 11.0, *) {
  2501. container.layer?.cornerCurve = .continuous
  2502. }
  2503. container.layer?.masksToBounds = true
  2504. if isUser {
  2505. container.layer?.backgroundColor = Theme.brandBlue.cgColor
  2506. } else {
  2507. container.layer?.backgroundColor = Theme.chromeBackground.cgColor
  2508. container.layer?.borderWidth = 1
  2509. container.layer?.borderColor = Theme.border.cgColor
  2510. }
  2511. let label = ChatBubbleLabel(wrappingLabelWithString: text)
  2512. label.font = .systemFont(ofSize: 13.5, weight: .regular)
  2513. label.textColor = isUser ? .white : Theme.primaryText
  2514. label.maximumNumberOfLines = 0
  2515. label.lineBreakMode = .byWordWrapping
  2516. label.alignment = AppLayoutDirection.naturalTextAlignment
  2517. label.baseWritingDirection = AppLayoutDirection.naturalWritingDirection
  2518. label.attributedStringValue = Self.chatBubbleAttributedString(
  2519. text,
  2520. color: label.textColor ?? Theme.primaryText,
  2521. font: label.font ?? .systemFont(ofSize: 13.5, weight: .regular)
  2522. )
  2523. label.translatesAutoresizingMaskIntoConstraints = false
  2524. label.setContentHuggingPriority(.defaultLow, for: .horizontal)
  2525. label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  2526. container.addSubview(label)
  2527. NSLayoutConstraint.activate([
  2528. label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14),
  2529. label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14),
  2530. label.topAnchor.constraint(equalTo: container.topAnchor, constant: 10),
  2531. label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10)
  2532. ])
  2533. return container
  2534. }
  2535. private func makeAssistantAvatarView() -> NSView {
  2536. let view = NSView()
  2537. view.translatesAutoresizingMaskIntoConstraints = false
  2538. view.wantsLayer = true
  2539. view.layer?.cornerRadius = 18
  2540. if #available(macOS 11.0, *) {
  2541. view.layer?.cornerCurve = .continuous
  2542. }
  2543. view.layer?.backgroundColor = Theme.settingsIconBackground.cgColor
  2544. view.layer?.borderWidth = 1
  2545. view.layer?.borderColor = Theme.proCardBorder.cgColor
  2546. let icon = NSImageView()
  2547. icon.translatesAutoresizingMaskIntoConstraints = false
  2548. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
  2549. icon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: AppMarketingLinks.appDisplayName)
  2550. icon.contentTintColor = Theme.brandBlue
  2551. view.addSubview(icon)
  2552. NSLayoutConstraint.activate([
  2553. icon.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  2554. icon.centerYAnchor.constraint(equalTo: view.centerYAnchor)
  2555. ])
  2556. return view
  2557. }
  2558. private func makeLoadMoreJobsRowView() -> NSView {
  2559. let row = NSView()
  2560. row.translatesAutoresizingMaskIntoConstraints = false
  2561. let button = HoverableButton()
  2562. button.pointerCursor = true
  2563. button.title = L("Show more jobs")
  2564. button.font = .systemFont(ofSize: 12, weight: .semibold)
  2565. button.bezelStyle = .rounded
  2566. button.controlSize = .regular
  2567. button.contentTintColor = Theme.brandBlue
  2568. button.target = self
  2569. button.action = #selector(didTapLoadMoreJobs)
  2570. button.translatesAutoresizingMaskIntoConstraints = false
  2571. trailingLoadMoreJobsButton = button
  2572. row.addSubview(button)
  2573. NSLayoutConstraint.activate([
  2574. button.leadingAnchor.constraint(equalTo: row.leadingAnchor),
  2575. button.topAnchor.constraint(equalTo: row.topAnchor, constant: 2),
  2576. button.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -2)
  2577. ])
  2578. return row
  2579. }
  2580. private func makeChatJobsStackView(jobs: [JobListing]) -> ChatJobsStackView {
  2581. let stack = ChatJobsStackView()
  2582. stack.orientation = .vertical
  2583. stack.spacing = 10
  2584. stack.alignment = .width
  2585. stack.distribution = .fill
  2586. stack.translatesAutoresizingMaskIntoConstraints = false
  2587. for job in jobs {
  2588. let card = makeJobListingCard(job, context: .homeSearchResults)
  2589. stack.addArrangedSubview(card)
  2590. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2591. }
  2592. return stack
  2593. }
  2594. private func scrollChatToBottom() {
  2595. let maxY = max(0, chatDocumentView.bounds.height - chatScrollView.contentView.bounds.height)
  2596. chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY))
  2597. chatScrollView.reflectScrolledClipView(chatScrollView.contentView)
  2598. }
  2599. /// 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.
  2600. private func scrollChatToShowTopOfView(_ view: NSView) {
  2601. chatDocumentView.layoutSubtreeIfNeeded()
  2602. view.layoutSubtreeIfNeeded()
  2603. let doc = chatDocumentView
  2604. let visibleHeight = chatScrollView.contentView.bounds.height
  2605. let docHeight = doc.bounds.height
  2606. guard docHeight > 0, visibleHeight > 0 else {
  2607. scrollChatToBottom()
  2608. return
  2609. }
  2610. let rectInDoc = view.convert(view.bounds, to: doc)
  2611. let maxY = max(0, docHeight - visibleHeight)
  2612. let targetY = min(max(0, rectInDoc.minY), maxY)
  2613. chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: targetY))
  2614. chatScrollView.reflectScrolledClipView(chatScrollView.contentView)
  2615. }
  2616. private func addInlineChatThinkingRow() {
  2617. removeInlineChatThinkingRow()
  2618. let host = NSView()
  2619. host.translatesAutoresizingMaskIntoConstraints = false
  2620. let indicator = ChatThinkingIndicatorView(compact: false, accessibilityLabel: L("Assistant is searching"))
  2621. host.addSubview(indicator)
  2622. NSLayoutConstraint.activate([
  2623. indicator.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 8),
  2624. indicator.topAnchor.constraint(equalTo: host.topAnchor),
  2625. indicator.bottomAnchor.constraint(equalTo: host.bottomAnchor, constant: -2)
  2626. ])
  2627. chatThinkingRowHost = host
  2628. chatStack.addArrangedSubview(host)
  2629. host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
  2630. indicator.startAnimatingIfNeeded()
  2631. DispatchQueue.main.async { [weak self] in
  2632. self?.updateChatBubbleWidths()
  2633. self?.scrollChatToBottom()
  2634. }
  2635. }
  2636. private func removeInlineChatThinkingRow() {
  2637. guard let host = chatThinkingRowHost else { return }
  2638. for sub in host.subviews {
  2639. (sub as? ChatThinkingIndicatorView)?.stopAnimating()
  2640. }
  2641. chatStack.removeArrangedSubview(host)
  2642. host.removeFromSuperview()
  2643. chatThinkingRowHost = nil
  2644. }
  2645. private func setInputEnabled(_ enabled: Bool) {
  2646. jobKeywordsField.isEnabled = enabled
  2647. findJobsButton.isEnabled = enabled
  2648. findJobsCTAHost.alphaValue = enabled ? 1 : 0.65
  2649. clearChatButton.isEnabled = enabled
  2650. clearChatButton.alphaValue = enabled ? 1 : 0.65
  2651. trailingLoadMoreJobsButton?.isEnabled = enabled
  2652. trailingLoadMoreJobsButton?.alphaValue = enabled ? 1 : 0.65
  2653. }
  2654. private func addIndeedSidebarLaunchRow() {
  2655. let isSelected = isIndeedSidebarSelected
  2656. let rowHost = SidebarNavRowView { [weak self] in
  2657. self?.selectIndeedSidebar()
  2658. }
  2659. rowHost.translatesAutoresizingMaskIntoConstraints = false
  2660. rowHost.wantsLayer = true
  2661. rowHost.layer?.cornerRadius = 8
  2662. rowHost.restingBackgroundColor = isSelected ? Theme.selectionFill : nil
  2663. rowHost.hoverBackgroundColor = isSelected ? Theme.selectionFillHover : Theme.sidebarRowHoverFill
  2664. rowHost.setAccessibilityLabel(AppMarketingLinks.localizedIndeedNavTitle)
  2665. rowHost.setAccessibilityRole(.button)
  2666. rowHost.setAccessibilitySelected(isSelected)
  2667. rowHost.setAccessibilityHelp(L("Open Indeed to search and apply for jobs"))
  2668. let row = NSStackView()
  2669. row.orientation = .horizontal
  2670. row.spacing = 8
  2671. row.alignment = .centerY
  2672. row.translatesAutoresizingMaskIntoConstraints = false
  2673. let icon = NSImageView()
  2674. icon.translatesAutoresizingMaskIntoConstraints = false
  2675. icon.image = IndeedSidebarNavIcon.image(filled: isSelected)
  2676. icon.imageScaling = .scaleProportionallyUpOrDown
  2677. icon.contentTintColor = isSelected ? Theme.brandBlue : Theme.secondaryText
  2678. icon.widthAnchor.constraint(equalToConstant: Self.sidebarNavIconSize).isActive = true
  2679. icon.heightAnchor.constraint(equalToConstant: Self.sidebarNavIconSize).isActive = true
  2680. let text = NSTextField(labelWithString: AppMarketingLinks.localizedIndeedNavTitle)
  2681. text.font = .systemFont(ofSize: 14, weight: .medium)
  2682. text.textColor = isSelected ? Theme.brandBlue : Theme.secondaryText
  2683. text.refusesFirstResponder = true
  2684. row.addArrangedSubview(icon)
  2685. row.addArrangedSubview(text)
  2686. rowHost.addSubview(row)
  2687. NSLayoutConstraint.activate([
  2688. row.leadingAnchor.constraint(equalTo: rowHost.leadingAnchor, constant: 10),
  2689. row.trailingAnchor.constraint(equalTo: rowHost.trailingAnchor, constant: -10),
  2690. row.topAnchor.constraint(equalTo: rowHost.topAnchor, constant: 8),
  2691. row.bottomAnchor.constraint(equalTo: rowHost.bottomAnchor, constant: -8)
  2692. ])
  2693. rowHost.setContentHuggingPriority(.defaultLow, for: .horizontal)
  2694. sidebar.addArrangedSubview(rowHost)
  2695. let sidebarHorizontalInset = sidebar.edgeInsets.left + sidebar.edgeInsets.right
  2696. rowHost.widthAnchor.constraint(equalTo: sidebar.widthAnchor, constant: -sidebarHorizontalInset).isActive = true
  2697. sidebar.setCustomSpacing(10, after: rowHost)
  2698. }
  2699. private func configureSidebar() {
  2700. let items = currentSidebarItems
  2701. sidebar.arrangedSubviews.forEach {
  2702. sidebar.removeArrangedSubview($0)
  2703. $0.removeFromSuperview()
  2704. }
  2705. let logo = IndeedLogoView(displayHeight: 34, variant: .compact)
  2706. logo.translatesAutoresizingMaskIntoConstraints = false
  2707. let brand = NSTextField(labelWithString: AppMarketingLinks.appDisplayName)
  2708. brand.font = .systemFont(ofSize: 14, weight: .semibold)
  2709. brand.textColor = Theme.brandBlue
  2710. brand.alignment = .left
  2711. brand.maximumNumberOfLines = 2
  2712. brand.preferredMaxLayoutWidth = 194
  2713. let brandHeader = NSStackView(views: [logo, brand])
  2714. brandHeader.orientation = .vertical
  2715. brandHeader.alignment = .leading
  2716. brandHeader.spacing = 6
  2717. brandHeader.translatesAutoresizingMaskIntoConstraints = false
  2718. sidebar.addArrangedSubview(brandHeader)
  2719. sidebar.setCustomSpacing(22, after: brandHeader)
  2720. items.enumerated().forEach { index, item in
  2721. let isSelected = index == selectedSidebarIndex && !isIndeedSidebarSelected
  2722. let rowHost = SidebarNavRowView { [weak self] in
  2723. self?.selectSidebarItem(at: index)
  2724. }
  2725. rowHost.translatesAutoresizingMaskIntoConstraints = false
  2726. rowHost.wantsLayer = true
  2727. rowHost.layer?.cornerRadius = 8
  2728. rowHost.restingBackgroundColor = isSelected ? Theme.selectionFill : nil
  2729. rowHost.hoverBackgroundColor = isSelected ? Theme.selectionFillHover : Theme.sidebarRowHoverFill
  2730. rowHost.setAccessibilityLabel(item.title)
  2731. rowHost.setAccessibilityRole(.button)
  2732. rowHost.setAccessibilitySelected(isSelected)
  2733. let row = NSStackView()
  2734. row.orientation = .horizontal
  2735. row.spacing = 8
  2736. row.alignment = .centerY
  2737. row.translatesAutoresizingMaskIntoConstraints = false
  2738. let icon = NSImageView()
  2739. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium)
  2740. icon.image = NSImage(systemSymbolName: item.systemImage, accessibilityDescription: item.title)
  2741. icon.contentTintColor = isSelected ? Theme.brandBlue : Theme.secondaryText
  2742. let text = NSTextField(labelWithString: item.title)
  2743. text.font = .systemFont(ofSize: 14, weight: .medium)
  2744. text.textColor = isSelected ? Theme.brandBlue : Theme.secondaryText
  2745. text.refusesFirstResponder = true
  2746. row.addArrangedSubview(icon)
  2747. row.addArrangedSubview(text)
  2748. if let badge = item.badge {
  2749. let badgeField = NSTextField(labelWithString: badge)
  2750. badgeField.font = .systemFont(ofSize: 11, weight: .semibold)
  2751. badgeField.textColor = Theme.primaryText
  2752. badgeField.wantsLayer = true
  2753. badgeField.layer?.backgroundColor = Theme.toggleBackground.cgColor
  2754. badgeField.layer?.cornerRadius = 8
  2755. badgeField.alignment = .center
  2756. badgeField.maximumNumberOfLines = 1
  2757. badgeField.lineBreakMode = .byClipping
  2758. badgeField.refusesFirstResponder = true
  2759. badgeField.translatesAutoresizingMaskIntoConstraints = false
  2760. badgeField.widthAnchor.constraint(equalToConstant: 42).isActive = true
  2761. row.addArrangedSubview(NSView())
  2762. row.addArrangedSubview(badgeField)
  2763. }
  2764. rowHost.addSubview(row)
  2765. NSLayoutConstraint.activate([
  2766. row.leadingAnchor.constraint(equalTo: rowHost.leadingAnchor, constant: 10),
  2767. row.trailingAnchor.constraint(equalTo: rowHost.trailingAnchor, constant: -10),
  2768. row.topAnchor.constraint(equalTo: rowHost.topAnchor, constant: 8),
  2769. row.bottomAnchor.constraint(equalTo: rowHost.bottomAnchor, constant: -8)
  2770. ])
  2771. rowHost.setContentHuggingPriority(.defaultLow, for: .horizontal)
  2772. sidebar.addArrangedSubview(rowHost)
  2773. let sidebarHorizontalInset = sidebar.edgeInsets.left + sidebar.edgeInsets.right
  2774. rowHost.widthAnchor.constraint(equalTo: sidebar.widthAnchor, constant: -sidebarHorizontalInset).isActive = true
  2775. if item.title == L("Home") {
  2776. addIndeedSidebarLaunchRow()
  2777. }
  2778. }
  2779. let sidebarBottomSpacer = NSView()
  2780. sidebarBottomSpacer.translatesAutoresizingMaskIntoConstraints = false
  2781. sidebarBottomSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
  2782. sidebarBottomSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  2783. sidebar.addArrangedSubview(sidebarBottomSpacer)
  2784. let upgradeCard = NSView()
  2785. upgradeCard.translatesAutoresizingMaskIntoConstraints = false
  2786. upgradeCard.wantsLayer = true
  2787. upgradeCard.layer?.backgroundColor = Theme.proCardFill.cgColor
  2788. upgradeCard.layer?.cornerRadius = 14
  2789. upgradeCard.layer?.borderWidth = 1
  2790. upgradeCard.layer?.borderColor = Theme.proCardBorder.cgColor
  2791. upgradeCard.layer?.masksToBounds = true
  2792. let accentBar = NSView()
  2793. accentBar.translatesAutoresizingMaskIntoConstraints = false
  2794. accentBar.wantsLayer = true
  2795. accentBar.layer?.backgroundColor = Theme.proAccent.cgColor
  2796. let inner = NSStackView()
  2797. inner.translatesAutoresizingMaskIntoConstraints = false
  2798. inner.orientation = .vertical
  2799. inner.spacing = 10
  2800. inner.alignment = .centerX
  2801. let proIcon = NSImageView()
  2802. proIcon.translatesAutoresizingMaskIntoConstraints = false
  2803. proIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  2804. proIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
  2805. proIcon.contentTintColor = Theme.proAccent
  2806. let proEyebrow = NSTextField(labelWithString: L("Premium"))
  2807. proEyebrow.font = .systemFont(ofSize: 11, weight: .heavy)
  2808. proEyebrow.textColor = Theme.proAccent
  2809. proEyebrow.alignment = .center
  2810. let eyebrowRow = NSStackView(views: [proIcon, proEyebrow])
  2811. eyebrowRow.orientation = .horizontal
  2812. eyebrowRow.spacing = 6
  2813. eyebrowRow.alignment = .centerY
  2814. let innerContentWidth = Self.sidebarProCardInnerContentWidth
  2815. let headline = NSTextField(wrappingLabelWithString: L("Upgrade to Pro"))
  2816. headline.font = .systemFont(ofSize: 16, weight: .bold)
  2817. headline.textColor = Theme.primaryText
  2818. headline.alignment = .center
  2819. headline.maximumNumberOfLines = 0
  2820. headline.lineBreakMode = .byWordWrapping
  2821. headline.preferredMaxLayoutWidth = innerContentWidth
  2822. let upgradeDescription = NSTextField(wrappingLabelWithString: L("Unlimited AI matches, smart alerts, and interview prep—all in one place."))
  2823. upgradeDescription.font = .systemFont(ofSize: 12, weight: .regular)
  2824. upgradeDescription.textColor = Theme.secondaryText
  2825. upgradeDescription.alignment = .center
  2826. upgradeDescription.preferredMaxLayoutWidth = innerContentWidth
  2827. let upgradeButton = HoverableButton(title: L("Try Pro"), target: self, action: #selector(didTapUpgradeToPro))
  2828. upgradeButton.isBordered = false
  2829. upgradeButton.bezelStyle = .rounded
  2830. upgradeButton.font = .systemFont(ofSize: 13, weight: .bold)
  2831. upgradeButton.contentTintColor = Theme.proCTAText
  2832. upgradeButton.alignment = .center
  2833. upgradeButton.lineBreakMode = .byWordWrapping
  2834. upgradeButton.wantsLayer = true
  2835. upgradeButton.layer?.backgroundColor = Theme.proCTABackground.cgColor
  2836. upgradeButton.layer?.cornerRadius = 8
  2837. upgradeButton.translatesAutoresizingMaskIntoConstraints = false
  2838. if let cell = upgradeButton.cell as? NSButtonCell {
  2839. cell.wraps = true
  2840. cell.isScrollable = false
  2841. }
  2842. upgradeButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 32).isActive = true
  2843. upgradeButton.pointerCursor = true
  2844. upgradeButton.hoverHandler = { [weak upgradeButton] hovering in
  2845. upgradeButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.proCTABackground).cgColor
  2846. }
  2847. inner.addArrangedSubview(eyebrowRow)
  2848. inner.addArrangedSubview(headline)
  2849. inner.addArrangedSubview(upgradeDescription)
  2850. inner.addArrangedSubview(upgradeButton)
  2851. upgradeCard.addSubview(accentBar)
  2852. upgradeCard.addSubview(inner)
  2853. NSLayoutConstraint.activate([
  2854. upgradeCard.widthAnchor.constraint(equalToConstant: Self.sidebarProCardWidth),
  2855. accentBar.topAnchor.constraint(equalTo: upgradeCard.topAnchor),
  2856. accentBar.leadingAnchor.constraint(equalTo: upgradeCard.leadingAnchor),
  2857. accentBar.trailingAnchor.constraint(equalTo: upgradeCard.trailingAnchor),
  2858. accentBar.heightAnchor.constraint(equalToConstant: 2),
  2859. inner.leadingAnchor.constraint(equalTo: upgradeCard.leadingAnchor, constant: Self.sidebarProCardInnerPadding),
  2860. inner.trailingAnchor.constraint(equalTo: upgradeCard.trailingAnchor, constant: -Self.sidebarProCardInnerPadding),
  2861. inner.topAnchor.constraint(equalTo: accentBar.bottomAnchor, constant: 12),
  2862. inner.bottomAnchor.constraint(equalTo: upgradeCard.bottomAnchor, constant: -Self.sidebarProCardInnerPadding),
  2863. headline.widthAnchor.constraint(equalTo: inner.widthAnchor),
  2864. upgradeDescription.widthAnchor.constraint(equalTo: inner.widthAnchor),
  2865. upgradeButton.widthAnchor.constraint(equalTo: inner.widthAnchor)
  2866. ])
  2867. let upgradeCardHost = NSView()
  2868. upgradeCardHost.translatesAutoresizingMaskIntoConstraints = false
  2869. upgradeCardHost.addSubview(upgradeCard)
  2870. NSLayoutConstraint.activate([
  2871. upgradeCard.centerXAnchor.constraint(equalTo: upgradeCardHost.centerXAnchor),
  2872. upgradeCard.topAnchor.constraint(equalTo: upgradeCardHost.topAnchor),
  2873. upgradeCard.bottomAnchor.constraint(equalTo: upgradeCardHost.bottomAnchor),
  2874. upgradeCard.leadingAnchor.constraint(greaterThanOrEqualTo: upgradeCardHost.leadingAnchor),
  2875. upgradeCard.trailingAnchor.constraint(lessThanOrEqualTo: upgradeCardHost.trailingAnchor)
  2876. ])
  2877. sidebar.addArrangedSubview(upgradeCardHost)
  2878. let upgradeCardHorizontalInset = sidebar.edgeInsets.left + sidebar.edgeInsets.right
  2879. upgradeCardHost.widthAnchor.constraint(equalTo: sidebar.widthAnchor, constant: -upgradeCardHorizontalInset).isActive = true
  2880. sidebarUpgradeCard = upgradeCard
  2881. sidebarUpgradeHeadline = headline
  2882. sidebarUpgradeDescription = upgradeDescription
  2883. sidebarUpgradeButton = upgradeButton
  2884. applyProSubscriptionToSidebar()
  2885. }
  2886. @objc private func didTapUpgradeToPro() {
  2887. Task { @MainActor in
  2888. await SubscriptionStore.shared.refreshEntitlements(deep: true)
  2889. applyProSubscriptionToSidebar()
  2890. presentPremiumPlansSheet()
  2891. }
  2892. }
  2893. private func selectSidebarItem(at index: Int) {
  2894. let leavingIndeedSelection = isIndeedSidebarSelected
  2895. isIndeedSidebarSelected = false
  2896. dismissIndeedJobBrowserEmbedded()
  2897. guard index >= 0, index < currentSidebarItems.count else { return }
  2898. let selectingHome = isHomeSidebarIndex(index)
  2899. if index == selectedSidebarIndex, !leavingIndeedSelection {
  2900. if selectingHome {
  2901. applyHomeState()
  2902. }
  2903. return
  2904. }
  2905. selectedSidebarIndex = index
  2906. configureSidebar()
  2907. updateMainContentVisibility()
  2908. if selectingHome {
  2909. applyHomeState()
  2910. }
  2911. }
  2912. }
  2913. private struct ChatMessage: Codable {
  2914. let role: String
  2915. let content: String
  2916. /// Job cards shown under this assistant message (not sent to the API).
  2917. var attachedJobs: [JobListing]?
  2918. enum CodingKeys: String, CodingKey {
  2919. case role
  2920. case content
  2921. }
  2922. init(role: String, content: String, attachedJobs: [JobListing]? = nil) {
  2923. self.role = role
  2924. self.content = content
  2925. self.attachedJobs = attachedJobs
  2926. }
  2927. }
  2928. private extension NSView {
  2929. func subviewsRecursive() -> [NSView] {
  2930. var result: [NSView] = []
  2931. var stack: [NSView] = [self]
  2932. while let view = stack.popLast() {
  2933. result.append(view)
  2934. stack.append(contentsOf: view.subviews)
  2935. }
  2936. return result
  2937. }
  2938. }
  2939. private final class OpenAIJobSearchService {
  2940. private let endpoint = URL(string: "https://api.openai.com/v1/responses")!
  2941. private let session = URLSession(configuration: .ephemeral)
  2942. func searchJobs(query: String, conversation: [ChatMessage], maxJobs: Int, completion: @escaping (Result<JobSearchOutput, Error>) -> Void) {
  2943. let jobLimit = max(1, min(maxJobs, 25))
  2944. let apiKey = OpenAIConfiguration.apiKey
  2945. guard OpenAIConfiguration.hasAPIKey else {
  2946. completion(.failure(NSError(
  2947. domain: "OpenAIJobSearchService",
  2948. code: 1,
  2949. userInfo: [NSLocalizedDescriptionKey: L("Job search is unavailable.")]
  2950. )))
  2951. return
  2952. }
  2953. var request = URLRequest(url: endpoint)
  2954. request.httpMethod = "POST"
  2955. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  2956. request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
  2957. request.timeoutInterval = 45
  2958. let recentContext = conversation.suffix(8)
  2959. .map { "\($0.role.uppercased()): \($0.content)" }
  2960. .joined(separator: "\n")
  2961. let contextBlock: String
  2962. if recentContext.isEmpty {
  2963. contextBlock = "No prior conversation context."
  2964. } else {
  2965. contextBlock = recentContext
  2966. }
  2967. let developerInstructions = """
  2968. 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.).
  2969. 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.
  2970. 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.
  2971. 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).
  2972. 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.
  2973. 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.
  2974. """
  2975. let userInput = """
  2976. 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).
  2977. Conversation context:
  2978. \(contextBlock)
  2979. Latest user query: "\(query)"
  2980. 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.
  2981. """
  2982. let payload = OpenAIJobSearchAPIRequest.jobSearchPayload(
  2983. model: "gpt-4o-mini",
  2984. instructions: developerInstructions,
  2985. input: userInput,
  2986. tools: [OpenAIResponsesTool(type: "web_search_preview")],
  2987. jobLimit: jobLimit
  2988. )
  2989. do {
  2990. request.httpBody = try JSONEncoder().encode(payload)
  2991. } catch {
  2992. completion(.failure(error))
  2993. return
  2994. }
  2995. session.dataTask(with: request) { data, response, error in
  2996. if let error {
  2997. completion(.failure(error))
  2998. return
  2999. }
  3000. guard let data else {
  3001. completion(.failure(NSError(
  3002. domain: "OpenAIJobSearchService",
  3003. code: 2,
  3004. userInfo: [NSLocalizedDescriptionKey: "API returned an empty response."]
  3005. )))
  3006. return
  3007. }
  3008. if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
  3009. _ = try? JSONDecoder().decode(OpenAIAPIErrorResponse.self, from: data)
  3010. completion(.failure(NSError(
  3011. domain: "OpenAIJobSearchService",
  3012. code: http.statusCode,
  3013. userInfo: [NSLocalizedDescriptionKey: "Job search request failed."]
  3014. )))
  3015. return
  3016. }
  3017. do {
  3018. let modelText = try Self.extractModelTextFromResponsesBody(data)
  3019. let trimmed = modelText.trimmingCharacters(in: .whitespacesAndNewlines)
  3020. guard !trimmed.isEmpty else {
  3021. throw NSError(
  3022. domain: "OpenAIJobSearchService",
  3023. code: 4,
  3024. userInfo: [NSLocalizedDescriptionKey: "The API returned an empty text payload."]
  3025. )
  3026. }
  3027. let jobs = try Self.parseJobListings(fromModelText: trimmed)
  3028. .filter(Self.jobListingUsesIndeedOrEmptyURL)
  3029. .map(Self.normalizedJobListing)
  3030. completion(.success(JobSearchOutput(jobs: jobs)))
  3031. } catch {
  3032. completion(.failure(error))
  3033. }
  3034. }.resume()
  3035. }
  3036. /// 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).
  3037. private static func extractModelTextFromResponsesBody(_ data: Data) throws -> String {
  3038. let rootObject: Any
  3039. do {
  3040. rootObject = try JSONSerialization.jsonObject(with: data, options: [])
  3041. } catch {
  3042. throw NSError(
  3043. domain: "OpenAIJobSearchService",
  3044. code: 5,
  3045. userInfo: [NSLocalizedDescriptionKey: "The job search service returned data that was not valid JSON."]
  3046. )
  3047. }
  3048. guard let root = rootObject as? [String: Any] else {
  3049. throw NSError(
  3050. domain: "OpenAIJobSearchService",
  3051. code: 5,
  3052. userInfo: [NSLocalizedDescriptionKey: "Unexpected response shape from the job search service."]
  3053. )
  3054. }
  3055. if let status = root["status"] as? String {
  3056. if status == "failed" {
  3057. throw NSError(
  3058. domain: "OpenAIJobSearchService",
  3059. code: 7,
  3060. userInfo: [NSLocalizedDescriptionKey: "The search request failed."]
  3061. )
  3062. }
  3063. if status == "incomplete",
  3064. let details = root["incomplete_details"] as? [String: Any],
  3065. let reason = details["reason"] as? String {
  3066. throw NSError(
  3067. domain: "OpenAIJobSearchService",
  3068. code: 8,
  3069. userInfo: [NSLocalizedDescriptionKey: "Search stopped early (\(reason)). Try a simpler query or try again."]
  3070. )
  3071. }
  3072. }
  3073. if let direct = root["output_text"] as? String {
  3074. let trimmed = direct.trimmingCharacters(in: .whitespacesAndNewlines)
  3075. if !trimmed.isEmpty { return trimmed }
  3076. }
  3077. guard let output = root["output"] as? [Any] else {
  3078. throw NSError(
  3079. domain: "OpenAIJobSearchService",
  3080. code: 9,
  3081. userInfo: [NSLocalizedDescriptionKey: "The search service returned no assistant text. Try again in a moment."]
  3082. )
  3083. }
  3084. var segments: [String] = []
  3085. for case let item as [String: Any] in output where (item["type"] as? String) == "message" {
  3086. collectOutputTextSegments(fromOutputItem: item, into: &segments)
  3087. }
  3088. if segments.isEmpty {
  3089. for case let item as [String: Any] in output {
  3090. collectOutputTextSegments(fromOutputItem: item, into: &segments)
  3091. }
  3092. }
  3093. let combined = segments.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
  3094. if !combined.isEmpty {
  3095. return combined
  3096. }
  3097. throw NSError(
  3098. domain: "OpenAIJobSearchService",
  3099. code: 9,
  3100. userInfo: [NSLocalizedDescriptionKey: "The model did not return readable job-search text. Try again."]
  3101. )
  3102. }
  3103. private static func collectOutputTextSegments(fromOutputItem item: [String: Any], into segments: inout [String]) {
  3104. guard let content = item["content"] as? [Any] else { return }
  3105. for case let part as [String: Any] in content {
  3106. guard (part["type"] as? String) == "output_text" else { continue }
  3107. if let s = part["text"] as? String {
  3108. segments.append(s)
  3109. } else if let nested = part["text"] as? [String: Any], let value = nested["value"] as? String {
  3110. segments.append(value)
  3111. }
  3112. }
  3113. }
  3114. private static func normalizedJobListing(_ job: JobListing) -> JobListing {
  3115. let trimmedURL = job.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  3116. if trimmedURL.isEmpty {
  3117. return JobListing(title: job.title, description: job.description, url: nil)
  3118. }
  3119. return JobListing(title: job.title, description: job.description, url: trimmedURL)
  3120. }
  3121. /// Drops listings whose `url` points at non-Indeed sites (e.g. LinkedIn) when the model ignores instructions.
  3122. private static func jobListingUsesIndeedOrEmptyURL(_ job: JobListing) -> Bool {
  3123. let trimmed = job.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  3124. if trimmed.isEmpty { return true }
  3125. return isIndeedJobURL(trimmed)
  3126. }
  3127. /// Host looks like an official Indeed property (`indeed.com`, `www.indeed.co.uk`, `ca.indeed.com`, …), not `notindeed.com`.
  3128. private static func isIndeedJobURL(_ string: String) -> Bool {
  3129. guard let host = URL(string: string)?.host?.lowercased() else { return false }
  3130. if host == "indeed.com" { return true }
  3131. if host.hasPrefix("indeed.") { return true }
  3132. return host.contains(".indeed.")
  3133. }
  3134. private static func parseJobListings(fromModelText text: String) throws -> [JobListing] {
  3135. let stripped = text.trimmingCharacters(in: .whitespacesAndNewlines)
  3136. if stripped.hasPrefix("{"), let directData = stripped.data(using: .utf8),
  3137. let payload = try? JSONDecoder().decode(JobSearchResultsPayload.self, from: directData) {
  3138. return payload.jobs
  3139. }
  3140. let jsonString = extractJobJSONObjectString(from: text) ?? extractJSONObject(from: text)
  3141. let jsonData = Data(jsonString.utf8)
  3142. if let payload = try? JSONDecoder().decode(JobSearchResultsPayload.self, from: jsonData) {
  3143. return payload.jobs
  3144. }
  3145. if let listings = try? JSONDecoder().decode([JobListing].self, from: jsonData) {
  3146. return listings
  3147. }
  3148. if let obj = try? JSONSerialization.jsonObject(with: jsonData, options: []) {
  3149. if let dict = obj as? [String: Any], let jobs = jobListings(fromFlexibleJSONObject: dict) {
  3150. return jobs
  3151. }
  3152. if let arr = obj as? [[String: Any]], let jobs = jobListings(fromFlexibleJobArray: arr) {
  3153. return jobs
  3154. }
  3155. }
  3156. throw NSError(
  3157. domain: "OpenAIJobSearchService",
  3158. code: 10,
  3159. userInfo: [NSLocalizedDescriptionKey: "The assistant reply did not include job listings in the expected JSON format. Try your search again."]
  3160. )
  3161. }
  3162. private static func jobListings(fromFlexibleJSONObject dict: [String: Any]) -> [JobListing]? {
  3163. for (key, value) in dict {
  3164. guard key.caseInsensitiveCompare("jobs") == .orderedSame, let arr = value as? [[String: Any]] else { continue }
  3165. if let jobs = jobListings(fromFlexibleJobArray: arr) { return jobs }
  3166. }
  3167. for wrapKey in ["data", "result", "results", "payload"] {
  3168. if let inner = dict[wrapKey] as? [String: Any], let nested = jobListings(fromFlexibleJSONObject: inner) {
  3169. return nested
  3170. }
  3171. }
  3172. return nil
  3173. }
  3174. private static func jobListings(fromFlexibleJobArray jobs: [[String: Any]]) -> [JobListing]? {
  3175. var out: [JobListing] = []
  3176. for item in jobs {
  3177. guard let title = firstString(valuesForKeys: ["title", "job_title", "name", "position"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines),
  3178. !title.isEmpty,
  3179. let desc = firstString(valuesForKeys: ["description", "snippet", "summary", "desc"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines),
  3180. !desc.isEmpty else { continue }
  3181. let urlRaw = firstString(valuesForKeys: ["url", "link", "apply_url", "job_url"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines)
  3182. let url: String? = (urlRaw?.isEmpty == true) ? nil : urlRaw
  3183. out.append(JobListing(title: title, description: desc, url: url))
  3184. }
  3185. return out.isEmpty ? nil : out
  3186. }
  3187. private static func firstString(valuesForKeys keys: [String], in dict: [String: Any]) -> String? {
  3188. for wanted in keys {
  3189. for (dk, dv) in dict {
  3190. guard dk.caseInsensitiveCompare(wanted) == .orderedSame, let s = dv as? String else { continue }
  3191. return s
  3192. }
  3193. }
  3194. return nil
  3195. }
  3196. private static func stripMarkdownCodeFence(_ text: String) -> String {
  3197. var s = text.trimmingCharacters(in: .whitespacesAndNewlines)
  3198. guard s.hasPrefix("```") else { return s }
  3199. s.removeFirst(3)
  3200. if s.lowercased().hasPrefix("json") {
  3201. s.removeFirst(4)
  3202. }
  3203. s = s.trimmingCharacters(in: .whitespacesAndNewlines)
  3204. if let fence = s.range(of: "```", options: .backwards) {
  3205. s = String(s[..<fence.lowerBound])
  3206. }
  3207. return s.trimmingCharacters(in: .whitespacesAndNewlines)
  3208. }
  3209. private static func balancedJSONObject(from openBrace: String.Index, in s: String) -> String? {
  3210. var depth = 0
  3211. var inString = false
  3212. var escaped = false
  3213. var i = openBrace
  3214. while i < s.endIndex {
  3215. let ch = s[i]
  3216. if inString {
  3217. if escaped {
  3218. escaped = false
  3219. } else if ch == "\\" {
  3220. escaped = true
  3221. } else if ch == "\"" {
  3222. inString = false
  3223. }
  3224. } else {
  3225. switch ch {
  3226. case "\"":
  3227. inString = true
  3228. case "{":
  3229. depth += 1
  3230. case "}":
  3231. depth -= 1
  3232. if depth == 0 {
  3233. return String(s[openBrace...i])
  3234. }
  3235. default:
  3236. break
  3237. }
  3238. }
  3239. i = s.index(after: i)
  3240. }
  3241. return nil
  3242. }
  3243. /// Prefers the JSON object that contains a `"jobs"` key so prose before/after the payload does not confuse the decoder.
  3244. private static func extractJobJSONObjectString(from text: String) -> String? {
  3245. let s = stripMarkdownCodeFence(text)
  3246. guard let jobsRange = s.range(of: "\"jobs\"", options: .caseInsensitive) else { return nil }
  3247. let head = s[..<jobsRange.lowerBound]
  3248. guard let open = head.lastIndex(of: "{") else { return nil }
  3249. return balancedJSONObject(from: open, in: s)
  3250. }
  3251. private static func extractJSONObject(from text: String) -> String {
  3252. if let extracted = extractJobJSONObjectString(from: text) {
  3253. return extracted
  3254. }
  3255. let stripped = stripMarkdownCodeFence(text)
  3256. if let first = stripped.firstIndex(of: "{"), let balanced = balancedJSONObject(from: first, in: stripped) {
  3257. return balanced
  3258. }
  3259. if let range = text.range(of: "\\{[\\s\\S]*\\}", options: .regularExpression) {
  3260. return String(text[range])
  3261. }
  3262. return text
  3263. }
  3264. }
  3265. /// Responses API request with structured JSON output so web-search replies cannot omit the `jobs` schema.
  3266. private struct OpenAIJobSearchAPIRequest: Encodable {
  3267. let model: String
  3268. let instructions: String
  3269. let input: String
  3270. let tools: [OpenAIResponsesTool]
  3271. let text: OpenAITextOutputConfig
  3272. static func jobSearchPayload(
  3273. model: String,
  3274. instructions: String,
  3275. input: String,
  3276. tools: [OpenAIResponsesTool],
  3277. jobLimit: Int
  3278. ) -> OpenAIJobSearchAPIRequest {
  3279. let itemProperties = OpenAIJobSearchJobItemProperties(
  3280. title: OpenAIJSONSchemaStringField(type: "string", description: "Job title as shown on the Indeed listing."),
  3281. description: OpenAIJSONSchemaStringField(type: "string", description: "One concise sentence summarizing the role from the Indeed posting."),
  3282. 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.")
  3283. )
  3284. let itemSchema = OpenAIJobSearchJobItemSchema(
  3285. type: "object",
  3286. properties: itemProperties,
  3287. required: ["title", "description", "url"],
  3288. additionalProperties: false
  3289. )
  3290. let jobsProperty = OpenAIJobSearchJobsArrayProperty(
  3291. type: "array",
  3292. 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.",
  3293. items: itemSchema
  3294. )
  3295. let rootProperties = OpenAIJobSearchRootProperties(jobs: jobsProperty)
  3296. let rootSchema = OpenAIJobSearchRootSchema(
  3297. type: "object",
  3298. properties: rootProperties,
  3299. required: ["jobs"],
  3300. additionalProperties: false
  3301. )
  3302. let format = OpenAIJobSearchResponseJSONSchemaFormat(
  3303. type: "json_schema",
  3304. name: "job_search_results",
  3305. strict: true,
  3306. schema: rootSchema
  3307. )
  3308. return OpenAIJobSearchAPIRequest(
  3309. model: model,
  3310. instructions: instructions,
  3311. input: input,
  3312. tools: tools,
  3313. text: OpenAITextOutputConfig(format: format)
  3314. )
  3315. }
  3316. }
  3317. private struct OpenAITextOutputConfig: Encodable {
  3318. let format: OpenAIJobSearchResponseJSONSchemaFormat
  3319. }
  3320. private struct OpenAIJobSearchResponseJSONSchemaFormat: Encodable {
  3321. let type: String
  3322. let name: String
  3323. let strict: Bool
  3324. let schema: OpenAIJobSearchRootSchema
  3325. }
  3326. private struct OpenAIJobSearchRootSchema: Encodable {
  3327. let type: String
  3328. let properties: OpenAIJobSearchRootProperties
  3329. let required: [String]
  3330. let additionalProperties: Bool
  3331. }
  3332. private struct OpenAIJobSearchRootProperties: Encodable {
  3333. let jobs: OpenAIJobSearchJobsArrayProperty
  3334. }
  3335. private struct OpenAIJobSearchJobsArrayProperty: Encodable {
  3336. let type: String
  3337. let description: String
  3338. let items: OpenAIJobSearchJobItemSchema
  3339. }
  3340. private struct OpenAIJobSearchJobItemSchema: Encodable {
  3341. let type: String
  3342. let properties: OpenAIJobSearchJobItemProperties
  3343. let required: [String]
  3344. let additionalProperties: Bool
  3345. }
  3346. private struct OpenAIJobSearchJobItemProperties: Encodable {
  3347. let title: OpenAIJSONSchemaStringField
  3348. let description: OpenAIJSONSchemaStringField
  3349. let url: OpenAIJSONSchemaStringField
  3350. }
  3351. private struct OpenAIJSONSchemaStringField: Encodable {
  3352. let type: String
  3353. let description: String
  3354. }
  3355. private struct OpenAIResponsesTool: Codable {
  3356. let type: String
  3357. }
  3358. private struct JobSearchResultsPayload: Codable {
  3359. let jobs: [JobListing]
  3360. }
  3361. private struct JobSearchOutput {
  3362. let jobs: [JobListing]
  3363. }
  3364. private struct OpenAIAPIErrorResponse: Codable {
  3365. let error: APIError
  3366. struct APIError: Codable {
  3367. let message: String
  3368. }
  3369. }
  3370. /// Decorative waves and faint sparkles behind the welcome hero (reference layout).
  3371. private final class WelcomeHeroBackgroundView: NSView {
  3372. /// Stroke color for side waves (pastel blue).
  3373. var waveTint = NSColor(srgbRed: 186 / 255, green: 210 / 255, blue: 253 / 255, alpha: 1)
  3374. override var isFlipped: Bool { true }
  3375. override func draw(_ dirtyRect: NSRect) {
  3376. NSColor.clear.setFill()
  3377. bounds.fill()
  3378. guard bounds.width > 24, bounds.height > 24 else { return }
  3379. drawSideWaves(in: bounds, isLeft: true)
  3380. drawSideWaves(in: bounds, isLeft: false)
  3381. drawAmbientSparkles(in: bounds)
  3382. }
  3383. private func drawSideWaves(in bounds: NSRect, isLeft: Bool) {
  3384. for i in 0..<9 {
  3385. let path = NSBezierPath()
  3386. path.lineWidth = 1
  3387. path.lineCapStyle = .round
  3388. let phase = CGFloat(i) * 0.88
  3389. let base = CGFloat(i + 1) * 11 + 4
  3390. var first = true
  3391. for y in stride(from: CGFloat(0), through: bounds.height, by: 2.8) {
  3392. let wobble = sin(y * 0.048 + phase) * (4 + CGFloat(i % 5))
  3393. let x = isLeft ? (base + wobble) : (bounds.width - base - wobble)
  3394. let point = NSPoint(x: x, y: y)
  3395. if first {
  3396. path.move(to: point)
  3397. first = false
  3398. } else {
  3399. path.line(to: point)
  3400. }
  3401. }
  3402. let fade = 1 - CGFloat(i) / 10
  3403. waveTint.withAlphaComponent((0.09 + CGFloat(i % 3) * 0.022) * fade).setStroke()
  3404. path.stroke()
  3405. }
  3406. }
  3407. private func drawAmbientSparkles(in bounds: NSRect) {
  3408. let accent = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1)
  3409. let specs: [(CGFloat, CGFloat, CGFloat, CGFloat)] = [
  3410. (0.06, 0.14, 9, 0.28),
  3411. (0.94, 0.12, 12, 0.34),
  3412. (0.18, 0.42, 5, 0.18),
  3413. (0.86, 0.44, 6, 0.2),
  3414. (0.5, 0.06, 7, 0.15)
  3415. ]
  3416. for (nx, ny, size, a) in specs {
  3417. let center = NSPoint(x: bounds.width * nx, y: bounds.height * ny)
  3418. fillFourPointStar(center: center, radius: size, color: accent.withAlphaComponent(a))
  3419. }
  3420. }
  3421. private func fillFourPointStar(center: NSPoint, radius: CGFloat, color: NSColor) {
  3422. let path = NSBezierPath()
  3423. for i in 0..<4 {
  3424. let angle = CGFloat(i) * .pi / 2 - .pi / 2
  3425. let x = center.x + cos(angle) * radius
  3426. let y = center.y + sin(angle) * radius
  3427. let point = NSPoint(x: x, y: y)
  3428. if i == 0 {
  3429. path.move(to: point)
  3430. } else {
  3431. path.line(to: point)
  3432. }
  3433. }
  3434. path.close()
  3435. color.setFill()
  3436. path.fill()
  3437. }
  3438. }
  3439. /// Home welcome row: three tappable shortcuts that seed the main search field (reference: white cards, pastel icon well, arrow at bottom trailing).
  3440. private final class FeatureShortcutCardView: NSView {
  3441. private static let cardCornerRadius: CGFloat = 14
  3442. var isSelected = false { didSet { updateSelectionAppearance() } }
  3443. private weak var titleField: NSTextField?
  3444. private weak var subtitleField: NSTextField?
  3445. private weak var iconHost: NSView?
  3446. private weak var iconView: NSImageView?
  3447. private weak var chevronView: NSImageView?
  3448. private weak var actionTarget: AnyObject?
  3449. private var actionSelector: Selector
  3450. init(symbolName: String, title: String, subtitle: String, target: AnyObject?, action: Selector) {
  3451. self.actionTarget = target
  3452. self.actionSelector = action
  3453. super.init(frame: .zero)
  3454. translatesAutoresizingMaskIntoConstraints = false
  3455. wantsLayer = true
  3456. layer?.cornerRadius = Self.cardCornerRadius
  3457. if #available(macOS 11.0, *) {
  3458. layer?.cornerCurve = .continuous
  3459. }
  3460. layer?.backgroundColor = AppDashboardTheme.featureCardBackground.cgColor
  3461. layer?.masksToBounds = false
  3462. layer?.shadowColor = NSColor.black.withAlphaComponent(AppDashboardTheme.isDark ? 0.35 : 0.06).cgColor
  3463. layer?.shadowOffset = CGSize(width: 0, height: 2)
  3464. layer?.shadowRadius = 12
  3465. layer?.shadowOpacity = 1
  3466. updateSelectionAppearance()
  3467. let iconSize: CGFloat = 40
  3468. let iconHost = NSView()
  3469. self.iconHost = iconHost
  3470. iconHost.translatesAutoresizingMaskIntoConstraints = false
  3471. iconHost.wantsLayer = true
  3472. iconHost.layer?.cornerRadius = iconSize / 2
  3473. let icon = NSImageView()
  3474. self.iconView = icon
  3475. icon.translatesAutoresizingMaskIntoConstraints = false
  3476. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
  3477. icon.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
  3478. iconHost.addSubview(icon)
  3479. let titleField = NSTextField(wrappingLabelWithString: title)
  3480. self.titleField = titleField
  3481. titleField.font = .systemFont(ofSize: 15, weight: .bold)
  3482. titleField.maximumNumberOfLines = 1
  3483. titleField.isEditable = false
  3484. titleField.isBordered = false
  3485. titleField.drawsBackground = false
  3486. titleField.alignment = .left
  3487. let subtitleField = NSTextField(wrappingLabelWithString: subtitle)
  3488. self.subtitleField = subtitleField
  3489. subtitleField.font = .systemFont(ofSize: 12, weight: .regular)
  3490. subtitleField.maximumNumberOfLines = 2
  3491. subtitleField.isEditable = false
  3492. subtitleField.isBordered = false
  3493. subtitleField.drawsBackground = false
  3494. subtitleField.alignment = .left
  3495. subtitleField.lineBreakMode = .byWordWrapping
  3496. subtitleField.setContentHuggingPriority(.defaultLow, for: .horizontal)
  3497. subtitleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  3498. let chevron = NSImageView()
  3499. self.chevronView = chevron
  3500. chevron.translatesAutoresizingMaskIntoConstraints = false
  3501. chevron.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  3502. chevron.image = NSImage(systemSymbolName: "arrow.right", accessibilityDescription: nil)
  3503. chevron.setContentHuggingPriority(.required, for: .horizontal)
  3504. chevron.setContentCompressionResistancePriority(.required, for: .horizontal)
  3505. let subtitleRow = NSStackView(views: [subtitleField, chevron])
  3506. subtitleRow.orientation = .horizontal
  3507. subtitleRow.spacing = 10
  3508. subtitleRow.alignment = .bottom
  3509. subtitleRow.distribution = .fill
  3510. subtitleRow.translatesAutoresizingMaskIntoConstraints = false
  3511. let textColumn = NSStackView(views: [titleField, subtitleRow])
  3512. textColumn.orientation = .vertical
  3513. textColumn.spacing = 6
  3514. textColumn.alignment = .leading
  3515. textColumn.translatesAutoresizingMaskIntoConstraints = false
  3516. textColumn.setContentHuggingPriority(.defaultLow, for: .horizontal)
  3517. textColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  3518. iconHost.setContentHuggingPriority(.required, for: .horizontal)
  3519. iconHost.setContentCompressionResistancePriority(.required, for: .horizontal)
  3520. let row = NSStackView(views: [iconHost, textColumn])
  3521. row.orientation = .horizontal
  3522. row.spacing = 16
  3523. row.alignment = .centerY
  3524. row.distribution = .fill
  3525. row.translatesAutoresizingMaskIntoConstraints = false
  3526. addSubview(row)
  3527. let inset: CGFloat = 16
  3528. NSLayoutConstraint.activate([
  3529. row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset),
  3530. row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset),
  3531. row.topAnchor.constraint(equalTo: topAnchor, constant: inset),
  3532. row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset),
  3533. iconHost.widthAnchor.constraint(equalToConstant: iconSize),
  3534. iconHost.heightAnchor.constraint(equalToConstant: iconSize),
  3535. icon.centerXAnchor.constraint(equalTo: iconHost.centerXAnchor),
  3536. icon.centerYAnchor.constraint(equalTo: iconHost.centerYAnchor)
  3537. ])
  3538. setAccessibilityElement(true)
  3539. setAccessibilityRole(.button)
  3540. setAccessibilityLabel("\(title). \(subtitle)")
  3541. applyCardAppearance()
  3542. }
  3543. func applyCardAppearance() {
  3544. layer?.backgroundColor = AppDashboardTheme.featureCardBackground.cgColor
  3545. layer?.shadowColor = NSColor.black.withAlphaComponent(AppDashboardTheme.isDark ? 0.35 : 0.06).cgColor
  3546. iconHost?.layer?.backgroundColor = AppDashboardTheme.featureIconWell.cgColor
  3547. let accent = AppDashboardTheme.featurePrimaryBlue
  3548. iconView?.contentTintColor = accent
  3549. titleField?.textColor = accent
  3550. subtitleField?.textColor = AppDashboardTheme.featureSecondaryText
  3551. chevronView?.contentTintColor = accent
  3552. updateSelectionAppearance()
  3553. applyLayoutDirection()
  3554. }
  3555. func applyLayoutDirection() {
  3556. let rtl = AppLayoutDirection.isRightToLeft
  3557. userInterfaceLayoutDirection = AppLayoutDirection.interface
  3558. let align: NSTextAlignment = rtl ? .right : .left
  3559. titleField?.alignment = align
  3560. subtitleField?.alignment = align
  3561. if let cell = titleField?.cell as? NSTextFieldCell {
  3562. cell.alignment = align
  3563. cell.baseWritingDirection = AppLayoutDirection.naturalWritingDirection
  3564. }
  3565. if let cell = subtitleField?.cell as? NSTextFieldCell {
  3566. cell.alignment = align
  3567. cell.baseWritingDirection = AppLayoutDirection.naturalWritingDirection
  3568. }
  3569. chevronView?.image = NSImage(
  3570. systemSymbolName: rtl ? "arrow.left" : "arrow.right",
  3571. accessibilityDescription: nil
  3572. )
  3573. }
  3574. override func viewDidChangeEffectiveAppearance() {
  3575. super.viewDidChangeEffectiveAppearance()
  3576. applyCardAppearance()
  3577. }
  3578. private func updateSelectionAppearance() {
  3579. guard let layer else { return }
  3580. let accent = AppDashboardTheme.featurePrimaryBlue
  3581. if isSelected {
  3582. layer.borderWidth = 2
  3583. layer.borderColor = accent.cgColor
  3584. layer.shadowOpacity = AppDashboardTheme.isDark ? 0.2 : 0.1
  3585. } else {
  3586. layer.borderWidth = 1
  3587. layer.borderColor = AppDashboardTheme.featureCardBorder.cgColor
  3588. layer.shadowOpacity = 1
  3589. }
  3590. setAccessibilitySelected(isSelected)
  3591. }
  3592. @available(*, unavailable)
  3593. required init?(coder: NSCoder) {
  3594. fatalError("init(coder:) has not been implemented")
  3595. }
  3596. override func layout() {
  3597. super.layout()
  3598. guard let layer = layer, bounds.width > 0, bounds.height > 0 else { return }
  3599. let r = bounds
  3600. let cr = Self.cardCornerRadius
  3601. layer.shadowPath = CGPath(roundedRect: r, cornerWidth: cr, cornerHeight: cr, transform: nil)
  3602. }
  3603. override func mouseDown(with event: NSEvent) {
  3604. if let target = actionTarget {
  3605. _ = target.perform(actionSelector, with: nil)
  3606. }
  3607. }
  3608. override func resetCursorRects() {
  3609. super.resetCursorRects()
  3610. addCursorRect(bounds, cursor: .pointingHand)
  3611. }
  3612. }
  3613. /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
  3614. private class JobPayloadButton: HoverableButton {
  3615. var jobPayload: JobListing?
  3616. var cardContext: JobListingCardContext = .homeSearchResults
  3617. }
  3618. /// Insets image + title so the Save pill matches the reference (balanced padding, not flush to the stroke).
  3619. private final class SaveJobButtonCell: NSButtonCell {
  3620. private let horizontalInset: CGFloat = 10
  3621. private let verticalInset: CGFloat = 3
  3622. private let imageTitleGap: CGFloat = 5
  3623. override func imageRect(forBounds rect: NSRect) -> NSRect {
  3624. super.imageRect(forBounds: rect.insetBy(dx: horizontalInset, dy: verticalInset))
  3625. }
  3626. override func titleRect(forBounds rect: NSRect) -> NSRect {
  3627. let padded = rect.insetBy(dx: horizontalInset, dy: verticalInset)
  3628. var t = super.titleRect(forBounds: padded)
  3629. t.origin.x += imageTitleGap
  3630. t.size.width = max(0, t.size.width - imageTitleGap)
  3631. return t
  3632. }
  3633. }
  3634. private final class SaveJobPayloadButton: JobPayloadButton {
  3635. override class var cellClass: AnyClass? {
  3636. get { SaveJobButtonCell.self }
  3637. set { }
  3638. }
  3639. }
  3640. /// `NSButton` with a tracking area that reports hover transitions and (optionally) swaps in a pointing-hand cursor while hovered.
  3641. private class HoverableButton: NSButton {
  3642. var hoverHandler: ((Bool) -> Void)?
  3643. var pointerCursor: Bool = false
  3644. private(set) var isHovering: Bool = false
  3645. private var trackingArea: NSTrackingArea?
  3646. private var didPushCursor: Bool = false
  3647. override func updateTrackingAreas() {
  3648. super.updateTrackingAreas()
  3649. if let area = trackingArea { removeTrackingArea(area) }
  3650. let area = NSTrackingArea(
  3651. rect: bounds,
  3652. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  3653. owner: self,
  3654. userInfo: nil
  3655. )
  3656. addTrackingArea(area)
  3657. trackingArea = area
  3658. }
  3659. override func mouseEntered(with event: NSEvent) {
  3660. super.mouseEntered(with: event)
  3661. isHovering = true
  3662. hoverHandler?(true)
  3663. if pointerCursor, !didPushCursor {
  3664. NSCursor.pointingHand.push()
  3665. didPushCursor = true
  3666. }
  3667. }
  3668. override func mouseExited(with event: NSEvent) {
  3669. super.mouseExited(with: event)
  3670. isHovering = false
  3671. hoverHandler?(false)
  3672. if didPushCursor {
  3673. NSCursor.pop()
  3674. didPushCursor = false
  3675. }
  3676. }
  3677. override func viewWillMove(toWindow newWindow: NSWindow?) {
  3678. super.viewWillMove(toWindow: newWindow)
  3679. // Guard against an unbalanced cursor stack if the button is removed mid-hover (e.g. job card replaced after a search).
  3680. if newWindow == nil, didPushCursor {
  3681. NSCursor.pop()
  3682. didPushCursor = false
  3683. isHovering = false
  3684. }
  3685. }
  3686. }
  3687. /// `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.
  3688. private class HoverableView: NSView {
  3689. var hoverHandler: ((Bool) -> Void)?
  3690. var pointerCursor: Bool = false
  3691. private(set) var isHovering: Bool = false
  3692. private var trackingArea: NSTrackingArea?
  3693. private var didPushCursor: Bool = false
  3694. override func updateTrackingAreas() {
  3695. super.updateTrackingAreas()
  3696. if let area = trackingArea { removeTrackingArea(area) }
  3697. let area = NSTrackingArea(
  3698. rect: bounds,
  3699. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  3700. owner: self,
  3701. userInfo: nil
  3702. )
  3703. addTrackingArea(area)
  3704. trackingArea = area
  3705. }
  3706. override func mouseEntered(with event: NSEvent) {
  3707. super.mouseEntered(with: event)
  3708. isHovering = true
  3709. hoverHandler?(true)
  3710. if pointerCursor, !didPushCursor {
  3711. NSCursor.pointingHand.push()
  3712. didPushCursor = true
  3713. }
  3714. }
  3715. override func mouseExited(with event: NSEvent) {
  3716. super.mouseExited(with: event)
  3717. isHovering = false
  3718. hoverHandler?(false)
  3719. if didPushCursor {
  3720. NSCursor.pop()
  3721. didPushCursor = false
  3722. }
  3723. }
  3724. override func viewWillMove(toWindow newWindow: NSWindow?) {
  3725. super.viewWillMove(toWindow: newWindow)
  3726. if newWindow == nil, didPushCursor {
  3727. NSCursor.pop()
  3728. didPushCursor = false
  3729. isHovering = false
  3730. }
  3731. }
  3732. }
  3733. /// 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).
  3734. private final class JobListingsDocumentView: NSView {
  3735. override var isFlipped: Bool { true }
  3736. }
  3737. /// 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.
  3738. private final class ChatJobsStackView: NSStackView {}
  3739. /// 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.
  3740. private final class ChatBubbleLabel: NSTextField {}
  3741. /// 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.
  3742. private final class SidebarNavRowView: NSView {
  3743. private let onSelect: () -> Void
  3744. var restingBackgroundColor: NSColor? {
  3745. didSet { applyBackground() }
  3746. }
  3747. var hoverBackgroundColor: NSColor?
  3748. private var isHovering: Bool = false
  3749. private var didPushCursor: Bool = false
  3750. init(onSelect: @escaping () -> Void) {
  3751. self.onSelect = onSelect
  3752. super.init(frame: .zero)
  3753. }
  3754. @available(*, unavailable)
  3755. required init?(coder: NSCoder) {
  3756. fatalError("init(coder:) has not been implemented")
  3757. }
  3758. override func hitTest(_ point: NSPoint) -> NSView? {
  3759. guard let superview else { return super.hitTest(point) }
  3760. let local = convert(point, from: superview)
  3761. return bounds.contains(local) ? self : nil
  3762. }
  3763. override func mouseDown(with event: NSEvent) {
  3764. onSelect()
  3765. }
  3766. override func updateTrackingAreas() {
  3767. super.updateTrackingAreas()
  3768. trackingAreas.forEach { removeTrackingArea($0) }
  3769. addTrackingArea(NSTrackingArea(
  3770. rect: bounds,
  3771. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  3772. owner: self,
  3773. userInfo: nil
  3774. ))
  3775. }
  3776. override func mouseEntered(with event: NSEvent) {
  3777. super.mouseEntered(with: event)
  3778. isHovering = true
  3779. applyBackground()
  3780. if !didPushCursor {
  3781. NSCursor.pointingHand.push()
  3782. didPushCursor = true
  3783. }
  3784. }
  3785. override func mouseExited(with event: NSEvent) {
  3786. super.mouseExited(with: event)
  3787. isHovering = false
  3788. applyBackground()
  3789. if didPushCursor {
  3790. NSCursor.pop()
  3791. didPushCursor = false
  3792. }
  3793. }
  3794. override func viewWillMove(toWindow newWindow: NSWindow?) {
  3795. super.viewWillMove(toWindow: newWindow)
  3796. if newWindow == nil, didPushCursor {
  3797. NSCursor.pop()
  3798. didPushCursor = false
  3799. isHovering = false
  3800. }
  3801. }
  3802. private func applyBackground() {
  3803. let color = isHovering ? (hoverBackgroundColor ?? restingBackgroundColor) : restingBackgroundColor
  3804. layer?.backgroundColor = color?.cgColor
  3805. }
  3806. }