mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
adcc984091
Lifts Scarf's Kanban surface from the v2.6 read-only list to a drag-and-drop board with the complete Hermes v0.12 mutation surface wired up, plus per-project boards bound to a Scarf-minted tenant slug and a read-only board on iOS. Why now: the v2.6 list was a placeholder shipped while upstream Kanban collab was still mid-rework. v0.12 stabilized the 27-verb CLI; this release makes Scarf a real GUI client for it. Driving real tasks end-to-end exposed and closed a connected bug pattern (claim vs dispatch, silent skipped_unassigned, integer-vs-ISO timestamps, parser-leaked "(no" sentinel) that would have shipped as latent UX papercuts otherwise. ScarfCore: KanbanService actor (Sendable, pure I/O) wrapping every verb; KanbanTenantReader cross-platform manifest projection; eight new model types (TaskDetail, Comment, Event, Run, Stats, Assignee, CreateRequest, Filters); KanbanError; pure transition planner that maps drag-drop column changes to verb sequences, tested against canonical Hermes JSON fixtures. Mac: KanbanBoardView orchestrator with five-column drag-drop layout, optimistic-merge state, KanbanInspectorPane side-pane (Comments / Events / Runs / Log tabs, Log streams worker stdout every 2s while running), inline assignee picker, health banner for unassigned and last-failed-run states. New Task sheet defaults to active profile and auto-fires kanban dispatch on submit. Sidebar moved Kanban from Manage to Monitor. Read-only KanbanListView preserved as Board|List toggle for narrow windows / accessibility. Per-project: DashboardTab.kanban tab on every project gated on hasKanban; KanbanTenantResolver mints scarf:<slug> tenants on first interaction and persists to .scarf/manifest.json (immutable across rename); ProjectAgentContextService surfaces the tenant in the AGENTS.md scarf-managed block so agents pass --tenant <slug> on kanban create. New kanban_summary dashboard widget; vocabulary mirrored in tools/widget-schema.json and site/widgets.js. iOS: read-only board on the project tab via paged single-column Picker, modal detail sheet with Comments / Events / Runs. Mutations + drag-drop deferred to v2.8. Tests: 19 new pure-logic tests covering decoding, planner verb mapping, argv assembly, glance string formatting, and parser rejection of the kanban assignees empty-state sentinel. All 348 ScarfCore tests pass. Constraints documented in CLAUDE.md: no within-column reorder (Hermes has no update --priority verb); no live watch streaming yet (5s polling for board, 2s for log); no bulk re-tag for legacy NULL-tenant tasks. Pre-v0.12 Hermes hosts gracefully hide the surface end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
4.5 KiB
Swift
135 lines
4.5 KiB
Swift
import SwiftUI
|
|
import ScarfCore
|
|
import ScarfDesign
|
|
|
|
/// One column of the Kanban board. Owns its drop target, header chrome,
|
|
/// scroll viewport, and per-column empty state. Cards are rendered via
|
|
/// `KanbanCardView`.
|
|
struct KanbanColumnView: View {
|
|
let column: KanbanBoardColumn
|
|
let tasks: [HermesKanbanTask]
|
|
/// Live indicator for the Running column — true when polling has
|
|
/// ticked within the last 6 seconds.
|
|
let isLive: Bool
|
|
/// "ready: N →" pill on the To Do column.
|
|
let readyPillCount: Int
|
|
let onTaskTap: (HermesKanbanTask) -> Void
|
|
let onCreate: () -> Void
|
|
let onDrop: (KanbanTaskRef) -> Void
|
|
let canCreate: Bool
|
|
|
|
@State private var isTargeted = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
.padding(.horizontal, ScarfSpace.s3)
|
|
.padding(.vertical, ScarfSpace.s2)
|
|
.background(ScarfColor.backgroundSecondary.opacity(0.001))
|
|
.background(.ultraThinMaterial)
|
|
Divider()
|
|
.opacity(0.5)
|
|
ScrollView {
|
|
LazyVStack(spacing: ScarfSpace.s2) {
|
|
if tasks.isEmpty {
|
|
emptyState
|
|
.padding(.top, ScarfSpace.s4)
|
|
} else {
|
|
ForEach(tasks) { task in
|
|
KanbanCardView(task: task) {
|
|
onTaskTap(task)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(ScarfSpace.s3)
|
|
}
|
|
}
|
|
.frame(minWidth: 240, idealWidth: 300, maxWidth: 360)
|
|
.frame(maxHeight: .infinity)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
|
|
.fill(ScarfColor.backgroundSecondary.opacity(0.6))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
|
|
.stroke(borderColor, lineWidth: isTargeted ? 2 : 1)
|
|
)
|
|
.animation(.easeInOut(duration: 0.12), value: isTargeted)
|
|
.dropDestination(for: KanbanTaskRef.self) { items, _ in
|
|
if let ref = items.first {
|
|
onDrop(ref)
|
|
return true
|
|
}
|
|
return false
|
|
} isTargeted: { targeted in
|
|
isTargeted = targeted
|
|
}
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var header: some View {
|
|
HStack(spacing: ScarfSpace.s2) {
|
|
Text(column.displayName.uppercased())
|
|
.scarfStyle(.captionUppercase)
|
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
|
ScarfBadge(String(tasks.count), kind: .neutral)
|
|
if column == .upNext, readyPillCount > 0 {
|
|
Text("ready: \(readyPillCount) →")
|
|
.scarfStyle(.caption)
|
|
.foregroundStyle(ScarfColor.info)
|
|
}
|
|
if column == .running, isLive {
|
|
liveIndicator
|
|
}
|
|
Spacer(minLength: 0)
|
|
if canCreate {
|
|
Button(action: onCreate) {
|
|
Image(systemName: "plus")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
}
|
|
.buttonStyle(ScarfGhostButton())
|
|
.help("New task in \(column.displayName)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var liveIndicator: some View {
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(ScarfColor.success)
|
|
.frame(width: 6, height: 6)
|
|
Text("live")
|
|
.scarfStyle(.caption)
|
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
|
}
|
|
}
|
|
|
|
private var borderColor: Color {
|
|
isTargeted ? ScarfColor.accent : ScarfColor.border
|
|
}
|
|
|
|
// MARK: - Empty state
|
|
|
|
private var emptyState: some View {
|
|
Text(emptyCopy)
|
|
.scarfStyle(.footnote)
|
|
.foregroundStyle(ScarfColor.foregroundFaint)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, ScarfSpace.s4)
|
|
}
|
|
|
|
private var emptyCopy: String {
|
|
switch column {
|
|
case .triage: return "Nothing waiting on you."
|
|
case .upNext: return "Empty queue. Drop a task here."
|
|
case .running: return "No live workers."
|
|
case .blocked: return "Nothing blocked."
|
|
case .done: return "Recent completions appear here."
|
|
case .archived: return "No archived tasks."
|
|
}
|
|
}
|
|
}
|