mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user