Ei kuvausta

DashboardView.swift 141KB

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