diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index dfd7c25..7cce5a7 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -44,6 +44,19 @@ struct ChatView: View { private var supportsImagePrompts: Bool { capabilitiesStore?.capabilities.hasACPImagePrompts ?? false } + + /// v0.13 `/goal` capability — drives the goal pill in `projectContextBar`. + /// Read-only on iOS in v2.8.0; users send `/goal` from the Mac. The pill + /// drops automatically when `vm.activeGoal` clears. + private var supportsActiveGoal: Bool { + capabilitiesStore?.capabilities.hasGoals ?? false + } + + /// v0.13 ACP `/queue` capability — drives the queue-count chip. Tap is a + /// no-op in v2.8.0 (no popover); previews live on the Mac app. + private var supportsACPQueue: Bool { + capabilitiesStore?.capabilities.hasACPQueue ?? false + } /// Drives the composer's keyboard. Bound to the TextField via /// `.focused(...)`; cleared by the scroll-to-dismiss gesture on /// the message list AND by an explicit keyboard-toolbar button. @@ -841,37 +854,47 @@ struct ChatView: View { /// informational. @ViewBuilder private var projectContextBar: some View { - if let projectName = controller.currentProjectName, - !projectName.isEmpty - { + // v2.8.0 (WS-9): the bar is no longer project-only — a non-empty + // active goal OR a non-empty queue mirror also light it up. Project + // chip, goal pill, and queue chip render independently and the bar + // shows when ANY of them is present. + let projectName = controller.currentProjectName ?? "" + let hasProject = !projectName.isEmpty + let hasGoal = supportsActiveGoal && controller.vm.activeGoal != nil + let hasQueue = supportsACPQueue && !controller.vm.queuedPrompts.isEmpty + if hasProject || hasGoal || hasQueue { HStack(spacing: 8) { - Image(systemName: "folder.fill") - .foregroundStyle(.tint) - .font(.caption) - VStack(alignment: .leading, spacing: 1) { - Text("Project chat") - .font(.caption2) - .foregroundStyle(ScarfColor.foregroundMuted) - HStack(spacing: 6) { - Text(projectName) - .font(.callout.weight(.medium)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - if let branch = controller.currentGitBranch, !branch.isEmpty { - Label(branch, systemImage: "arrow.triangle.branch") - .font(.caption2) - .foregroundStyle(.tint) - .labelStyle(.titleAndIcon) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(.tint.opacity(0.15), in: Capsule()) + if hasProject { + Image(systemName: "folder.fill") + .foregroundStyle(.tint) + .font(.caption) + VStack(alignment: .leading, spacing: 1) { + Text("Project chat") + .font(.caption2) + .foregroundStyle(ScarfColor.foregroundMuted) + HStack(spacing: 6) { + Text(projectName) + .font(.callout.weight(.medium)) + .foregroundStyle(.primary) .lineLimit(1) + .truncationMode(.tail) + if let branch = controller.currentGitBranch, !branch.isEmpty { + Label(branch, systemImage: "arrow.triangle.branch") + .font(.caption2) + .foregroundStyle(.tint) + .labelStyle(.titleAndIcon) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.tint.opacity(0.15), in: Capsule()) + .lineLimit(1) + } } } } + if hasGoal { goalChip } + if hasQueue { queueChip } Spacer() - if !controller.vm.projectScopedCommands.isEmpty { + if hasProject && !controller.vm.projectScopedCommands.isEmpty { Button { showSlashCommandsSheet = true } label: { @@ -893,6 +916,8 @@ struct ChatView: View { .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) .background(.tint.opacity(0.1)) + .animation(.spring(response: 0.3, dampingFraction: 0.75), value: hasGoal) + .animation(.spring(response: 0.3, dampingFraction: 0.75), value: hasQueue) .sheet(isPresented: $showSlashCommandsSheet) { ProjectSlashCommandsBrowser( projectName: projectName, @@ -902,6 +927,55 @@ struct ChatView: View { } } + /// v0.13 goal pill — purely informational mirror of the agent's + /// currently-locked `/goal`. Read-only on iOS; `/goal --clear` lives on + /// the Mac app and the pill drops on the next VM update. Semantic + /// `.subheadline` font so the goal text scales with Dynamic Type + /// (it's content the user reads, not chrome). VoiceOver gets the full + /// untruncated text via the accessibility label. + @ViewBuilder + private var goalChip: some View { + if let goal = controller.vm.activeGoal { + Label(truncatedGoalText(goal.text), systemImage: "scope") + .labelStyle(.titleAndIcon) + .font(.subheadline) + .foregroundStyle(ScarfColor.info) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(ScarfColor.info.opacity(0.16), in: Capsule()) + .lineLimit(1) + .accessibilityLabel("Goal locked: \(goal.text)") + .transition(.opacity.combined(with: .scale(scale: 0.92))) + } + } + + /// v0.13 queue chip — read-only count of prompts queued via `/queue`. + /// Tap is a no-op in v2.8.0 (no popover); the source of truth lives on + /// the Mac app. Defaults to one fixed pill regardless of count. + @ViewBuilder + private var queueChip: some View { + let count = controller.vm.queuedPrompts.count + if count > 0 { + Label("\(count) queued", systemImage: "tray.full") + .labelStyle(.titleAndIcon) + .font(.caption.weight(.medium)) + .foregroundStyle(.tint) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.tint.opacity(0.18), in: Capsule()) + .lineLimit(1) + .accessibilityLabel("\(count) prompt\(count == 1 ? "" : "s") queued — manage on the Mac app") + .transition(.opacity.combined(with: .scale(scale: 0.92))) + } + } + + /// Trim long goal text to fit a chip beside the project name on iPhone + /// portrait. The full text rides VoiceOver via the chip's accessibility + /// label. + private func truncatedGoalText(_ text: String) -> String { + text.count <= 28 ? text : String(text.prefix(25)) + "…" + } + /// Shown while we're opening the SSH exec channel + spawning /// `hermes acp` + creating the ACP session. Typically ~0.5–1.5 s /// on a warm network — silent before this overlay existed, which diff --git a/scarf/Scarf iOS/Curator/CuratorView.swift b/scarf/Scarf iOS/Curator/CuratorView.swift index bf33db4..407ac49 100644 --- a/scarf/Scarf iOS/Curator/CuratorView.swift +++ b/scarf/Scarf iOS/Curator/CuratorView.swift @@ -15,18 +15,15 @@ struct CuratorView: View { @State private var viewModel: CuratorViewModel @Environment(\.hermesCapabilities) private var capabilitiesStore - // TODO(WS-9): add a read-only "Archived" section mirroring the Mac - // surface (no per-row Restore/Prune mutations on iOS in this - // release). Gate on `capabilitiesStore?.capabilities.hasCuratorArchive`. - init(context: ServerContext) { _viewModel = State(initialValue: CuratorViewModel(context: context)) } - /// Whether the connected host runs curator synchronously. Threaded - /// into `runNow` so v0.13+ hosts block-with-spinner; pre-v0.13 fire - /// and forget. WS-9 will surface a richer iOS progress affordance - /// alongside the read-only Archived section. + /// v0.13 capability gate. Drives both the synchronous `runNow` + /// blocking-with-spinner behavior AND the read-only Archived + /// section. Pre-v0.13 hosts skip the archive load entirely so we + /// don't spam `hermes curator list-archived` against a binary that + /// would error out. private var archiveAvailable: Bool { capabilitiesStore?.capabilities.hasCuratorArchive ?? false } @@ -91,18 +88,88 @@ struct CuratorView: View { .textSelection(.enabled) } } + + if archiveAvailable { + archivedSection + } } .navigationTitle("Curator") .navigationBarTitleDisplayMode(.large) .refreshable { await viewModel.load() + if archiveAvailable { + await viewModel.loadArchive() + } } .overlay(alignment: .bottom) { if let toast = viewModel.transientMessage { toastView(toast) } } - .task { await viewModel.load() } + .task { + await viewModel.load() + if archiveAvailable { + await viewModel.loadArchive() + } + } + } + + /// v0.13 read-only Archived list. iOS doesn't expose Restore / + /// Prune-this / Prune-all — that's a Mac-only surface in v2.8.0. + /// The footer signposts the user to the Mac app when there are + /// rows to act on. + @ViewBuilder + private var archivedSection: some View { + Section { + if viewModel.archivedSkills.isEmpty { + Text("No archived skills — Curator will move stale skills here after the next review cycle.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.archivedSkills) { skill in + archivedRow(skill) + } + } + } header: { + Text("Archived") + } footer: { + if !viewModel.archivedSkills.isEmpty { + Text("Restore or prune archived skills from the Mac app.") + .font(.caption) + } + } + } + + @ViewBuilder + private func archivedRow(_ skill: HermesCuratorArchivedSkill) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(skill.name) + .font(.body) + .lineLimit(1) + Spacer() + if let category = skill.category, !category.isEmpty { + ScarfBadge(category, kind: .neutral) + } + } + HStack(spacing: 6) { + if let reason = skill.reason, !reason.isEmpty { + Text(reason) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + Text(skill.archivedAtLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + } + if let size = skill.sizeBytes, size > 0 { + Text(skill.sizeLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } } private var statusRow: some View { diff --git a/scarf/Scarf iOS/Kanban/DiagnosticDetailSheet.swift b/scarf/Scarf iOS/Kanban/DiagnosticDetailSheet.swift new file mode 100644 index 0000000..b56cfcf --- /dev/null +++ b/scarf/Scarf iOS/Kanban/DiagnosticDetailSheet.swift @@ -0,0 +1,86 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// iOS substitute for the Mac inspector's `.help()` tooltip on a Kanban +/// diagnostic chip. iOS doesn't have hover, so each diagnostic chip in +/// the detail sheet is tappable; tap presents this sheet with the kind, +/// severity, server-supplied message, and detection timestamp. +/// +/// Read-only — there are no recovery actions on iOS in v2.8.0. The +/// surface is deliberately small (one screen, no scroll padding) so it +/// reads as a fast peek rather than a full editor. +struct DiagnosticDetailSheet: View { + let diagnostic: HermesKanbanDiagnostic + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + LabeledContent("Kind") { + Text(diagnostic.kind) + .font(.body.monospaced()) + .foregroundStyle(.primary) + } + LabeledContent("Severity") { + ScarfBadge(severityLabel, kind: severityBadgeKind) + } + if let detectedAt = diagnostic.detectedAt, !detectedAt.isEmpty { + LabeledContent("Detected at") { + Text(detectedAt) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + } header: { + Text("Diagnostic") + } + + if let message = diagnostic.message, !message.isEmpty { + Section { + Text(message) + .font(.body) + .textSelection(.enabled) + } header: { + Text("Message") + } + } + + Section { + Label("Recovery actions live on the Mac app — open this task there to verify, reject, or unblock.", systemImage: "info.circle") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .scrollContentBackground(.hidden) + .background(ScarfColor.backgroundPrimary) + .navigationTitle("Diagnostic") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } + + private var severityLabel: String { + let kind = KanbanDiagnosticKind.from(diagnostic.kind) + switch kind.severity { + case .danger: return "danger" + case .warning: return "warning" + case .neutral: return "neutral" + } + } + + private var severityBadgeKind: ScarfBadgeKind { + let kind = KanbanDiagnosticKind.from(diagnostic.kind) + switch kind.severity { + case .danger: return .danger + case .warning: return .warning + case .neutral: return .neutral + } + } +} diff --git a/scarf/Scarf iOS/Kanban/ScarfGoKanbanDetailSheet.swift b/scarf/Scarf iOS/Kanban/ScarfGoKanbanDetailSheet.swift index b8e9127..965491d 100644 --- a/scarf/Scarf iOS/Kanban/ScarfGoKanbanDetailSheet.swift +++ b/scarf/Scarf iOS/Kanban/ScarfGoKanbanDetailSheet.swift @@ -15,12 +15,14 @@ struct ScarfGoKanbanDetailSheet: View { 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" @@ -29,6 +31,13 @@ struct ScarfGoKanbanDetailSheet: View { 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 @@ -41,6 +50,9 @@ struct ScarfGoKanbanDetailSheet: View { } } .task(id: taskId) { await load() } + .sheet(item: $selectedDiagnostic) { diag in + DiagnosticDetailSheet(diagnostic: diag) + } } @ViewBuilder @@ -62,6 +74,8 @@ struct ScarfGoKanbanDetailSheet: View { 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) @@ -71,6 +85,9 @@ struct ScarfGoKanbanDetailSheet: View { .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) @@ -90,7 +107,9 @@ struct ScarfGoKanbanDetailSheet: View { private func headerCard(_ task: HermesKanbanTask) -> some View { VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { + // 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) @@ -101,6 +120,10 @@ struct ScarfGoKanbanDetailSheet: View { 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)") @@ -110,6 +133,100 @@ struct ScarfGoKanbanDetailSheet: View { } } + /// 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 { @@ -194,6 +311,10 @@ struct ScarfGoKanbanDetailSheet: View { .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)) diff --git a/scarf/Scarf iOS/Settings/SettingsView.swift b/scarf/Scarf iOS/Settings/SettingsView.swift index 75b33ec..7e3353d 100644 --- a/scarf/Scarf iOS/Settings/SettingsView.swift +++ b/scarf/Scarf iOS/Settings/SettingsView.swift @@ -13,6 +13,7 @@ struct SettingsView: View { @State private var vm: IOSSettingsViewModel @State private var showRawYAML = false @State private var editingSpec: SettingSpec? + @State private var showV013FeaturesSheet = false /// v2.7 — Scarf-local opt-in to bulk-fetch tool result CONTENT /// when resuming past chats. Default off; the shared /// `RichChatViewModel` reads this same UserDefaults key on @@ -21,6 +22,16 @@ struct SettingsView: View { @AppStorage(RichChatViewModel.loadHistoricalToolResultsKey) private var loadHistoricalToolResults: Bool = false + /// Drives v0.13 read-only surfaces (features-active badge, + /// platforms-section additions). Defensive `?? .empty` resolves + /// every gate to `false` outside `ContextBoundRoot` (preview / + /// smoke harness) so the v2.7.5 layout is the unconditional + /// fallback. + @Environment(\.hermesCapabilities) private var capabilitiesStore + private var caps: HermesCapabilities { + capabilitiesStore?.capabilities ?? .empty + } + private static let sharedContextID: ServerID = ServerID( uuidString: "00000000-0000-0000-0000-0000000000A1" )! @@ -40,6 +51,10 @@ struct SettingsView: View { } } + if caps.isV013OrLater { + v013ActiveBadgeSection + } + if !vm.isLoading || vm.config.model != "unknown" { quickEditsSection modelSection @@ -79,6 +94,35 @@ struct SettingsView: View { onDismiss: {} ) } + .sheet(isPresented: $showV013FeaturesSheet) { + V013FeaturesSheet() + } + } + + /// v0.13 features-active badge. Only shown when the connected host + /// is on the v0.13 line; tap presents `V013FeaturesSheet`. Read-only + /// — there's no settings change behind the badge, just a + /// what's-new affordance. + @ViewBuilder + private var v013ActiveBadgeSection: some View { + Section { + Button { + showV013FeaturesSheet = true + } label: { + HStack(spacing: 8) { + ScarfBadge("v0.13 features active", kind: .success) + Spacer() + Text("Learn more") + .font(.caption) + .foregroundStyle(.tint) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .buttonStyle(.plain) + } + .listRowBackground(ScarfColor.success.opacity(0.06)) } @ViewBuilder @@ -284,9 +328,119 @@ struct SettingsView: View { yesNoRow("Telegram: require mention", vm.config.telegram.requireMention) LabeledContent("Slack: reply mode", value: vm.config.slack.replyToMode) yesNoRow("Matrix: require mention", vm.config.matrix.requireMention) + + // v0.13 additions: each is independently capability-gated + // and read-only on iOS in v2.8.0. Editing lives on Mac. + if caps.hasGoogleChatPlatform { + LabeledContent("Google Chat", value: googleChatStatusLabel) + } + if caps.hasGatewayBusyAckToggle { + gatewayBusyAckRow + } + if caps.hasGatewayRestartNotification { + gatewayRestartNotificationRow + } + if caps.hasGatewayAllowlists { + gatewayAllowlistsRows + } } } + /// v0.13 Google Chat status. Whether the platform shows up at all + /// is driven by whether `gateway.platforms.google-chat.*` exists in + /// config.yaml on the remote — if absent, we render "Not configured". + /// Hermes accepts either `google-chat` or `googlechat` as the + /// identifier; check both spellings defensively. + private var googleChatStatusLabel: String { + if vm.config.gatewayPlatforms["google-chat"] != nil + || vm.config.gatewayPlatforms["googlechat"] != nil { + return "configured" + } + return "not configured" + } + + /// v0.13 cross-platform busy-ack toggle. We summarize per platform + /// so users on iOS get a faithful read of the per-platform flag — + /// "off on slack, on elsewhere" is a real configuration shape. + /// Empty `gatewayPlatforms` shows "default". + @ViewBuilder + private var gatewayBusyAckRow: some View { + let value = summariseGatewayBool(\GatewayPlatformSettings.busyAckEnabled, defaultLabel: "on") + LabeledContent("Gateway: busy ack", value: value) + } + + @ViewBuilder + private var gatewayRestartNotificationRow: some View { + let value = summariseGatewayBool(\GatewayPlatformSettings.gatewayRestartNotification, defaultLabel: "off") + LabeledContent("Gateway: restart notification", value: value) + } + + /// Render a per-key summary across `gatewayPlatforms`. When all + /// configured platforms agree on the same value we show a single + /// "yes" / "no". When they disagree we show "mixed (N platforms)" + /// to nudge the user to the Mac app for the per-platform detail. + private func summariseGatewayBool( + _ keyPath: KeyPath, + defaultLabel: String + ) -> String { + let values = vm.config.gatewayPlatforms.values.map { $0[keyPath: keyPath] } + guard !values.isEmpty else { return defaultLabel + " (default)" } + let allTrue = values.allSatisfy { $0 } + let allFalse = values.allSatisfy { !$0 } + if allTrue { return "yes" } + if allFalse { return "no" } + return "mixed (\(values.count) platforms)" + } + + /// v0.13 cross-platform allowlist summaries. Each kind + /// (channels / chats / rooms) renders as a DisclosureGroup with the + /// total count in the label and a flat list of "platform: id" rows + /// when expanded. iPhone-friendly: collapsed by default so the + /// section stays compact. + @ViewBuilder + private var gatewayAllowlistsRows: some View { + gatewayAllowlistDisclosure(kind: .channels) + gatewayAllowlistDisclosure(kind: .chats) + gatewayAllowlistDisclosure(kind: .rooms) + } + + @ViewBuilder + private func gatewayAllowlistDisclosure(kind: GatewayAllowlistKind) -> some View { + let entries = gatewayAllowlistEntries(kind: kind) + if !entries.isEmpty { + DisclosureGroup { + ForEach(entries, id: \.self) { entry in + Text(entry) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } label: { + LabeledContent("Allowed \(kind.pluralNoun)") { + Text("\(entries.count)") + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + } + + /// Flatten the per-platform allowlists for `kind` across every + /// configured platform. Each entry is rendered as + /// `"platformName: id"` so the user sees which platform the id + /// belongs to without an extra DisclosureGroup level. + private func gatewayAllowlistEntries(kind: GatewayAllowlistKind) -> [String] { + var out: [String] = [] + for (platform, settings) in vm.config.gatewayPlatforms.sorted(by: { $0.key < $1.key }) { + guard GatewayAllowlistKind.kind(for: platform) == kind else { continue } + for item in settings.items(for: kind) where !item.isEmpty { + out.append("\(platform): \(item)") + } + } + return out + } + /// Diagnostics → Performance entry point. Hidden from the /// `quickEditsSection` flow because it doesn't touch config.yaml /// — it controls the in-process ScarfMon backend set instead. Off diff --git a/scarf/Scarf iOS/Settings/V013FeaturesSheet.swift b/scarf/Scarf iOS/Settings/V013FeaturesSheet.swift new file mode 100644 index 0000000..21d03e4 --- /dev/null +++ b/scarf/Scarf iOS/Settings/V013FeaturesSheet.swift @@ -0,0 +1,83 @@ +import SwiftUI +import ScarfDesign + +/// "Learn more" sheet behind the v0.13 features-active badge in +/// `SettingsView`. Text-only summary of what shipped in Hermes v0.13 +/// (Persistent Goals, ACP /queue, Kanban diagnostics, hallucination +/// gate, Curator archive, Google Chat platform). Every row spells out +/// where the editing lives — Mac for v2.8.0; iOS write surfaces are +/// deferred to v2.8.x. +/// +/// No deep-linking from rows in v2.8.0 — that's a v2.8.x polish. +struct V013FeaturesSheet: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + featureRow( + icon: "scope", + title: "Persistent goals", + description: "Type /goal in chat to lock the agent on a target across turns. Send and clear from the Mac app in v2.8." + ) + featureRow( + icon: "tray.full", + title: "ACP /queue", + description: "Queue prompts to run after the current turn finishes. Send and manage from the Mac app in v2.8." + ) + featureRow( + icon: "stethoscope", + title: "Kanban diagnostics", + description: "Worker distress signals (heartbeat stalls, retry caps, zombies) surface on the task detail." + ) + featureRow( + icon: "questionmark.diamond.fill", + title: "Hallucination gate", + description: "Worker-created cards are flagged for verify or reject. Verify on the Mac app." + ) + featureRow( + icon: "archivebox", + title: "Curator archive", + description: "Stale skills move to an Archived list. Restore or prune from the Mac app." + ) + featureRow( + icon: "bubble.left.and.bubble.right", + title: "Google Chat platform", + description: "New messaging-gateway target. Configure on the Mac app." + ) + } header: { + Text("What's new in v0.13") + } footer: { + Text("This iOS release surfaces v0.13 features read-only. Editing lives in the Mac app for v2.8.") + .font(.caption) + } + } + .scrollContentBackground(.hidden) + .background(ScarfColor.backgroundPrimary) + .navigationTitle("v0.13 features") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } + + private func featureRow(icon: String, title: String, description: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .foregroundStyle(.tint) + .font(.title3) + .frame(width: 28) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.body.weight(.semibold)) + Text(description) + .font(.callout) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +}