Няма описание

DashboardView.swift 150KB

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