Questions tab, question bubbles, answer snapshots; errors to transcript
This commit is contained in:
@@ -0,0 +1,655 @@
|
||||
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)
|
||||
|
||||
struct QuestionsView: View {
|
||||
@Environment(Store.self) private var store
|
||||
|
||||
var body: some View {
|
||||
let taskId = store.selectedTaskId
|
||||
let picked = store.question.flatMap { $0.taskId == taskId ? store.questionItem($0.itemId) : nil }
|
||||
let shown = picked ?? 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 shown {
|
||||
let key = "\(shown.id)/\(shown.state.rawValue)/\(shown.responses.count)"
|
||||
ScrollView {
|
||||
QuestionCard(item: shown)
|
||||
.id(key)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.id(shown.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
|
||||
@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
|
||||
}
|
||||
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)
|
||||
if store.question?.itemId == item.id {
|
||||
store.question = nil
|
||||
}
|
||||
let others = store.inbox.contains { $0.taskId == item.taskId && $0.id != item.id }
|
||||
if !others && store.focus.tab == .questions && 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
}
|
||||
|
||||
private struct CardBody: View {
|
||||
let model: CardModel
|
||||
var focus: FocusState<String?>.Binding
|
||||
@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)
|
||||
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)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user