From e05165723da78711fa6d325b84363fc0b9b6c3fc Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 21 Oct 2026 07:20:16 -0700 Subject: [PATCH] Unify attention cards --- mac/Sources/Byre/Attention.swift | 175 +++++++++++++++++++++++++++++++ mac/Sources/Byre/Changes.swift | 17 ++- mac/Sources/Byre/Chat.swift | 164 +++++++---------------------- mac/Sources/Byre/Drafts.swift | 10 +- mac/Sources/Byre/Questions.swift | 159 ++++------------------------ 5 files changed, 256 insertions(+), 269 deletions(-) create mode 100644 mac/Sources/Byre/Attention.swift diff --git a/mac/Sources/Byre/Attention.swift b/mac/Sources/Byre/Attention.swift new file mode 100644 index 0000000..a76ce30 --- /dev/null +++ b/mac/Sources/Byre/Attention.swift @@ -0,0 +1,175 @@ +import AppKit +import SwiftUI + +let otherStop = "\u{0}other" + +@MainActor +func otherOpen(_ store: Store, _ key: String) -> Bool { + (store.drafts?.hasTextAnswer(key) ?? false) || store.images.has(key) +} + +struct AttentionCard: View { + let open: Bool + @ViewBuilder let content: Content + + var body: some View { + content + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(open ? Palette.warnTint : Palette.warnFaint, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(open ? Palette.warn.opacity(0.55) : Palette.border)) + } +} + +struct AttentionHeader: View { + let label: String + let when: Date + let open: Bool + + var body: some View { + HStack(spacing: 8) { + Text(label) + .font(Fonts.font(11, weight: open ? 700 : 400)) + .foregroundStyle(open ? Palette.warn : Palette.muted) + WhenText(when: when) + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + } + } +} + +struct AttentionReply: View { + let key: String + let placeholder: String + let busy: Bool + var focus: FocusState.Binding + let send: () -> Void + @ViewBuilder let leading: Leading + @ViewBuilder let trailing: Trailing + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + OtherEntry(key: key, placeholder: placeholder, busy: busy, focus: focus, send: send) + HStack(spacing: 8) { + leading + Spacer(minLength: 0) + trailing + } + .disabled(busy) + } + } +} + +private struct OtherEntry: View { + let key: String + let placeholder: String + let busy: Bool + var focus: FocusState.Binding + let send: () -> Void + @Environment(Store.self) private var store + @FocusState private var editing: Bool + @ViewState private var hover = false + @ViewState private var text = "" + + var body: some View { + let focused = focus.wrappedValue == otherStop + Group { + if otherOpen(store, key) { + VStack(alignment: .leading, spacing: 6) { + Thumbnails(key: key) + TextEditor(text: $text) + .font(Fonts.font(13)) + .scrollContentBackground(.hidden) + .writingToolsBehavior(.disabled) + .focused($editing) + .disabled(busy) + .onKeyPress(.return, phases: .down) { press in + let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false + if press.modifiers.contains(.shift) || marked { + return .ignored + } + send() + return .handled + } + .padding(.horizontal, 5) + .padding(.vertical, 6) + .frame(height: 66) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(alignment: .topLeading) { + if text.isEmpty { + Text(placeholder) + .foregroundStyle(Palette.muted) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .allowsHitTesting(false) + } + } + .overlay(RoundedRectangle(cornerRadius: 6).stroke(editing ? Palette.accent : Palette.border)) + .imageInput(key) + } + .onChange(of: editing) { + if !editing { + if trimmed(text).isEmpty && !store.images.has(key) && !busy { + store.drafts?.dropTextAnswer(key) + } + store.drafts?.flush() + } + } + } else { + Text("Other…") + .foregroundStyle(Palette.muted) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(hover || focused ? Palette.accent : Palette.border)) + .overlay { + if focused { + RoundedRectangle(cornerRadius: 6).stroke(Palette.accent, lineWidth: 2).padding(-2) + } + } + .opacity(busy ? 0.6 : 1) + .contentShape(Rectangle()) + .focusable(!busy) + .focusEffectDisabled() + .focused(focus, equals: otherStop) + .onTapGesture { + open() + } + .onKeyPress(keys: [.return, .space]) { _ in + open() + return .handled + } + .onHover { hover = $0 } + } + } + .onAppear { + text = store.drafts?.textAnswer(key) ?? "" + } + .onChange(of: text) { + if text != store.drafts?.textAnswer(key) ?? "" { + store.drafts?.setTextAnswer(key, text) + } + store.paneFocus.setDraft(key, !trimmed(text).isEmpty) + } + .onChange(of: store.drafts?.textAnswer(key)) { _, new in + if (new ?? "") != text { + text = new ?? "" + } + } + .onDisappear { + store.paneFocus.setDraft(key, false) + } + } + + private func open() { + if busy { + return + } + store.drafts?.setTextAnswer(key, "") + DispatchQueue.main.async { + editing = true + } + } +} diff --git a/mac/Sources/Byre/Changes.swift b/mac/Sources/Byre/Changes.swift index 31ff075..f2965c4 100644 --- a/mac/Sources/Byre/Changes.swift +++ b/mac/Sources/Byre/Changes.swift @@ -662,14 +662,14 @@ private struct LineCommentBox: View { let anchor: LineAnchor @Environment(Store.self) private var store @FocusState private var focused: Bool + @ViewState private var text = "" var body: some View { - let text = Binding(get: { drafts.lineComment(taskId, anchor)?.text ?? "" }, set: { drafts.setLineComment(taskId, anchor, $0) }) let key = lineCommentImageKey(taskId, anchor) VStack(alignment: .trailing, spacing: 4) { Thumbnails(key: key) .frame(maxWidth: .infinity, alignment: .leading) - TextEditor(text: text) + TextEditor(text: $text) .font(Fonts.font(12)) .scrollContentBackground(.hidden) .writingToolsBehavior(.disabled) @@ -679,7 +679,7 @@ private struct LineCommentBox: View { .frame(height: 64) .background(Palette.surface, in: RoundedRectangle(cornerRadius: 6)) .overlay(alignment: .topLeading) { - if text.wrappedValue.isEmpty { + if text.isEmpty { Text("Comment on line \(anchor.line)…") .foregroundStyle(Palette.muted) .padding(.horizontal, 10) @@ -696,9 +696,18 @@ private struct LineCommentBox: View { .buttonStyle(SmallLinkButtonStyle()) } .onAppear { - if text.wrappedValue.isEmpty { + text = drafts.lineComment(taskId, anchor)?.text ?? "" + if text.isEmpty { focused = true } } + .onChange(of: text) { + drafts.setLineComment(taskId, anchor, text) + } + .onChange(of: drafts.lineComment(taskId, anchor)?.text) { _, new in + if let new, new != text { + text = new + } + } } } diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index ec39535..2dcc76d 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -630,9 +630,8 @@ private struct MergeButton: View { let task: ByreTask let busy: Bool let unmerged: Bool - var small = false var keepOpen: Bool? - var highlight = false + var primary = false @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @@ -646,7 +645,7 @@ private struct MergeButton: View { await confirmMerge(task.id, store, overlays, keepOpen: true) } } - .buttonStyle(SmallLinkButtonStyle(border: highlight ? Palette.accent : Palette.border)) + .buttonStyle(WebButtonStyle(primary: primary)) .disabled(busy || held || intoParent || !unmerged) .help("The agent merges \(target) in, then squashes the work so far into one commit on \(target) and pushes it, keeping the task open; the task's other agents wait until it's done") } else { @@ -665,18 +664,12 @@ private struct MergeButton: View { : task.parentTaskId != nil ? "Merge this subtask into its parent task's branch, or into \(target) (⌘S)" : "The agent merges \(target) in, then byre squashes into one commit on \(target) and pushes it to origin; held while another task in the project is merging (⌘S)" - let button = Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : keepOpen == false ? "Merge & complete" : "Merge") { + Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : keepOpen == false ? "Merge & complete" : "Merge") { Task { await confirmMerge(task.id, store, overlays) } } - Group { - if small { - button.buttonStyle(SmallLinkButtonStyle(border: highlight ? Palette.accent : Palette.border)) - } else { - button.buttonStyle(WebButtonStyle()) - } - } + .buttonStyle(WebButtonStyle(primary: primary)) .disabled(busy || held || intoParent || !unmerged) .help(help) } @@ -685,23 +678,16 @@ private struct MergeButton: View { private struct DiscardButton: View { let task: ByreTask let busy: Bool - var small = false @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays var body: some View { - let button = Button("Discard") { + Button("Discard") { Task { await confirmDiscard(task, store, overlays) } } - Group { - if small { - button.buttonStyle(SmallLinkButtonStyle(color: Palette.danger)) - } else { - button.buttonStyle(WebButtonStyle(danger: true)) - } - } + .buttonStyle(WebButtonStyle(danger: true)) .disabled(busy) .help("The agent writes the task summary, then byre deletes the worktree and branch without merging") } @@ -1336,19 +1322,14 @@ private struct QuestionBubble: View { var body: some View { let open = item.state == .open && !inherited - Group { + AttentionCard(open: open) { if open { QuestionCard(item: item) .id("\(item.id)/\(item.state.rawValue)/\(item.responses.count)") } else { DetailsBox(boxed: false) { VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Text("asked") - WhenText(when: item.createdAt) - } - .font(Fonts.font(11)) - .foregroundStyle(Palette.muted) + AttentionHeader(label: "asked", when: item.createdAt, open: false) MarkdownView(blocks: parseMarkdown(jsString(item.payload.value["question"] ?? "")), ctx: MDContext(taskId: item.taskId)) Text(status) .font(Fonts.font(11)) @@ -1365,11 +1346,6 @@ private struct QuestionBubble: View { } } } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(open ? Palette.warnTint : Palette.warnFaint, in: RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(open ? Palette.warn.opacity(0.55) : Palette.border)) } private var status: String { @@ -1438,117 +1414,52 @@ private struct ReadyBubble: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var dismissing = false - @ViewState private var other: String? - @FocusState private var editing: Bool + @FocusState private var focus: String? var body: some View { let task = taskId.flatMap { store.findTask($0) } let current = latest && task?.status == .active - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text(discard ? "suggests discarding" : "ready for merge") - .font(Fonts.font(11, weight: current ? 700 : 400)) - .foregroundStyle(current ? Palette.warn : Palette.muted) - WhenText(when: when) - .font(Fonts.font(11)) - .foregroundStyle(Palette.muted) - } - MarkdownView(blocks: blocks, ctx: MDContext(taskId: taskId)) - if current, let task { - let busy = store.taskBusy(task.id) - otherEntry(task) + AttentionCard(open: current) { + VStack(alignment: .leading, spacing: 4) { + AttentionHeader(label: discard ? "suggests discarding" : "ready for merge", when: when, open: current) + MarkdownView(blocks: blocks, ctx: MDContext(taskId: taskId)) + if current, let task { + let busy = store.taskBusy(task.id) + AttentionReply(key: draftKey(task), placeholder: "Something else to tell the agent; sending it dismisses this", busy: dismissing, focus: $focus, send: { send(task) }) { + Button("Dismiss") { + dismiss(task, then: nil) + } + .buttonStyle(WebButtonStyle()) + .help(discard ? "Drop this suggestion; the task stays open" : "Drop this ready call; the task stays open") + DiscardButton(task: task, busy: busy) + } trailing: { + if !discard { + MergeButton(task: task, busy: busy, unmerged: unmerged, keepOpen: true, primary: keepOpen) + MergeButton(task: task, busy: busy, unmerged: unmerged, keepOpen: false, primary: !keepOpen) + } + } .padding(.top, 4) - HStack(spacing: 8) { - Spacer(minLength: 0) - Button("Dismiss") { - dismiss(task, then: nil) - } - .buttonStyle(SmallLinkButtonStyle()) - .disabled(dismissing) - .help(discard ? "Drop this suggestion; the task stays open" : "Drop this ready call; the task stays open") - DiscardButton(task: task, busy: busy, small: true) - if !discard { - MergeButton(task: task, busy: busy, unmerged: unmerged, small: true, keepOpen: true, highlight: keepOpen) - MergeButton(task: task, busy: busy, unmerged: unmerged, small: true, keepOpen: false, highlight: !keepOpen) - } } } } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(current ? Palette.warnTint : Palette.warnFaint, in: RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(current ? Palette.warn.opacity(0.55) : Palette.border)) } - private func imageKey(_ task: ByreTask) -> String { + private func draftKey(_ task: ByreTask) -> String { "ready:\(task.id):\(when.timeIntervalSince1970)" } - @ViewBuilder - private func otherEntry(_ task: ByreTask) -> some View { - let key = imageKey(task) - let hasImages = store.images.has(key) - if other != nil || hasImages { - let text = other ?? "" - VStack(alignment: .leading, spacing: 6) { - Thumbnails(key: key) - TextEditor(text: Binding(get: { text }, set: { other = $0 })) - .font(Fonts.font(13)) - .scrollContentBackground(.hidden) - .writingToolsBehavior(.disabled) - .focused($editing) - .disabled(dismissing) - .onKeyPress(.return, phases: .down) { press in - let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false - if press.modifiers.contains(.shift) || marked || (trimmed(text).isEmpty && !hasImages) { - return .ignored - } - dismiss(task, then: text) - return .handled - } - .padding(.horizontal, 5) - .padding(.vertical, 6) - .frame(height: 66) - .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) - .overlay(alignment: .topLeading) { - if text.isEmpty { - Text("Something else to tell the agent; sending it dismisses this") - .foregroundStyle(Palette.muted) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .allowsHitTesting(false) - } - } - .overlay(RoundedRectangle(cornerRadius: 6).stroke(editing ? Palette.accent : Palette.border)) - .imageInput(key) - .onChange(of: editing) { - if !editing && trimmed(other ?? "").isEmpty && !store.images.has(key) { - other = nil - } - } - } - } else { - Text("Other…") - .foregroundStyle(Palette.muted) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) - .contentShape(Rectangle()) - .onTapGesture { - other = "" - DispatchQueue.main.async { - editing = true - } - } + private func send(_ task: ByreTask) { + let key = draftKey(task) + let text = store.drafts?.textAnswer(key) ?? "" + if trimmed(text).isEmpty && !store.images.has(key) { + return } + dismiss(task, then: text) } private func dismiss(_ task: ByreTask, then text: String?) { dismissing = true - let key = imageKey(task) + let key = draftKey(task) let imgs = store.images.take(key) Task { do { @@ -1556,8 +1467,9 @@ private struct ReadyBubble: View { _ = try await store.api.client.dismissReady(path: .init(taskId: task.id)).ok if let text { try await store.messageTask(task.id, text, images: ids) - other = nil } + store.drafts?.dropTextAnswer(key) + store.paneFocus.setDraft(key, false) } catch { store.images.restore(key, imgs) overlays.showAlert(errorText(error)) diff --git a/mac/Sources/Byre/Drafts.swift b/mac/Sources/Byre/Drafts.swift index 406991b..f585353 100644 --- a/mac/Sources/Byre/Drafts.swift +++ b/mac/Sources/Byre/Drafts.swift @@ -152,7 +152,7 @@ final class Drafts { comments[taskId] = nil changed = true } - for id in Set(checked.keys).union(texts.keys) where !store.inbox.contains(where: { $0.id == id }) { + for id in Set(checked.keys).union(texts.keys) where !keepsAnswer(store, id) { checked[id] = nil texts[id] = nil changed = true @@ -162,6 +162,14 @@ final class Drafts { } } + private func keepsAnswer(_ store: Store, _ id: String) -> Bool { + let parts = id.split(separator: ":") + if parts.first == "ready" && parts.count > 1 { + return store.findTask(String(parts[1]))?.status == .active + } + return store.inbox.contains { $0.id == id } + } + private func save() { dirty = true timer?.cancel() diff --git a/mac/Sources/Byre/Questions.swift b/mac/Sources/Byre/Questions.swift index 59b3df6..cf07c65 100644 --- a/mac/Sources/Byre/Questions.swift +++ b/mac/Sources/Byre/Questions.swift @@ -6,7 +6,6 @@ typealias Attachment = Components.Schemas.Attachment typealias InboxResponseCreate = Components.Schemas.InboxResponseCreate private let delegateText = "Use your judgment for this decision and continue within the existing task instructions." -private let otherStop = "\u{0}other" private let httpURL = Re(#"^https?://"#, caseless: true) private func numberChoices(_ blocks: [MDBlock], _ next: inout Int) -> [MDBlock] { @@ -80,7 +79,7 @@ private final class CardModel { } var key: String { - "inbox:\(item.id)" + item.id } var options: [String] { @@ -120,7 +119,7 @@ private final class CardModel { } var editorOpen: Bool { - (store.drafts?.hasTextAnswer(item.id) ?? false) || hasImages + otherOpen(store, key) } func toggle(_ text: String, _ on: Bool) { @@ -213,13 +212,7 @@ struct QuestionCard: View { VStack(spacing: 0) { if let model { VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text("question\(model.answered ? " · \(item.state.rawValue)" : "")") - Spacer(minLength: 0) - Text(ago(item.createdAt)).help(isoText(item.createdAt)) - } - .font(Fonts.font(11)) - .foregroundStyle(Palette.muted) + AttentionHeader(label: "question", when: item.createdAt, open: !model.answered) CardBody(model: model, focus: $focus) Thread(item: item) if !model.answered { @@ -452,139 +445,29 @@ private struct CardActions: View { var body: some View { let disabled = model.typed.isEmpty && !model.hasImages && (!model.multiple || model.checked.isEmpty) - VStack(alignment: .leading, spacing: 6) { - TextEntry(model: model, focus: focus) { - model.submitQuestion() + AttentionReply(key: model.key, placeholder: model.multiple ? "Your answer, a question back, or new instructions" : "Your answer, or notes to send with a choice you click", busy: model.busy, focus: focus, send: model.submitQuestion) { + Button("Let the agent decide") { + model.submit(InboxResponseCreate(itemId: model.item.id, _type: .other, text: delegateText)) } - HStack(spacing: 8) { - Button("Let the agent decide") { - model.submit(InboxResponseCreate(itemId: model.item.id, _type: .other, text: delegateText)) + .buttonStyle(WebButtonStyle()) + if model.again { + Button("Cancel") { + model.again = false } .buttonStyle(WebButtonStyle()) - if model.again { - Button("Cancel") { - model.again = false - } - .buttonStyle(WebButtonStyle()) - } else { - Button("Ignore") { - model.dismiss() - } - .buttonStyle(WebButtonStyle()) - .help("Dismiss this question without answering it; the agent is not told") - } - Spacer() - Button("Submit") { - model.submitQuestion() - } - .buttonStyle(WebButtonStyle(primary: true)) - .disabled(disabled) - } - .disabled(model.busy) - } - } -} - -private struct TextEntry: View { - let model: CardModel - var focus: FocusState.Binding - let go: () -> Void - @Environment(Store.self) private var store - @Environment(Overlays.self) private var overlays - @Environment(\.paneAccent) private var accent - @FocusState private var editing: Bool - @ViewState private var hover = false - - var body: some View { - let id = model.item.id - let key = model.key - let text = Binding(get: { store.drafts?.textAnswer(id) ?? "" }, set: { v in - store.drafts?.setTextAnswer(id, v) - store.paneFocus.setDraft(key, !trimmed(v).isEmpty) - }) - let focused = focus.wrappedValue == otherStop - Group { - if model.editorOpen { - VStack(alignment: .leading, spacing: 6) { - Thumbnails(key: key) - TextEditor(text: text) - .font(Fonts.font(13)) - .scrollContentBackground(.hidden) - .writingToolsBehavior(.disabled) - .focused($editing) - .disabled(model.busy) - .onKeyPress(.return, phases: .down) { press in - let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false - if press.modifiers.contains(.shift) || marked { - return .ignored - } - go() - return .handled - } - .padding(.horizontal, 5) - .padding(.vertical, 6) - .frame(height: 66) - .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) - .overlay(alignment: .topLeading) { - if text.wrappedValue.isEmpty { - Text(model.multiple ? "Your answer, a question back, or new instructions" : "Your answer, or notes to send with a choice you click") - .foregroundStyle(Palette.muted) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .allowsHitTesting(false) - } - } - .overlay(RoundedRectangle(cornerRadius: 6).stroke(editing ? Palette.accent : Palette.border)) - .imageInput(key) - } - .onChange(of: editing) { - if !editing { - if trimmed(text.wrappedValue).isEmpty && !model.hasImages && !model.busy { - store.drafts?.dropTextAnswer(id) - } - store.drafts?.flush() - } - } } else { - Text("Other…") - .foregroundStyle(Palette.muted) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(hover || focused ? Palette.accent : Palette.border)) - .overlay { - if focused { - RoundedRectangle(cornerRadius: 6).stroke(Palette.accent, lineWidth: 2).padding(-2) - } - } - .opacity(model.busy ? 0.6 : 1) - .contentShape(Rectangle()) - .focusable(!model.busy) - .focusEffectDisabled() - .focused(focus, equals: otherStop) - .onTapGesture { - open() - } - .onKeyPress(keys: [.return, .space]) { _ in - open() - return .handled - } - .onHover { hover = $0 } + Button("Ignore") { + model.dismiss() + } + .buttonStyle(WebButtonStyle()) + .help("Dismiss this question without answering it; the agent is not told") } - } - .onDisappear { - store.paneFocus.setDraft(key, false) - } - } - - private func open() { - if model.busy { - return - } - store.drafts?.setTextAnswer(model.item.id, "") - DispatchQueue.main.async { - editing = true + } trailing: { + Button("Submit") { + model.submitQuestion() + } + .buttonStyle(WebButtonStyle(primary: true)) + .disabled(disabled) } } }