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) struct InboxView: View { @Environment(Store.self) private var store var body: some View { let taskId = store.selectedTaskId let top = store.inbox.filter { $0.taskId == taskId }.sorted { $0.createdAt < $1.createdAt }.first VStack(spacing: 0) { ChangesView(compact: true) if taskId == nil { EmptyText(text: "No task selected.") } else if let top { ScrollView { InboxCard(item: top) .id(top.id) .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 16) } .id(top.id) } else { EmptyText(text: "Nothing waiting on you in this task.") } } .frame(maxHeight: .infinity, alignment: .top) } } 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 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 checked: [String] { 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 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) let others = store.inbox.contains { $0.taskId == item.taskId && $0.id != item.id } if !others && store.focus.tab == .inbox && store.selectedTaskId == item.taskId { store.focusTab(.transcript) } } 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) } } func submitOther() { if typed.isEmpty && !hasImages { return } submit(InboxResponseCreate(itemId: item.id, _type: .other, text: store.drafts?.textAnswer(item.id) ?? ""), withImages: true) } } private struct InboxCard: 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 { Group { if let model { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .firstTextBaseline, spacing: 10) { Text(item.kind.rawValue.replacingOccurrences(of: "_", with: " ")) 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) CardActions(model: model, focus: $focus) .padding(.top, 6) } .onKeyPress(keys: [.downArrow, .upArrow]) { press in move(model, press.key == .downArrow ? 1 : -1) } } } .onAppear { if model == nil { model = CardModel(item, store, overlays) } } } private func move(_ model: CardModel, _ by: Int) -> KeyPress.Result { var stops = model.options 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 } } private struct CardBody: View { let model: CardModel var focus: FocusState.Binding var body: some View { let p = model.payload let item = model.item VStack(alignment: .leading, spacing: 0) { switch item.kind { case .question: MarkdownView(blocks: model.context, ctx: MDContext(taskId: item.taskId, choice: { md in AnyView(ChoiceBox(model: model, item: md, focus: focus)) })) .padding(.vertical, 10) 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) case .sessionError: Text(jsString(p["error"] ?? "")) .foregroundStyle(Palette.danger) .textSelection(.enabled) .padding(.vertical, 10) case .rateLimited: let limit = dict(p, "rate_limit") ?? [:] let resets = (limit["resetsAt"] as? Double ?? (limit["resetsAt"] as? Int).map(Double.init)).map { Date(timeIntervalSince1970: $0).formatted(date: .numeric, time: .standard) } ?? "unknown" VStack(alignment: .leading, spacing: 10) { Text(jsString(p["error"] ?? "")).foregroundStyle(Palette.danger) Text("\(jsString(p["vendor"] ?? "")) \(limit["rateLimitType"].map { jsString($0) } ?? "") limit resets \(resets)") } .textSelection(.enabled) .padding(.vertical, 10) } } } 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 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: MDContext(taskId: model.item.taskId)) } } } } private func box(_ text: String) -> some View { let rec = model.recommended.contains(text) let focused = focus.wrappedValue == text let lit = (hover && !model.multiple) || focused let hasDetail = !item.inline.isEmpty || !item.blocks.isEmpty let on = model.checked.contains(text) 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) } InlineView(inlines: parseInline(text), ctx: MDContext(taskId: model.item.taskId)) .font(Fonts.font(13, weight: 600)) .frame(maxWidth: .infinity, alignment: .leading) if rec { Text("recommended").font(Fonts.font(11)).foregroundStyle(Palette.ok) } } .padding(.horizontal, 10) .padding(.vertical, 6) .background(lit ? Palette.accentTint : rec ? Palette.ok.opacity(0.12) : Palette.surface) if hasDetail { ItemBody(item: MDItem(inline: item.inline, blocks: item.blocks), ctx: MDContext(taskId: model.item.taskId)) .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) .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) .focusEffectDisabled() .focused(focus, equals: text) .onTapGesture { pick(text) } .onKeyPress(keys: [.return, .space]) { _ in pick(text) return .handled } .onHover { hover = $0 } .padding(.top, 8) } private func pick(_ text: String) { if model.busy { return } if model.multiple { model.toggle(text, !model.checked.contains(text)) return } model.submit(InboxResponseCreate(itemId: model.item.id, _type: .option, options: [text])) } } private struct CardActions: View { let model: CardModel var focus: FocusState.Binding var body: some View { if model.item.kind == .question { let disabled = model.typed.isEmpty && !model.hasImages && (!model.multiple || model.checked.isEmpty) VStack(alignment: .leading, spacing: 6) { TextEntry(model: model, label: "Other…", placeholder: "Your answer, a question back, or new instructions", shared: true, 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) } } else { HStack(alignment: .top, spacing: 8) { TextEntry(model: model, label: "Reply…", placeholder: "Reply to the agent", shared: false, focus: focus) { model.submitOther() } Button("Dismiss") { model.submit(InboxResponseCreate(itemId: model.item.id, _type: .dismiss)) } .buttonStyle(WebButtonStyle()) .disabled(model.busy) } } } } private struct TextEntry: View { let model: CardModel let label: String let placeholder: String let shared: Bool 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(placeholder) .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 } } if !shared { HStack { Spacer() Button("Submit", action: go) .buttonStyle(WebButtonStyle(primary: true)) .disabled(model.busy || (model.typed.isEmpty && !model.hasImages)) } } } .onChange(of: editing) { if !editing { if trimmed(text.wrappedValue).isEmpty && !model.hasImages && !model.busy { store.drafts?.dropTextAnswer(id) } store.drafts?.flush() } } } else { Text(label) .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 { var a = AttributedString(s) a.backgroundColor = Palette.surface return Text(a) } @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) ?? "" if let source = worktreeImageSource(src, taskId) { ByreImage(source: source, src: src) } case .diagram: DiagramView(source: a.mermaid ?? "") case .task: let id = a.taskId ?? "" Button("task \(id)") { store.focusTask(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) } } } }