Keine Beschreibung

DashboardView.swift 176KB

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