feat(ios-skills): port v0.11 features to new file structure (Phase D)

Re-port the four v0.11 iOS Skills features that lived in the now-
deleted Skills/SkillsListView.swift into the new Installed/SkillDetailView
+ Skills/SkillsView surfaces.

iOS Components/FlowLayout.swift (NEW, promoted helper):
- 50-line struct conforming to SwiftUI's Layout protocol; wraps
  subviews onto multiple lines on overflow. Built-in API, no third-
  party dep. Originally inline in the deleted SkillsListView; promoted
  so multiple iOS views can share without duplicating ~30 lines.

iOS Skills/Installed/SkillDetailView.swift (extend):
- design-md npx prereq banner: yellow "Prerequisite missing" section
  triggered by .task(id: skill.id) probing `which npx` via
  SkillPrereqService when the selected skill is design-md.
- Spotify info row: green "Authentication" section pointing users at
  the Mac sheet or shell for OAuth — phone OAuth flows are deferred
  in v2.5.
- SKILL.md frontmatter chip rows: three sections (Allowed tools /
  Related skills / Dependencies) using a chipRow helper backed by
  the shared FlowLayout. Each section hides itself when its
  HermesSkill field is nil — old skills without v0.11 frontmatter
  show none of these.

iOS Skills/SkillsView.swift (extend):
- "What's New" pill at the top of the tab (above the sub-tab
  picker). Driven by SkillSnapshotService.diff() against the per-
  server last-seen snapshot. First-load primes silently so users
  don't see "everything is new!" noise on day one.
- Recomputes on .task and .refreshable.
- "Seen" button persists the current set + dismisses.

