Files
byre/mac/Sources/Byre/Questions.swift
T

648 lines
23 KiB
Swift

import ByreAPI
import OpenAPIRuntime
import SwiftUI
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] {
blocks.map { block in
switch block {
case .list(let ordered, let start, let items, let grid):
return .list(ordered: ordered, start: start, items: items.map { item in
var out = item
if item.choice != nil {
out.choiceIndex = next
next += 1
}
out.blocks = numberChoices(item.blocks, &next)
return out
}, grid: grid)
case .quote(let inner):
return .quote(numberChoices(inner, &next))
case .details(let summary, let body):
return .details(summary: summary, body: numberChoices(body, &next))
default:
return block
}
}
}
private func firstChoices(_ blocks: [MDBlock], _ out: inout [String: Int]) {
for block in blocks {
switch block {
case .list(_, _, let items, _):
for item in items {
if let c = item.choice, out[c] == nil {
out[c] = item.choiceIndex
}
firstChoices(item.blocks, &out)
}
case .quote(let inner):
firstChoices(inner, &out)
case .details(_, let body):
firstChoices(body, &out)
default:
break
}
}
}
@MainActor
@Observable
private final class CardModel {
let item: InboxItem
let store: Store
let overlays: Overlays
let context: [MDBlock]
let firstChoice: [String: Int]
var busy = false
@ObservationIgnored var shown: Set<String> = []
init(_ item: InboxItem, _ store: Store, _ overlays: Overlays) {
self.item = item
self.store = store
self.overlays = overlays
var next = 0
context = numberChoices(parseMarkdown(jsString(item.payload.value["context"] ?? ""), choices: true), &next)
var first: [String: Int] = [:]
firstChoices(context, &first)
firstChoice = first
}
var payload: JSONDict {
item.payload.value
}
var key: String {
"inbox:\(item.id)"
}
var options: [String] {
(payload["options"] as? [(any Sendable)?] ?? []).compactMap { $0 as? String }
}
var recommended: [String] {
(payload["recommended"] as? [(any Sendable)?] ?? []).compactMap { $0 as? String }
}
var multiple: Bool {
bool(payload, "multiple")
}
var answered: Bool {
item.state != .open
}
var chosen: [String] {
item.responses.flatMap { $0.options ?? [] }
}
var checked: [String] {
answered ? chosen : store.drafts?.checkedAnswer(item.id) ?? []
}
var hasImages: Bool {
!store.images.images(key).isEmpty
}
var typed: String {
trimmed(store.drafts?.textAnswer(item.id) ?? "")
}
var editorOpen: Bool {
(store.drafts?.hasTextAnswer(item.id) ?? false) || hasImages
}
func toggle(_ text: String, _ on: Bool) {
var now = checked.filter { $0 != text }
if on {
now.append(text)
}
store.drafts?.setCheckedAnswer(item.id, options.filter { now.contains($0) })
}
func pick(_ text: String) {
if busy || answered {
return
}
if multiple {
toggle(text, !checked.contains(text))
return
}
let notes = typed.isEmpty ? nil : store.drafts?.textAnswer(item.id)
submit(InboxResponseCreate(itemId: item.id, _type: .option, options: [text], text: notes), withImages: true)
}
func submit(_ response: InboxResponseCreate, withImages: Bool = false) {
if busy {
return
}
busy = true
let imgs = withImages ? store.images.take(key) : []
Task {
do {
var r = response
r.images = try await uploadImages(store.api, imgs)
_ = try await store.api.client.respondInbox(body: .json(.init(responses: [r]))).ok
store.drafts?.clearAnswer(item.id)
store.paneFocus.setDraft(key, false)
} catch {
store.images.restore(key, imgs)
overlays.showAlert(errorText(error))
busy = false
}
}
}
func submitQuestion() {
let opts = multiple ? checked : []
if !opts.isEmpty {
submit(InboxResponseCreate(itemId: item.id, _type: .option, options: opts, text: typed.isEmpty ? nil : store.drafts?.textAnswer(item.id)), withImages: true)
return
}
if !typed.isEmpty || hasImages {
submit(InboxResponseCreate(itemId: item.id, _type: .other, text: store.drafts?.textAnswer(item.id) ?? ""), withImages: true)
}
}
}
struct QuestionCard: View {
let item: InboxItem
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var model: CardModel?
@FocusState private var focus: String?
var body: some 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)
CardBody(model: model, focus: $focus)
Thread(item: item)
if !model.answered {
CardActions(model: model, focus: $focus)
.padding(.top, 6)
}
}
.onKeyPress(keys: [.downArrow, .upArrow]) { press in
move(model, press.key == .downArrow ? 1 : -1)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.onAppear {
if model == nil {
model = CardModel(item, store, overlays)
}
}
}
private func move(_ model: CardModel, _ by: Int) -> KeyPress.Result {
var stops = model.firstChoice.filter { model.shown.contains($0.key) }.sorted { $0.value < $1.value }.map(\.key)
if !model.editorOpen {
stops.append(otherStop)
}
guard let current = focus, let at = stops.firstIndex(of: current) else {
return .ignored
}
focus = stops[min(max(at + by, 0), stops.count - 1)]
return .handled
}
}
struct QuestionDetails: View {
let item: InboxItem
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var model: CardModel?
@FocusState private var focus: String?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let model {
CardBody(model: model, focus: $focus, question: false)
Thread(item: item)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.onAppear {
if model == nil {
model = CardModel(item, store, overlays)
}
}
}
}
private struct CardBody: View {
let model: CardModel
var focus: FocusState<String?>.Binding
var question = true
@ViewState private var wide = true
private var contextCtx: MDContext {
MDContext(taskId: model.item.taskId, choice: { md, first in
AnyView(ChoiceBox(model: model, item: md, first: first, ctx: self.contextCtx, focus: focus))
}, gridColumns: wide ? 4 : 2)
}
var body: some View {
let p = model.payload
let item = model.item
VStack(alignment: .leading, spacing: 0) {
MarkdownView(blocks: model.context, ctx: contextCtx)
.onGeometryChange(for: Bool.self) { $0.size.width > 480 } action: { wide = $0 }
.padding(.vertical, 10)
if question {
MarkdownView(blocks: parseMarkdown(jsString(p["question"] ?? "")), ctx: MDContext(taskId: item.taskId))
.padding(.top, 10)
.padding(.bottom, 4)
}
WhyLine(text: jsString(p["why_blocked"] ?? ""), taskId: item.taskId)
.padding(.bottom, 8)
Attachments(list: attachments(p["attachments"] ?? nil), taskId: item.taskId)
}
}
private func attachments(_ v: (any Sendable)?) -> [Attachment] {
guard let v else {
return []
}
do {
return try jsonDecoder().decode([Attachment].self, from: try JSONEncoder().encode(try OpenAPIValueContainer(unvalidatedValue: v)))
} catch {
log.error("decode attachments: \(String(describing: error), privacy: .public)")
return []
}
}
}
private struct WhyLine: View {
let text: String
let taskId: String
@Environment(Store.self) private var store
var body: some View {
var out = AttributedString("Blocked on: ")
out.foregroundColor = Palette.text
var rest = InlineRenderer(store: store, size: 13, taskId: taskId).text(parseInline(text))
rest.foregroundColor = rest.foregroundColor ?? Palette.muted
return Text(out + rest)
.foregroundStyle(Palette.muted)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
private struct ChoiceBox: View {
let model: CardModel
let item: MDItem
let first: Bool
let ctx: MDContext
var focus: FocusState<String?>.Binding
@Environment(Store.self) private var store
@ViewState private var hover = false
var body: some View {
let text = item.choice ?? ""
if model.options.contains(text) && model.firstChoice[text] == item.choiceIndex {
box(text)
} else {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("•")
VStack(alignment: .leading, spacing: 6) {
InlineView(inlines: parseInline(item.raw), ctx: MDContext(taskId: model.item.taskId))
ItemBody(item: item, ctx: ctx)
}
}
}
}
private func box(_ text: String) -> some View {
let rec = model.recommended.contains(text)
let focused = focus.wrappedValue == text
let picked = model.answered && model.chosen.contains(text)
let lit = (hover && !model.multiple && !model.answered) || focused || picked
let hasDetail = !item.inline.isEmpty || !item.blocks.isEmpty
let on = model.checked.contains(text)
let keys: Set<KeyEquivalent> = model.multiple ? [.space] : [.return, .space]
return VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
if model.multiple {
Toggle("", isOn: Binding(get: { on }, set: { model.toggle(text, $0) }))
.toggleStyle(.checkbox)
.labelsHidden()
.tint(Palette.accent)
.disabled(model.busy || model.answered)
}
InlineView(inlines: parseInline(text), ctx: MDContext(taskId: model.item.taskId, weight: 600))
.frame(maxWidth: .infinity, alignment: .leading)
if picked {
Text("chosen")
.font(Fonts.font(11))
.foregroundStyle(Palette.accent)
.allowsHitTesting(false)
}
if rec {
Text("recommended")
.font(Fonts.font(11))
.foregroundStyle(Palette.ok)
.allowsHitTesting(false)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background {
(lit ? Palette.accentTint : rec ? Palette.ok.opacity(0.12) : Palette.surface)
.onTapGesture {
model.pick(text)
}
}
if hasDetail {
ItemBody(item: MDItem(inline: item.inline, blocks: item.blocks), ctx: ctx)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background {
Palette.raised
.onTapGesture {
model.pick(text)
}
}
.environment(\.choiceTap, { [model] in
model.pick(text)
})
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(lit ? Palette.accent : rec ? Palette.ok.opacity(0.55) : Palette.border))
.overlay {
if focused {
RoundedRectangle(cornerRadius: 6).stroke(Palette.accent, lineWidth: 2).padding(-2)
}
}
.opacity(model.busy && !model.multiple ? 0.6 : 1)
.contentShape(Rectangle())
.focusable(!model.busy && !model.answered)
.focusEffectDisabled()
.focused(focus, equals: text)
.onKeyPress(keys: keys) { _ in
model.pick(text)
return .handled
}
.onHover { hover = $0 }
.onAppear {
model.shown.insert(text)
}
.onDisappear {
model.shown.remove(text)
}
.padding(.top, first ? 0 : 6)
}
}
private struct CardActions: View {
let model: CardModel
var focus: FocusState<String?>.Binding
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()
}
HStack(spacing: 8) {
Button("Let the agent decide") {
model.submit(InboxResponseCreate(itemId: model.item.id, _type: .other, text: delegateText))
}
.buttonStyle(WebButtonStyle())
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
@ViewState private var targeted = 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)
.focusedValue(\.composer, ComposerTarget(paste: { pb in store.images.add(key, pasteboard: pb, overlays) }))
.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(targeted || editing ? Palette.accent : Palette.border))
.overlay {
Color.clear
.contentShape(Rectangle())
.allowsHitTesting(false)
.onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in
store.images.add(key, providers: providers, overlays)
return true
}
}
}
.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)
.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 }
}
}
.onDisappear {
store.paneFocus.setDraft(key, false)
}
}
private func open() {
if model.busy {
return
}
store.drafts?.setTextAnswer(model.item.id, "")
DispatchQueue.main.async {
editing = true
}
}
}
private struct Thread: View {
let item: InboxItem
var body: some View {
if !item.responses.isEmpty {
VStack(alignment: .leading, spacing: 2) {
ForEach(item.responses, id: \.id) { r in
let images = r.images.isEmpty ? "" : " (\(r.images.count == 1 ? "1 image" : "\(r.images.count) images"))"
let rest = " \(r._type.rawValue.replacingOccurrences(of: "_", with: " "))\(r.options.map { ": \($0.joined(separator: "; "))" } ?? "")\(r.text.map { " — \($0)" } ?? "")\(images)"
Text("\(Text(r.user).font(Fonts.font(11, weight: 700)))\(rest)")
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
.font(Fonts.font(11))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: 6))
.padding(.vertical, 8)
}
}
}
private struct Attachments: View {
let list: [Attachment]
let taskId: String
@Environment(Store.self) private var store
var body: some View {
if !list.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(list.enumerated()), id: \.offset) { _, a in
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("•")
VStack(alignment: .leading, spacing: 2) {
content(a)
if let caption = a.caption, !caption.isEmpty {
Text(caption).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
}
}
}
.padding(.leading, 6)
.padding(.vertical, 6)
}
}
private func code(_ s: String) -> Text {
Text(verbatim: s)
}
@ViewBuilder
private func content(_ a: Attachment) -> some View {
switch a.kind {
case .file:
let lines = a.startLine.map { ":\($0)\(a.endLine.map { "-\($0)" } ?? "")" } ?? ""
code("\(a.path ?? "")\(lines)").textSelection(.enabled)
case .diff:
Text("diff \(code(a.ref ?? ""))").textSelection(.enabled)
case .image:
let src = (a.image?.isEmpty == false ? a.image : a.path) ?? ""
let caption = a.caption ?? ""
if let source = worktreeImageSource(src, taskId) {
ByreImage(source: source, src: src)
} else if !src.isEmpty {
InlineView(inlines: [.link(href: src, [.text(caption.isEmpty ? src : caption)])], ctx: MDContext(taskId: taskId))
}
case .diagram:
DiagramView(source: a.mermaid ?? "")
case .task:
let id = a.taskId ?? ""
Button("task \(id)") {
store.route("#/task/\(id)")
}
.buttonStyle(.plain)
.foregroundStyle(store.taskColor(id) ?? Palette.accent)
case .snapshot:
Text("snapshot \(code(a.snapshotId ?? ""))").textSelection(.enabled)
case .url:
let url = a.url ?? ""
if httpURL.test(url), let u = URL(string: url) {
Link(url, destination: u).foregroundStyle(Palette.accent)
} else {
code(url).textSelection(.enabled)
}
}
}
}