648 lines
23 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|