Verified: iOS build succeeds. The chip-row data path is now
end-to-end (SkillsScanner → HermesSkill → SkillDetailView chips)
and the snapshot pill matches the Mac SkillsView placement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 10:00:55 +02:00
parent 84b033814b
commit 26c034ea6f
3 changed files with 223 additions and 2 deletions
@@ -0,0 +1,53 @@
import SwiftUI
/// Minimal flow layout wraps subviews onto multiple lines when they
/// overflow the available width. Built on the Layout protocol; no
/// third-party dependency.
///
/// Used for chip rows that need to wrap on iPhone-narrow screens
/// SKILL.md frontmatter chips (`allowed_tools` / `related_skills` /
/// `dependencies`) on the Skills detail view, and any future place
/// that wants pill-shaped wrapping content.
///
/// Promoted from the deleted `iOS Skills/SkillsListView.swift`'s
/// inline definition during the v2.5 iOS-design merge so multiple
/// views can share it without duplicating ~30 lines.
struct FlowLayout: Layout {
var spacing: CGFloat = 4
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard let maxWidth = proposal.width else { return .zero }
var rowWidth: CGFloat = 0
var totalHeight: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if rowWidth + size.width > maxWidth, rowWidth > 0 {
totalHeight += rowHeight + spacing
rowWidth = 0
rowHeight = 0
}
rowWidth += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
totalHeight += rowHeight
return CGSize(width: maxWidth, height: totalHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX, x > bounds.minX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
@@ -4,11 +4,23 @@ import ScarfCore
/// Installed skill detail. Shows location + required-config warning
/// banner + file picker + content viewer. Edit and Uninstall buttons
/// live in the toolbar.
///
/// v2.5 surfaces three Hermes v0.11+ pieces here:
/// - Spotify info row (when this is the spotify skill) points users
/// back to Mac for the OAuth flow.
/// - design-md `npx` prereq banner flagged when Node.js isn't on
/// the host's PATH.
/// - SKILL.md frontmatter chip rows (`allowed_tools` / `related_skills`
/// / `dependencies`) populated by `SkillsScanner` from the file's
/// YAML frontmatter, hidden when nil.
struct SkillDetailView: View {
let skill: HermesSkill
@Bindable var vm: SkillsViewModel
@Environment(\.serverContext) private var serverContext
@State private var showEditor: Bool = false
/// design-md npx probe result. Refetched per-skill via .task(id:).
@State private var npxStatus: SkillPrereqService.Status?
var body: some View {
List {
@@ -20,6 +32,71 @@ struct SkillDetailView: View {
.textSelection(.enabled)
}
// v2.5 design-md prereq banner. Only when this is the
// design-md skill AND `which npx` came back missing.
if skill.name.lowercased() == "design-md",
case .missing(let hint) = npxStatus {
Section("Prerequisite missing") {
Label {
VStack(alignment: .leading, spacing: 4) {
Text("`npx` not found on the Hermes host.")
.font(.callout.weight(.medium))
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
.padding(.vertical, 4)
}
}
// v2.5 Spotify auth note. iOS doesn't run the OAuth flow
// in-app (phones + browser callbacks are awkward); points
// users at the Mac sheet or shell. Once authed, the iOS
// skill picks up the credential from ~/.hermes/auth.json.
if skill.name.lowercased() == "spotify" {
Section("Authentication") {
Label {
VStack(alignment: .leading, spacing: 4) {
Text("Spotify needs OAuth")
.font(.callout.weight(.medium))
Text("Run `hermes auth spotify` from the Scarf macOS app or a shell — it opens your browser to complete the OAuth flow. Once authorised, this skill picks up the credentials from `~/.hermes/auth.json` automatically.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
} icon: {
Image(systemName: "music.note")
.foregroundStyle(.green)
}
.padding(.vertical, 4)
}
}
// v2.5 SKILL.md frontmatter chip rows. Each section
// hides itself when its corresponding HermesSkill field
// is nil old skills without v0.11 frontmatter show
// none of these.
if let tools = skill.allowedTools, !tools.isEmpty {
Section("Allowed tools") {
chipRow(tools)
}
}
if let related = skill.relatedSkills, !related.isEmpty {
Section("Related skills") {
chipRow(related)
}
}
if let deps = skill.dependencies, !deps.isEmpty {
Section("Dependencies") {
chipRow(deps)
}
}
if !vm.missingConfig.isEmpty {
Section {
HStack(alignment: .top, spacing: 8) {
@@ -91,6 +168,19 @@ struct SkillDetailView: View {
// missingConfig diagnostics. Idempotent on re-appears.
vm.selectSkill(skill)
}
.task(id: skill.id) {
// v2.5: probe `npx` only when this is the design-md skill
// the only skill that surfaces a host-side prereq today.
// Cheap (single SSH `which`); not cached across navigations
// so users see a fresh result if they install Node and come
// back.
guard skill.name.lowercased() == "design-md" else {
npxStatus = nil
return
}
let svc = SkillPrereqService(context: serverContext)
npxStatus = await svc.probe(binary: "npx")
}
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
if vm.selectedFileName != nil {
@@ -123,4 +213,23 @@ struct SkillDetailView: View {
)
return (try? AttributedString(markdown: raw, options: opts)) ?? AttributedString(raw)
}
/// Render a list of strings as wrapping pill chips (v2.5 SKILL.md
/// frontmatter sections). Uses the shared `FlowLayout` from
/// `Components/FlowLayout.swift` so chips wrap onto multiple lines
/// on iPhone-narrow screens.
@ViewBuilder
private func chipRow(_ items: [String]) -> some View {
FlowLayout(spacing: 6) {
ForEach(items, id: \.self) { item in
Text(item)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.secondary.opacity(0.12), in: Capsule())
}
}
.padding(.vertical, 4)
}
}
+61 -2
View File
@@ -18,6 +18,10 @@ struct SkillsView: View {
@State private var vm: SkillsViewModel
@State private var currentTab: Tab = .installed
/// v2.5 SkillSnapshotService diff against the per-server last-seen
/// snapshot. Drives the "What's New" pill at the top of the tab.
/// Nil before first compute or when there's nothing changed.
@State private var snapshotDiff: SkillSnapshotDiff?
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1"
@@ -39,6 +43,15 @@ struct SkillsView: View {
var body: some View {
VStack(spacing: 0) {
// v2.5 "What's New" pill surfaced above the sub-tab
// picker when the per-server snapshot diff has changes.
// First-load with no prior snapshot silently primes (no
// pill, the snapshot just records what's there).
if let diff = snapshotDiff,
diff.hasChanges,
!diff.previousSnapshotEmpty {
whatsNewPill(diff: diff)
}
tabPicker
.padding(.horizontal)
.padding(.top, 8)
@@ -49,8 +62,54 @@ struct SkillsView: View {
}
.navigationTitle(titleString)
.navigationBarTitleDisplayMode(.inline)
.task { await vm.load() }
.refreshable { await vm.load() }
.task {
await vm.load()
recomputeSnapshotDiff()
}
.refreshable {
await vm.load()
recomputeSnapshotDiff()
}
}
/// Compute the snapshot diff against the per-server last-seen
/// state. First load with no prior snapshot silently primes
/// the pill never renders for users on day one.
private func recomputeSnapshotDiff() {
let allSkills = vm.categories.flatMap(\.skills)
let svc = SkillSnapshotService(serverID: Self.sharedContextID)
let diff = svc.diff(against: allSkills)
if diff.previousSnapshotEmpty {
svc.markSeen(allSkills)
snapshotDiff = nil
} else {
snapshotDiff = diff
}
}
/// "2 new, 4 updated since you last looked" pill at the top of
/// the tab. Tapping "Seen" persists the current set as the new
/// baseline + dismisses the pill.
@ViewBuilder
private func whatsNewPill(diff: SkillSnapshotDiff) -> some View {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.foregroundStyle(.tint)
Text(diff.label)
.font(.callout)
.foregroundStyle(.primary)
Spacer()
Button("Seen") {
SkillSnapshotService(serverID: Self.sharedContextID)
.markSeen(vm.categories.flatMap(\.skills))
snapshotDiff = nil
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.tint.opacity(0.1))
}
private var titleString: String {