Support configuring effort alongside model
This commit is contained in:
+16
-12
@@ -488,17 +488,17 @@ final class ChatModel {
|
||||
}
|
||||
}
|
||||
|
||||
func send(sessionId: String?, text: String, images: [String]?, model: String?, firstModel: String, sendID: String) async throws {
|
||||
func send(sessionId: String?, text: String, images: [String]?, model: String?, effort: String?, firstModel: String, firstEffort: String, sendID: String) async throws {
|
||||
let api = store.api.client
|
||||
switch kind {
|
||||
case .lead:
|
||||
_ = try await api.messageLead(body: .json(.init(text: text, images: images, model: sessionId != nil ? model : firstModel, sendId: sendID))).accepted
|
||||
_ = try await api.messageLead(body: .json(.init(text: text, images: images, model: sessionId != nil ? model : firstModel, effort: sessionId != nil ? effort : firstEffort, sendId: sendID))).accepted
|
||||
case .task(let id):
|
||||
if closed {
|
||||
guard let sessionId else {
|
||||
throw ApiError(status: 0, message: "This task has no agent conversation to continue.")
|
||||
}
|
||||
let task = try await api.continueSession(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: team ? nil : model, sendId: sendID))).created.body.json
|
||||
let task = try await api.continueSession(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: team ? nil : model, effort: team ? nil : effort, sendId: sendID))).created.body.json
|
||||
store.focusTask(task.id)
|
||||
return
|
||||
}
|
||||
@@ -510,10 +510,10 @@ final class ChatModel {
|
||||
return
|
||||
}
|
||||
if let sessionId {
|
||||
_ = try await api.sendMessage(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: model, sendId: sendID))).accepted
|
||||
_ = try await api.sendMessage(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: model, effort: effort, sendId: sendID))).accepted
|
||||
return
|
||||
}
|
||||
_ = try await api.createSession(path: .init(taskId: id ?? ""), body: .json(.init(prompt: text, model: firstModel, images: images, sendId: sendID))).created
|
||||
_ = try await api.createSession(path: .init(taskId: id ?? ""), body: .json(.init(prompt: text, model: firstModel, effort: firstEffort, images: images, sendId: sendID))).created
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,7 +522,7 @@ final class ChatModel {
|
||||
let cancel = builder.showPending(leadSetup, [], sendID)
|
||||
Task {
|
||||
do {
|
||||
try await send(sessionId: liveSession?.id, text: leadSetup, images: nil, model: nil, firstModel: store.defaultModel, sendID: sendID)
|
||||
try await send(sessionId: liveSession?.id, text: leadSetup, images: nil, model: nil, effort: nil, firstModel: store.defaultModel, firstEffort: store.defaultEffort, sendID: sendID)
|
||||
} catch {
|
||||
if cancel() {
|
||||
overlays.showAlert(errorText(error))
|
||||
@@ -663,7 +663,7 @@ private struct MemberPicker: View {
|
||||
PaneTabButton(label: s.state == .running ? "\(s.member) ●" : s.member, active: s.member == picked) {
|
||||
store.pickedMembers[taskId] = s.member
|
||||
}
|
||||
.help("Show \(s.member)'s transcript (\(s.model.isEmpty ? s.vendor.rawValue : s.model), \(s.state.rawValue))")
|
||||
.help("Show \(s.member)'s transcript (\(s.model.isEmpty ? s.vendor.rawValue : s.model)\(s.effort.isEmpty ? "" : " at \(effortLabel(s.effort))"), \(s.state.rawValue))")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -781,8 +781,12 @@ struct CompactButton: View {
|
||||
let disabled = sessionId == nil || state != .idle || sending
|
||||
Menu {
|
||||
ForEach(models, id: \.self) { m in
|
||||
Button("Continue on \(m)") {
|
||||
compact(m)
|
||||
Menu("Continue on \(m)") {
|
||||
ForEach(efforts(m), id: \.self) { e in
|
||||
Button("At \(effortLabel(e))") {
|
||||
compact(m, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -807,7 +811,7 @@ struct CompactButton: View {
|
||||
.opacity(disabled ? 0.45 : 1)
|
||||
.contentShape(Rectangle())
|
||||
} primaryAction: {
|
||||
compact(nil)
|
||||
compact(nil, nil)
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.menuIndicator(.hidden)
|
||||
@@ -820,14 +824,14 @@ struct CompactButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func compact(_ model: String?) {
|
||||
private func compact(_ model: String?, _ effort: String?) {
|
||||
guard let sessionId else {
|
||||
return
|
||||
}
|
||||
sending = true
|
||||
Task {
|
||||
do {
|
||||
_ = try await store.api.client.compactSession(path: .init(sessionId: sessionId), body: .json(.init(model: model))).accepted
|
||||
_ = try await store.api.client.compactSession(path: .init(sessionId: sessionId), body: .json(.init(model: model, effort: effort))).accepted
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
sending = false
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -48,12 +48,18 @@ struct SettingsView: View {
|
||||
DeviceNotifications()
|
||||
PrefsBox()
|
||||
heading("Default model")
|
||||
Text("New tasks you start from byre run on this model.")
|
||||
Text("New tasks you start from byre run on this model, at this effort.")
|
||||
.foregroundStyle(Palette.muted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
ModelMenu(current: store.defaultModel, help: "Model for new tasks") { m in
|
||||
try await store.api.client.updateDefaultModel(body: .json(.init(model: m))).ok.body.json
|
||||
HStack(spacing: 8) {
|
||||
PickMenu(current: store.defaultModel, options: models, label: { $0 }, help: "Model for new tasks") { m in
|
||||
try await store.api.client.updateDefaultModel(body: .json(.init(model: m, effort: effortFor(m, store.defaultEffort)))).ok.body.json
|
||||
}
|
||||
PickMenu(current: store.defaultEffort, options: efforts(store.defaultModel), label: effortLabel, help: "Effort for new tasks") { e in
|
||||
try await store.api.client.updateDefaultModel(body: .json(.init(model: store.defaultModel, effort: e))).ok.body.json
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
heading("Action button workflow")
|
||||
Text("The iPhone's Action button, set to byre's Run workflow shortcut, opens a page to type a prompt and start this workflow with it. When more than one server has one set, the page asks which to start.")
|
||||
.foregroundStyle(Palette.muted)
|
||||
@@ -415,8 +421,10 @@ private struct SavedCommands: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModelMenu: View {
|
||||
private struct PickMenu: View {
|
||||
let current: String
|
||||
let options: [String]
|
||||
let label: (String) -> String
|
||||
let help: String
|
||||
let pick: (String) async throws -> User
|
||||
@Environment(Store.self) private var store
|
||||
@@ -424,8 +432,8 @@ private struct ModelMenu: View {
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(models, id: \.self) { m in
|
||||
Button(m) {
|
||||
ForEach(options, id: \.self) { m in
|
||||
Button(label(m)) {
|
||||
Task {
|
||||
do {
|
||||
store.me = try await pick(m)
|
||||
@@ -437,7 +445,7 @@ private struct ModelMenu: View {
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Text(current)
|
||||
Text(label(current))
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(Fonts.font(9, weight: 600))
|
||||
}
|
||||
@@ -450,6 +458,5 @@ private struct ModelMenu: View {
|
||||
.menuIndicator(.hidden)
|
||||
.fixedSize()
|
||||
.help(help)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ final class Store {
|
||||
todoBusy[key] = discuss ? "discuss" : "start"
|
||||
Task {
|
||||
do {
|
||||
_ = try await api.client.startTodoItem(path: .init(projectId: projectId), body: .json(.init(title: title, model: defaultModel, discuss: discuss))).created
|
||||
_ = try await api.client.startTodoItem(path: .init(projectId: projectId), body: .json(.init(title: title, model: defaultModel, effort: defaultEffort, discuss: discuss))).created
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
@@ -181,7 +181,7 @@ final class Store {
|
||||
todoBusy[key] = "start"
|
||||
Task {
|
||||
do {
|
||||
_ = try await api.client.createTask(path: .init(projectId: projectId), body: .json(.init(title: title, prompt: brief, model: defaultModel))).created
|
||||
_ = try await api.client.createTask(path: .init(projectId: projectId), body: .json(.init(title: title, prompt: brief, model: defaultModel, effort: defaultEffort))).created
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
@@ -199,7 +199,7 @@ final class Store {
|
||||
let live = lead != nil && lead!.state != .completed
|
||||
Task {
|
||||
do {
|
||||
_ = try await api.client.messageLead(body: .json(.init(text: "Let's discuss your suggestion \"\(title)\" before starting it:\n\n\(brief)", model: live ? nil : defaultModel))).accepted
|
||||
_ = try await api.client.messageLead(body: .json(.init(text: "Let's discuss your suggestion \"\(title)\" before starting it:\n\n\(brief)", model: live ? nil : defaultModel, effort: live ? nil : defaultEffort))).accepted
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
@@ -330,6 +330,10 @@ final class Store {
|
||||
me!.defaultModel
|
||||
}
|
||||
|
||||
var defaultEffort: String {
|
||||
me!.defaultEffort
|
||||
}
|
||||
|
||||
func loadMe() async throws {
|
||||
me = try await api.client.getMe().ok.body.json
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ final class SwitcherModel {
|
||||
Task {
|
||||
do {
|
||||
let ids = try await uploadImages(store.api, imgs)
|
||||
let task = try await store.api.client.createTask(path: .init(projectId: projectId), body: .json(.init(prompt: message.isEmpty ? nil : message, images: ids, model: store.defaultModel))).created.body.json
|
||||
let task = try await store.api.client.createTask(path: .init(projectId: projectId), body: .json(.init(prompt: message.isEmpty ? nil : message, images: ids, model: store.defaultModel, effort: store.defaultEffort))).created.body.json
|
||||
store.composerFocus = task.id
|
||||
store.focusTask(task.id)
|
||||
} catch {
|
||||
|
||||
@@ -915,10 +915,12 @@ final class TranscriptBuilder {
|
||||
|
||||
case "session.created":
|
||||
let model = str(p, "model") ?? ""
|
||||
return note([.text("\(ev.actor) started a \(jsString(p["vendor"])) session\(model.isEmpty ? "" : " (\(model))")")])
|
||||
let effort = str(p, "effort") ?? ""
|
||||
let detail = [model, effort.isEmpty ? "" : effortLabel(effort)].filter { !$0.isEmpty }.joined(separator: ", ")
|
||||
return note([.text("\(ev.actor) started a \(jsString(p["vendor"])) session\(detail.isEmpty ? "" : " (\(detail))")")])
|
||||
|
||||
case "session.model":
|
||||
return note([.text("\(ev.actor) switched the model to \(jsString(p["model"]))")])
|
||||
return note([.text("\(ev.actor) switched the model to \(jsString(p["model"])) at \(effortLabel(str(p, "effort") ?? ""))")])
|
||||
|
||||
case "session.state":
|
||||
guard let error = str(p, "error"), !error.isEmpty else {
|
||||
|
||||
@@ -16,6 +16,7 @@ final class StepNode: Identifiable {
|
||||
var kind: Kind
|
||||
var name = ""
|
||||
var model = ""
|
||||
var effort = ""
|
||||
var prompt = ""
|
||||
var weight = 0.0
|
||||
var agents: [TeamMember]?
|
||||
@@ -42,6 +43,7 @@ final class StepNode: Identifiable {
|
||||
self.init(kind: .stage)
|
||||
name = step.stage ?? ""
|
||||
model = step.model ?? ""
|
||||
effort = step.effort ?? ""
|
||||
prompt = step.prompt ?? ""
|
||||
agents = step.agents
|
||||
}
|
||||
@@ -52,7 +54,7 @@ final class StepNode: Identifiable {
|
||||
let w = parent?.kind == .random ? weight : nil
|
||||
return switch kind {
|
||||
case .stage:
|
||||
WorkflowStep(stage: name, model: agents == nil ? model : nil, prompt: prompt, weight: w, agents: agents)
|
||||
WorkflowStep(stage: name, model: agents == nil ? model : nil, effort: agents == nil && !effort.isEmpty ? effort : nil, prompt: prompt, weight: w, agents: agents)
|
||||
case .order:
|
||||
WorkflowStep(order: children.map(\.step), weight: w)
|
||||
case .parallel:
|
||||
@@ -90,7 +92,7 @@ final class StepNode: Identifiable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func newStage(_ root: StepNode, _ model: String) -> StepNode {
|
||||
private func newStage(_ root: StepNode, _ model: String, _ effort: String) -> StepNode {
|
||||
let names = Set(root.stageNames())
|
||||
var n = names.count + 1
|
||||
while names.contains("Stage \(n)") {
|
||||
@@ -99,6 +101,7 @@ private func newStage(_ root: StepNode, _ model: String) -> StepNode {
|
||||
let s = StepNode(kind: .stage)
|
||||
s.name = "Stage \(n)"
|
||||
s.model = model
|
||||
s.effort = effort
|
||||
s.open = true
|
||||
return s
|
||||
}
|
||||
@@ -251,7 +254,7 @@ struct WorkflowEditor: View {
|
||||
}
|
||||
.padding(.top, 12)
|
||||
Button("Add stage") {
|
||||
root.insert(newStage(root, store.defaultModel), at: root.children.count)
|
||||
root.insert(newStage(root, store.defaultModel, store.defaultEffort), at: root.children.count)
|
||||
}
|
||||
.buttonStyle(SmallLinkButtonStyle())
|
||||
.padding(.top, 8)
|
||||
@@ -285,7 +288,7 @@ struct WorkflowEditor: View {
|
||||
private func load() async {
|
||||
guard let original else {
|
||||
let r = StepNode(kind: .order)
|
||||
r.insert(newStage(r, store.defaultModel), at: 0)
|
||||
r.insert(newStage(r, store.defaultModel, store.defaultEffort), at: 0)
|
||||
root = r
|
||||
return
|
||||
}
|
||||
@@ -355,7 +358,7 @@ private struct StepRow: View {
|
||||
if let agents = node.agents {
|
||||
Text("team of \(agents.count)").foregroundStyle(Palette.muted).fixedSize()
|
||||
} else {
|
||||
ModelPicker(model: $node.model)
|
||||
ModelPicker(model: $node.model, effort: $node.effort)
|
||||
}
|
||||
case .order:
|
||||
Text("in order").foregroundStyle(Palette.muted)
|
||||
@@ -389,7 +392,8 @@ private struct StepRow: View {
|
||||
TextField("Agent name", text: Binding(get: { node.agents?[i].name ?? "" }, set: { node.agents?[i].name = $0 }))
|
||||
.textFieldStyle(.plain)
|
||||
.font(Fonts.font(13, weight: 700))
|
||||
ModelPicker(model: Binding(get: { node.agents?[i].model ?? "" }, set: { node.agents?[i].model = $0 }))
|
||||
ModelPicker(model: Binding(get: { node.agents?[i].model ?? "" }, set: { node.agents?[i].model = $0 }),
|
||||
effort: Binding(get: { node.agents?[i].effort ?? "" }, set: { node.agents?[i].effort = $0.isEmpty ? nil : $0 }))
|
||||
}
|
||||
.padding(.top, 4)
|
||||
PromptEditor(text: Binding(get: { node.agents?[i].prompt ?? "" }, set: { node.agents?[i].prompt = $0 }))
|
||||
@@ -443,7 +447,7 @@ private struct StepRow: View {
|
||||
private let groupKinds: [(StepNode.Kind, String)] = [(.order, "in order"), (.parallel, "in parallel"), (.random, "one at random")]
|
||||
|
||||
private func stage() -> StepNode {
|
||||
newStage(root, store.defaultModel)
|
||||
newStage(root, store.defaultModel, store.defaultEffort)
|
||||
}
|
||||
|
||||
private func addAfter() {
|
||||
@@ -528,10 +532,17 @@ private struct WeightField: View {
|
||||
|
||||
struct ModelPicker: View {
|
||||
@Binding var model: String
|
||||
@Binding var effort: String
|
||||
|
||||
var body: some View {
|
||||
let list = model.isEmpty || models.contains(model) ? models : [model] + models
|
||||
ChoiceMenu(value: $model, options: list.map { ($0, $0) }, placeholder: "model")
|
||||
HStack(spacing: 6) {
|
||||
ChoiceMenu(value: $model, options: list.map { ($0, $0) }, placeholder: "model")
|
||||
ChoiceMenu(value: $effort, options: efforts(model).map { ($0, effortLabel($0)) })
|
||||
}
|
||||
.onChange(of: model) {
|
||||
effort = effortFor(model, effort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user