mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
a8cdb3e663
Mirrors the v0.13 surfaces from WS-2 (Persistent Goals + ACP /queue),
WS-3 (Kanban diagnostics + hallucination gate), WS-4 (Curator archive),
and WS-5 (Google Chat platform + cross-platform allowlists + behavior
toggles) onto ScarfGo. Per Phase H precedent, every iOS surface is
strictly read-only — write verbs (Verify / Reject, /goal --clear, queue
send, allowlist editing, archive Restore / Prune) live on Mac in v2.8.0
and are deferred to v2.8.x.
Five iOS additions, all capability-gated so pre-v0.13 hosts see the
v2.7.5 layout unchanged:
1. Chat — goal pill ("Goal: <text>") and queue chip ("N queued") render
inside `projectContextBar` whenever a project, goal, or queue is
present. The bar is no longer project-only; goal/queue chips render
even outside a project chat. Goal text scales with Dynamic Type
(semantic `.subheadline`); the full untruncated text rides VoiceOver
via the chip's accessibility label.
2. Kanban — `ScarfGoKanbanDetailSheet` gains a `retries: N` chip in the
header `FlowLayout`, a yellow "Worker-created — verify on Mac" badge
for `pending` hallucination state, a red "Auto-blocked" banner with
the server-supplied `auto_blocked_reason`, and tappable diagnostics
chip-lists (task-level + per-run) that present a new
`DiagnosticDetailSheet` with kind / severity / message / timestamp.
No Verify or Reject buttons; the badge copy points users to the Mac
app.
3. Curator — `CuratorView` appends a read-only "Archived" section that
loads via `viewModel.loadArchive()` on appear and pull-to-refresh.
Per-row name + category badge + reason + archived-at + size; footer
signposts users to the Mac app for Restore / Prune.
4. Settings → Platforms — adds a Google Chat status row (configured /
not configured), busy-ack and restart-notification rows summarized
across `gatewayPlatforms` (yes / no / mixed (N platforms)), and
collapsed DisclosureGroups for allowed channels / chats / rooms with
monospaced "platform: id" entries when expanded. No editor.
5. Settings — green "v0.13 features active" `ScarfBadge` above the
quick-edits section when `caps.isV013OrLater`. Tap presents a new
`V013FeaturesSheet` listing the six v0.13 surfaces with one-sentence
summaries; the section footer is explicit that editing lives on Mac.
Implements WS-9 of Scarf v2.8.0 (Hermes v0.13.0 catch-up).
Plan: scarf/docs/v2.8/WS-9-ios-v0.13-plan.md (on coordination/v2.8.0-plans).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
365 lines
15 KiB
Swift
365 lines
15 KiB
Swift
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
|
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
|
|
|
@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
|
|
@State private var selectedDiagnostic: HermesKanbanDiagnostic?
|
|
|
|
enum DetailTab: String, CaseIterable, Identifiable {
|
|
case comments = "Comments"
|
|
case events = "Events"
|
|
case runs = "Runs"
|
|
var id: String { rawValue }
|
|
}
|
|
|
|
/// v0.13 capability gate. Defensive default `false` when no
|
|
/// capabilities store is present (preview / smoke harness) so the
|
|
/// sheet renders the v2.7.5 layout unchanged.
|
|
private var diagnosticsAvailable: Bool {
|
|
capabilitiesStore?.capabilities.hasKanbanDiagnostics ?? false
|
|
}
|
|
|
|
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() }
|
|
.sheet(item: $selectedDiagnostic) { diag in
|
|
DiagnosticDetailSheet(diagnostic: diag)
|
|
}
|
|
}
|
|
|
|
@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)
|
|
hallucinationBadge(detail.task)
|
|
autoBlockedBanner(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)
|
|
}
|
|
}
|
|
if diagnosticsAvailable, !detail.task.diagnostics.isEmpty {
|
|
diagnosticsBlock(detail.task.diagnostics, label: "Diagnostics")
|
|
}
|
|
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) {
|
|
// Wrap chips in FlowLayout so the new v0.13 `retries` chip
|
|
// doesn't push the row over the iPhone-portrait width budget.
|
|
FlowLayout(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 diagnosticsAvailable, let maxRetries = task.maxRetries {
|
|
ScarfBadge("retries: \(maxRetries)", kind: .neutral)
|
|
.accessibilityLabel("Max retries \(maxRetries)")
|
|
}
|
|
}
|
|
if let priority = task.priority {
|
|
Text("Priority \(priority)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// v0.13 hallucination gate. Worker-created cards land in the
|
|
/// `pending` state until a human verifies — Mac surfaces a Verify /
|
|
/// Reject button pair; iOS in v2.8.0 stays read-only and points
|
|
/// the user to the Mac app via the badge copy.
|
|
@ViewBuilder
|
|
private func hallucinationBadge(_ task: HermesKanbanTask) -> some View {
|
|
if diagnosticsAvailable,
|
|
KanbanHallucinationGate.from(task.hallucinationGateStatus) == .pending {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "questionmark.diamond.fill")
|
|
.foregroundStyle(ScarfColor.warning)
|
|
Text("Worker-created — verify on Mac")
|
|
.font(.subheadline)
|
|
.foregroundStyle(ScarfColor.warning)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
ScarfColor.warning.opacity(0.10),
|
|
in: RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
|
|
.strokeBorder(ScarfColor.warning.opacity(0.4), lineWidth: 1)
|
|
)
|
|
.accessibilityHint("Open this task on the Mac app to verify or reject the worker's claim.")
|
|
}
|
|
}
|
|
|
|
/// v0.13 auto-blocked banner. Surfaces `auto_blocked_reason` verbatim
|
|
/// when Hermes auto-blocks a task (retry cap hit, repeated tool
|
|
/// errors, etc.). Server-supplied copy — render verbatim.
|
|
@ViewBuilder
|
|
private func autoBlockedBanner(_ task: HermesKanbanTask) -> some View {
|
|
if diagnosticsAvailable,
|
|
KanbanStatus.from(task.status) == .blocked,
|
|
let reason = task.autoBlockedReason, !reason.isEmpty {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: "exclamationmark.octagon.fill")
|
|
.foregroundStyle(ScarfColor.danger)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Auto-blocked")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(ScarfColor.danger)
|
|
Text(reason)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
ScarfColor.danger.opacity(0.08),
|
|
in: RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Tap-target diagnostic chip list. iOS substitute for the Mac
|
|
/// inspector's `.help()` tooltip — chips are tappable, tap presents
|
|
/// `DiagnosticDetailSheet` with the full message + timestamp.
|
|
@ViewBuilder
|
|
private func diagnosticsBlock(_ diags: [HermesKanbanDiagnostic], label: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(label)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
FlowLayout(spacing: 6) {
|
|
ForEach(diags) { diag in
|
|
Button {
|
|
selectedDiagnostic = diag
|
|
} label: {
|
|
ScarfBadge(diag.kind, kind: diagnosticBadgeKind(diag))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(diag.message ?? diag.kind)
|
|
.accessibilityHint("Tap to see the full diagnostic message and timestamp.")
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
/// Maps the typed `KanbanDiagnosticKind.severity` enum into the
|
|
/// `ScarfBadgeKind` palette. Mirrors the Mac inspector's
|
|
/// `diagnosticBadge` helper so the two surfaces tint identically.
|
|
private func diagnosticBadgeKind(_ diag: HermesKanbanDiagnostic) -> ScarfBadgeKind {
|
|
switch KanbanDiagnosticKind.from(diag.kind).severity {
|
|
case .danger: return .danger
|
|
case .warning: return .warning
|
|
case .neutral: return .neutral
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
if diagnosticsAvailable, !run.diagnostics.isEmpty {
|
|
diagnosticsBlock(run.diagnostics, label: "Run diagnostics")
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
}
|
|
}
|