|
|
@@ -87,10 +87,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
87
|
87
|
private let chatScrollView = NSScrollView()
|
|
88
|
88
|
private let chatDocumentView = JobListingsDocumentView()
|
|
89
|
89
|
private let chatStack = NSStackView()
|
|
90
|
|
- private let jobListingsScrollView = NSScrollView()
|
|
91
|
|
- /// Flipped so short result lists stay visually under the search bar instead of leaving a gap above the cards.
|
|
92
|
|
- private let jobListingsContainer = JobListingsDocumentView()
|
|
93
|
|
- private let jobListingsStack = NSStackView()
|
|
94
|
90
|
/// Shown when a sidebar item other than Home is selected.
|
|
95
|
91
|
private let nonHomeHost = NSView()
|
|
96
|
92
|
private let nonHomeGenericContainer = NSView()
|
|
|
@@ -107,9 +103,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
107
|
103
|
|
|
108
|
104
|
private var currentSidebarItems: [SidebarItem] = []
|
|
109
|
105
|
private var selectedSidebarIndex: Int = 0
|
|
110
|
|
- /// Last successful search result set (used when removing a card with the dismiss control).
|
|
|
106
|
+ /// All jobs that have been shown in the current chat session, oldest first. Used to deduplicate continuation searches so "show more" doesn't re-display the same listings.
|
|
111
|
107
|
private var lastSearchResults: [JobListing] = []
|
|
112
|
|
- private var lastNoResultsQuery: String?
|
|
113
|
108
|
/// Most recently saved jobs appear first; persisted across launches.
|
|
114
|
109
|
private var savedJobOrder: [JobListing] = []
|
|
115
|
110
|
private var chatMessages: [ChatMessage] = []
|
|
|
@@ -132,6 +127,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
132
|
127
|
findJobsCTAGradientLayer?.frame = findJobsCTAChrome.bounds
|
|
133
|
128
|
updateFindJobsCTAShadowPath()
|
|
134
|
129
|
updateJobListingDescriptionWidths()
|
|
|
130
|
+ updateChatBubbleWidths()
|
|
135
|
131
|
}
|
|
136
|
132
|
|
|
137
|
133
|
func render(_ data: DashboardData) {
|
|
|
@@ -143,7 +139,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
143
|
139
|
}
|
|
144
|
140
|
configureSidebar()
|
|
145
|
141
|
savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load())
|
|
146
|
|
- configureJobListings([], noResultsForQuery: nil)
|
|
147
|
142
|
resetChatState()
|
|
148
|
143
|
updateMainContentVisibility()
|
|
149
|
144
|
}
|
|
|
@@ -220,46 +215,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
220
|
215
|
|
|
221
|
216
|
let midSpacer = NSView()
|
|
222
|
217
|
midSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
223
|
|
- midSpacer.heightAnchor.constraint(equalToConstant: 20).isActive = true
|
|
224
|
|
-
|
|
225
|
|
- let listingsTopSpacer = NSView()
|
|
226
|
|
- listingsTopSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
227
|
|
- listingsTopSpacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
|
|
228
|
|
-
|
|
229
|
|
- jobListingsContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
230
|
|
- jobListingsStack.orientation = .vertical
|
|
231
|
|
- jobListingsStack.spacing = 14
|
|
232
|
|
- // `.leading` keeps cards left-anchored; explicit width constraints below stretch each card across the full row.
|
|
233
|
|
- jobListingsStack.alignment = .leading
|
|
234
|
|
- jobListingsStack.distribution = .fill
|
|
235
|
|
- jobListingsStack.translatesAutoresizingMaskIntoConstraints = false
|
|
236
|
|
- jobListingsStack.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
|
237
|
|
- jobListingsStack.setHuggingPriority(.defaultLow, for: .horizontal)
|
|
238
|
|
- jobListingsContainer.addSubview(jobListingsStack)
|
|
239
|
|
- NSLayoutConstraint.activate([
|
|
240
|
|
- jobListingsStack.leadingAnchor.constraint(equalTo: jobListingsContainer.leadingAnchor),
|
|
241
|
|
- jobListingsStack.trailingAnchor.constraint(equalTo: jobListingsContainer.trailingAnchor),
|
|
242
|
|
- jobListingsStack.topAnchor.constraint(equalTo: jobListingsContainer.topAnchor),
|
|
243
|
|
- jobListingsStack.bottomAnchor.constraint(equalTo: jobListingsContainer.bottomAnchor)
|
|
244
|
|
- ])
|
|
|
218
|
+ midSpacer.heightAnchor.constraint(equalToConstant: 18).isActive = true
|
|
|
219
|
+
|
|
|
220
|
+ let chatTopSpacer = NSView()
|
|
|
221
|
+ chatTopSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
222
|
+ chatTopSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true
|
|
245
|
223
|
|
|
246
|
|
- jobListingsScrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
247
|
|
- jobListingsScrollView.hasVerticalScroller = true
|
|
248
|
|
- jobListingsScrollView.hasHorizontalScroller = false
|
|
249
|
|
- jobListingsScrollView.autohidesScrollers = true
|
|
250
|
|
- jobListingsScrollView.drawsBackground = false
|
|
251
|
|
- jobListingsScrollView.borderType = .noBorder
|
|
252
|
|
- jobListingsScrollView.documentView = jobListingsContainer
|
|
253
|
|
- jobListingsScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
254
|
|
- jobListingsScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
224
|
+ let chatBottomSpacer = NSView()
|
|
|
225
|
+ chatBottomSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
226
|
+ chatBottomSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true
|
|
255
|
227
|
|
|
256
|
228
|
mainOverlay.addArrangedSubview(topInset)
|
|
257
|
229
|
mainOverlay.addArrangedSubview(titleBlock)
|
|
258
|
230
|
mainOverlay.addArrangedSubview(midSpacer)
|
|
259
|
231
|
mainOverlay.addArrangedSubview(chatStatusStack)
|
|
260
|
|
- mainOverlay.addArrangedSubview(listingsTopSpacer)
|
|
|
232
|
+ mainOverlay.addArrangedSubview(chatTopSpacer)
|
|
261
|
233
|
mainOverlay.addArrangedSubview(chatScrollView)
|
|
262
|
|
- mainOverlay.addArrangedSubview(jobListingsScrollView)
|
|
|
234
|
+ mainOverlay.addArrangedSubview(chatBottomSpacer)
|
|
263
|
235
|
mainOverlay.addArrangedSubview(searchBarShadowHost)
|
|
264
|
236
|
|
|
265
|
237
|
contentStack.addArrangedSubview(sidebar)
|
|
|
@@ -292,12 +264,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
292
|
264
|
searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
293
|
265
|
chatStatusStack.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
294
|
266
|
chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
295
|
|
- jobListingsScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
296
|
|
-
|
|
297
|
|
- jobListingsContainer.topAnchor.constraint(equalTo: jobListingsScrollView.contentView.topAnchor),
|
|
298
|
|
- jobListingsContainer.leadingAnchor.constraint(equalTo: jobListingsScrollView.contentView.leadingAnchor),
|
|
299
|
|
- jobListingsContainer.widthAnchor.constraint(equalTo: jobListingsScrollView.contentView.widthAnchor),
|
|
300
|
|
- jobListingsScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
|
301
|
267
|
|
|
302
|
268
|
greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 16),
|
|
303
|
269
|
greetingLabel.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor, constant: -16),
|
|
|
@@ -327,16 +293,16 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
327
|
293
|
|
|
328
|
294
|
chatDocumentView.translatesAutoresizingMaskIntoConstraints = false
|
|
329
|
295
|
chatStack.orientation = .vertical
|
|
330
|
|
- chatStack.spacing = 10
|
|
|
296
|
+ chatStack.spacing = 18
|
|
331
|
297
|
chatStack.alignment = .width
|
|
332
|
298
|
chatStack.distribution = .fill
|
|
333
|
299
|
chatStack.translatesAutoresizingMaskIntoConstraints = false
|
|
334
|
300
|
chatDocumentView.addSubview(chatStack)
|
|
335
|
301
|
NSLayoutConstraint.activate([
|
|
336
|
|
- chatStack.leadingAnchor.constraint(equalTo: chatDocumentView.leadingAnchor),
|
|
337
|
|
- chatStack.trailingAnchor.constraint(equalTo: chatDocumentView.trailingAnchor),
|
|
338
|
|
- chatStack.topAnchor.constraint(equalTo: chatDocumentView.topAnchor, constant: 4),
|
|
339
|
|
- chatStack.bottomAnchor.constraint(equalTo: chatDocumentView.bottomAnchor, constant: -4)
|
|
|
302
|
+ chatStack.leadingAnchor.constraint(equalTo: chatDocumentView.leadingAnchor, constant: 4),
|
|
|
303
|
+ chatStack.trailingAnchor.constraint(equalTo: chatDocumentView.trailingAnchor, constant: -4),
|
|
|
304
|
+ chatStack.topAnchor.constraint(equalTo: chatDocumentView.topAnchor, constant: 8),
|
|
|
305
|
+ chatStack.bottomAnchor.constraint(equalTo: chatDocumentView.bottomAnchor, constant: -8)
|
|
340
|
306
|
])
|
|
341
|
307
|
|
|
342
|
308
|
chatScrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -347,12 +313,41 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
347
|
313
|
chatScrollView.borderType = .noBorder
|
|
348
|
314
|
chatScrollView.documentView = chatDocumentView
|
|
349
|
315
|
chatScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
350
|
|
- chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 260).isActive = true
|
|
|
316
|
+ chatScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
317
|
+ chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true
|
|
351
|
318
|
}
|
|
352
|
319
|
|
|
353
|
320
|
private func updateJobListingDescriptionWidths() {
|
|
354
|
|
- updateDescriptionColumnWidths(in: jobListingsStack, containerWidth: jobListingsContainer.bounds.width)
|
|
355
|
321
|
updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
|
|
|
322
|
+ walkChatJobStacks { stack in
|
|
|
323
|
+ updateDescriptionColumnWidths(in: stack, containerWidth: stack.bounds.width)
|
|
|
324
|
+ }
|
|
|
325
|
+ }
|
|
|
326
|
+
|
|
|
327
|
+ private func walkChatJobStacks(_ visitor: (ChatJobsStackView) -> Void) {
|
|
|
328
|
+ func walk(_ view: NSView) {
|
|
|
329
|
+ if let stack = view as? ChatJobsStackView {
|
|
|
330
|
+ visitor(stack)
|
|
|
331
|
+ }
|
|
|
332
|
+ for sub in view.subviews { walk(sub) }
|
|
|
333
|
+ }
|
|
|
334
|
+ walk(chatStack)
|
|
|
335
|
+ }
|
|
|
336
|
+
|
|
|
337
|
+ /// 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.
|
|
|
338
|
+ private func updateChatBubbleWidths() {
|
|
|
339
|
+ func walk(_ view: NSView) {
|
|
|
340
|
+ if let label = view as? ChatBubbleLabel,
|
|
|
341
|
+ let bubble = label.superview, bubble.bounds.width > 1 {
|
|
|
342
|
+ let target = max(40, bubble.bounds.width - 28)
|
|
|
343
|
+ if abs(label.preferredMaxLayoutWidth - target) > 0.5 {
|
|
|
344
|
+ label.preferredMaxLayoutWidth = target
|
|
|
345
|
+ label.invalidateIntrinsicContentSize()
|
|
|
346
|
+ }
|
|
|
347
|
+ }
|
|
|
348
|
+ for sub in view.subviews { walk(sub) }
|
|
|
349
|
+ }
|
|
|
350
|
+ walk(chatStack)
|
|
356
|
351
|
}
|
|
357
|
352
|
|
|
358
|
353
|
private func updateDescriptionColumnWidths(in stack: NSStackView, containerWidth: CGFloat) {
|
|
|
@@ -383,34 +378,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
383
|
378
|
}
|
|
384
|
379
|
}
|
|
385
|
380
|
|
|
386
|
|
- private func configureJobListings(_ jobs: [JobListing], noResultsForQuery: String?, updateLastResults: Bool = true) {
|
|
387
|
|
- if updateLastResults {
|
|
388
|
|
- lastSearchResults = jobs
|
|
389
|
|
- lastNoResultsQuery = noResultsForQuery
|
|
390
|
|
- }
|
|
391
|
|
- jobListingsStack.arrangedSubviews.forEach {
|
|
392
|
|
- jobListingsStack.removeArrangedSubview($0)
|
|
393
|
|
- $0.removeFromSuperview()
|
|
394
|
|
- }
|
|
395
|
|
- if jobs.isEmpty, let query = noResultsForQuery, !query.isEmpty {
|
|
396
|
|
- let empty = NSTextField(wrappingLabelWithString: "No jobs match “\(query)”. Try different keywords or browse the full list with an empty search.")
|
|
397
|
|
- empty.font = .systemFont(ofSize: 14, weight: .regular)
|
|
398
|
|
- empty.textColor = Theme.secondaryText
|
|
399
|
|
- empty.alignment = .center
|
|
400
|
|
- empty.maximumNumberOfLines = 0
|
|
401
|
|
- empty.translatesAutoresizingMaskIntoConstraints = false
|
|
402
|
|
- jobListingsStack.addArrangedSubview(empty)
|
|
403
|
|
- empty.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
|
|
404
|
|
- return
|
|
405
|
|
- }
|
|
406
|
|
- for job in jobs {
|
|
407
|
|
- let card = makeJobListingCard(job, context: .homeSearchResults)
|
|
408
|
|
- jobListingsStack.addArrangedSubview(card)
|
|
409
|
|
- // Force every card to span the full row instead of hugging its intrinsic content width.
|
|
410
|
|
- card.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
|
|
411
|
|
- }
|
|
412
|
|
- }
|
|
413
|
|
-
|
|
414
|
381
|
private static func normalizedSavedJobs(_ jobs: [JobListing]) -> [JobListing] {
|
|
415
|
382
|
var seen = Set<JobListing>()
|
|
416
|
383
|
var out: [JobListing] = []
|
|
|
@@ -632,15 +599,30 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
632
|
599
|
guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return }
|
|
633
|
600
|
switch button.cardContext {
|
|
634
|
601
|
case .homeSearchResults:
|
|
635
|
|
- lastSearchResults.removeAll { $0 == job }
|
|
636
|
|
- configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery)
|
|
|
602
|
+ removeJobCardFromChat(originating: button, job: job)
|
|
637
|
603
|
case .savedJobsPage:
|
|
638
|
604
|
applySavedState(false, for: job)
|
|
639
|
605
|
reloadSavedJobsListings()
|
|
640
|
|
- if isHomeSidebarIndex(selectedSidebarIndex), !lastSearchResults.isEmpty {
|
|
641
|
|
- configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery, updateLastResults: false)
|
|
|
606
|
+ }
|
|
|
607
|
+ }
|
|
|
608
|
+
|
|
|
609
|
+ /// 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.
|
|
|
610
|
+ private func removeJobCardFromChat(originating button: NSView, job: JobListing) {
|
|
|
611
|
+ var node: NSView? = button
|
|
|
612
|
+ var card: NSView?
|
|
|
613
|
+ var stack: ChatJobsStackView?
|
|
|
614
|
+ while let v = node {
|
|
|
615
|
+ if let parent = v.superview as? ChatJobsStackView {
|
|
|
616
|
+ card = v
|
|
|
617
|
+ stack = parent
|
|
|
618
|
+ break
|
|
642
|
619
|
}
|
|
|
620
|
+ node = v.superview
|
|
643
|
621
|
}
|
|
|
622
|
+ guard let card, let stack else { return }
|
|
|
623
|
+ stack.removeArrangedSubview(card)
|
|
|
624
|
+ card.removeFromSuperview()
|
|
|
625
|
+ lastSearchResults.removeAll { $0 == job }
|
|
644
|
626
|
}
|
|
645
|
627
|
|
|
646
|
628
|
private func configureSearchBar() {
|
|
|
@@ -1166,15 +1148,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1166
|
1148
|
}
|
|
1167
|
1149
|
}
|
|
1168
|
1150
|
|
|
1169
|
|
- /// Removes result cards or the “no matches” message so a new search starts from a blank listing area.
|
|
1170
|
|
- private func clearJobSearchResultsUI() {
|
|
1171
|
|
- configureJobListings([], noResultsForQuery: nil)
|
|
1172
|
|
- if let doc = jobListingsScrollView.documentView {
|
|
1173
|
|
- doc.scroll(NSPoint(x: 0, y: 0))
|
|
1174
|
|
- }
|
|
1175
|
|
- }
|
|
1176
|
|
-
|
|
1177
|
|
- /// Restores the main job-search experience: cleared query and no listings until the user searches again.
|
|
|
1151
|
+ /// Restores the main job-search experience: cleared query and a fresh chat history.
|
|
1178
|
1152
|
private func applyHomeState() {
|
|
1179
|
1153
|
jobKeywordsField.stringValue = ""
|
|
1180
|
1154
|
resetChatState()
|
|
|
@@ -1196,7 +1170,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1196
|
1170
|
@objc private func didSubmitSearch() {
|
|
1197
|
1171
|
let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1198
|
1172
|
guard !prompt.isEmpty, !isAwaitingResponse else { return }
|
|
1199
|
|
- clearJobSearchResultsUI()
|
|
|
1173
|
+ let isContinuation = isContinuationPrompt(prompt)
|
|
|
1174
|
+ let effectiveQuery = resolvedSearchQuery(for: prompt)
|
|
1200
|
1175
|
appendChatBubble(text: prompt, isUser: true)
|
|
1201
|
1176
|
chatMessages.append(ChatMessage(role: "user", content: prompt))
|
|
1202
|
1177
|
jobKeywordsField.stringValue = ""
|
|
|
@@ -1204,7 +1179,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1204
|
1179
|
chatStatusLabel.stringValue = "Thinking..."
|
|
1205
|
1180
|
setInputEnabled(false)
|
|
1206
|
1181
|
let contextMessages = chatMessages
|
|
1207
|
|
- jobSearchService.searchJobs(query: prompt, conversation: contextMessages) { [weak self] result in
|
|
|
1182
|
+ jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages) { [weak self] result in
|
|
1208
|
1183
|
DispatchQueue.main.async {
|
|
1209
|
1184
|
guard let self else { return }
|
|
1210
|
1185
|
self.isAwaitingResponse = false
|
|
|
@@ -1212,15 +1187,26 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1212
|
1187
|
switch result {
|
|
1213
|
1188
|
case .success(let output):
|
|
1214
|
1189
|
let normalizedJobs = self.normalizedJobs(output.jobs)
|
|
1215
|
|
- self.configureJobListings(normalizedJobs, noResultsForQuery: prompt)
|
|
1216
|
|
- let reply = self.makeAssistantSearchReply(query: prompt, jobs: normalizedJobs)
|
|
|
1190
|
+ let freshJobs: [JobListing]
|
|
|
1191
|
+ if isContinuation {
|
|
|
1192
|
+ // Continuations append only the *new* matches; previous cards already live in their own assistant message above.
|
|
|
1193
|
+ let alreadySeen = Set(self.lastSearchResults)
|
|
|
1194
|
+ freshJobs = normalizedJobs.filter { !alreadySeen.contains($0) }
|
|
|
1195
|
+ } else {
|
|
|
1196
|
+ freshJobs = normalizedJobs
|
|
|
1197
|
+ }
|
|
|
1198
|
+ self.lastSearchResults.append(contentsOf: freshJobs)
|
|
|
1199
|
+ let reply = self.makeAssistantSearchReply(
|
|
|
1200
|
+ query: effectiveQuery,
|
|
|
1201
|
+ newJobsCount: freshJobs.count,
|
|
|
1202
|
+ isContinuation: isContinuation
|
|
|
1203
|
+ )
|
|
1217
|
1204
|
self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
|
|
1218
|
|
- self.appendChatBubble(text: reply, isUser: false)
|
|
1219
|
|
- self.chatStatusLabel.stringValue = "Ask for another job, company, or skill match"
|
|
|
1205
|
+ self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
|
|
|
1206
|
+ self.chatStatusLabel.stringValue = "Ask for another role, company, or skill match"
|
|
1220
|
1207
|
case .failure(let error):
|
|
1221
|
1208
|
self.appendChatBubble(text: error.localizedDescription, isUser: false)
|
|
1222
|
1209
|
self.chatStatusLabel.stringValue = "Could not reach API. Try again."
|
|
1223
|
|
- self.configureJobListings([], noResultsForQuery: prompt)
|
|
1224
|
1210
|
}
|
|
1225
|
1211
|
}
|
|
1226
|
1212
|
}
|
|
|
@@ -1238,14 +1224,48 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1238
|
1224
|
return trimmed.filter { !$0.title.isEmpty && !$0.description.isEmpty }
|
|
1239
|
1225
|
}
|
|
1240
|
1226
|
|
|
1241
|
|
- private func makeAssistantSearchReply(query: String, jobs: [JobListing]) -> String {
|
|
1242
|
|
- if jobs.isEmpty {
|
|
1243
|
|
- return "No jobs were found for \"\(query)\". Try another title, skill, company, or location."
|
|
|
1227
|
+ private func makeAssistantSearchReply(query: String, newJobsCount: Int, isContinuation: Bool) -> String {
|
|
|
1228
|
+ if newJobsCount == 0 {
|
|
|
1229
|
+ if isContinuation {
|
|
|
1230
|
+ return "I couldn't find new matches for \u{201C}\(query)\u{201D}. Try a different angle or a more specific keyword."
|
|
|
1231
|
+ }
|
|
|
1232
|
+ return "No jobs found for \u{201C}\(query)\u{201D}. Try another title, skill, company, or location."
|
|
1244
|
1233
|
}
|
|
1245
|
|
- return """
|
|
1246
|
|
- Found \(jobs.count) job result(s) for "\(query)".
|
|
1247
|
|
- Each result is shown as a card with the role, job description, and an Apply button that opens the job link.
|
|
1248
|
|
- """
|
|
|
1234
|
+ let plural = newJobsCount == 1 ? "match" : "matches"
|
|
|
1235
|
+ if isContinuation {
|
|
|
1236
|
+ return "Here are \(newJobsCount) more \(plural) for \u{201C}\(query)\u{201D}."
|
|
|
1237
|
+ }
|
|
|
1238
|
+ return "Found \(newJobsCount) \(plural) for \u{201C}\(query)\u{201D}. Tap Apply to open the listing or Save to revisit later."
|
|
|
1239
|
+ }
|
|
|
1240
|
+
|
|
|
1241
|
+ private func resolvedSearchQuery(for prompt: String) -> String {
|
|
|
1242
|
+ guard isContinuationPrompt(prompt) else { return prompt }
|
|
|
1243
|
+ for message in chatMessages.reversed() where message.role == "user" {
|
|
|
1244
|
+ let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
1245
|
+ if !candidate.isEmpty, !isContinuationPrompt(candidate) {
|
|
|
1246
|
+ return candidate
|
|
|
1247
|
+ }
|
|
|
1248
|
+ }
|
|
|
1249
|
+ return prompt
|
|
|
1250
|
+ }
|
|
|
1251
|
+
|
|
|
1252
|
+ private func isContinuationPrompt(_ prompt: String) -> Bool {
|
|
|
1253
|
+ let normalized = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
1254
|
+ let continuationPhrases: Set<String> = [
|
|
|
1255
|
+ "more",
|
|
|
1256
|
+ "show more",
|
|
|
1257
|
+ "more jobs",
|
|
|
1258
|
+ "more results",
|
|
|
1259
|
+ "do more searches",
|
|
|
1260
|
+ "more searches",
|
|
|
1261
|
+ "search more",
|
|
|
1262
|
+ "continue",
|
|
|
1263
|
+ "next"
|
|
|
1264
|
+ ]
|
|
|
1265
|
+ if continuationPhrases.contains(normalized) {
|
|
|
1266
|
+ return true
|
|
|
1267
|
+ }
|
|
|
1268
|
+ return normalized.contains("more search") || normalized.contains("more job")
|
|
1249
|
1269
|
}
|
|
1250
|
1270
|
|
|
1251
|
1271
|
func controlTextDidBeginEditing(_ obj: Notification) {
|
|
|
@@ -1276,6 +1296,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1276
|
1296
|
|
|
1277
|
1297
|
private func resetChatState() {
|
|
1278
|
1298
|
chatMessages.removeAll()
|
|
|
1299
|
+ lastSearchResults.removeAll()
|
|
1279
|
1300
|
chatStack.arrangedSubviews.forEach {
|
|
1280
|
1301
|
chatStack.removeArrangedSubview($0)
|
|
1281
|
1302
|
$0.removeFromSuperview()
|
|
|
@@ -1283,48 +1304,159 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1283
|
1304
|
let welcome = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary."
|
|
1284
|
1305
|
chatMessages.append(ChatMessage(role: "assistant", content: welcome))
|
|
1285
|
1306
|
appendChatBubble(text: welcome, isUser: false)
|
|
1286
|
|
- chatStatusLabel.stringValue = "Opening the vault..."
|
|
|
1307
|
+ chatStatusLabel.stringValue = "Ask me to find jobs"
|
|
1287
|
1308
|
}
|
|
1288
|
1309
|
|
|
1289
|
|
- private func appendChatBubble(text: String, isUser: Bool) {
|
|
|
1310
|
+ private func appendChatBubble(text: String, isUser: Bool, jobs: [JobListing]? = nil) {
|
|
1290
|
1311
|
let host = NSView()
|
|
1291
|
1312
|
host.translatesAutoresizingMaskIntoConstraints = false
|
|
1292
|
1313
|
|
|
1293
|
|
- let bubble = NSTextField(wrappingLabelWithString: text)
|
|
1294
|
|
- bubble.font = .systemFont(ofSize: 13, weight: .regular)
|
|
1295
|
|
- bubble.maximumNumberOfLines = 0
|
|
1296
|
|
- bubble.lineBreakMode = .byWordWrapping
|
|
1297
|
|
- bubble.alignment = .left
|
|
1298
|
|
- bubble.textColor = isUser ? .white : Theme.primaryText
|
|
1299
|
|
- bubble.wantsLayer = true
|
|
1300
|
|
- bubble.layer?.cornerRadius = 12
|
|
1301
|
|
- bubble.layer?.masksToBounds = true
|
|
1302
|
|
- bubble.layer?.backgroundColor = (isUser ? Theme.brandBlue : Theme.chromeBackground).cgColor
|
|
1303
|
|
- bubble.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1314
|
+ if isUser {
|
|
|
1315
|
+ installUserBubble(text: text, into: host)
|
|
|
1316
|
+ } else {
|
|
|
1317
|
+ installAssistantBubble(text: text, jobs: jobs, into: host)
|
|
|
1318
|
+ }
|
|
1304
|
1319
|
|
|
1305
|
|
- host.addSubview(bubble)
|
|
1306
|
1320
|
chatStack.addArrangedSubview(host)
|
|
1307
|
1321
|
host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
|
|
1308
|
1322
|
|
|
1309
|
|
- let leading = bubble.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 8)
|
|
1310
|
|
- let trailing = bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor, constant: -8)
|
|
1311
|
|
- let maxWidth = bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78)
|
|
1312
|
|
- maxWidth.priority = .required
|
|
|
1323
|
+ DispatchQueue.main.async { [weak self] in
|
|
|
1324
|
+ self?.updateChatBubbleWidths()
|
|
|
1325
|
+ self?.scrollChatToBottom()
|
|
|
1326
|
+ }
|
|
|
1327
|
+ }
|
|
1313
|
1328
|
|
|
|
1329
|
+ private func installUserBubble(text: String, into host: NSView) {
|
|
|
1330
|
+ let bubble = makeChatBubbleContainer(text: text, isUser: true)
|
|
|
1331
|
+ host.addSubview(bubble)
|
|
1314
|
1332
|
NSLayoutConstraint.activate([
|
|
1315
|
1333
|
bubble.topAnchor.constraint(equalTo: host.topAnchor),
|
|
1316
|
1334
|
bubble.bottomAnchor.constraint(equalTo: host.bottomAnchor),
|
|
1317
|
|
- bubble.heightAnchor.constraint(greaterThanOrEqualToConstant: 34),
|
|
1318
|
|
- maxWidth,
|
|
1319
|
|
- isUser ? leading.withPriority(.defaultLow) : leading.withPriority(.required),
|
|
1320
|
|
- isUser ? trailing.withPriority(.required) : trailing.withPriority(.defaultLow)
|
|
|
1335
|
+ bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
|
|
1336
|
+ bubble.leadingAnchor.constraint(greaterThanOrEqualTo: host.leadingAnchor, constant: 64),
|
|
|
1337
|
+ bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78)
|
|
|
1338
|
+ ])
|
|
|
1339
|
+ }
|
|
|
1340
|
+
|
|
|
1341
|
+ private func installAssistantBubble(text: String, jobs: [JobListing]?, into host: NSView) {
|
|
|
1342
|
+ let avatar = makeAssistantAvatarView()
|
|
|
1343
|
+ let nameLabel = NSTextField(labelWithString: "AI Job Finder")
|
|
|
1344
|
+ nameLabel.font = .systemFont(ofSize: 11, weight: .semibold)
|
|
|
1345
|
+ nameLabel.textColor = Theme.secondaryText
|
|
|
1346
|
+ nameLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1347
|
+
|
|
|
1348
|
+ let bubble = makeChatBubbleContainer(text: text, isUser: false)
|
|
|
1349
|
+
|
|
|
1350
|
+ let column = NSStackView(views: [nameLabel, bubble])
|
|
|
1351
|
+ column.orientation = .vertical
|
|
|
1352
|
+ column.spacing = 6
|
|
|
1353
|
+ column.alignment = .leading
|
|
|
1354
|
+ column.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1355
|
+
|
|
|
1356
|
+ if let jobs, !jobs.isEmpty {
|
|
|
1357
|
+ let jobsStack = makeChatJobsStackView(jobs: jobs)
|
|
|
1358
|
+ column.addArrangedSubview(jobsStack)
|
|
|
1359
|
+ jobsStack.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true
|
|
|
1360
|
+ }
|
|
|
1361
|
+
|
|
|
1362
|
+ host.addSubview(avatar)
|
|
|
1363
|
+ host.addSubview(column)
|
|
|
1364
|
+ NSLayoutConstraint.activate([
|
|
|
1365
|
+ avatar.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
|
|
1366
|
+ avatar.topAnchor.constraint(equalTo: host.topAnchor),
|
|
|
1367
|
+ avatar.widthAnchor.constraint(equalToConstant: 36),
|
|
|
1368
|
+ avatar.heightAnchor.constraint(equalToConstant: 36),
|
|
|
1369
|
+
|
|
|
1370
|
+ column.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 12),
|
|
|
1371
|
+ column.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
|
|
1372
|
+ column.topAnchor.constraint(equalTo: host.topAnchor),
|
|
|
1373
|
+ column.bottomAnchor.constraint(equalTo: host.bottomAnchor)
|
|
1321
|
1374
|
])
|
|
|
1375
|
+ }
|
|
1322
|
1376
|
|
|
1323
|
|
- DispatchQueue.main.async {
|
|
1324
|
|
- let maxY = max(0, self.chatDocumentView.bounds.height - self.chatScrollView.contentView.bounds.height)
|
|
1325
|
|
- self.chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY))
|
|
1326
|
|
- self.chatScrollView.reflectScrolledClipView(self.chatScrollView.contentView)
|
|
|
1377
|
+ private func makeChatBubbleContainer(text: String, isUser: Bool) -> NSView {
|
|
|
1378
|
+ let container = NSView()
|
|
|
1379
|
+ container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1380
|
+ container.wantsLayer = true
|
|
|
1381
|
+ container.layer?.cornerRadius = 14
|
|
|
1382
|
+ if #available(macOS 11.0, *) {
|
|
|
1383
|
+ container.layer?.cornerCurve = .continuous
|
|
1327
|
1384
|
}
|
|
|
1385
|
+ container.layer?.masksToBounds = true
|
|
|
1386
|
+ if isUser {
|
|
|
1387
|
+ container.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
1388
|
+ } else {
|
|
|
1389
|
+ container.layer?.backgroundColor = Theme.chromeBackground.cgColor
|
|
|
1390
|
+ container.layer?.borderWidth = 1
|
|
|
1391
|
+ container.layer?.borderColor = Theme.border.cgColor
|
|
|
1392
|
+ }
|
|
|
1393
|
+
|
|
|
1394
|
+ let label = ChatBubbleLabel(wrappingLabelWithString: text)
|
|
|
1395
|
+ label.font = .systemFont(ofSize: 13.5, weight: .regular)
|
|
|
1396
|
+ label.textColor = isUser ? .white : Theme.primaryText
|
|
|
1397
|
+ label.maximumNumberOfLines = 0
|
|
|
1398
|
+ label.lineBreakMode = .byWordWrapping
|
|
|
1399
|
+ label.alignment = .left
|
|
|
1400
|
+ label.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1401
|
+ label.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
1402
|
+ label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
1403
|
+
|
|
|
1404
|
+ container.addSubview(label)
|
|
|
1405
|
+ NSLayoutConstraint.activate([
|
|
|
1406
|
+ label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14),
|
|
|
1407
|
+ label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14),
|
|
|
1408
|
+ label.topAnchor.constraint(equalTo: container.topAnchor, constant: 10),
|
|
|
1409
|
+ label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10)
|
|
|
1410
|
+ ])
|
|
|
1411
|
+ return container
|
|
|
1412
|
+ }
|
|
|
1413
|
+
|
|
|
1414
|
+ private func makeAssistantAvatarView() -> NSView {
|
|
|
1415
|
+ let view = NSView()
|
|
|
1416
|
+ view.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1417
|
+ view.wantsLayer = true
|
|
|
1418
|
+ view.layer?.cornerRadius = 18
|
|
|
1419
|
+ if #available(macOS 11.0, *) {
|
|
|
1420
|
+ view.layer?.cornerCurve = .continuous
|
|
|
1421
|
+ }
|
|
|
1422
|
+ view.layer?.backgroundColor = Theme.settingsIconBackground.cgColor
|
|
|
1423
|
+ view.layer?.borderWidth = 1
|
|
|
1424
|
+ view.layer?.borderColor = Theme.proCardBorder.cgColor
|
|
|
1425
|
+
|
|
|
1426
|
+ let icon = NSImageView()
|
|
|
1427
|
+ icon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1428
|
+ icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
|
|
|
1429
|
+ icon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "AI Job Finder")
|
|
|
1430
|
+ icon.contentTintColor = Theme.brandBlue
|
|
|
1431
|
+
|
|
|
1432
|
+ view.addSubview(icon)
|
|
|
1433
|
+ NSLayoutConstraint.activate([
|
|
|
1434
|
+ icon.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
|
1435
|
+ icon.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
|
|
1436
|
+ ])
|
|
|
1437
|
+ return view
|
|
|
1438
|
+ }
|
|
|
1439
|
+
|
|
|
1440
|
+ private func makeChatJobsStackView(jobs: [JobListing]) -> ChatJobsStackView {
|
|
|
1441
|
+ let stack = ChatJobsStackView()
|
|
|
1442
|
+ stack.orientation = .vertical
|
|
|
1443
|
+ stack.spacing = 10
|
|
|
1444
|
+ stack.alignment = .leading
|
|
|
1445
|
+ stack.distribution = .fill
|
|
|
1446
|
+ stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1447
|
+
|
|
|
1448
|
+ for job in jobs {
|
|
|
1449
|
+ let card = makeJobListingCard(job, context: .homeSearchResults)
|
|
|
1450
|
+ stack.addArrangedSubview(card)
|
|
|
1451
|
+ card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
|
1452
|
+ }
|
|
|
1453
|
+ return stack
|
|
|
1454
|
+ }
|
|
|
1455
|
+
|
|
|
1456
|
+ private func scrollChatToBottom() {
|
|
|
1457
|
+ let maxY = max(0, chatDocumentView.bounds.height - chatScrollView.contentView.bounds.height)
|
|
|
1458
|
+ chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY))
|
|
|
1459
|
+ chatScrollView.reflectScrolledClipView(chatScrollView.contentView)
|
|
1328
|
1460
|
}
|
|
1329
|
1461
|
|
|
1330
|
1462
|
private func setInputEnabled(_ enabled: Bool) {
|
|
|
@@ -1777,13 +1909,6 @@ private struct OpenAIAPIErrorResponse: Codable {
|
|
1777
|
1909
|
}
|
|
1778
|
1910
|
}
|
|
1779
|
1911
|
|
|
1780
|
|
-private extension NSLayoutConstraint {
|
|
1781
|
|
- func withPriority(_ priority: NSLayoutConstraint.Priority) -> NSLayoutConstraint {
|
|
1782
|
|
- self.priority = priority
|
|
1783
|
|
- return self
|
|
1784
|
|
- }
|
|
1785
|
|
-}
|
|
1786
|
|
-
|
|
1787
|
1912
|
/// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
|
|
1788
|
1913
|
private final class JobPayloadButton: HoverableButton {
|
|
1789
|
1914
|
var jobPayload: JobListing?
|
|
|
@@ -1898,6 +2023,12 @@ private final class JobListingsDocumentView: NSView {
|
|
1898
|
2023
|
override var isFlipped: Bool { true }
|
|
1899
|
2024
|
}
|
|
1900
|
2025
|
|
|
|
2026
|
+/// 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.
|
|
|
2027
|
+private final class ChatJobsStackView: NSStackView {}
|
|
|
2028
|
+
|
|
|
2029
|
+/// 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.
|
|
|
2030
|
+private final class ChatBubbleLabel: NSTextField {}
|
|
|
2031
|
+
|
|
1901
|
2032
|
/// 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.
|
|
1902
|
2033
|
private final class SidebarNavRowView: NSView {
|
|
1903
|
2034
|
private let onSelect: () -> Void
|