mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
cedee04f2a
Layers Hermes v0.13's reliability + recovery affordances on top of the v2.7.5 Kanban v3 board. New surface — gated end-to-end on `HermesCapabilities.hasKanbanDiagnostics` (>= v0.13.0): - **Hallucination gate.** Worker-created cards land in `pending` until the user verifies the underlying work exists. Inspector renders a yellow Verify / Reject banner above the body; cards dim to 0.6 with a question-mark glyph. Verify is optimistic — banner clears immediately, polling confirms. Reject routes through `comment` + `archive` so there's an audit trail. - **Generic diagnostics engine.** `HermesKanbanDiagnostic` (new model + typed-mirror enum `KanbanDiagnosticKind`) renders cross-run signals on the inspector header and per-run signals under each Runs row. Card footer gains a stethoscope dot when any signal is attached. - **`max_retries` create-time field + inspector chip.** Toggle-gated Stepper in the create sheet sends `--max-retries N`; chip on the inspector header reads it back read-only with a tooltip explaining there's no update verb. - **Multi-line title input.** Create sheet's title becomes a `TextField(axis: .vertical, lineLimit: 1...4)`. Newlines are stripped client-side on pre-v0.13 hosts (which truncate at the first `\n`). - **Auto-blocked reason banner.** When `task.auto_blocked_reason` is set, replaces the generic "Last run: blocked" with a red banner rendering the server reason verbatim. Card footer shows a 1-line truncated copy in red. - **Tolerant decode contract.** Every new field is `Optional` with `decodeIfPresent`; diagnostics arrays use `try?` so a single malformed entry doesn't poison the row. v0.12 hosts decode unchanged. Implements WS-3 of Scarf v2.8.0 (Hermes v0.13.0 catch-up). Plan: scarf/docs/v2.8/WS-3-kanban-v0.13-plan.md (on coordination/v2.8.0-plans). TODOs marked inline pending integration against a live v0.13 binary: WS-3-Q1 (verify verb name), WS-3-Q2 (diagnostics envelope vs task), WS-3-Q4 (failure_count placement), WS-3-Q5 (darwin-zombie kind string), WS-3-Q6 (max_retries default). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
372 lines
14 KiB
Swift
372 lines
14 KiB
Swift
import SwiftUI
|
|
import ScarfCore
|
|
import ScarfDesign
|
|
import CoreTransferable
|
|
|
|
/// Transferable wrapper for a kanban task id. We tunnel the payload
|
|
/// through `String` via `ProxyRepresentation` (no custom UTI needed)
|
|
/// because SwiftUI's drag-drop with custom-UTI `CodableRepresentation`
|
|
/// requires a registered exported type in Info.plist to round-trip
|
|
/// reliably; the proxy form skips that ceremony and consistently lands
|
|
/// drops in v15 / 26.
|
|
struct KanbanTaskRef: Transferable {
|
|
let id: String
|
|
|
|
static var transferRepresentation: some TransferRepresentation {
|
|
ProxyRepresentation(
|
|
exporting: { (ref: KanbanTaskRef) in ref.id },
|
|
importing: { (id: String) in KanbanTaskRef(id: id) }
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Single Kanban card. Variant chrome differs by status:
|
|
/// - **Running** gets a blue left-edge accent + live shimmer
|
|
/// - **Blocked** gets a warning left-edge accent + ⚠ glyph
|
|
/// - **Done** dims to 0.7 opacity (0.55 in dark mode)
|
|
/// - **Hallucination-gate pending** (v0.13+) dims to 0.6 + ⚠ glyph and
|
|
/// shows a one-line auto-blocked reason in the footer when present.
|
|
struct KanbanCardView: View {
|
|
let task: HermesKanbanTask
|
|
let onTap: () -> Void
|
|
/// True when the connected Hermes is on v0.13+ — gates the
|
|
/// hallucination dim/glyph, auto-block sub-line, and diagnostics
|
|
/// dot on the card. Pre-v0.13 hosts see the v2.7.5 chrome unchanged.
|
|
let supportsKanbanDiagnostics: Bool
|
|
/// Optimistic-aware accessor. Pre-v0.13 always nil. Otherwise delegates
|
|
/// to the board VM so a Verify click un-dims the card immediately.
|
|
let effectiveHallucinationGate: (HermesKanbanTask) -> KanbanHallucinationGate?
|
|
|
|
init(
|
|
task: HermesKanbanTask,
|
|
supportsKanbanDiagnostics: Bool = false,
|
|
effectiveHallucinationGate: @escaping (HermesKanbanTask) -> KanbanHallucinationGate? = { _ in nil },
|
|
onTap: @escaping () -> Void
|
|
) {
|
|
self.task = task
|
|
self.supportsKanbanDiagnostics = supportsKanbanDiagnostics
|
|
self.effectiveHallucinationGate = effectiveHallucinationGate
|
|
self.onTap = onTap
|
|
}
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
/// Cached gate read — derived once per body eval rather than recomputed
|
|
/// in each subview helper.
|
|
private var hallucinationGate: KanbanHallucinationGate? {
|
|
guard supportsKanbanDiagnostics else { return nil }
|
|
return effectiveHallucinationGate(task)
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
|
titleRow
|
|
if hasMetaRow1 {
|
|
metaRow1
|
|
}
|
|
if !task.skills.isEmpty {
|
|
skillsRow
|
|
}
|
|
footerRow
|
|
}
|
|
.padding(ScarfSpace.s3)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
|
.fill(ScarfColor.backgroundPrimary)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
|
.stroke(ScarfColor.border, lineWidth: 1)
|
|
)
|
|
.overlay(alignment: .leading) {
|
|
if let edgeColor {
|
|
Rectangle()
|
|
.fill(edgeColor)
|
|
.frame(width: 2)
|
|
.clipShape(
|
|
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
|
)
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.scarfShadow(.sm)
|
|
// v0.13: hallucination-pending cards dim to 0.6 to signal "needs
|
|
// verification before running" without making them unreadable.
|
|
// Done cards stay at the established doneOpacity (0.7 / 0.55).
|
|
.opacity(cardOpacity)
|
|
.draggable(KanbanTaskRef(id: task.id)) {
|
|
// Drag preview — the live card with a heavier shadow.
|
|
self.dragPreview
|
|
}
|
|
}
|
|
|
|
private var cardOpacity: Double {
|
|
if task.isDone { return doneOpacity }
|
|
if hallucinationGate == .pending { return 0.6 }
|
|
return 1.0
|
|
}
|
|
|
|
private var titleRow: some View {
|
|
HStack(alignment: .top, spacing: ScarfSpace.s2) {
|
|
statusGlyph
|
|
Text(task.title)
|
|
.scarfStyle(.bodyEmph)
|
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.leading)
|
|
Spacer(minLength: 0)
|
|
// v0.13 hallucination glyph takes precedence over the
|
|
// unassigned glyph — the hallucination state is the more
|
|
// specific signal (a worker created this card; verify it).
|
|
if hallucinationGate == .pending {
|
|
Image(systemName: "questionmark.diamond.fill")
|
|
.foregroundStyle(ScarfColor.warning)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.help("Worker-created — verify before running")
|
|
} else if needsAssignmentWarning {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(ScarfColor.warning)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.help("Unassigned — Hermes's dispatcher silently skips tasks with no assignee, so this task will never run automatically. Open the task and add an assignee, or recreate it with one set.")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cards in `todo` or `ready` with no `assignee` are about to land
|
|
/// in a silent zombie state — Hermes's dispatcher's `--json`
|
|
/// output literally lists them under `skipped_unassigned` and
|
|
/// moves on. Surfacing this on the card itself (vs. only inside
|
|
/// the inspector) is the only way the user has a chance to notice
|
|
/// before they sit there confused.
|
|
private var needsAssignmentWarning: Bool {
|
|
let column = KanbanStatus.from(task.status).boardColumn
|
|
guard column == .upNext || column == .triage else { return false }
|
|
return (task.assignee?.isEmpty ?? true)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusGlyph: some View {
|
|
switch KanbanStatus.from(task.status) {
|
|
case .blocked:
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(ScarfColor.warning)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.padding(.top, 2)
|
|
case .done:
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(ScarfColor.success)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.padding(.top, 2)
|
|
case .running:
|
|
// No leading glyph — the left-edge accent + shimmer
|
|
// already encodes the live state.
|
|
EmptyView()
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
private var hasMetaRow1: Bool {
|
|
task.assignee?.isEmpty == false || task.workspaceKind != nil
|
|
}
|
|
|
|
private var metaRow1: some View {
|
|
HStack(spacing: ScarfSpace.s2) {
|
|
if let assignee = task.assignee, !assignee.isEmpty {
|
|
assigneeChip(assignee)
|
|
} else {
|
|
unassignedChip
|
|
}
|
|
if let workspace = task.workspaceKind {
|
|
ScarfBadge(workspace, kind: .neutral)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
private func assigneeChip(_ name: String) -> some View {
|
|
HStack(spacing: 4) {
|
|
Text(initials(of: name))
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundStyle(ScarfColor.accentActive)
|
|
.frame(width: 16, height: 16)
|
|
.background(ScarfColor.accentTint)
|
|
.clipShape(Circle())
|
|
Text(name)
|
|
.scarfStyle(.caption)
|
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
|
}
|
|
}
|
|
|
|
private var unassignedChip: some View {
|
|
Text("Unassigned")
|
|
.scarfStyle(.caption)
|
|
.foregroundStyle(ScarfColor.foregroundFaint)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: ScarfRadius.sm, style: .continuous)
|
|
.stroke(
|
|
ScarfColor.borderStrong,
|
|
style: StrokeStyle(lineWidth: 1, dash: [2, 2])
|
|
)
|
|
)
|
|
}
|
|
|
|
private var skillsRow: some View {
|
|
HStack(spacing: 4) {
|
|
let visible = task.skills.prefix(2)
|
|
ForEach(Array(visible.enumerated()), id: \.offset) { _, skill in
|
|
ScarfBadge(skill, kind: .brand)
|
|
}
|
|
if task.skills.count > 2 {
|
|
ScarfBadge("+\(task.skills.count - 2)", kind: .neutral)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
private var footerRow: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
// v0.13: server-supplied auto-blocked reason. Renders verbatim
|
|
// (truncated to one line; full reason in the inspector).
|
|
// Pre-v0.13 hosts always have task.autoBlockedReason == nil.
|
|
if supportsKanbanDiagnostics,
|
|
KanbanStatus.from(task.status) == .blocked,
|
|
let reason = task.autoBlockedReason, !reason.isEmpty {
|
|
Text(reason)
|
|
.scarfStyle(.caption)
|
|
.foregroundStyle(ScarfColor.danger)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.help(reason)
|
|
}
|
|
HStack(spacing: ScarfSpace.s2) {
|
|
Text(relativeTimeLabel)
|
|
.scarfStyle(.caption)
|
|
.foregroundStyle(ScarfColor.foregroundFaint)
|
|
Spacer(minLength: 0)
|
|
// v0.13: diagnostics dot — small stethoscope glyph when
|
|
// any cross-run distress signal is attached. Matches the
|
|
// chip count in the inspector.
|
|
if supportsKanbanDiagnostics, !task.diagnostics.isEmpty {
|
|
Image(systemName: "stethoscope")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(ScarfColor.warning)
|
|
.help("\(task.diagnostics.count) diagnostic signal\(task.diagnostics.count == 1 ? "" : "s")")
|
|
}
|
|
if let priority = task.priority, priority >= 70 {
|
|
priorityIndicator(priority)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func priorityIndicator(_ priority: Int) -> some View {
|
|
let color: Color = priority >= 90 ? ScarfColor.danger : ScarfColor.warning
|
|
return RoundedRectangle(cornerRadius: 2, style: .continuous)
|
|
.fill(color)
|
|
.frame(width: 8, height: 8)
|
|
.help("Priority \(priority)")
|
|
}
|
|
|
|
private var dragPreview: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(task.title)
|
|
.scarfStyle(.bodyEmph)
|
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
|
.lineLimit(1)
|
|
if let assignee = task.assignee, !assignee.isEmpty {
|
|
Text(assignee)
|
|
.scarfStyle(.caption)
|
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
|
}
|
|
}
|
|
.padding(.horizontal, ScarfSpace.s2)
|
|
.padding(.vertical, 6)
|
|
.background(ScarfColor.backgroundPrimary)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
|
|
.stroke(ScarfColor.accent, lineWidth: 1)
|
|
)
|
|
.scarfShadow(.lg)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private var edgeColor: Color? {
|
|
switch KanbanStatus.from(task.status) {
|
|
case .running: return ScarfColor.info
|
|
case .blocked: return ScarfColor.warning
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
private var doneOpacity: Double {
|
|
colorScheme == .dark ? 0.55 : 0.7
|
|
}
|
|
|
|
/// Display string for the footer's relative time slot. The "since"
|
|
/// reference depends on status — running tasks show how long
|
|
/// they've been running; blocked show how long blocked, etc.
|
|
private var relativeTimeLabel: String {
|
|
switch KanbanStatus.from(task.status) {
|
|
case .running:
|
|
if let started = task.startedAt, let label = relativeShort(from: started) {
|
|
return "running \(label)"
|
|
}
|
|
return "running"
|
|
case .blocked:
|
|
// Hermes doesn't expose blocked-since separately; fall
|
|
// back to created_at as a coarse signal.
|
|
if let created = task.createdAt, let label = relativeShort(from: created) {
|
|
return "blocked \(label)"
|
|
}
|
|
return "blocked"
|
|
case .done:
|
|
if let completed = task.completedAt, let label = relativeShort(from: completed) {
|
|
return "done \(label) ago"
|
|
}
|
|
return "done"
|
|
default:
|
|
if let created = task.createdAt, let label = relativeShort(from: created) {
|
|
return "\(label) ago"
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
|
|
private func relativeShort(from iso: String) -> String? {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let date = formatter.date(from: iso) {
|
|
return Self.relativeFormatter.localizedString(for: date, relativeTo: Date())
|
|
}
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
if let date = formatter.date(from: iso) {
|
|
return Self.relativeFormatter.localizedString(for: date, relativeTo: Date())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
|
let f = RelativeDateTimeFormatter()
|
|
f.unitsStyle = .abbreviated
|
|
return f
|
|
}()
|
|
|
|
private func initials(of name: String) -> String {
|
|
let parts = name.split(whereSeparator: { !$0.isLetter && !$0.isNumber })
|
|
let letters = parts.prefix(2).compactMap { $0.first.map(String.init) }
|
|
return letters.joined().uppercased()
|
|
}
|
|
}
|
|
|
|
private extension HermesKanbanTask {
|
|
var isDone: Bool { KanbanStatus.from(status) == .done }
|
|
}
|