|
|
@@ -111,6 +111,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
111
|
111
|
private var isAwaitingResponse = false
|
|
112
|
112
|
private let jobSearchService = OpenAIJobSearchService()
|
|
113
|
113
|
|
|
|
114
|
+ private static let chatStatusSparklePulseKey = "chatStatusSparklePulse"
|
|
|
115
|
+ private static let welcomeSubtitleBreathKey = "welcomeSubtitleBreath"
|
|
|
116
|
+
|
|
114
|
117
|
override init(frame frameRect: NSRect) {
|
|
115
|
118
|
super.init(frame: frameRect)
|
|
116
|
119
|
setupLayout()
|
|
|
@@ -200,6 +203,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
200
|
203
|
subtitleLabel.textColor = Theme.welcomeSubtitleText
|
|
201
|
204
|
subtitleLabel.alignment = .center
|
|
202
|
205
|
subtitleLabel.maximumNumberOfLines = 2
|
|
|
206
|
+ subtitleLabel.wantsLayer = true
|
|
203
|
207
|
|
|
204
|
208
|
let topInset = NSView()
|
|
205
|
209
|
topInset.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -279,6 +283,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
279
|
283
|
chatStatusStack.translatesAutoresizingMaskIntoConstraints = false
|
|
280
|
284
|
|
|
281
|
285
|
chatStatusIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
286
|
+ chatStatusIcon.wantsLayer = true
|
|
282
|
287
|
chatStatusIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 36, weight: .regular)
|
|
283
|
288
|
chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
|
|
284
|
289
|
chatStatusIcon.contentTintColor = Theme.brandBlue
|
|
|
@@ -325,6 +330,76 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
325
|
330
|
])
|
|
326
|
331
|
}
|
|
327
|
332
|
|
|
|
333
|
+ private func setChatStatusLabel(_ text: String) {
|
|
|
334
|
+ chatStatusLabel.stringValue = text
|
|
|
335
|
+ syncChatStatusSparkleAnimation()
|
|
|
336
|
+ }
|
|
|
337
|
+
|
|
|
338
|
+ private func isWelcomeHeroVisible() -> Bool {
|
|
|
339
|
+ !mainOverlay.isHidden
|
|
|
340
|
+ }
|
|
|
341
|
+
|
|
|
342
|
+ private var prefersReducedMotion: Bool {
|
|
|
343
|
+ NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
|
344
|
+ }
|
|
|
345
|
+
|
|
|
346
|
+ private func shouldAnimateChatStatusSparkles(for statusText: String) -> Bool {
|
|
|
347
|
+ statusText == "Ask me to find jobs"
|
|
|
348
|
+ || statusText == "Ask for another role, company, or skill match"
|
|
|
349
|
+ }
|
|
|
350
|
+
|
|
|
351
|
+ private func syncChatStatusSparkleAnimation() {
|
|
|
352
|
+ guard !prefersReducedMotion else {
|
|
|
353
|
+ stopChatStatusSparkleAnimation()
|
|
|
354
|
+ return
|
|
|
355
|
+ }
|
|
|
356
|
+ guard isWelcomeHeroVisible(), shouldAnimateChatStatusSparkles(for: chatStatusLabel.stringValue) else {
|
|
|
357
|
+ stopChatStatusSparkleAnimation()
|
|
|
358
|
+ return
|
|
|
359
|
+ }
|
|
|
360
|
+ guard let layer = chatStatusIcon.layer, layer.animation(forKey: Self.chatStatusSparklePulseKey) == nil else { return }
|
|
|
361
|
+
|
|
|
362
|
+ let scale = CABasicAnimation(keyPath: "transform.scale")
|
|
|
363
|
+ scale.fromValue = 1.0
|
|
|
364
|
+ scale.toValue = 1.1
|
|
|
365
|
+ scale.duration = 1.25
|
|
|
366
|
+ scale.autoreverses = true
|
|
|
367
|
+ scale.repeatCount = .greatestFiniteMagnitude
|
|
|
368
|
+ scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
|
369
|
+ layer.add(scale, forKey: Self.chatStatusSparklePulseKey)
|
|
|
370
|
+ }
|
|
|
371
|
+
|
|
|
372
|
+ private func stopChatStatusSparkleAnimation() {
|
|
|
373
|
+ chatStatusIcon.layer?.removeAnimation(forKey: Self.chatStatusSparklePulseKey)
|
|
|
374
|
+ chatStatusIcon.layer?.transform = CATransform3DIdentity
|
|
|
375
|
+ }
|
|
|
376
|
+
|
|
|
377
|
+ private func syncWelcomeSubtitleBreathingAnimation() {
|
|
|
378
|
+ guard !prefersReducedMotion else {
|
|
|
379
|
+ stopWelcomeSubtitleBreathingAnimation()
|
|
|
380
|
+ return
|
|
|
381
|
+ }
|
|
|
382
|
+ guard isWelcomeHeroVisible(), !subtitleLabel.stringValue.isEmpty else {
|
|
|
383
|
+ stopWelcomeSubtitleBreathingAnimation()
|
|
|
384
|
+ return
|
|
|
385
|
+ }
|
|
|
386
|
+ guard let layer = subtitleLabel.layer, layer.animation(forKey: Self.welcomeSubtitleBreathKey) == nil else { return }
|
|
|
387
|
+
|
|
|
388
|
+ let pulse = CABasicAnimation(keyPath: "opacity")
|
|
|
389
|
+ pulse.fromValue = 1.0
|
|
|
390
|
+ pulse.toValue = 0.86
|
|
|
391
|
+ pulse.duration = 2.4
|
|
|
392
|
+ pulse.autoreverses = true
|
|
|
393
|
+ pulse.repeatCount = .greatestFiniteMagnitude
|
|
|
394
|
+ pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
|
395
|
+ layer.add(pulse, forKey: Self.welcomeSubtitleBreathKey)
|
|
|
396
|
+ }
|
|
|
397
|
+
|
|
|
398
|
+ private func stopWelcomeSubtitleBreathingAnimation() {
|
|
|
399
|
+ subtitleLabel.layer?.removeAnimation(forKey: Self.welcomeSubtitleBreathKey)
|
|
|
400
|
+ subtitleLabel.layer?.opacity = 1
|
|
|
401
|
+ }
|
|
|
402
|
+
|
|
328
|
403
|
private func updateJobListingDescriptionWidths() {
|
|
329
|
404
|
updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
|
|
330
|
405
|
walkChatJobStacks { stack in
|
|
|
@@ -1155,6 +1230,13 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1155
|
1230
|
nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title
|
|
1156
|
1231
|
}
|
|
1157
|
1232
|
}
|
|
|
1233
|
+ if home {
|
|
|
1234
|
+ syncWelcomeSubtitleBreathingAnimation()
|
|
|
1235
|
+ syncChatStatusSparkleAnimation()
|
|
|
1236
|
+ } else {
|
|
|
1237
|
+ stopWelcomeSubtitleBreathingAnimation()
|
|
|
1238
|
+ stopChatStatusSparkleAnimation()
|
|
|
1239
|
+ }
|
|
1158
|
1240
|
}
|
|
1159
|
1241
|
|
|
1160
|
1242
|
/// Restores the main job-search experience: cleared query and a fresh chat history.
|
|
|
@@ -1185,7 +1267,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1185
|
1267
|
chatMessages.append(ChatMessage(role: "user", content: prompt))
|
|
1186
|
1268
|
jobKeywordsField.stringValue = ""
|
|
1187
|
1269
|
isAwaitingResponse = true
|
|
1188
|
|
- chatStatusLabel.stringValue = "Thinking..."
|
|
|
1270
|
+ setChatStatusLabel("Thinking...")
|
|
1189
|
1271
|
setInputEnabled(false)
|
|
1190
|
1272
|
let contextMessages = chatMessages
|
|
1191
|
1273
|
jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages) { [weak self] result in
|
|
|
@@ -1212,13 +1294,13 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1212
|
1294
|
)
|
|
1213
|
1295
|
self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
|
|
1214
|
1296
|
self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
|
|
1215
|
|
- self.chatStatusLabel.stringValue = "Ask for another role, company, or skill match"
|
|
|
1297
|
+ self.setChatStatusLabel("Ask for another role, company, or skill match")
|
|
1216
|
1298
|
case .failure(let error):
|
|
1217
|
1299
|
self.appendChatBubble(text: error.localizedDescription, isUser: false)
|
|
1218
|
1300
|
if error is URLError {
|
|
1219
|
|
- self.chatStatusLabel.stringValue = "Could not reach API. Try again."
|
|
|
1301
|
+ self.setChatStatusLabel("Could not reach API. Try again.")
|
|
1220
|
1302
|
} else {
|
|
1221
|
|
- self.chatStatusLabel.stringValue = "Search did not finish. Try again."
|
|
|
1303
|
+ self.setChatStatusLabel("Search did not finish. Try again.")
|
|
1222
|
1304
|
}
|
|
1223
|
1305
|
}
|
|
1224
|
1306
|
}
|
|
|
@@ -1327,7 +1409,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1327
|
1409
|
func controlTextDidBeginEditing(_ obj: Notification) {
|
|
1328
|
1410
|
applySearchFieldInsertionPoint(obj.object)
|
|
1329
|
1411
|
if (obj.object as? NSTextField) === jobKeywordsField {
|
|
1330
|
|
- chatStatusLabel.stringValue = "Opening the vault..."
|
|
|
1412
|
+ setChatStatusLabel("Opening the vault...")
|
|
1331
|
1413
|
}
|
|
1332
|
1414
|
}
|
|
1333
|
1415
|
|
|
|
@@ -1360,7 +1442,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1360
|
1442
|
let welcome = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary."
|
|
1361
|
1443
|
chatMessages.append(ChatMessage(role: "assistant", content: welcome))
|
|
1362
|
1444
|
appendChatBubble(text: welcome, isUser: false)
|
|
1363
|
|
- chatStatusLabel.stringValue = "Ask me to find jobs"
|
|
|
1445
|
+ setChatStatusLabel("Ask me to find jobs")
|
|
1364
|
1446
|
}
|
|
1365
|
1447
|
|
|
1366
|
1448
|
private func appendChatBubble(text: String, isUser: Bool, jobs: [JobListing]? = nil) {
|
|
|
@@ -1376,9 +1458,25 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1376
|
1458
|
chatStack.addArrangedSubview(host)
|
|
1377
|
1459
|
host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
|
|
1378
|
1460
|
|
|
|
1461
|
+ if prefersReducedMotion {
|
|
|
1462
|
+ host.alphaValue = 1
|
|
|
1463
|
+ } else {
|
|
|
1464
|
+ host.alphaValue = 0
|
|
|
1465
|
+ }
|
|
1379
|
1466
|
DispatchQueue.main.async { [weak self] in
|
|
1380
|
|
- self?.updateChatBubbleWidths()
|
|
1381
|
|
- self?.scrollChatToBottom()
|
|
|
1467
|
+ guard let self else { return }
|
|
|
1468
|
+ if self.prefersReducedMotion {
|
|
|
1469
|
+ self.updateChatBubbleWidths()
|
|
|
1470
|
+ self.scrollChatToBottom()
|
|
|
1471
|
+ return
|
|
|
1472
|
+ }
|
|
|
1473
|
+ NSAnimationContext.runAnimationGroup { ctx in
|
|
|
1474
|
+ ctx.duration = 0.3
|
|
|
1475
|
+ ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
|
|
1476
|
+ host.animator().alphaValue = 1
|
|
|
1477
|
+ }
|
|
|
1478
|
+ self.updateChatBubbleWidths()
|
|
|
1479
|
+ self.scrollChatToBottom()
|
|
1382
|
1480
|
}
|
|
1383
|
1481
|
}
|
|
1384
|
1482
|
|