mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat(ios): v0.13 read-only catch-up — goal pill, queue chip, Kanban diagnostics, Curator archived, Platforms (WS-9)
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>
This commit is contained in:
@@ -44,6 +44,19 @@ struct ChatView: View {
|
|||||||
private var supportsImagePrompts: Bool {
|
private var supportsImagePrompts: Bool {
|
||||||
capabilitiesStore?.capabilities.hasACPImagePrompts ?? false
|
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
|
/// Drives the composer's keyboard. Bound to the TextField via
|
||||||
/// `.focused(...)`; cleared by the scroll-to-dismiss gesture on
|
/// `.focused(...)`; cleared by the scroll-to-dismiss gesture on
|
||||||
/// the message list AND by an explicit keyboard-toolbar button.
|
/// the message list AND by an explicit keyboard-toolbar button.
|
||||||
@@ -841,10 +854,17 @@ struct ChatView: View {
|
|||||||
/// informational.
|
/// informational.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var projectContextBar: some View {
|
private var projectContextBar: some View {
|
||||||
if let projectName = controller.currentProjectName,
|
// v2.8.0 (WS-9): the bar is no longer project-only — a non-empty
|
||||||
!projectName.isEmpty
|
// 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) {
|
HStack(spacing: 8) {
|
||||||
|
if hasProject {
|
||||||
Image(systemName: "folder.fill")
|
Image(systemName: "folder.fill")
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -870,8 +890,11 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if hasGoal { goalChip }
|
||||||
|
if hasQueue { queueChip }
|
||||||
Spacer()
|
Spacer()
|
||||||
if !controller.vm.projectScopedCommands.isEmpty {
|
if hasProject && !controller.vm.projectScopedCommands.isEmpty {
|
||||||
Button {
|
Button {
|
||||||
showSlashCommandsSheet = true
|
showSlashCommandsSheet = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -893,6 +916,8 @@ struct ChatView: View {
|
|||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(.tint.opacity(0.1))
|
.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) {
|
.sheet(isPresented: $showSlashCommandsSheet) {
|
||||||
ProjectSlashCommandsBrowser(
|
ProjectSlashCommandsBrowser(
|
||||||
projectName: projectName,
|
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
|
/// Shown while we're opening the SSH exec channel + spawning
|
||||||
/// `hermes acp` + creating the ACP session. Typically ~0.5–1.5 s
|
/// `hermes acp` + creating the ACP session. Typically ~0.5–1.5 s
|
||||||
/// on a warm network — silent before this overlay existed, which
|
/// on a warm network — silent before this overlay existed, which
|
||||||
|
|||||||
@@ -15,18 +15,15 @@ struct CuratorView: View {
|
|||||||
@State private var viewModel: CuratorViewModel
|
@State private var viewModel: CuratorViewModel
|
||||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
@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) {
|
init(context: ServerContext) {
|
||||||
_viewModel = State(initialValue: CuratorViewModel(context: context))
|
_viewModel = State(initialValue: CuratorViewModel(context: context))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the connected host runs curator synchronously. Threaded
|
/// v0.13 capability gate. Drives both the synchronous `runNow`
|
||||||
/// into `runNow` so v0.13+ hosts block-with-spinner; pre-v0.13 fire
|
/// blocking-with-spinner behavior AND the read-only Archived
|
||||||
/// and forget. WS-9 will surface a richer iOS progress affordance
|
/// section. Pre-v0.13 hosts skip the archive load entirely so we
|
||||||
/// alongside the read-only Archived section.
|
/// don't spam `hermes curator list-archived` against a binary that
|
||||||
|
/// would error out.
|
||||||
private var archiveAvailable: Bool {
|
private var archiveAvailable: Bool {
|
||||||
capabilitiesStore?.capabilities.hasCuratorArchive ?? false
|
capabilitiesStore?.capabilities.hasCuratorArchive ?? false
|
||||||
}
|
}
|
||||||
@@ -91,18 +88,88 @@ struct CuratorView: View {
|
|||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if archiveAvailable {
|
||||||
|
archivedSection
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Curator")
|
.navigationTitle("Curator")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.load()
|
await viewModel.load()
|
||||||
|
if archiveAvailable {
|
||||||
|
await viewModel.loadArchive()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
if let toast = viewModel.transientMessage {
|
if let toast = viewModel.transientMessage {
|
||||||
toastView(toast)
|
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 {
|
private var statusRow: some View {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,12 +15,14 @@ struct ScarfGoKanbanDetailSheet: View {
|
|||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
|
||||||
@State private var detail: HermesKanbanTaskDetail?
|
@State private var detail: HermesKanbanTaskDetail?
|
||||||
@State private var runs: [HermesKanbanRun] = []
|
@State private var runs: [HermesKanbanRun] = []
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var selectedTab: DetailTab = .comments
|
@State private var selectedTab: DetailTab = .comments
|
||||||
|
@State private var selectedDiagnostic: HermesKanbanDiagnostic?
|
||||||
|
|
||||||
enum DetailTab: String, CaseIterable, Identifiable {
|
enum DetailTab: String, CaseIterable, Identifiable {
|
||||||
case comments = "Comments"
|
case comments = "Comments"
|
||||||
@@ -29,6 +31,13 @@ struct ScarfGoKanbanDetailSheet: View {
|
|||||||
var id: String { rawValue }
|
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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
content
|
content
|
||||||
@@ -41,6 +50,9 @@ struct ScarfGoKanbanDetailSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task(id: taskId) { await load() }
|
.task(id: taskId) { await load() }
|
||||||
|
.sheet(item: $selectedDiagnostic) { diag in
|
||||||
|
DiagnosticDetailSheet(diagnostic: diag)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -62,6 +74,8 @@ struct ScarfGoKanbanDetailSheet: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
headerCard(detail.task)
|
headerCard(detail.task)
|
||||||
|
hallucinationBadge(detail.task)
|
||||||
|
autoBlockedBanner(detail.task)
|
||||||
if let body = detail.task.body, !body.isEmpty {
|
if let body = detail.task.body, !body.isEmpty {
|
||||||
if let attributed = try? AttributedString(markdown: body) {
|
if let attributed = try? AttributedString(markdown: body) {
|
||||||
Text(attributed)
|
Text(attributed)
|
||||||
@@ -71,6 +85,9 @@ struct ScarfGoKanbanDetailSheet: View {
|
|||||||
.font(.body)
|
.font(.body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if diagnosticsAvailable, !detail.task.diagnostics.isEmpty {
|
||||||
|
diagnosticsBlock(detail.task.diagnostics, label: "Diagnostics")
|
||||||
|
}
|
||||||
Picker("Section", selection: $selectedTab) {
|
Picker("Section", selection: $selectedTab) {
|
||||||
ForEach(DetailTab.allCases) { tab in
|
ForEach(DetailTab.allCases) { tab in
|
||||||
Text(tab.rawValue).tag(tab)
|
Text(tab.rawValue).tag(tab)
|
||||||
@@ -90,7 +107,9 @@ struct ScarfGoKanbanDetailSheet: View {
|
|||||||
|
|
||||||
private func headerCard(_ task: HermesKanbanTask) -> some View {
|
private func headerCard(_ task: HermesKanbanTask) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
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))
|
ScarfBadge(task.status.lowercased(), kind: badgeKind(for: task.status))
|
||||||
if let assignee = task.assignee, !assignee.isEmpty {
|
if let assignee = task.assignee, !assignee.isEmpty {
|
||||||
ScarfBadge(assignee, kind: .neutral)
|
ScarfBadge(assignee, kind: .neutral)
|
||||||
@@ -101,6 +120,10 @@ struct ScarfGoKanbanDetailSheet: View {
|
|||||||
if let tenant = task.tenant, !tenant.isEmpty {
|
if let tenant = task.tenant, !tenant.isEmpty {
|
||||||
ScarfBadge(tenant, kind: .brand)
|
ScarfBadge(tenant, kind: .brand)
|
||||||
}
|
}
|
||||||
|
if diagnosticsAvailable, let maxRetries = task.maxRetries {
|
||||||
|
ScarfBadge("retries: \(maxRetries)", kind: .neutral)
|
||||||
|
.accessibilityLabel("Max retries \(maxRetries)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let priority = task.priority {
|
if let priority = task.priority {
|
||||||
Text("Priority \(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 {
|
private func commentsSection(_ comments: [HermesKanbanComment]) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if comments.isEmpty {
|
if comments.isEmpty {
|
||||||
@@ -194,6 +311,10 @@ struct ScarfGoKanbanDetailSheet: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
|
if diagnosticsAvailable, !run.diagnostics.isEmpty {
|
||||||
|
diagnosticsBlock(run.diagnostics, label: "Run diagnostics")
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(ScarfColor.backgroundSecondary.opacity(0.4))
|
.background(ScarfColor.backgroundSecondary.opacity(0.4))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct SettingsView: View {
|
|||||||
@State private var vm: IOSSettingsViewModel
|
@State private var vm: IOSSettingsViewModel
|
||||||
@State private var showRawYAML = false
|
@State private var showRawYAML = false
|
||||||
@State private var editingSpec: SettingSpec?
|
@State private var editingSpec: SettingSpec?
|
||||||
|
@State private var showV013FeaturesSheet = false
|
||||||
/// v2.7 — Scarf-local opt-in to bulk-fetch tool result CONTENT
|
/// v2.7 — Scarf-local opt-in to bulk-fetch tool result CONTENT
|
||||||
/// when resuming past chats. Default off; the shared
|
/// when resuming past chats. Default off; the shared
|
||||||
/// `RichChatViewModel` reads this same UserDefaults key on
|
/// `RichChatViewModel` reads this same UserDefaults key on
|
||||||
@@ -21,6 +22,16 @@ struct SettingsView: View {
|
|||||||
@AppStorage(RichChatViewModel.loadHistoricalToolResultsKey)
|
@AppStorage(RichChatViewModel.loadHistoricalToolResultsKey)
|
||||||
private var loadHistoricalToolResults: Bool = false
|
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(
|
private static let sharedContextID: ServerID = ServerID(
|
||||||
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
||||||
)!
|
)!
|
||||||
@@ -40,6 +51,10 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if caps.isV013OrLater {
|
||||||
|
v013ActiveBadgeSection
|
||||||
|
}
|
||||||
|
|
||||||
if !vm.isLoading || vm.config.model != "unknown" {
|
if !vm.isLoading || vm.config.model != "unknown" {
|
||||||
quickEditsSection
|
quickEditsSection
|
||||||
modelSection
|
modelSection
|
||||||
@@ -79,6 +94,35 @@ struct SettingsView: View {
|
|||||||
onDismiss: {}
|
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
|
@ViewBuilder
|
||||||
@@ -284,7 +328,117 @@ struct SettingsView: View {
|
|||||||
yesNoRow("Telegram: require mention", vm.config.telegram.requireMention)
|
yesNoRow("Telegram: require mention", vm.config.telegram.requireMention)
|
||||||
LabeledContent("Slack: reply mode", value: vm.config.slack.replyToMode)
|
LabeledContent("Slack: reply mode", value: vm.config.slack.replyToMode)
|
||||||
yesNoRow("Matrix: require mention", vm.config.matrix.requireMention)
|
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<GatewayPlatformSettings, Bool>,
|
||||||
|
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
|
/// Diagnostics → Performance entry point. Hidden from the
|
||||||
|
|||||||
@@ -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 <text> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user