Unify attention cards

This commit is contained in:
Ian Gulliver
2026-10-21 07:20:16 -07:00
parent 0d3ed1fdd3
commit e05165723d
5 changed files with 256 additions and 269 deletions
+21 -138
View File
@@ -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<String?>.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)
}
}
}