Geen omschrijving

DashboardView.swift 175KB

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