feat(skills): SkillSnapshotService + 'What's New' pill (Phase 3.4)

Per-server snapshot of skill signatures so the Skills tab can show
"2 new, 4 updated since you last looked" — same pattern Hermes's
`hermes skills update` CLI shows on the host.

ScarfCore SkillSnapshotService:
- [skillId: signature] map, signature is `<fileCount>:<sorted-files>`.
  New / removed / files-changed all show up as a delta.
- diff(against:) returns SkillSnapshotDiff with counts + a label
  string for the pill.
- markSeen(_:) persists the current set.
- Backend abstraction: file-based on Mac, UserDefaults on iOS,
  in-memory for tests.
- previousSnapshotEmpty silently primes first-load so users don't
  see "everything is new!" noise.

Mac SkillsView:
- whatsNewPill(diff:) tinted pill at the top with "Mark as seen".
- recomputeSnapshotDiff() on .task and on totalSkillCount change.

iOS SkillsListView:
- Same pill rendered as a Section row with "Seen" button.
- Recompute on .task + .refreshable.

Verified: Mac + iOS builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 09:22:38 +02:00
parent 5c08c09dde
commit 751c9e6778
3 changed files with 373 additions and 2 deletions
+46 -2
View File
@@ -8,6 +8,7 @@ struct SkillsListView: View {
let config: IOSServerConfig
@State private var vm: IOSSkillsViewModel
@State private var snapshotDiff: SkillSnapshotDiff?
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1"
@@ -21,6 +22,28 @@ struct SkillsListView: View {
var body: some View {
List {
if let diff = snapshotDiff,
diff.hasChanges,
!diff.previousSnapshotEmpty {
Section {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
Text(diff.label)
.font(.callout)
}
Spacer()
Button("Seen") {
SkillSnapshotService(serverID: Self.sharedContextID)
.markSeen(vm.categories.flatMap(\.skills))
snapshotDiff = nil
}
.controlSize(.small)
.buttonStyle(.bordered)
}
}
}
if let err = vm.lastError {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
@@ -71,8 +94,29 @@ struct SkillsListView: View {
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.refreshable { await vm.load() }
.task { await vm.load() }
.refreshable {
await vm.load()
recomputeSnapshotDiff()
}
.task {
await vm.load()
recomputeSnapshotDiff()
}
}
/// v2.5 "What's New" diff against the last-seen snapshot for this
/// server. First-time users get a silent prime the pill only
/// renders on subsequent loads when something actually changed.
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
}
}
}