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 = [] 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 } submit(InboxResponseCreate(itemId: item.id, _type: .option, options: [text])) } 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.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.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 = 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.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.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) .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("Your answer, a question back, or new instructions") .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) } } } }