|
|
@@ -107,12 +107,24 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
107
|
107
|
private var selectedSidebarIndex: Int = 0
|
|
108
|
108
|
/// 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.
|
|
109
|
109
|
private var lastSearchResults: [JobListing] = []
|
|
|
110
|
+ /// "Show more jobs" row under the latest assistant message that listed jobs; removed when a newer listing block replaces it.
|
|
|
111
|
+ private var trailingLoadMoreJobsRow: NSView?
|
|
|
112
|
+ private weak var trailingLoadMoreJobsButton: HoverableButton?
|
|
110
|
113
|
/// Most recently saved jobs appear first; persisted across launches.
|
|
111
|
114
|
private var savedJobOrder: [JobListing] = []
|
|
112
|
115
|
private var chatMessages: [ChatMessage] = []
|
|
113
|
116
|
private var isAwaitingResponse = false
|
|
114
|
117
|
private let jobSearchService = OpenAIJobSearchService()
|
|
115
|
118
|
|
|
|
119
|
+ /// Upper bound sent to the model per request (was fixed at 8 in the prompt). Clamped when calling the API.
|
|
|
120
|
+ private static let jobsPerSearchDefault = 15
|
|
|
121
|
+ private static let jobsPerSearchMin = 1
|
|
|
122
|
+ private static let jobsPerSearchMaxCap = 25
|
|
|
123
|
+
|
|
|
124
|
+ private static func clampedJobsPerRequest(_ requested: Int = jobsPerSearchDefault) -> Int {
|
|
|
125
|
+ min(jobsPerSearchMaxCap, max(jobsPerSearchMin, requested))
|
|
|
126
|
+ }
|
|
|
127
|
+
|
|
116
|
128
|
private static let chatStatusSparklePulseKey = "chatStatusSparklePulse"
|
|
117
|
129
|
private static let welcomeSubtitleBreathKey = "welcomeSubtitleBreath"
|
|
118
|
130
|
|
|
|
@@ -1308,11 +1320,27 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1308
|
1320
|
appendChatBubble(text: prompt, isUser: true)
|
|
1309
|
1321
|
chatMessages.append(ChatMessage(role: "user", content: prompt))
|
|
1310
|
1322
|
jobKeywordsField.stringValue = ""
|
|
|
1323
|
+ startJobSearchRequest(effectiveQuery: effectiveQuery, isContinuation: isContinuation)
|
|
|
1324
|
+ window?.makeFirstResponder(nil)
|
|
|
1325
|
+ }
|
|
|
1326
|
+
|
|
|
1327
|
+ @objc private func didTapLoadMoreJobs() {
|
|
|
1328
|
+ let prompt = "Show more jobs"
|
|
|
1329
|
+ guard !isAwaitingResponse, isContinuationPrompt(prompt) else { return }
|
|
|
1330
|
+ if anchorUserJobQuery(excludingLatestUserMessage: prompt) == nil { return }
|
|
|
1331
|
+ appendChatBubble(text: prompt, isUser: true)
|
|
|
1332
|
+ chatMessages.append(ChatMessage(role: "user", content: prompt))
|
|
|
1333
|
+ let effectiveQuery = resolvedSearchQuery(for: prompt)
|
|
|
1334
|
+ startJobSearchRequest(effectiveQuery: effectiveQuery, isContinuation: true)
|
|
|
1335
|
+ }
|
|
|
1336
|
+
|
|
|
1337
|
+ private func startJobSearchRequest(effectiveQuery: String, isContinuation: Bool) {
|
|
1311
|
1338
|
isAwaitingResponse = true
|
|
1312
|
1339
|
setChatStatusLabel("Thinking...")
|
|
1313
|
1340
|
setInputEnabled(false)
|
|
1314
|
1341
|
let contextMessages = chatMessages
|
|
1315
|
|
- jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages) { [weak self] result in
|
|
|
1342
|
+ let maxJobs = Self.clampedJobsPerRequest()
|
|
|
1343
|
+ jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages, maxJobs: maxJobs) { [weak self] result in
|
|
1316
|
1344
|
DispatchQueue.main.async {
|
|
1317
|
1345
|
guard let self else { return }
|
|
1318
|
1346
|
self.isAwaitingResponse = false
|
|
|
@@ -1347,7 +1375,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1347
|
1375
|
}
|
|
1348
|
1376
|
}
|
|
1349
|
1377
|
}
|
|
1350
|
|
- window?.makeFirstResponder(nil)
|
|
1351
|
1378
|
}
|
|
1352
|
1379
|
|
|
1353
|
1380
|
private func normalizedJobs(_ jobs: [JobListing]) -> [JobListing] {
|
|
|
@@ -1475,6 +1502,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1475
|
1502
|
}
|
|
1476
|
1503
|
|
|
1477
|
1504
|
private func resetChatState() {
|
|
|
1505
|
+ trailingLoadMoreJobsRow = nil
|
|
|
1506
|
+ trailingLoadMoreJobsButton = nil
|
|
1478
|
1507
|
chatMessages.removeAll()
|
|
1479
|
1508
|
lastSearchResults.removeAll()
|
|
1480
|
1509
|
chatStack.arrangedSubviews.forEach {
|
|
|
@@ -1550,9 +1579,16 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1550
|
1579
|
column.translatesAutoresizingMaskIntoConstraints = false
|
|
1551
|
1580
|
|
|
1552
|
1581
|
if let jobs, !jobs.isEmpty {
|
|
|
1582
|
+ trailingLoadMoreJobsRow?.removeFromSuperview()
|
|
|
1583
|
+ trailingLoadMoreJobsRow = nil
|
|
|
1584
|
+ trailingLoadMoreJobsButton = nil
|
|
1553
|
1585
|
let jobsStack = makeChatJobsStackView(jobs: jobs)
|
|
1554
|
1586
|
column.addArrangedSubview(jobsStack)
|
|
1555
|
1587
|
jobsStack.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true
|
|
|
1588
|
+ let moreRow = makeLoadMoreJobsRowView()
|
|
|
1589
|
+ column.addArrangedSubview(moreRow)
|
|
|
1590
|
+ moreRow.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true
|
|
|
1591
|
+ trailingLoadMoreJobsRow = moreRow
|
|
1556
|
1592
|
}
|
|
1557
|
1593
|
|
|
1558
|
1594
|
host.addSubview(avatar)
|
|
|
@@ -1633,6 +1669,29 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1633
|
1669
|
return view
|
|
1634
|
1670
|
}
|
|
1635
|
1671
|
|
|
|
1672
|
+ private func makeLoadMoreJobsRowView() -> NSView {
|
|
|
1673
|
+ let row = NSView()
|
|
|
1674
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1675
|
+ let button = HoverableButton()
|
|
|
1676
|
+ button.pointerCursor = true
|
|
|
1677
|
+ button.title = "Show more jobs"
|
|
|
1678
|
+ button.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
|
1679
|
+ button.bezelStyle = .rounded
|
|
|
1680
|
+ button.controlSize = .regular
|
|
|
1681
|
+ button.contentTintColor = Theme.brandBlue
|
|
|
1682
|
+ button.target = self
|
|
|
1683
|
+ button.action = #selector(didTapLoadMoreJobs)
|
|
|
1684
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1685
|
+ trailingLoadMoreJobsButton = button
|
|
|
1686
|
+ row.addSubview(button)
|
|
|
1687
|
+ NSLayoutConstraint.activate([
|
|
|
1688
|
+ button.leadingAnchor.constraint(equalTo: row.leadingAnchor),
|
|
|
1689
|
+ button.topAnchor.constraint(equalTo: row.topAnchor, constant: 2),
|
|
|
1690
|
+ button.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -2)
|
|
|
1691
|
+ ])
|
|
|
1692
|
+ return row
|
|
|
1693
|
+ }
|
|
|
1694
|
+
|
|
1636
|
1695
|
private func makeChatJobsStackView(jobs: [JobListing]) -> ChatJobsStackView {
|
|
1637
|
1696
|
let stack = ChatJobsStackView()
|
|
1638
|
1697
|
stack.orientation = .vertical
|
|
|
@@ -1659,6 +1718,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1659
|
1718
|
jobKeywordsField.isEnabled = enabled
|
|
1660
|
1719
|
findJobsButton.isEnabled = enabled
|
|
1661
|
1720
|
findJobsButton.alphaValue = enabled ? 1 : 0.65
|
|
|
1721
|
+ trailingLoadMoreJobsButton?.isEnabled = enabled
|
|
|
1722
|
+ trailingLoadMoreJobsButton?.alphaValue = enabled ? 1 : 0.65
|
|
1662
|
1723
|
}
|
|
1663
|
1724
|
|
|
1664
|
1725
|
private func configureSidebar() {
|
|
|
@@ -1885,7 +1946,8 @@ private final class OpenAIJobSearchService {
|
|
1885
|
1946
|
private let endpoint = URL(string: "https://api.openai.com/v1/responses")!
|
|
1886
|
1947
|
private let session = URLSession(configuration: .ephemeral)
|
|
1887
|
1948
|
|
|
1888
|
|
- func searchJobs(query: String, conversation: [ChatMessage], completion: @escaping (Result<JobSearchOutput, Error>) -> Void) {
|
|
|
1949
|
+ func searchJobs(query: String, conversation: [ChatMessage], maxJobs: Int, completion: @escaping (Result<JobSearchOutput, Error>) -> Void) {
|
|
|
1950
|
+ let jobLimit = max(1, min(maxJobs, 25))
|
|
1889
|
1951
|
let apiKey = OpenAIConfiguration.apiKey
|
|
1890
|
1952
|
guard OpenAIConfiguration.hasAPIKey else {
|
|
1891
|
1953
|
completion(.failure(NSError(
|
|
|
@@ -1927,7 +1989,8 @@ private final class OpenAIJobSearchService {
|
|
1927
|
1989
|
- The JSON must match exactly this shape (lowercase key "jobs"):
|
|
1928
|
1990
|
{"jobs":[{"title":"...","description":"...","url":"https://..."}]}
|
|
1929
|
1991
|
- If you find no suitable listings, return {"jobs":[]} — still valid JSON only.
|
|
1930
|
|
- Keep each description to one sentence; prefer real listing URLs; at most 8 jobs.
|
|
|
1992
|
+ 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.
|
|
|
1993
|
+ Keep each description to one sentence; prefer real listing URLs.
|
|
1931
|
1994
|
"""
|
|
1932
|
1995
|
let payload = OpenAIResponsesRequest(
|
|
1933
|
1996
|
model: "gpt-4o-mini",
|