No Description

DashboardView.swift 144KB

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