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>
362 lines
13 KiB
Swift
362 lines
13 KiB
Swift
import SwiftUI
|
|
import ScarfCore
|
|
import ScarfDesign
|
|
|
|
/// Full drag-and-drop Kanban board. Renders the visible columns side
|
|
/// by side, supports drag-drop for column transitions, and slides in
|
|
/// a side-pane inspector when a card is tapped.
|
|
///
|
|
/// Two flavors:
|
|
/// - **Global**: pass `tenantFilter: nil` and `projectPath: nil`.
|
|
/// - **Per-project**: pass the project's `kanbanTenant` slug + the
|
|
/// project path so the New Task sheet pre-fills the workspace and
|
|
/// tenant.
|
|
struct KanbanBoardView: View {
|
|
@State private var viewModel: KanbanBoardViewModel
|
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
|
|
|
/// When non-nil, a project board hosts this view. Drives header
|
|
/// chrome (subtitle, hidden tenant filter) and create-sheet
|
|
/// defaults.
|
|
let projectName: String?
|
|
|
|
init(
|
|
context: ServerContext,
|
|
tenantFilter: String? = nil,
|
|
projectPath: String? = nil,
|
|
projectName: String? = nil
|
|
) {
|
|
_viewModel = State(initialValue: KanbanBoardViewModel(
|
|
context: context,
|
|
tenantFilter: tenantFilter,
|
|
projectPath: projectPath
|
|
))
|
|
self.projectName = projectName
|
|
}
|
|
|
|
/// Convenience read for the v0.13 diagnostics flag — gates the
|
|
/// max_retries field, hallucination banner, diagnostics rendering,
|
|
/// and the auto-blocked reason banner. Pre-v0.13 hosts get the
|
|
/// v2.7.5 surface unchanged. Treats a missing store as "off" so
|
|
/// harness contexts (Previews) don't accidentally surface gated UI.
|
|
private var supportsKanbanDiagnostics: Bool {
|
|
capabilitiesStore?.capabilities.hasKanbanDiagnostics ?? false
|
|
}
|
|
|
|
@State private var inspectorTaskId: String?
|
|
@State private var showingCreateSheet = false
|
|
@State private var blockSheetTaskId: String?
|
|
@State private var blockSheetTitle: String = ""
|
|
@State private var blockSheetDestination: KanbanBoardColumn = .blocked
|
|
@State private var completeSheetTaskId: String?
|
|
@State private var completeSheetTitle: String = ""
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
ScarfDivider()
|
|
if let err = viewModel.lastError {
|
|
errorBanner(err)
|
|
}
|
|
if let notice = viewModel.transientNotice {
|
|
noticeBanner(notice)
|
|
}
|
|
HStack(spacing: 0) {
|
|
boardArea
|
|
if inspectorTaskId != nil {
|
|
ScarfDivider()
|
|
.frame(width: 1)
|
|
inspectorPane
|
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
|
}
|
|
}
|
|
}
|
|
.background(ScarfColor.backgroundPrimary)
|
|
.onAppear {
|
|
viewModel.startPolling()
|
|
Task { await viewModel.refreshAssignees() }
|
|
}
|
|
.onDisappear { viewModel.stopPolling() }
|
|
.sheet(isPresented: $showingCreateSheet) {
|
|
KanbanCreateSheet(
|
|
assignees: viewModel.assignees,
|
|
tenantPrefill: viewModel.tenantFilter,
|
|
projectWorkspacePath: viewModel.projectPath,
|
|
supportsKanbanDiagnostics: supportsKanbanDiagnostics
|
|
) { request in
|
|
_ = try await viewModel.createTask(request)
|
|
}
|
|
}
|
|
.sheet(isPresented: blockSheetBinding) {
|
|
KanbanBlockReasonSheet(taskTitle: blockSheetTitle) { reason in
|
|
if let taskId = blockSheetTaskId {
|
|
viewModel.attemptMove(
|
|
taskId: taskId,
|
|
to: blockSheetDestination,
|
|
blockReason: reason
|
|
)
|
|
}
|
|
blockSheetTaskId = nil
|
|
}
|
|
}
|
|
.sheet(isPresented: completeSheetBinding) {
|
|
KanbanCompleteResultSheet(taskTitle: completeSheetTitle) { result in
|
|
if let taskId = completeSheetTaskId {
|
|
viewModel.attemptMove(
|
|
taskId: taskId,
|
|
to: .done,
|
|
completeResult: result
|
|
)
|
|
}
|
|
completeSheetTaskId = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var header: some View {
|
|
ScarfPageHeader(
|
|
"Kanban",
|
|
subtitle: subtitle
|
|
) {
|
|
HStack(spacing: ScarfSpace.s2) {
|
|
glanceText
|
|
if viewModel.tenantFilter == nil {
|
|
assigneeFilterMenu
|
|
}
|
|
Toggle("Show archived", isOn: $viewModel.showArchived)
|
|
.toggleStyle(.switch)
|
|
.labelsHidden()
|
|
.help("Show archived tasks")
|
|
Button {
|
|
Task { await viewModel.refresh() }
|
|
} label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(ScarfGhostButton())
|
|
.help("Refresh now")
|
|
Button {
|
|
showingCreateSheet = true
|
|
} label: {
|
|
Label("New Task", systemImage: "plus")
|
|
}
|
|
.buttonStyle(ScarfPrimaryButton())
|
|
}
|
|
}
|
|
}
|
|
|
|
private var subtitle: String {
|
|
if let projectName, let tenant = viewModel.tenantFilter, !tenant.isEmpty {
|
|
return "\(projectName) · tenant \(tenant)"
|
|
}
|
|
return "Hermes task board"
|
|
}
|
|
|
|
private var glanceText: some View {
|
|
let text = viewModel.stats.glanceString
|
|
return Text(text.isEmpty ? " " : text)
|
|
.scarfStyle(.caption)
|
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
|
.frame(minWidth: 60)
|
|
}
|
|
|
|
private var assigneeFilterMenu: some View {
|
|
Menu {
|
|
Button("All assignees") { viewModel.assigneeFilter = nil }
|
|
if !viewModel.assignees.isEmpty {
|
|
Divider()
|
|
ForEach(viewModel.assignees) { row in
|
|
Button(row.profile) { viewModel.assigneeFilter = row.profile }
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
|
Text(viewModel.assigneeFilter ?? "All")
|
|
.scarfStyle(.caption)
|
|
}
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.menuIndicator(.hidden)
|
|
}
|
|
|
|
// MARK: - Board area
|
|
|
|
private var boardArea: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: ScarfSpace.s4) {
|
|
ForEach(viewModel.visibleColumns, id: \.self) { column in
|
|
KanbanColumnView(
|
|
column: column,
|
|
tasks: viewModel.tasks(in: column),
|
|
isLive: column == .running && isLive,
|
|
readyPillCount: column == .upNext ? readyCount : 0,
|
|
onTaskTap: { task in
|
|
inspectorTaskId = task.id
|
|
},
|
|
onCreate: { showingCreateSheet = true },
|
|
onDrop: { ref in
|
|
handleDrop(ref.id, on: column)
|
|
},
|
|
canCreate: column == .upNext || column == .triage,
|
|
supportsKanbanDiagnostics: supportsKanbanDiagnostics,
|
|
effectiveHallucinationGate: { viewModel.effectiveHallucinationGate($0) }
|
|
)
|
|
}
|
|
Spacer(minLength: ScarfSpace.s4)
|
|
}
|
|
.padding(ScarfSpace.s4)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
// MARK: - Inspector
|
|
|
|
@ViewBuilder
|
|
private var inspectorPane: some View {
|
|
if let taskId = inspectorTaskId,
|
|
let task = viewModel.tasks.first(where: { $0.id == taskId }) {
|
|
KanbanInspectorPane(
|
|
service: viewModel.service,
|
|
taskId: taskId,
|
|
availableAssignees: viewModel.assignees,
|
|
supportsKanbanDiagnostics: supportsKanbanDiagnostics,
|
|
effectiveHallucinationGate: { viewModel.effectiveHallucinationGate($0) },
|
|
onClose: { inspectorTaskId = nil },
|
|
onClaim: {
|
|
viewModel.attemptMove(taskId: taskId, to: .running)
|
|
inspectorTaskId = nil
|
|
},
|
|
onComplete: {
|
|
completeSheetTaskId = taskId
|
|
completeSheetTitle = task.title
|
|
},
|
|
onBlock: {
|
|
blockSheetTaskId = taskId
|
|
blockSheetTitle = task.title
|
|
blockSheetDestination = .blocked
|
|
},
|
|
onUnblock: {
|
|
viewModel.attemptMove(taskId: taskId, to: .upNext)
|
|
inspectorTaskId = nil
|
|
},
|
|
onArchive: {
|
|
viewModel.archive(taskId: taskId)
|
|
inspectorTaskId = nil
|
|
},
|
|
onReassign: { profile in
|
|
viewModel.reassignTask(taskId: taskId, to: profile)
|
|
},
|
|
onVerifyHallucination: {
|
|
viewModel.verifyHallucination(taskId: taskId)
|
|
},
|
|
onRejectHallucination: {
|
|
viewModel.rejectHallucination(taskId: taskId)
|
|
// Card vanishes from active board after archive — close
|
|
// the inspector so it doesn't dangle on a deleted task.
|
|
inspectorTaskId = nil
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Drop handling
|
|
|
|
private func handleDrop(_ taskId: String, on destination: KanbanBoardColumn) {
|
|
guard let task = viewModel.tasks.first(where: { $0.id == taskId }) else { return }
|
|
// Sheets first when the transition needs user input.
|
|
switch destination {
|
|
case .blocked:
|
|
blockSheetTaskId = taskId
|
|
blockSheetTitle = task.title
|
|
blockSheetDestination = .blocked
|
|
case .done:
|
|
// Manual checkoffs from running don't strictly need a result,
|
|
// but we offer the sheet anyway so users can record one
|
|
// when relevant. The move fires regardless on submit.
|
|
if KanbanStatus.from(task.status) == .running {
|
|
completeSheetTaskId = taskId
|
|
completeSheetTitle = task.title
|
|
} else {
|
|
viewModel.attemptMove(taskId: taskId, to: destination)
|
|
}
|
|
default:
|
|
viewModel.attemptMove(taskId: taskId, to: destination)
|
|
}
|
|
}
|
|
|
|
private var blockSheetBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { blockSheetTaskId != nil },
|
|
set: { if !$0 { blockSheetTaskId = nil } }
|
|
)
|
|
}
|
|
|
|
private var completeSheetBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { completeSheetTaskId != nil },
|
|
set: { if !$0 { completeSheetTaskId = nil } }
|
|
)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private var isLive: Bool {
|
|
guard let lastPoll = viewModel.lastPollAt else { return false }
|
|
return Date().timeIntervalSince(lastPoll) < 6
|
|
}
|
|
|
|
/// Tasks currently in `ready` (a Hermes status that the dispatcher
|
|
/// will promote to `running` next tick). Surfaced as a pill on the
|
|
/// To Do column header.
|
|
private var readyCount: Int {
|
|
viewModel.tasks.filter { KanbanStatus.from($0.status) == .ready }.count
|
|
}
|
|
|
|
private func errorBanner(_ message: String) -> some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(ScarfColor.warning)
|
|
Text(message)
|
|
.scarfStyle(.caption)
|
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
|
Spacer()
|
|
Button {
|
|
viewModel.lastError = nil
|
|
Task { await viewModel.refresh() }
|
|
} label: {
|
|
Text("Retry")
|
|
.scarfStyle(.caption)
|
|
}
|
|
.buttonStyle(ScarfGhostButton())
|
|
}
|
|
.padding(.horizontal, ScarfSpace.s3)
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(ScarfColor.warning.opacity(0.12))
|
|
}
|
|
|
|
private func noticeBanner(_ message: String) -> some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "info.circle")
|
|
.foregroundStyle(ScarfColor.info)
|
|
Text(message)
|
|
.scarfStyle(.caption)
|
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
|
Spacer()
|
|
Button {
|
|
viewModel.transientNotice = nil
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 10))
|
|
}
|
|
.buttonStyle(ScarfGhostButton())
|
|
}
|
|
.padding(.horizontal, ScarfSpace.s3)
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(ScarfColor.info.opacity(0.12))
|
|
}
|
|
}
|