|
|
@@ -87,10 +87,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
private let chatScrollView = NSScrollView()
|
|
|
private let chatDocumentView = JobListingsDocumentView()
|
|
|
private let chatStack = NSStackView()
|
|
|
- private let jobListingsScrollView = NSScrollView()
|
|
|
- /// Flipped so short result lists stay visually under the search bar instead of leaving a gap above the cards.
|
|
|
- private let jobListingsContainer = JobListingsDocumentView()
|
|
|
- private let jobListingsStack = NSStackView()
|
|
|
/// Shown when a sidebar item other than Home is selected.
|
|
|
private let nonHomeHost = NSView()
|
|
|
private let nonHomeGenericContainer = NSView()
|
|
|
@@ -107,9 +103,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
private var currentSidebarItems: [SidebarItem] = []
|
|
|
private var selectedSidebarIndex: Int = 0
|
|
|
- /// Last successful search result set (used when removing a card with the dismiss control).
|
|
|
+ /// 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.
|
|
|
private var lastSearchResults: [JobListing] = []
|
|
|
- private var lastNoResultsQuery: String?
|
|
|
/// Most recently saved jobs appear first; persisted across launches.
|
|
|
private var savedJobOrder: [JobListing] = []
|
|
|
private var chatMessages: [ChatMessage] = []
|
|
|
@@ -132,6 +127,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
findJobsCTAGradientLayer?.frame = findJobsCTAChrome.bounds
|
|
|
updateFindJobsCTAShadowPath()
|
|
|
updateJobListingDescriptionWidths()
|
|
|
+ updateChatBubbleWidths()
|
|
|
}
|
|
|
|
|
|
func render(_ data: DashboardData) {
|
|
|
@@ -143,7 +139,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
|
configureSidebar()
|
|
|
savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load())
|
|
|
- configureJobListings([], noResultsForQuery: nil)
|
|
|
resetChatState()
|
|
|
updateMainContentVisibility()
|
|
|
}
|
|
|
@@ -220,46 +215,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
let midSpacer = NSView()
|
|
|
midSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- midSpacer.heightAnchor.constraint(equalToConstant: 20).isActive = true
|
|
|
-
|
|
|
- let listingsTopSpacer = NSView()
|
|
|
- listingsTopSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- listingsTopSpacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
|
|
|
-
|
|
|
- jobListingsContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- jobListingsStack.orientation = .vertical
|
|
|
- jobListingsStack.spacing = 14
|
|
|
- // `.leading` keeps cards left-anchored; explicit width constraints below stretch each card across the full row.
|
|
|
- jobListingsStack.alignment = .leading
|
|
|
- jobListingsStack.distribution = .fill
|
|
|
- jobListingsStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- jobListingsStack.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
|
|
- jobListingsStack.setHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
- jobListingsContainer.addSubview(jobListingsStack)
|
|
|
- NSLayoutConstraint.activate([
|
|
|
- jobListingsStack.leadingAnchor.constraint(equalTo: jobListingsContainer.leadingAnchor),
|
|
|
- jobListingsStack.trailingAnchor.constraint(equalTo: jobListingsContainer.trailingAnchor),
|
|
|
- jobListingsStack.topAnchor.constraint(equalTo: jobListingsContainer.topAnchor),
|
|
|
- jobListingsStack.bottomAnchor.constraint(equalTo: jobListingsContainer.bottomAnchor)
|
|
|
- ])
|
|
|
+ midSpacer.heightAnchor.constraint(equalToConstant: 18).isActive = true
|
|
|
+
|
|
|
+ let chatTopSpacer = NSView()
|
|
|
+ chatTopSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ chatTopSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true
|
|
|
|
|
|
- jobListingsScrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- jobListingsScrollView.hasVerticalScroller = true
|
|
|
- jobListingsScrollView.hasHorizontalScroller = false
|
|
|
- jobListingsScrollView.autohidesScrollers = true
|
|
|
- jobListingsScrollView.drawsBackground = false
|
|
|
- jobListingsScrollView.borderType = .noBorder
|
|
|
- jobListingsScrollView.documentView = jobListingsContainer
|
|
|
- jobListingsScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
|
- jobListingsScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
+ let chatBottomSpacer = NSView()
|
|
|
+ chatBottomSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ chatBottomSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true
|
|
|
|
|
|
mainOverlay.addArrangedSubview(topInset)
|
|
|
mainOverlay.addArrangedSubview(titleBlock)
|
|
|
mainOverlay.addArrangedSubview(midSpacer)
|
|
|
mainOverlay.addArrangedSubview(chatStatusStack)
|
|
|
- mainOverlay.addArrangedSubview(listingsTopSpacer)
|
|
|
+ mainOverlay.addArrangedSubview(chatTopSpacer)
|
|
|
mainOverlay.addArrangedSubview(chatScrollView)
|
|
|
- mainOverlay.addArrangedSubview(jobListingsScrollView)
|
|
|
+ mainOverlay.addArrangedSubview(chatBottomSpacer)
|
|
|
mainOverlay.addArrangedSubview(searchBarShadowHost)
|
|
|
|
|
|
contentStack.addArrangedSubview(sidebar)
|
|
|
@@ -292,12 +264,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
chatStatusStack.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
- jobListingsScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
-
|
|
|
- jobListingsContainer.topAnchor.constraint(equalTo: jobListingsScrollView.contentView.topAnchor),
|
|
|
- jobListingsContainer.leadingAnchor.constraint(equalTo: jobListingsScrollView.contentView.leadingAnchor),
|
|
|
- jobListingsContainer.widthAnchor.constraint(equalTo: jobListingsScrollView.contentView.widthAnchor),
|
|
|
- jobListingsScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
|
|
|
|
|
greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 16),
|
|
|
greetingLabel.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor, constant: -16),
|
|
|
@@ -327,16 +293,16 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
chatDocumentView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
chatStack.orientation = .vertical
|
|
|
- chatStack.spacing = 10
|
|
|
+ chatStack.spacing = 18
|
|
|
chatStack.alignment = .width
|
|
|
chatStack.distribution = .fill
|
|
|
chatStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
chatDocumentView.addSubview(chatStack)
|
|
|
NSLayoutConstraint.activate([
|
|
|
- chatStack.leadingAnchor.constraint(equalTo: chatDocumentView.leadingAnchor),
|
|
|
- chatStack.trailingAnchor.constraint(equalTo: chatDocumentView.trailingAnchor),
|
|
|
- chatStack.topAnchor.constraint(equalTo: chatDocumentView.topAnchor, constant: 4),
|
|
|
- chatStack.bottomAnchor.constraint(equalTo: chatDocumentView.bottomAnchor, constant: -4)
|
|
|
+ chatStack.leadingAnchor.constraint(equalTo: chatDocumentView.leadingAnchor, constant: 4),
|
|
|
+ chatStack.trailingAnchor.constraint(equalTo: chatDocumentView.trailingAnchor, constant: -4),
|
|
|
+ chatStack.topAnchor.constraint(equalTo: chatDocumentView.topAnchor, constant: 8),
|
|
|
+ chatStack.bottomAnchor.constraint(equalTo: chatDocumentView.bottomAnchor, constant: -8)
|
|
|
])
|
|
|
|
|
|
chatScrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -347,12 +313,41 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
chatScrollView.borderType = .noBorder
|
|
|
chatScrollView.documentView = chatDocumentView
|
|
|
chatScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
|
- chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 260).isActive = true
|
|
|
+ chatScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
+ chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true
|
|
|
}
|
|
|
|
|
|
private func updateJobListingDescriptionWidths() {
|
|
|
- updateDescriptionColumnWidths(in: jobListingsStack, containerWidth: jobListingsContainer.bounds.width)
|
|
|
updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
|
|
|
+ walkChatJobStacks { stack in
|
|
|
+ updateDescriptionColumnWidths(in: stack, containerWidth: stack.bounds.width)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func walkChatJobStacks(_ visitor: (ChatJobsStackView) -> Void) {
|
|
|
+ func walk(_ view: NSView) {
|
|
|
+ if let stack = view as? ChatJobsStackView {
|
|
|
+ visitor(stack)
|
|
|
+ }
|
|
|
+ for sub in view.subviews { walk(sub) }
|
|
|
+ }
|
|
|
+ walk(chatStack)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 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.
|
|
|
+ private func updateChatBubbleWidths() {
|
|
|
+ func walk(_ view: NSView) {
|
|
|
+ if let label = view as? ChatBubbleLabel,
|
|
|
+ let bubble = label.superview, bubble.bounds.width > 1 {
|
|
|
+ let target = max(40, bubble.bounds.width - 28)
|
|
|
+ if abs(label.preferredMaxLayoutWidth - target) > 0.5 {
|
|
|
+ label.preferredMaxLayoutWidth = target
|
|
|
+ label.invalidateIntrinsicContentSize()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for sub in view.subviews { walk(sub) }
|
|
|
+ }
|
|
|
+ walk(chatStack)
|
|
|
}
|
|
|
|
|
|
private func updateDescriptionColumnWidths(in stack: NSStackView, containerWidth: CGFloat) {
|
|
|
@@ -383,34 +378,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func configureJobListings(_ jobs: [JobListing], noResultsForQuery: String?, updateLastResults: Bool = true) {
|
|
|
- if updateLastResults {
|
|
|
- lastSearchResults = jobs
|
|
|
- lastNoResultsQuery = noResultsForQuery
|
|
|
- }
|
|
|
- jobListingsStack.arrangedSubviews.forEach {
|
|
|
- jobListingsStack.removeArrangedSubview($0)
|
|
|
- $0.removeFromSuperview()
|
|
|
- }
|
|
|
- if jobs.isEmpty, let query = noResultsForQuery, !query.isEmpty {
|
|
|
- let empty = NSTextField(wrappingLabelWithString: "No jobs match “\(query)”. Try different keywords or browse the full list with an empty search.")
|
|
|
- empty.font = .systemFont(ofSize: 14, weight: .regular)
|
|
|
- empty.textColor = Theme.secondaryText
|
|
|
- empty.alignment = .center
|
|
|
- empty.maximumNumberOfLines = 0
|
|
|
- empty.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- jobListingsStack.addArrangedSubview(empty)
|
|
|
- empty.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
|
|
|
- return
|
|
|
- }
|
|
|
- for job in jobs {
|
|
|
- let card = makeJobListingCard(job, context: .homeSearchResults)
|
|
|
- jobListingsStack.addArrangedSubview(card)
|
|
|
- // Force every card to span the full row instead of hugging its intrinsic content width.
|
|
|
- card.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
private static func normalizedSavedJobs(_ jobs: [JobListing]) -> [JobListing] {
|
|
|
var seen = Set<JobListing>()
|
|
|
var out: [JobListing] = []
|
|
|
@@ -632,15 +599,30 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return }
|
|
|
switch button.cardContext {
|
|
|
case .homeSearchResults:
|
|
|
- lastSearchResults.removeAll { $0 == job }
|
|
|
- configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery)
|
|
|
+ removeJobCardFromChat(originating: button, job: job)
|
|
|
case .savedJobsPage:
|
|
|
applySavedState(false, for: job)
|
|
|
reloadSavedJobsListings()
|
|
|
- if isHomeSidebarIndex(selectedSidebarIndex), !lastSearchResults.isEmpty {
|
|
|
- configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery, updateLastResults: false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 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.
|
|
|
+ private func removeJobCardFromChat(originating button: NSView, job: JobListing) {
|
|
|
+ var node: NSView? = button
|
|
|
+ var card: NSView?
|
|
|
+ var stack: ChatJobsStackView?
|
|
|
+ while let v = node {
|
|
|
+ if let parent = v.superview as? ChatJobsStackView {
|
|
|
+ card = v
|
|
|
+ stack = parent
|
|
|
+ break
|
|
|
}
|
|
|
+ node = v.superview
|
|
|
}
|
|
|
+ guard let card, let stack else { return }
|
|
|
+ stack.removeArrangedSubview(card)
|
|
|
+ card.removeFromSuperview()
|
|
|
+ lastSearchResults.removeAll { $0 == job }
|
|
|
}
|
|
|
|
|
|
private func configureSearchBar() {
|
|
|
@@ -1166,15 +1148,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /// Removes result cards or the “no matches” message so a new search starts from a blank listing area.
|
|
|
- private func clearJobSearchResultsUI() {
|
|
|
- configureJobListings([], noResultsForQuery: nil)
|
|
|
- if let doc = jobListingsScrollView.documentView {
|
|
|
- doc.scroll(NSPoint(x: 0, y: 0))
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /// Restores the main job-search experience: cleared query and no listings until the user searches again.
|
|
|
+ /// Restores the main job-search experience: cleared query and a fresh chat history.
|
|
|
private func applyHomeState() {
|
|
|
jobKeywordsField.stringValue = ""
|
|
|
resetChatState()
|
|
|
@@ -1196,7 +1170,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
@objc private func didSubmitSearch() {
|
|
|
let prompt = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
guard !prompt.isEmpty, !isAwaitingResponse else { return }
|
|
|
- clearJobSearchResultsUI()
|
|
|
+ let isContinuation = isContinuationPrompt(prompt)
|
|
|
+ let effectiveQuery = resolvedSearchQuery(for: prompt)
|
|
|
appendChatBubble(text: prompt, isUser: true)
|
|
|
chatMessages.append(ChatMessage(role: "user", content: prompt))
|
|
|
jobKeywordsField.stringValue = ""
|
|
|
@@ -1204,7 +1179,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
chatStatusLabel.stringValue = "Thinking..."
|
|
|
setInputEnabled(false)
|
|
|
let contextMessages = chatMessages
|
|
|
- jobSearchService.searchJobs(query: prompt, conversation: contextMessages) { [weak self] result in
|
|
|
+ jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages) { [weak self] result in
|
|
|
DispatchQueue.main.async {
|
|
|
guard let self else { return }
|
|
|
self.isAwaitingResponse = false
|
|
|
@@ -1212,15 +1187,26 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
switch result {
|
|
|
case .success(let output):
|
|
|
let normalizedJobs = self.normalizedJobs(output.jobs)
|
|
|
- self.configureJobListings(normalizedJobs, noResultsForQuery: prompt)
|
|
|
- let reply = self.makeAssistantSearchReply(query: prompt, jobs: normalizedJobs)
|
|
|
+ let freshJobs: [JobListing]
|
|
|
+ if isContinuation {
|
|
|
+ // Continuations append only the *new* matches; previous cards already live in their own assistant message above.
|
|
|
+ let alreadySeen = Set(self.lastSearchResults)
|
|
|
+ freshJobs = normalizedJobs.filter { !alreadySeen.contains($0) }
|
|
|
+ } else {
|
|
|
+ freshJobs = normalizedJobs
|
|
|
+ }
|
|
|
+ self.lastSearchResults.append(contentsOf: freshJobs)
|
|
|
+ let reply = self.makeAssistantSearchReply(
|
|
|
+ query: effectiveQuery,
|
|
|
+ newJobsCount: freshJobs.count,
|
|
|
+ isContinuation: isContinuation
|
|
|
+ )
|
|
|
self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
|
|
|
- self.appendChatBubble(text: reply, isUser: false)
|
|
|
- self.chatStatusLabel.stringValue = "Ask for another job, company, or skill match"
|
|
|
+ self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
|
|
|
+ self.chatStatusLabel.stringValue = "Ask for another role, company, or skill match"
|
|
|
case .failure(let error):
|
|
|
self.appendChatBubble(text: error.localizedDescription, isUser: false)
|
|
|
self.chatStatusLabel.stringValue = "Could not reach API. Try again."
|
|
|
- self.configureJobListings([], noResultsForQuery: prompt)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -1238,14 +1224,48 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
return trimmed.filter { !$0.title.isEmpty && !$0.description.isEmpty }
|
|
|
}
|
|
|
|
|
|
- private func makeAssistantSearchReply(query: String, jobs: [JobListing]) -> String {
|
|
|
- if jobs.isEmpty {
|
|
|
- return "No jobs were found for \"\(query)\". Try another title, skill, company, or location."
|
|
|
+ private func makeAssistantSearchReply(query: String, newJobsCount: Int, isContinuation: Bool) -> String {
|
|
|
+ if newJobsCount == 0 {
|
|
|
+ if isContinuation {
|
|
|
+ return "I couldn't find new matches for \u{201C}\(query)\u{201D}. Try a different angle or a more specific keyword."
|
|
|
+ }
|
|
|
+ return "No jobs found for \u{201C}\(query)\u{201D}. Try another title, skill, company, or location."
|
|
|
}
|
|
|
- return """
|
|
|
- Found \(jobs.count) job result(s) for "\(query)".
|
|
|
- Each result is shown as a card with the role, job description, and an Apply button that opens the job link.
|
|
|
- """
|
|
|
+ let plural = newJobsCount == 1 ? "match" : "matches"
|
|
|
+ if isContinuation {
|
|
|
+ return "Here are \(newJobsCount) more \(plural) for \u{201C}\(query)\u{201D}."
|
|
|
+ }
|
|
|
+ return "Found \(newJobsCount) \(plural) for \u{201C}\(query)\u{201D}. Tap Apply to open the listing or Save to revisit later."
|
|
|
+ }
|
|
|
+
|
|
|
+ private func resolvedSearchQuery(for prompt: String) -> String {
|
|
|
+ guard isContinuationPrompt(prompt) else { return prompt }
|
|
|
+ for message in chatMessages.reversed() where message.role == "user" {
|
|
|
+ let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if !candidate.isEmpty, !isContinuationPrompt(candidate) {
|
|
|
+ return candidate
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return prompt
|
|
|
+ }
|
|
|
+
|
|
|
+ private func isContinuationPrompt(_ prompt: String) -> Bool {
|
|
|
+ let normalized = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
+ let continuationPhrases: Set<String> = [
|
|
|
+ "more",
|
|
|
+ "show more",
|
|
|
+ "more jobs",
|
|
|
+ "more results",
|
|
|
+ "do more searches",
|
|
|
+ "more searches",
|
|
|
+ "search more",
|
|
|
+ "continue",
|
|
|
+ "next"
|
|
|
+ ]
|
|
|
+ if continuationPhrases.contains(normalized) {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ return normalized.contains("more search") || normalized.contains("more job")
|
|
|
}
|
|
|
|
|
|
func controlTextDidBeginEditing(_ obj: Notification) {
|
|
|
@@ -1276,6 +1296,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
private func resetChatState() {
|
|
|
chatMessages.removeAll()
|
|
|
+ lastSearchResults.removeAll()
|
|
|
chatStack.arrangedSubviews.forEach {
|
|
|
chatStack.removeArrangedSubview($0)
|
|
|
$0.removeFromSuperview()
|
|
|
@@ -1283,48 +1304,159 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
let welcome = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary."
|
|
|
chatMessages.append(ChatMessage(role: "assistant", content: welcome))
|
|
|
appendChatBubble(text: welcome, isUser: false)
|
|
|
- chatStatusLabel.stringValue = "Opening the vault..."
|
|
|
+ chatStatusLabel.stringValue = "Ask me to find jobs"
|
|
|
}
|
|
|
|
|
|
- private func appendChatBubble(text: String, isUser: Bool) {
|
|
|
+ private func appendChatBubble(text: String, isUser: Bool, jobs: [JobListing]? = nil) {
|
|
|
let host = NSView()
|
|
|
host.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
- let bubble = NSTextField(wrappingLabelWithString: text)
|
|
|
- bubble.font = .systemFont(ofSize: 13, weight: .regular)
|
|
|
- bubble.maximumNumberOfLines = 0
|
|
|
- bubble.lineBreakMode = .byWordWrapping
|
|
|
- bubble.alignment = .left
|
|
|
- bubble.textColor = isUser ? .white : Theme.primaryText
|
|
|
- bubble.wantsLayer = true
|
|
|
- bubble.layer?.cornerRadius = 12
|
|
|
- bubble.layer?.masksToBounds = true
|
|
|
- bubble.layer?.backgroundColor = (isUser ? Theme.brandBlue : Theme.chromeBackground).cgColor
|
|
|
- bubble.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ if isUser {
|
|
|
+ installUserBubble(text: text, into: host)
|
|
|
+ } else {
|
|
|
+ installAssistantBubble(text: text, jobs: jobs, into: host)
|
|
|
+ }
|
|
|
|
|
|
- host.addSubview(bubble)
|
|
|
chatStack.addArrangedSubview(host)
|
|
|
host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
|
|
|
|
|
|
- let leading = bubble.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 8)
|
|
|
- let trailing = bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor, constant: -8)
|
|
|
- let maxWidth = bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78)
|
|
|
- maxWidth.priority = .required
|
|
|
+ DispatchQueue.main.async { [weak self] in
|
|
|
+ self?.updateChatBubbleWidths()
|
|
|
+ self?.scrollChatToBottom()
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
+ private func installUserBubble(text: String, into host: NSView) {
|
|
|
+ let bubble = makeChatBubbleContainer(text: text, isUser: true)
|
|
|
+ host.addSubview(bubble)
|
|
|
NSLayoutConstraint.activate([
|
|
|
bubble.topAnchor.constraint(equalTo: host.topAnchor),
|
|
|
bubble.bottomAnchor.constraint(equalTo: host.bottomAnchor),
|
|
|
- bubble.heightAnchor.constraint(greaterThanOrEqualToConstant: 34),
|
|
|
- maxWidth,
|
|
|
- isUser ? leading.withPriority(.defaultLow) : leading.withPriority(.required),
|
|
|
- isUser ? trailing.withPriority(.required) : trailing.withPriority(.defaultLow)
|
|
|
+ bubble.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
|
|
+ bubble.leadingAnchor.constraint(greaterThanOrEqualTo: host.leadingAnchor, constant: 64),
|
|
|
+ bubble.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.78)
|
|
|
+ ])
|
|
|
+ }
|
|
|
+
|
|
|
+ private func installAssistantBubble(text: String, jobs: [JobListing]?, into host: NSView) {
|
|
|
+ let avatar = makeAssistantAvatarView()
|
|
|
+ let nameLabel = NSTextField(labelWithString: "AI Job Finder")
|
|
|
+ nameLabel.font = .systemFont(ofSize: 11, weight: .semibold)
|
|
|
+ nameLabel.textColor = Theme.secondaryText
|
|
|
+ nameLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ let bubble = makeChatBubbleContainer(text: text, isUser: false)
|
|
|
+
|
|
|
+ let column = NSStackView(views: [nameLabel, bubble])
|
|
|
+ column.orientation = .vertical
|
|
|
+ column.spacing = 6
|
|
|
+ column.alignment = .leading
|
|
|
+ column.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ if let jobs, !jobs.isEmpty {
|
|
|
+ let jobsStack = makeChatJobsStackView(jobs: jobs)
|
|
|
+ column.addArrangedSubview(jobsStack)
|
|
|
+ jobsStack.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true
|
|
|
+ }
|
|
|
+
|
|
|
+ host.addSubview(avatar)
|
|
|
+ host.addSubview(column)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ avatar.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
|
|
+ avatar.topAnchor.constraint(equalTo: host.topAnchor),
|
|
|
+ avatar.widthAnchor.constraint(equalToConstant: 36),
|
|
|
+ avatar.heightAnchor.constraint(equalToConstant: 36),
|
|
|
+
|
|
|
+ column.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 12),
|
|
|
+ column.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
|
|
+ column.topAnchor.constraint(equalTo: host.topAnchor),
|
|
|
+ column.bottomAnchor.constraint(equalTo: host.bottomAnchor)
|
|
|
])
|
|
|
+ }
|
|
|
|
|
|
- DispatchQueue.main.async {
|
|
|
- let maxY = max(0, self.chatDocumentView.bounds.height - self.chatScrollView.contentView.bounds.height)
|
|
|
- self.chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY))
|
|
|
- self.chatScrollView.reflectScrolledClipView(self.chatScrollView.contentView)
|
|
|
+ private func makeChatBubbleContainer(text: String, isUser: Bool) -> NSView {
|
|
|
+ let container = NSView()
|
|
|
+ container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ container.wantsLayer = true
|
|
|
+ container.layer?.cornerRadius = 14
|
|
|
+ if #available(macOS 11.0, *) {
|
|
|
+ container.layer?.cornerCurve = .continuous
|
|
|
}
|
|
|
+ container.layer?.masksToBounds = true
|
|
|
+ if isUser {
|
|
|
+ container.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
+ } else {
|
|
|
+ container.layer?.backgroundColor = Theme.chromeBackground.cgColor
|
|
|
+ container.layer?.borderWidth = 1
|
|
|
+ container.layer?.borderColor = Theme.border.cgColor
|
|
|
+ }
|
|
|
+
|
|
|
+ let label = ChatBubbleLabel(wrappingLabelWithString: text)
|
|
|
+ label.font = .systemFont(ofSize: 13.5, weight: .regular)
|
|
|
+ label.textColor = isUser ? .white : Theme.primaryText
|
|
|
+ label.maximumNumberOfLines = 0
|
|
|
+ label.lineBreakMode = .byWordWrapping
|
|
|
+ label.alignment = .left
|
|
|
+ label.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ label.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
+ label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
+
|
|
|
+ container.addSubview(label)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14),
|
|
|
+ label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14),
|
|
|
+ label.topAnchor.constraint(equalTo: container.topAnchor, constant: 10),
|
|
|
+ label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10)
|
|
|
+ ])
|
|
|
+ return container
|
|
|
+ }
|
|
|
+
|
|
|
+ private func makeAssistantAvatarView() -> NSView {
|
|
|
+ let view = NSView()
|
|
|
+ view.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ view.wantsLayer = true
|
|
|
+ view.layer?.cornerRadius = 18
|
|
|
+ if #available(macOS 11.0, *) {
|
|
|
+ view.layer?.cornerCurve = .continuous
|
|
|
+ }
|
|
|
+ view.layer?.backgroundColor = Theme.settingsIconBackground.cgColor
|
|
|
+ view.layer?.borderWidth = 1
|
|
|
+ view.layer?.borderColor = Theme.proCardBorder.cgColor
|
|
|
+
|
|
|
+ let icon = NSImageView()
|
|
|
+ icon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
|
|
|
+ icon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "AI Job Finder")
|
|
|
+ icon.contentTintColor = Theme.brandBlue
|
|
|
+
|
|
|
+ view.addSubview(icon)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ icon.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
|
+ icon.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
|
|
+ ])
|
|
|
+ return view
|
|
|
+ }
|
|
|
+
|
|
|
+ private func makeChatJobsStackView(jobs: [JobListing]) -> ChatJobsStackView {
|
|
|
+ let stack = ChatJobsStackView()
|
|
|
+ stack.orientation = .vertical
|
|
|
+ stack.spacing = 10
|
|
|
+ stack.alignment = .leading
|
|
|
+ stack.distribution = .fill
|
|
|
+ stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ for job in jobs {
|
|
|
+ let card = makeJobListingCard(job, context: .homeSearchResults)
|
|
|
+ stack.addArrangedSubview(card)
|
|
|
+ card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
|
+ }
|
|
|
+ return stack
|
|
|
+ }
|
|
|
+
|
|
|
+ private func scrollChatToBottom() {
|
|
|
+ let maxY = max(0, chatDocumentView.bounds.height - chatScrollView.contentView.bounds.height)
|
|
|
+ chatScrollView.contentView.scroll(to: NSPoint(x: 0, y: maxY))
|
|
|
+ chatScrollView.reflectScrolledClipView(chatScrollView.contentView)
|
|
|
}
|
|
|
|
|
|
private func setInputEnabled(_ enabled: Bool) {
|
|
|
@@ -1777,13 +1909,6 @@ private struct OpenAIAPIErrorResponse: Codable {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-private extension NSLayoutConstraint {
|
|
|
- func withPriority(_ priority: NSLayoutConstraint.Priority) -> NSLayoutConstraint {
|
|
|
- self.priority = priority
|
|
|
- return self
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
/// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
|
|
|
private final class JobPayloadButton: HoverableButton {
|
|
|
var jobPayload: JobListing?
|
|
|
@@ -1898,6 +2023,12 @@ private final class JobListingsDocumentView: NSView {
|
|
|
override var isFlipped: Bool { true }
|
|
|
}
|
|
|
|
|
|
+/// 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.
|
|
|
+private final class ChatJobsStackView: NSStackView {}
|
|
|
+
|
|
|
+/// 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.
|
|
|
+private final class ChatBubbleLabel: NSTextField {}
|
|
|
+
|
|
|
/// 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.
|
|
|
private final class SidebarNavRowView: NSView {
|
|
|
private let onSelect: () -> Void
|