feat(kanban): full read/write board with per-project tenants

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>
This commit is contained in:
Alan Wizemann
2026-05-08 11:24:55 +02:00
parent fd80f4f95a
commit adcc984091
40 changed files with 5832 additions and 163 deletions
@@ -0,0 +1,243 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Read-only Kanban task detail sheet for iOS. Mirrors the Mac
/// inspector's 3-tab layout (Comments | Events | Runs) but routes
/// through a `NavigationStack` for iOS-native chrome and dismisses
/// to the parent kanban view, not to the board.
///
/// No mutations in v2.7.5 write actions land on iOS in a later
/// release via a bottom action bar with explicit verb buttons (no
/// drag-drop).
struct ScarfGoKanbanDetailSheet: View {
let taskId: String
let context: ServerContext
@Environment(\.dismiss) private var dismiss
@State private var detail: HermesKanbanTaskDetail?
@State private var runs: [HermesKanbanRun] = []
@State private var isLoading = true
@State private var error: String?
@State private var selectedTab: DetailTab = .comments
enum DetailTab: String, CaseIterable, Identifiable {
case comments = "Comments"
case events = "Events"
case runs = "Runs"
var id: String { rawValue }
}
var body: some View {
NavigationStack {
content
.navigationTitle(detail?.task.title ?? "Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.task(id: taskId) { await load() }
}
@ViewBuilder
private var content: some View {
if isLoading && detail == nil {
ProgressView("Loading…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error {
ContentUnavailableView {
Label("Couldn't load task", systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button("Try Again") {
Task { await load() }
}
}
} else if let detail {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
headerCard(detail.task)
if let body = detail.task.body, !body.isEmpty {
if let attributed = try? AttributedString(markdown: body) {
Text(attributed)
.font(.body)
} else {
Text(body)
.font(.body)
}
}
Picker("Section", selection: $selectedTab) {
ForEach(DetailTab.allCases) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
switch selectedTab {
case .comments: commentsSection(detail.comments)
case .events: eventsSection(detail.events)
case .runs: runsSection
}
}
.padding()
}
}
}
private func headerCard(_ task: HermesKanbanTask) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
ScarfBadge(task.status.lowercased(), kind: badgeKind(for: task.status))
if let assignee = task.assignee, !assignee.isEmpty {
ScarfBadge(assignee, kind: .neutral)
}
if let workspace = task.workspaceKind {
ScarfBadge(workspace, kind: .neutral)
}
if let tenant = task.tenant, !tenant.isEmpty {
ScarfBadge(tenant, kind: .brand)
}
}
if let priority = task.priority {
Text("Priority \(priority)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private func commentsSection(_ comments: [HermesKanbanComment]) -> some View {
VStack(alignment: .leading, spacing: 8) {
if comments.isEmpty {
Text("No comments yet.")
.font(.callout)
.foregroundStyle(.tertiary)
} else {
ForEach(comments) { comment in
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(comment.author)
.font(.subheadline)
.bold()
Text(comment.createdAt)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Text(comment.body)
.font(.body)
.foregroundStyle(.secondary)
}
.padding(8)
.background(ScarfColor.backgroundSecondary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous))
}
}
}
}
private func eventsSection(_ events: [HermesKanbanEvent]) -> some View {
VStack(alignment: .leading, spacing: 6) {
if events.isEmpty {
Text("No events yet.")
.font(.callout)
.foregroundStyle(.tertiary)
} else {
ForEach(events) { event in
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 2) {
Text(event.kind)
.font(.subheadline)
.bold()
Text(event.createdAt)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
}
}
private var runsSection: some View {
VStack(alignment: .leading, spacing: 8) {
if runs.isEmpty {
Text("No runs yet.")
.font(.callout)
.foregroundStyle(.tertiary)
} else {
ForEach(runs) { run in
VStack(alignment: .leading, spacing: 2) {
HStack {
ScarfBadge(run.outcome ?? run.status, kind: outcomeKind(run.outcome ?? run.status))
if let profile = run.profile {
Text(profile)
.font(.subheadline)
}
Spacer()
Text(run.startedAt)
.font(.caption2)
.foregroundStyle(.tertiary)
}
if let summary = run.summary, !summary.isEmpty {
Text(summary)
.font(.caption)
.foregroundStyle(.secondary)
}
if let err = run.error, !err.isEmpty {
Text(err)
.font(.caption)
.foregroundStyle(.red)
}
}
.padding(8)
.background(ScarfColor.backgroundSecondary.opacity(0.4))
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous))
}
}
}
}
private func badgeKind(for status: String) -> ScarfBadgeKind {
switch KanbanStatus.from(status) {
case .running, .ready: return .info
case .done: return .success
case .blocked: return .warning
default: return .neutral
}
}
private func outcomeKind(_ outcome: String) -> ScarfBadgeKind {
switch outcome.lowercased() {
case "completed", "done": return .success
case "blocked": return .warning
case "crashed", "timed_out", "spawn_failed", "failed": return .danger
case "running": return .info
default: return .neutral
}
}
// MARK: - Loading
private func load() async {
isLoading = true
defer { isLoading = false }
let svc = KanbanService(context: context)
do {
async let detailLoaded = svc.show(taskId: taskId)
async let runsLoaded = svc.runs(taskId: taskId)
self.detail = try await detailLoaded
self.runs = (try? await runsLoaded) ?? []
self.error = nil
} catch let err as KanbanError {
self.error = err.errorDescription
} catch {
self.error = error.localizedDescription
}
}
}
@@ -0,0 +1,236 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Read-only Kanban surface for iOS / iPadOS, scoped to one project's
/// tenant. Renders the 5 standard board columns as a horizontally-
/// paged `TabView` of single-column lists HIG-friendly on iPhone
/// where a 5-column grid would force unreadable card widths.
///
/// Mutations + drag-drop are deferred to a later release per
/// CLAUDE.md's iOS catch-up policy. Tap a card to open a read-only
/// detail sheet that surfaces the same comments / events / runs the
/// Mac inspector shows. iPad gets the same view (no drag-drop yet)
/// same UI for both form factors keeps the future mutation path
/// straightforward.
struct ScarfGoKanbanView: View {
let project: ProjectEntry
let context: ServerContext
@State private var tasks: [HermesKanbanTask] = []
@State private var stats: HermesKanbanStats = .empty
@State private var isLoading = true
@State private var error: String?
@State private var selectedColumn: KanbanBoardColumn = .upNext
@State private var inspectorTaskId: String?
@State private var pollTask: Task<Void, Never>?
private var resolvedTenant: String? {
KanbanTenantReader(context: context).tenant(forProjectPath: project.path)
}
var body: some View {
VStack(spacing: 0) {
if !stats.glanceString.isEmpty {
Text(stats.glanceString)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 4)
}
columnPicker
.padding(.horizontal)
.padding(.bottom, 4)
Divider()
content
}
.background(ScarfColor.backgroundPrimary)
.task(id: project.id) {
await refresh()
startPolling()
}
.onDisappear { pollTask?.cancel() }
.sheet(item: Binding(
get: { inspectorTaskId.map { TaskIDBox(id: $0) } },
set: { inspectorTaskId = $0?.id }
)) { box in
ScarfGoKanbanDetailSheet(
taskId: box.id,
context: context
)
}
}
private var columnPicker: some View {
Picker("Column", selection: $selectedColumn) {
ForEach(visibleColumns, id: \.self) { column in
Text("\(column.displayName) (\(taskCount(in: column)))").tag(column)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var content: some View {
if let error {
errorView(error)
} else if isLoading && tasks.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
taskList
}
}
private var taskList: some View {
let rows = tasks(in: selectedColumn)
return Group {
if rows.isEmpty {
ContentUnavailableView(
emptyTitle(for: selectedColumn),
systemImage: "rectangle.split.3x1",
description: Text(emptyCopy(for: selectedColumn))
)
} else {
List(rows) { task in
Button {
inspectorTaskId = task.id
} label: {
cardRow(task)
}
.buttonStyle(.plain)
}
.listStyle(.plain)
.refreshable {
await refresh()
}
}
}
}
private func cardRow(_ task: HermesKanbanTask) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.font(.headline)
.foregroundStyle(.primary)
.lineLimit(2)
HStack(spacing: 8) {
if let assignee = task.assignee, !assignee.isEmpty {
Label(assignee, systemImage: "person.fill")
.labelStyle(.titleAndIcon)
.font(.caption)
.foregroundStyle(.secondary)
}
if let workspace = task.workspaceKind {
ScarfBadge(workspace, kind: .neutral)
}
if let priority = task.priority, priority >= 70 {
ScarfBadge("p\(priority)", kind: priority >= 90 ? .danger : .warning)
}
Spacer()
}
if !task.skills.isEmpty {
Text(task.skills.prefix(2).joined(separator: ", ") + (task.skills.count > 2 ? " +\(task.skills.count - 2)" : ""))
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
.padding(.vertical, 4)
}
private func errorView(_ message: String) -> some View {
ContentUnavailableView {
Label("Couldn't load tasks", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("Try Again") {
Task { await refresh() }
}
}
}
// MARK: - Loading
private func startPolling() {
pollTask?.cancel()
pollTask = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 5_000_000_000)
if Task.isCancelled { break }
await refresh()
}
}
}
private func refresh() async {
isLoading = true
defer { isLoading = false }
guard let tenant = resolvedTenant, !tenant.isEmpty else {
tasks = []
error = "No Kanban tenant has been minted for this project yet. Open the Kanban tab on the Mac app to mint one."
return
}
let svc = KanbanService(context: context)
let filter = KanbanListFilter(tenant: tenant)
do {
let polled = try await svc.list(filter)
tasks = polled
stats = (try? await svc.stats()) ?? .empty
error = nil
} catch let err as KanbanError {
error = err.errorDescription
} catch {
self.error = error.localizedDescription
}
}
// MARK: - Column projection
private var visibleColumns: [KanbanBoardColumn] {
var cols: [KanbanBoardColumn] = []
if !tasks(in: .triage).isEmpty { cols.append(.triage) }
cols.append(contentsOf: [.upNext, .running, .blocked, .done])
return cols
}
private func taskCount(in column: KanbanBoardColumn) -> Int {
tasks(in: column).count
}
private func tasks(in column: KanbanBoardColumn) -> [HermesKanbanTask] {
tasks.filter { KanbanStatus.from($0.status).boardColumn == column }
.sorted { lhs, rhs in
let lp = lhs.priority ?? 0
let rp = rhs.priority ?? 0
if lp != rp { return lp > rp }
return (lhs.createdAt ?? "") > (rhs.createdAt ?? "")
}
}
private func emptyTitle(for column: KanbanBoardColumn) -> String {
switch column {
case .triage: return "Triage empty"
case .upNext: return "Queue empty"
case .running: return "No live workers"
case .blocked: return "Nothing blocked"
case .done: return "No completions yet"
case .archived: return "No archived tasks"
}
}
private func emptyCopy(for column: KanbanBoardColumn) -> String {
switch column {
case .triage: return "No tasks waiting on a specifier."
case .upNext: return "Drop a task on the Mac board, or create one with `hermes kanban create`."
case .running: return "No workers are running tasks for this project right now."
case .blocked: return "Nothing is blocked. When a worker hits a block, it'll show up here."
case .done: return "Recent completions will land here."
case .archived: return "Archived tasks are hidden by default."
}
}
}
private struct TaskIDBox: Identifiable {
let id: String
}
@@ -19,6 +19,7 @@ struct ProjectDetailView: View {
let config: IOSServerConfig
@Environment(\.scarfGoCoordinator) private var coordinator
@Environment(\.hermesCapabilities) private var capabilitiesStore
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A2"
@@ -35,7 +36,7 @@ struct ProjectDetailView: View {
@State private var lastDashboardMtime: Date?
enum DetailTab: Hashable {
case dashboard, site, sessions
case dashboard, site, sessions, kanban
}
private var serverContext: ServerContext {
@@ -55,6 +56,9 @@ struct ProjectDetailView: View {
var tabs: [DetailTab] = [.dashboard]
if siteWidget != nil { tabs.append(.site) }
tabs.append(.sessions)
if capabilitiesStore?.capabilities.hasKanban ?? false {
tabs.append(.kanban)
}
return tabs
}
@@ -111,6 +115,7 @@ struct ProjectDetailView: View {
case .dashboard: return "Dashboard"
case .site: return "Site"
case .sessions: return "Sessions"
case .kanban: return "Kanban"
}
}
@@ -129,6 +134,8 @@ struct ProjectDetailView: View {
}
case .sessions:
ProjectSessionsView_iOS(project: project)
case .kanban:
ScarfGoKanbanView(project: project, context: serverContext)
}
}