Без опису

DashboardView.swift 186KB

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