Unify attention cards
This commit is contained in:
@@ -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<Content: View>: 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<Leading: View, Trailing: View>: View {
|
||||
let key: String
|
||||
let placeholder: String
|
||||
let busy: Bool
|
||||
var focus: FocusState<String?>.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<String?>.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+38
-126
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user