Support configuring effort alongside model

This commit is contained in:
Ian Gulliver
2026-10-23 08:40:00 -07:00
parent 6bbae3fffe
commit d6d9dd86d9
40 changed files with 805 additions and 541 deletions
+54 -3
View File
@@ -80,6 +80,9 @@ func uploadImages(_ api: API, _ imgs: [PendingImage]) async throws -> [String]?
@MainActor
var modelChoices: [String: String] = [:]
@MainActor
var effortChoices: [String: String] = [:]
func vendorOf(_ model: String) -> String {
if model.hasPrefix("gpt-") {
return "codex"
@@ -90,6 +93,18 @@ func vendorOf(_ model: String) -> String {
return "claude"
}
func efforts(_ model: String) -> [String] {
vendorOf(model) == "codex" ? ["", "low", "medium", "high", "xhigh"] : ["", "low", "medium", "high", "xhigh", "max"]
}
func effortLabel(_ effort: String) -> String {
effort.isEmpty ? "default effort" : "\(effort) effort"
}
func effortFor(_ model: String, _ effort: String) -> String {
efforts(model).contains(effort) ? effort : ""
}
@MainActor
@Observable
final class ImageDrafts {
@@ -392,7 +407,9 @@ struct ComposerMenu: View {
let photos: Bool
var models: [String] = []
var selected = ""
var effort = ""
var choose: @MainActor @Sendable (String) -> Void = { _ in }
var chooseEffort: @MainActor @Sendable (String) -> Void = { _ in }
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var picking = false
@@ -419,6 +436,12 @@ struct ComposerMenu: View {
}
}
.pickerStyle(.menu)
Picker("Effort for the next turn", selection: Binding(get: { effort }, set: chooseEffort)) {
ForEach(efforts(selected), id: \.self) { option in
Text(effortLabel(option)).tag(option)
}
}
.pickerStyle(.menu)
}
} label: {
Image(systemName: "ellipsis")
@@ -548,6 +571,7 @@ struct ComposerView: View {
@Environment(\.inTask) private var inTask
@ViewState private var text = ""
@ViewState private var pick: String?
@ViewState private var effortPick: String?
@ViewState private var shownOverride: Bool?
@FocusState private var focused: Bool
@@ -579,19 +603,28 @@ struct ComposerView: View {
private struct ModelState {
let live: AgentSession?
let current: String
let currentEffort: String
let options: [String]
let modelKey: String
let selected: String
let selectedEffort: String
var changed: Bool {
selected != current || selectedEffort != currentEffort
}
}
private func modelState() -> ModelState {
let live = chat.closed ? chat.session : chat.liveSession
let current = live.map { $0.model.isEmpty ? chat.builder.context[$0.id]?.model ?? "" : $0.model } ?? ""
let currentEffort = live?.effort ?? ""
let offered = live.map { s in models.filter { vendorOf($0) == s.vendor.rawValue } } ?? models
let options = !current.isEmpty && !offered.contains(current) ? [current] + offered : offered
let modelKey = "message:\(live?.id ?? key)"
let fresh = live == nil ? store.defaultModel : options[0]
return ModelState(live: live, current: current, options: options, modelKey: modelKey, selected: pick ?? modelChoices[modelKey] ?? (current.isEmpty ? fresh : current))
let selected = pick ?? modelChoices[modelKey] ?? (current.isEmpty ? fresh : current)
let effort = effortPick ?? effortChoices[modelKey] ?? (live == nil ? store.defaultEffort : currentEffort)
return ModelState(live: live, current: current, currentEffort: currentEffort, options: options, modelKey: modelKey, selected: selected, selectedEffort: effortFor(selected, effort))
}
var body: some View {
@@ -668,6 +701,7 @@ struct ComposerView: View {
}
.onChange(of: m.modelKey) {
pick = nil
effortPick = nil
}
.onChange(of: overlays.leadFocus) {
if chat.kind == .lead {
@@ -702,9 +736,12 @@ struct ComposerView: View {
.buttonStyle(WebButtonStyle())
.help(shown ? "Hide the message box" : "Show the message box")
#if os(iOS)
ComposerMenu(key: draftKey, photos: shown, models: chat.team ? [] : m.options, selected: m.selected) { option in
ComposerMenu(key: draftKey, photos: shown, models: chat.team ? [] : m.options, selected: m.selected, effort: m.selectedEffort) { option in
pick = option
modelChoices[m.modelKey] = option
} chooseEffort: { option in
effortPick = option
effortChoices[m.modelKey] = option
}
#else
if !chat.team {
@@ -720,6 +757,18 @@ struct ComposerView: View {
.pickerStyle(.menu)
.fixedSize()
.help("Model for the next turn")
Picker("", selection: Binding(get: { m.selectedEffort }, set: { v in
effortPick = v
effortChoices[m.modelKey] = v
})) {
ForEach(efforts(m.selected), id: \.self) { option in
Text(effortLabel(option)).tag(option)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Effort for the next turn")
}
#endif
}
@@ -799,9 +848,11 @@ struct ComposerView: View {
ids = try await uploadImages(store.api, imgs)
uploaded = true
}
try await chat.send(sessionId: sessionId, text: value, images: ids, model: m.selected != m.current ? m.selected : nil, firstModel: m.selected, sendID: sendID)
try await chat.send(sessionId: sessionId, text: value, images: ids, model: m.changed ? m.selected : nil, effort: m.changed ? m.selectedEffort : nil, firstModel: m.selected, firstEffort: m.selectedEffort, sendID: sendID)
modelChoices[m.modelKey] = nil
effortChoices[m.modelKey] = nil
pick = nil
effortPick = nil
drafts.endSend(sent, true)
return
} catch {