Merge branch 'claude/pedantic-mcnulty-bac7cc' (iOS UI refactor)

Brings the major iOS UI refactor into scarf-mobile-development on top
of the v0.11 work that landed after the merge base (commit 6808adf).

Reconciled in this merge:

- iOS Chat/ChatView.swift — auto-merged. Their project-chat handoff
  (lines 75-148: pendingProjectChat consumer + consumePendingProjectChat
  helper) sits cleanly alongside my v0.11 chat additions at lines 350+
  (slash command chip + browser sheet), 500+ (/steer toast), 700+
  (per-turn stopwatch + git branch chip).
- Mac Features/Skills/Views/SkillsView.swift — manual resolution.
  Took their async-wrap of viewModel.load() (the new ScarfCore
  SkillsViewModel.load is async) AND kept my v0.11 modifiers
  (designMdNpxStatus probe + recomputeSnapshotDiff + .onChange + .task)
  + helpers (recomputeSnapshotDiff, whatsNewPill).
- M5FeatureVMTests.swift — auto-merged. Their 3-line rename of
  IOSSkillsViewModel → SkillsViewModel is in a different region from
  my Phase 1.10 slash-command tests.
- iOS Skills/SkillsListView.swift — resolved as DELETE (their
  refactor replaces it with Skills/Installed/SkillDetailView and
  Skills/SkillsView). My v0.11 features there (Spotify info row,
  design-md banner, frontmatter chips, What's New pill) get re-ported
  to the new files in follow-up commits.
- ScarfCore IOSSkillsViewModel.swift — resolved as DELETE (replaced
  by the shared SkillsViewModel in ScarfCore). My parseFrontmatter
  function relocates to SkillFrontmatterParser via Phase C.
- ProjectSlashCommandsViewModel.swift — git's location-conflict
  heuristic moved my Mac VM into ScarfCore (because the parent dir
  was renamed). Manually relocated back to scarf/scarf/Features/Projects/ViewModels/
  where it belongs (the file imports ScarfCore as a dependency, can't
  live inside it).

Wholesale-accepted (no overlap with v0.11):
- ScarfCore: SkillsScanner, SkillFrontmatterParser, HermesSkillsHubParser,
  SkillsViewModel, ProjectSessionsViewModel + new tests.
- iOS Projects/ feature (NEW): ProjectsListView, ProjectDetailView,
  ProjectSessionsView_iOS, ProjectSiteView, Widgets/ subdir.
- iOS Skills/ refactor (NEW): SkillsView (3-sub-tab switcher),
  Hub/HubBrowseView, Installed/{InstalledSkillsListView, SkillDetailView,
  SkillEditorSheet}, Updates/UpdatesView.
- ScarfGoCoordinator: pendingProjectChat, startChatInProject(path:).
- ScarfGoTabRoot: 5-tab nav (Dashboard / Projects / Chat / Skills /
  System) replacing the old Chat / Dashboard / Memory / More.

Verified: ScarfCore + Mac + iOS schemes all build clean on first try
post-merge. Phase C/D/E follow-up commits will:
1. Extend SkillsScanner so HermesSkill.allowedTools / relatedSkills /
   dependencies populate (currently nil because the new scanner only
   parses skill.yaml's required_config).
2. Port my v0.11 iOS Skills features into the new SkillDetailView /
   SkillsView (Spotify info row, design-md npx banner, frontmatter
   chips, What's New pill).
3. Clean up Mac dead code (HermesFileService.parseSkillFrontmatter,
   parseSkillRequiredConfig — superseded by SkillsScanner /
   SkillFrontmatterParser).
This commit is contained in:
Alan Wizemann
2026-04-25 09:56:13 +02:00
34 changed files with 2624 additions and 842 deletions
+23 -5
View File
@@ -5,10 +5,11 @@ import ScarfCore
/// `AppCoordinator` pattern: an `@Observable` carrier injected via
/// `.environment(_:)` that any view in the tab tree can reach.
///
/// Single responsibility in M9 scope: route "user tapped a recent
/// session in Dashboard" "open the Chat tab with a resume request."
/// Future uses (project-scoped chat handoff, notification deep-link
/// specific session) compose naturally on the same primitive.
/// v2.5 expands the surface to include project handoff: tapping
/// "New Chat" inside a Project Detail view sets `pendingProjectChat`
/// and routes to the Chat tab, where ChatController consumes it and
/// dispatches `resetAndStartInProject(_:)` (same wiring the in-Chat
/// project picker sheet already uses).
@Observable
@MainActor
final class ScarfGoCoordinator {
@@ -23,8 +24,15 @@ final class ScarfGoCoordinator {
/// ChatController after it honours the request.
var pendingResumeSessionID: String?
/// If non-nil, the Chat tab should start an in-project session at
/// this absolute remote path on next appear instead of a quick
/// chat. Consumed (cleared) by ChatController after it kicks off
/// `resetAndStartInProject(_:)`. Mirrors Mac's
/// `AppCoordinator.pendingProjectChat`.
var pendingProjectChat: String?
enum Tab: Hashable {
case chat, dashboard, memory, more
case dashboard, projects, chat, skills, system
}
/// Convenience: route to Chat and queue a resume. Dashboard rows
@@ -35,6 +43,16 @@ final class ScarfGoCoordinator {
pendingResumeSessionID = id
selectedTab = .chat
}
/// Convenience: route to Chat and queue a project-scoped session
/// start at `path`. Project Detail's "New Chat" toolbar button
/// calls this. Clearing `pendingProjectChat` is the consumer's
/// responsibility (ChatController) once `resetAndStartInProject`
/// has been dispatched.
func startChatInProject(path: String) {
pendingProjectChat = path
selectedTab = .chat
}
}
/// Environment key so subviews can pull the coordinator without
+70 -49
View File
@@ -2,22 +2,26 @@ import SwiftUI
import ScarfCore
import ScarfIOS
/// ScarfGo's primary navigation surface. Replaces the pre-M8
/// "Dashboard is the hub" pattern where Chat/Memory/Cron/Skills/
/// Settings lived as NavigationLink rows three-quarters of the way
/// down a scrolling List pass-1 user-visible complaint:
/// ScarfGo's primary navigation surface. v2.5 expands the original
/// 4-tab layout (Chat | Dashboard | Memory | More) to 5 primary tabs
/// with Chat in the mathematical center:
///
/// > "We should have the actions for the user in a permanent footer?
/// > I don't see any navigation."
/// Dashboard | Projects | Chat | Skills | System
///
/// 4 primary tabs + a "More" bucket for the read-heavy / seldom-used
/// features. Uses iOS 18's `.sidebarAdaptable` tab style so the same
/// tree degrades to a bottom tab bar on iPhone and gets a native
/// sidebar on iPadOS / macCatalyst if we ever add those targets.
/// "Chat in the middle" is the v2.5 product ask chat is the action
/// users come back for, so it's the most thumb-reachable slot on a
/// phone-sized device. We stay on Apple's native `TabView` instead of
/// drawing a custom raised center button: 5 tabs is exactly the iPhone
/// system maximum (no auto-collapse to "More"), and `.sidebarAdaptable`
/// continues to give us a real sidebar on iPad / macCatalyst for free.
/// Memory drops out of primary slots and lives inside the renamed
/// "System" tab (was "More"). Skills graduates from a System sub-row
/// into its own primary tab to match v2.5's full Mac parity for skills
/// (Installed / Browse Hub / Updates).
///
/// Each tab wraps its feature view in its own `NavigationStack` so
/// push navigation (Cron editor, Memory detail, etc.) stays scoped
/// to the tab instead of bleeding across.
/// Each tab wraps its feature view in its own `NavigationStack` so push
/// navigation (Cron editor, Memory detail, Project detail, etc.) stays
/// scoped to the tab instead of bleeding across.
struct ScarfGoTabRoot: View {
let serverID: ServerID
let config: IOSServerConfig
@@ -26,8 +30,9 @@ struct ScarfGoTabRoot: View {
let onForget: @MainActor () async -> Void
/// One coordinator per server-connected session. Cross-tab
/// signalling (Dashboard row Chat tab resume, eventually
/// notification deep-link Chat) flows through here.
/// signalling (Dashboard row Chat tab resume, Project Detail
/// in-project chat handoff, notification deep-link Chat) flows
/// through here.
@State private var coordinator = ScarfGoCoordinator()
var body: some View {
@@ -39,18 +44,7 @@ struct ScarfGoTabRoot: View {
// SSH channel contention.
let ctx = config.toServerContext(id: serverID)
TabView(selection: $coordinator.selectedTab) {
// 1 Chat: the reason the app is on your phone. Primary
// tab; opens straight into the chat surface.
NavigationStack {
ChatView(config: config, key: key)
}
.tabItem {
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
}
.tag(ScarfGoCoordinator.Tab.chat)
// 2 Dashboard: stats + recent sessions (no surfaces list
// anymore those live in More).
// 1 Dashboard: stats + recent sessions.
NavigationStack {
DashboardView(config: config, key: key)
}
@@ -58,33 +52,59 @@ struct ScarfGoTabRoot: View {
Label("Dashboard", systemImage: "gauge.with.needle")
}
.tag(ScarfGoCoordinator.Tab.dashboard)
.accessibilityLabel("Dashboard tab")
// 3 Memory: MEMORY.md + USER.md + SOUL.md.
// 2 Projects: registered projects per-project dashboard,
// site, and sessions. Read-only registry on iOS add /
// rename / archive happens in the Mac app.
NavigationStack {
MemoryListView(config: config)
ProjectsListView(config: config)
}
.tabItem {
Label("Memory", systemImage: "brain.head.profile")
Label("Projects", systemImage: "square.grid.2x2")
}
.tag(ScarfGoCoordinator.Tab.memory)
.tag(ScarfGoCoordinator.Tab.projects)
.accessibilityLabel("Projects tab")
// 4 More: Cron, Skills, Settings, plus the destructive
// "Forget this server" action. Named "More" because on
// iOS 18 with .sidebarAdaptable the system collapses
// leftover tabs into a disclosure group with that exact
// label automatically; choosing the same word keeps our
// More tab visually consistent with the system default.
// 3 Chat: the reason the app is on your phone. Centered
// among the 5 tabs for thumb reach + visual prominence.
NavigationStack {
MoreTab(
ChatView(config: config, key: key)
}
.tabItem {
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
}
.tag(ScarfGoCoordinator.Tab.chat)
.accessibilityLabel("Chat tab")
// 4 Skills: Installed | Browse Hub | Updates, mirroring
// the Mac app's 3-tab skills surface.
NavigationStack {
SkillsView(config: config)
}
.tabItem {
Label("Skills", systemImage: "lightbulb")
}
.tag(ScarfGoCoordinator.Tab.skills)
.accessibilityLabel("Skills tab")
// 5 System: server identity, Memory, Cron, Settings, plus
// the destructive disconnect / forget actions. Renamed from
// "More" to match the user-facing v2.5 vocabulary; the
// .sidebarAdaptable system fallback label happens not to
// matter here because we never overflow.
NavigationStack {
SystemTab(
config: config,
onSoftDisconnect: onSoftDisconnect,
onForget: onForget
)
}
.tabItem {
Label("More", systemImage: "ellipsis.circle")
Label("System", systemImage: "gearshape.fill")
}
.tag(ScarfGoCoordinator.Tab.more)
.tag(ScarfGoCoordinator.Tab.system)
.accessibilityLabel("System tab")
}
// Pulls the sidebar-on-iPad affordance into the same code path
// as the bottom-bar-on-iPhone one. No-op on iPhone today.
@@ -101,14 +121,15 @@ struct ScarfGoTabRoot: View {
}
}
/// Groups the features that don't deserve a primary tab on a phone:
/// Cron (infrequent edits), Skills (read-only), Settings (read-only
/// until M9 scoped editor), plus the destructive server-forget action.
/// Server identity + Memory + Cron + Settings + destructive actions.
/// "System" reads as configuration / server-meta; the reorganization
/// in v2.5 promotes Skills out of here into its own primary tab and
/// pulls Memory in from a primary tab into a NavigationLink row.
///
/// Kept private to this file because we don't expect it to be reused
/// elsewhere if a feature graduates to a primary tab, that's a
/// deliberate design decision.
private struct MoreTab: View {
private struct SystemTab: View {
let config: IOSServerConfig
let onSoftDisconnect: @MainActor () async -> Void
let onForget: @MainActor () async -> Void
@@ -131,15 +152,15 @@ private struct MoreTab: View {
Section("Features") {
NavigationLink {
CronListView(config: config)
MemoryListView(config: config)
} label: {
Label("Cron jobs", systemImage: "clock.arrow.circlepath")
Label("Memory", systemImage: "brain.head.profile")
}
.scarfGoCompactListRow()
NavigationLink {
SkillsListView(config: config)
CronListView(config: config)
} label: {
Label("Skills", systemImage: "sparkles")
Label("Cron jobs", systemImage: "clock.arrow.circlepath")
}
.scarfGoCompactListRow()
NavigationLink {
@@ -194,7 +215,7 @@ private struct MoreTab: View {
}
}
.scarfGoListDensity()
.navigationTitle("More")
.navigationTitle("System")
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
"Forget this server?",
+40 -11
View File
@@ -79,29 +79,37 @@ struct ChatView: View {
)
}
.task {
// Dashboard row taps set `pendingResumeSessionID` on the
// coordinator before switching to the Chat tab. Honor
// that if present, else open a fresh session. Clearing
// the coordinator value is the consumer's responsibility
// (us) otherwise a later plain tap on the Chat tab
// would accidentally re-resume the old session.
// Dashboard row taps set `pendingResumeSessionID`, Project
// Detail's "New Chat" sets `pendingProjectChat`. Both fire
// a tab switch to .chat alongside the value set; we
// consume + clear here on first appear. Resume wins over
// project-chat if both somehow get set in a single hop
// but in practice the coordinator never sets both at once.
if let sessionID = coordinator?.pendingResumeSessionID {
coordinator?.pendingResumeSessionID = nil
await controller.startResuming(sessionID: sessionID)
} else if let projectPath = coordinator?.pendingProjectChat {
coordinator?.pendingProjectChat = nil
await consumePendingProjectChat(projectPath)
} else {
await controller.start()
}
}
// Also react to a coordinator change that happens while Chat
// is already mounted (e.g., user is in Chat, switches to
// Dashboard, taps a session row coordinator flips the tab
// AND sets pendingResumeSessionID. The `.task` above only
// fires on first appear; this is the mid-session hook.)
// React to coordinator changes that happen while Chat is
// already mounted (e.g., user is in Chat, taps Projects, opens
// a project detail, taps "New Chat" coordinator flips the
// tab AND sets pendingProjectChat. The `.task` above only
// fires on first appear; these are the mid-session hooks.)
.onChange(of: coordinator?.pendingResumeSessionID) { _, new in
guard let sessionID = new else { return }
coordinator?.pendingResumeSessionID = nil
Task { await controller.startResuming(sessionID: sessionID) }
}
.onChange(of: coordinator?.pendingProjectChat) { _, new in
guard let projectPath = new else { return }
coordinator?.pendingProjectChat = nil
Task { await consumePendingProjectChat(projectPath) }
}
// Deliberately NOT tearing down the ACP session on .onDisappear.
// `TabView` unmounts tab content when the user switches tabs
// (disappear fires), but `@State var controller` keeps the
@@ -144,6 +152,27 @@ struct ChatView: View {
}
}
/// Resolve a project absolute path to a `ProjectEntry` via the
/// transport-backed registry, then dispatch `resetAndStartInProject`.
/// If the path isn't registered (race with a Mac-app removal, or
/// SFTP read failure), fall back to a synthesized entry whose name
/// is the path's last component chat still starts and the user
/// sees a usable project chip.
private func consumePendingProjectChat(_ path: String) async {
let ctx = config.toServerContext(id: Self.sharedContextID)
let entry: ProjectEntry = await Task.detached {
let registry = ProjectDashboardService(context: ctx).loadRegistry()
if let match = registry.projects.first(where: { $0.path == path }) {
return match
}
return ProjectEntry(
name: (path as NSString).lastPathComponent.isEmpty ? path : (path as NSString).lastPathComponent,
path: path
)
}.value
await controller.resetAndStartInProject(entry)
}
// MARK: - Subviews
@ViewBuilder
@@ -0,0 +1,215 @@
import SwiftUI
import ScarfCore
/// Per-project detail view, presented when a row in `ProjectsListView`
/// is tapped. Mirrors the Mac three-tab layout (Dashboard | Site |
/// Sessions) using a segmented `Picker`. The Site segment is gated on
/// the dashboard containing a `webview` widget empty dashboards or
/// dashboards without a site URL hide the segment to match Mac's
/// `visibleTabs` logic in `ProjectsView.swift`.
///
/// "New Chat" toolbar button calls `ScarfGoCoordinator.startChatInProject`
/// which sets `pendingProjectChat` and routes to the Chat tab.
/// `ChatController` consumes `pendingProjectChat` on next appear and
/// dispatches `resetAndStartInProject(_:)` same wiring the existing
/// in-Chat picker sheet uses.
struct ProjectDetailView: View {
let project: ProjectEntry
let config: IOSServerConfig
@Environment(\.scarfGoCoordinator) private var coordinator
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A2"
)!
@State private var dashboard: ProjectDashboard?
@State private var dashboardError: String?
@State private var isLoading: Bool = true
@State private var selectedTab: DetailTab = .dashboard
/// Last-seen mtime on `<project>/.scarf/dashboard.json`. The
/// foreground poll task compares this against a fresh stat to
/// decide whether to re-parse cheap when the file is unchanged,
/// and the poll only runs while the view is visible.
@State private var lastDashboardMtime: Date?
enum DetailTab: Hashable {
case dashboard, site, sessions
}
private var serverContext: ServerContext {
config.toServerContext(id: Self.sharedContextID)
}
/// First webview widget across all sections, if any. Nil Site
/// segment hidden. Mirrors Mac `siteWidget`.
private var siteWidget: DashboardWidget? {
dashboard?
.sections
.flatMap(\.widgets)
.first { $0.type == "webview" }
}
private var visibleTabs: [DetailTab] {
var tabs: [DetailTab] = [.dashboard]
if siteWidget != nil { tabs.append(.site) }
tabs.append(.sessions)
return tabs
}
var body: some View {
VStack(spacing: 0) {
tabPicker
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 6)
Divider()
tabContent
}
.navigationTitle(project.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
coordinator?.startChatInProject(path: project.path)
} label: {
Label("New Chat", systemImage: "message.badge.filled.fill")
}
.accessibilityLabel("Start new chat in \(project.name)")
.accessibilityHint("Opens the Chat tab and begins a session scoped to this project")
}
}
.task(id: project.id) { await loadDashboard() }
.task(id: project.id) { await pollDashboardMtime() }
.refreshable { await loadDashboard() }
.onChange(of: visibleTabs) { _, newTabs in
// If the user was on Site and a refresh removed the
// webview widget, fall back to Dashboard so the segmented
// picker doesn't end up out-of-sync with its segments.
if !newTabs.contains(selectedTab) {
selectedTab = .dashboard
}
}
}
// MARK: - Tab picker
@ViewBuilder
private var tabPicker: some View {
Picker("Section", selection: $selectedTab) {
ForEach(visibleTabs, id: \.self) { tab in
Text(label(for: tab)).tag(tab)
}
}
.pickerStyle(.segmented)
}
private func label(for tab: DetailTab) -> String {
switch tab {
case .dashboard: return "Dashboard"
case .site: return "Site"
case .sessions: return "Sessions"
}
}
// MARK: - Tab content
@ViewBuilder
private var tabContent: some View {
switch selectedTab {
case .dashboard:
dashboardTab
case .site:
if let widget = siteWidget {
ProjectSiteView(widget: widget)
} else {
emptyDashboard
}
case .sessions:
ProjectSessionsView_iOS(project: project)
}
}
@ViewBuilder
private var dashboardTab: some View {
if isLoading && dashboard == nil {
ProgressView("Loading dashboard…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let dash = dashboard {
DashboardWidgetsView(dashboard: dash)
} else {
emptyDashboard
}
}
private var emptyDashboard: some View {
ContentUnavailableView {
Label("No Dashboard", systemImage: "rectangle.dashed")
} description: {
Text(dashboardError ?? "This project doesn't have a dashboard at \(project.dashboardPath) yet.")
.font(.caption)
} actions: {
Button("Try Again") {
Task { await loadDashboard() }
}
}
}
// MARK: - Loading
/// Load the project's dashboard via `ProjectDashboardService` on a
/// background task same `Task.detached` pattern the registry
/// loader uses to keep the SFTP read off MainActor.
private func loadDashboard() async {
isLoading = true
defer { isLoading = false }
let ctx = serverContext
let proj = project
let result: (ProjectDashboard?, String?, Date?) = await Task.detached {
let service = ProjectDashboardService(context: ctx)
if !service.dashboardExists(for: proj) {
return (nil, "No dashboard found at \(proj.dashboardPath)", nil)
}
let mtime = service.dashboardModificationDate(for: proj)
if let loaded = service.loadDashboard(for: proj) {
return (loaded, nil, mtime)
}
return (nil, "Failed to parse dashboard JSON", mtime)
}.value
dashboard = result.0
dashboardError = result.1
lastDashboardMtime = result.2
}
/// Poll the dashboard file's mtime every 4 seconds while the view
/// is foregrounded; reload on any change. iOS doesn't have an
/// inotify-style watcher over SFTP, but a per-view poll is cheap
/// (one stat call per tick) and stops the moment the user
/// navigates away the `.task` modifier cancels the loop on view
/// disappear automatically.
private func pollDashboardMtime() async {
let ctx = serverContext
let proj = project
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 4_000_000_000)
if Task.isCancelled { break }
let fresh: Date? = await Task.detached {
ProjectDashboardService(context: ctx)
.dashboardModificationDate(for: proj)
}.value
// First tick after a missing-dashboard error: nil nil is
// a no-op; nil Date triggers a reload (file just appeared).
// Date newer Date triggers a reload. Same Date is a no-op.
switch (lastDashboardMtime, fresh) {
case (nil, nil), (_, nil):
continue
case (nil, _):
await loadDashboard()
case (let prev?, let now?) where now > prev:
await loadDashboard()
default:
continue
}
}
}
}
@@ -0,0 +1,185 @@
import SwiftUI
import ScarfCore
/// iOS twin of the Mac per-project Sessions tab. Reuses the
/// ScarfCore-side `ProjectSessionsViewModel` (promoted from the Mac
/// target in v2.5) so attribution + filtering semantics stay
/// identical. The "New Chat" button routes into the Chat tab via
/// `ScarfGoCoordinator.startChatInProject(path:)`; row taps route via
/// `coordinator.resumeSession(_:)`, the same primitive
/// `DashboardView` already uses.
struct ProjectSessionsView_iOS: View {
let project: ProjectEntry
@Environment(\.scarfGoCoordinator) private var coordinator
@Environment(\.serverContext) private var serverContext
@State private var viewModel: ProjectSessionsViewModel?
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
}
.task(id: project.id) {
// Rebuild the VM when the project changes so stale state
// from a previously-selected project doesn't bleed
// through.
viewModel = ProjectSessionsViewModel(
context: serverContext,
project: project
)
await viewModel?.load()
}
.onDisappear {
// Release the SQLite handle so it doesn't dangle once
// the user leaves this tab. `load()` will re-open next
// time. Mirrors ActivityView's disappear cleanup.
Task { await viewModel?.close() }
}
}
// MARK: - Header
private var header: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("Sessions in this project")
.font(.subheadline)
.fontWeight(.semibold)
Text("Chats you start here are attributed automatically.")
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Button {
coordinator?.startChatInProject(path: project.path)
} label: {
Label("New Chat", systemImage: "message.badge.filled.fill")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.accessibilityLabel("Start new chat in \(project.name)")
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
// MARK: - Content
@ViewBuilder
private var content: some View {
if let vm = viewModel {
if vm.isLoading && vm.sessions.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else if vm.sessions.isEmpty {
emptyState(hint: vm.emptyStateHint)
} else {
sessionList(vm.sessions)
}
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
private func emptyState(hint: String?) -> some View {
VStack(spacing: 10) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 36))
.foregroundStyle(.tertiary)
Text(hint ?? "No sessions yet.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
}
private func sessionList(_ sessions: [HermesSession]) -> some View {
List {
ForEach(sessions) { session in
Button {
coordinator?.resumeSession(session.id)
} label: {
ProjectSessionRow_iOS(session: session)
}
.buttonStyle(.plain)
.scarfGoCompactListRow()
}
}
.scarfGoListDensity()
}
}
/// Single row in the per-project Sessions list. Mirrors the Mac
/// `ProjectSessionRow` content but uses iOS-friendly text sizing.
private struct ProjectSessionRow_iOS: View {
let session: HermesSession
var body: some View {
HStack(spacing: 10) {
Image(systemName: iconForSource(session.source))
.foregroundStyle(.secondary)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(displayTitle)
.font(.callout)
.lineLimit(1)
HStack(spacing: 6) {
Text(session.id.prefix(12))
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
if let started = formattedStart {
Text("·")
.foregroundStyle(.tertiary)
Text(started)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
Spacer(minLength: 12)
VStack(alignment: .trailing, spacing: 2) {
Text("\(session.messageCount)")
.font(.caption.monospaced())
Text("msgs")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
.contentShape(Rectangle())
}
private var displayTitle: String {
if let t = session.title, !t.isEmpty { return t }
return "Untitled session"
}
private var formattedStart: String? {
guard let date = session.startedAt else { return nil }
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: date)
}
private func iconForSource(_ source: String) -> String {
switch source.lowercased() {
case "cli", "acp": return "terminal"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
default: return "message"
}
}
}
@@ -0,0 +1,16 @@
import SwiftUI
import ScarfCore
/// Full-canvas webview wrapper for the Site sub-tab. Reuses the
/// `WebviewWidgetView` representable in its `fullCanvas: true` mode so
/// rendering, error handling, and the non-persistent data store all
/// stay in one place.
struct ProjectSiteView: View {
let widget: DashboardWidget
var body: some View {
WebviewWidgetView(widget: widget, fullCanvas: true)
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
}
@@ -0,0 +1,144 @@
import SwiftUI
import ScarfCore
/// Top-level Projects tab. Lists registered Scarf projects from
/// `~/.hermes/scarf/projects.json`. Folder groupings + archive flags
/// from the v2.3 registry schema are honored archived projects are
/// hidden, top-level projects render flat, and any non-empty folder
/// labels become a `Section` per folder.
///
/// Read-only on iOS for v2.5 add / rename / move / archive happens
/// in the Mac app, where the template installer + ConfigEditor live.
/// The empty state copy directs users there.
struct ProjectsListView: View {
let config: IOSServerConfig
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A2"
)!
@State private var projects: [ProjectEntry] = []
@State private var isLoading: Bool = true
@State private var loadError: String?
private var serverContext: ServerContext {
config.toServerContext(id: Self.sharedContextID)
}
var body: some View {
Group {
if isLoading && projects.isEmpty {
ProgressView("Loading projects…")
} else if let err = loadError, projects.isEmpty {
ContentUnavailableView {
Label("Couldn't load projects", systemImage: "exclamationmark.triangle.fill")
} description: {
Text(err)
}
} else if visibleProjects.isEmpty {
ContentUnavailableView {
Label("No projects yet", systemImage: "square.grid.2x2")
} description: {
Text("Use the Mac app to add and configure projects — they'll appear here automatically.")
}
} else {
projectList
}
}
.navigationTitle("Projects")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: ProjectEntry.self) { project in
ProjectDetailView(project: project, config: config)
}
.refreshable { await load() }
.task { await load() }
}
@ViewBuilder
private var projectList: some View {
let folders = folderLabels
List {
// Top-level (no folder) projects first, then folder
// disclosure sections same shape as Mac
// ProjectsSidebar.swift renders.
let topLevel = visibleProjects.filter { ($0.folder ?? "").isEmpty }
if !topLevel.isEmpty {
Section {
ForEach(topLevel) { project in
projectRow(project)
}
}
}
ForEach(folders, id: \.self) { folder in
Section(folder) {
ForEach(visibleProjects.filter { $0.folder == folder }) { project in
projectRow(project)
}
}
}
}
.scarfGoListDensity()
}
private func projectRow(_ project: ProjectEntry) -> some View {
NavigationLink(value: project) {
HStack(alignment: .center, spacing: 12) {
Image(systemName: "folder.fill")
.font(.title3)
.foregroundStyle(.tint)
.frame(width: 28)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(project.name)
.font(.body)
.foregroundStyle(.primary)
Text(project.path)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
.scarfGoCompactListRow()
.accessibilityElement(children: .combine)
.accessibilityLabel("\(project.name), at \(project.path)")
.accessibilityHint("Opens project dashboard, site, and sessions")
}
/// Visible projects = registry minus archived, sorted alphabetically.
/// Mirrors Mac sidebar's default filter.
private var visibleProjects: [ProjectEntry] {
projects
.filter { !$0.archived }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
/// Distinct, sorted folder labels across the visible set. Empty
/// strings are treated as top-level (filtered out here so they
/// don't render as a "" section title).
private var folderLabels: [String] {
let set = Set(visibleProjects.compactMap(\.folder).filter { !$0.isEmpty })
return set.sorted()
}
/// Load the project registry over the active transport. Same
/// pattern as `ProjectPickerSheet.loadProjects` wrap the
/// synchronous `ProjectDashboardService` calls in `Task.detached`
/// so the SFTP read doesn't run on the MainActor.
private func load() async {
isLoading = true
defer { isLoading = false }
let ctx = serverContext
do {
let loaded: [ProjectEntry] = try await Task.detached {
let service = ProjectDashboardService(context: ctx)
return service.loadRegistry().projects
}.value
projects = loaded
loadError = nil
} catch {
loadError = error.localizedDescription
}
}
}
@@ -0,0 +1,83 @@
import SwiftUI
import ScarfCore
import Charts
// Flattened data point for Charts to avoid complex nested generic inference
private struct PlottablePoint: Identifiable {
let id = UUID()
let seriesName: String
let x: String
let y: Double
let color: Color
}
struct ChartWidgetView: View {
let widget: DashboardWidget
private var points: [PlottablePoint] {
guard let series = widget.series else { return [] }
return series.flatMap { s in
let color = parseColor(s.color)
return s.data.map { d in
PlottablePoint(seriesName: s.name, x: d.x, y: d.y, color: color)
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
chartContent
.frame(height: 150)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
@ViewBuilder
private var chartContent: some View {
switch widget.chartType {
case "pie":
pieChart
case "bar":
barChart
default:
lineChart
}
}
private var lineChart: some View {
Chart(points) { point in
LineMark(
x: .value("X", point.x),
y: .value("Y", point.y)
)
.foregroundStyle(point.color)
.symbol(by: .value("Series", point.seriesName))
}
}
private var barChart: some View {
Chart(points) { point in
BarMark(
x: .value("X", point.x),
y: .value("Y", point.y)
)
.foregroundStyle(point.color)
}
}
private var pieChart: some View {
Chart(points) { point in
SectorMark(
angle: .value(point.x, point.y),
innerRadius: .ratio(0.5)
)
.foregroundStyle(point.color)
}
}
}
@@ -0,0 +1,117 @@
import SwiftUI
import ScarfCore
/// iOS dashboard layout. ScrollView of sections; each section is a
/// `LazyVGrid` whose column count is clamped to the device's
/// `horizontalSizeClass`. iPhone (compact) 1 column. iPad / split-
/// view (regular) 2 columns max, even when the dashboard JSON asks
/// for 3 (3-column on a 13" iPad portrait still cramps individual
/// widgets).
///
/// Webview widgets in card mode render inline like any other widget.
/// The full-canvas Site tab is rendered separately by `ProjectSiteView`
/// and excluded from this grid by `ProjectDetailView` before passing
/// the dashboard down so we don't filter here.
struct DashboardWidgetsView: View {
let dashboard: ProjectDashboard
@Environment(\.horizontalSizeClass) private var hSizeClass
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if let description = dashboard.description, !description.isEmpty {
Text(description)
.font(.callout)
.foregroundStyle(.secondary)
.padding(.horizontal)
}
ForEach(dashboard.sections) { section in
sectionView(section)
}
}
.padding(.vertical, 12)
}
}
@ViewBuilder
private func sectionView(_ section: DashboardSection) -> some View {
// Filter out webview widgets those are rendered full-screen
// in the Site tab instead. Matches Mac DashboardSectionView.
let displayWidgets = section.widgets.filter { $0.type != "webview" }
if !displayWidgets.isEmpty {
let cols = columnCount(for: section)
VStack(alignment: .leading, spacing: 8) {
if !section.title.isEmpty {
Text(section.title)
.font(.headline)
.padding(.horizontal)
}
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: cols),
spacing: 10
) {
ForEach(displayWidgets) { widget in
WidgetView(widget: widget)
}
}
.padding(.horizontal)
}
}
}
/// Cap the requested column count by available width. Compact
/// (iPhone) is always 1; regular (iPad / large split-view) caps at
/// 2 to avoid a 3-up layout that crowds chart + table widgets.
private func columnCount(for section: DashboardSection) -> Int {
switch hSizeClass {
case .compact: return 1
case .regular: return min(section.columnCount, 2)
default: return 1
}
}
}
/// Widget-type dispatcher. Mirrors Mac's `WidgetView` switch in
/// `scarf/Features/Projects/Views/ProjectsView.swift`. Unknown types
/// fall through to a small placeholder so a manifest from a future
/// schema version doesn't crash the UI.
struct WidgetView: View {
let widget: DashboardWidget
var body: some View {
switch widget.type {
case "stat":
StatWidgetView(widget: widget)
case "progress":
ProgressWidgetView(widget: widget)
case "text":
TextWidgetView(widget: widget)
case "table":
TableWidgetView(widget: widget)
case "chart":
ChartWidgetView(widget: widget)
case "list":
ListWidgetView(widget: widget)
case "webview":
WebviewWidgetView(widget: widget)
default:
unsupportedView
}
}
private var unsupportedView: some View {
VStack(alignment: .leading, spacing: 4) {
Label(widget.title, systemImage: "questionmark.app.dashed")
.font(.caption)
.foregroundStyle(.secondary)
Text("Widget type \"\(widget.type)\" isn't supported in this version of Scarf yet.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,55 @@
import SwiftUI
import ScarfCore
struct ListWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(.secondary)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
}
if let items = widget.items {
ForEach(items) { item in
HStack(spacing: 6) {
Image(systemName: statusIcon(item.status))
.font(.caption2)
.foregroundStyle(statusColor(item.status))
Text(item.text)
.font(.callout)
.strikethrough(item.status == "done")
.foregroundStyle(item.status == "done" ? .secondary : .primary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func statusIcon(_ status: String?) -> String {
switch status {
case "done": return "checkmark.circle.fill"
case "active": return "circle.inset.filled"
case "pending": return "circle"
default: return "circle"
}
}
private func statusColor(_ status: String?) -> Color {
switch status {
case "done": return .green
case "active": return .blue
default: return .secondary
}
}
}
@@ -0,0 +1,33 @@
import SwiftUI
import ScarfCore
struct ProgressWidgetView: View {
let widget: DashboardWidget
private var progressValue: Double {
switch widget.value {
case .number(let n): return n
default: return 0
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
ProgressView(value: progressValue) {
if let label = widget.label {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.tint(parseColor(widget.color))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,38 @@
import SwiftUI
import ScarfCore
struct StatWidgetView: View {
let widget: DashboardWidget
private var widgetColor: Color {
parseColor(widget.color)
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(widgetColor)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
}
if let value = widget.value {
Text(value.displayString)
.font(.system(.title2, design: .monospaced, weight: .semibold))
}
if let subtitle = widget.subtitle {
Text(subtitle)
.font(.caption2)
.foregroundStyle(widgetColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,40 @@
import SwiftUI
import ScarfCore
struct TableWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
if let columns = widget.columns, let rows = widget.rows {
ScrollView(.horizontal, showsIndicators: false) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) {
GridRow {
ForEach(columns, id: \.self) { col in
Text(col)
.font(.caption.bold())
.foregroundStyle(.secondary)
}
}
Divider()
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
GridRow {
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
Text(cell)
.font(.callout)
}
}
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,35 @@
import SwiftUI
import ScarfCore
struct TextWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
if let content = widget.content {
if widget.format == "markdown" {
// SwiftUI's built-in inline markdown via AttributedString.
// Doesn't support block elements (lists, tables) the way
// Mac's MarkdownContentView does, but covers the common
// dashboard cases (bold, italic, links, inline code).
Text(attributed(content))
.font(.callout)
} else {
Text(content)
.font(.callout)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func attributed(_ markdown: String) -> AttributedString {
(try? AttributedString(markdown: markdown)) ?? AttributedString(markdown)
}
}
@@ -0,0 +1,123 @@
import SwiftUI
import ScarfCore
import WebKit
/// iOS twin of Mac's `WebviewWidgetView`. Same two modes (inline card
/// + full-canvas Site tab); the only platform-specific bit is the
/// `UIViewRepresentable` wrapper around `WKWebView` (Mac uses
/// `NSViewRepresentable`).
struct WebviewWidgetView: View {
let widget: DashboardWidget
var fullCanvas: Bool = false
private var webURL: URL? {
guard let urlString = widget.url else { return nil }
return URL(string: urlString)
}
private var viewHeight: CGFloat {
CGFloat(widget.height ?? 400)
}
var body: some View {
if fullCanvas {
fullCanvasView
} else {
cardView
}
}
// MARK: - Full Canvas (Site tab)
private var fullCanvasView: some View {
VStack(spacing: 0) {
if let url = webURL {
WebViewRepresentable(url: url)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
ContentUnavailableView {
Label("Invalid URL", systemImage: "globe")
} description: {
Text(widget.url ?? "No URL provided")
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Card (inline widget)
private var cardView: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(.secondary)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if let urlString = widget.url {
Text(urlString)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
}
}
if let url = webURL {
WebViewRepresentable(url: url)
.frame(height: viewHeight)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
ContentUnavailableView {
Label("Invalid URL", systemImage: "globe")
} description: {
Text(widget.url ?? "No URL provided")
}
.frame(height: viewHeight)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - WKWebView Wrapper
private struct WebViewRepresentable: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
if webView.url != url {
webView.load(URLRequest(url: url))
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
print("[Scarf] WebView navigation failed: \(error.localizedDescription)")
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
print("[Scarf] WebView failed to load: \(error.localizedDescription)")
}
}
}
@@ -0,0 +1,23 @@
import SwiftUI
/// Map a server-supplied color name to a SwiftUI `Color`. iOS twin of
/// the Mac helper at `scarf/Features/Projects/Views/Widgets/WidgetHelpers.swift`.
/// Unknown names default to `.blue` to keep dashboards visually
/// consistent across platforms.
func parseColor(_ name: String?) -> Color {
switch name?.lowercased() {
case "red": return .red
case "orange": return .orange
case "yellow": return .yellow
case "green": return .green
case "blue": return .blue
case "purple": return .purple
case "pink": return .pink
case "teal", "cyan": return .teal
case "indigo": return .indigo
case "mint": return .mint
case "brown": return .brown
case "gray", "grey": return .gray
default: return .blue
}
}
@@ -0,0 +1,151 @@
import SwiftUI
import ScarfCore
/// Browse / search the Hermes skills hub. Source picker is a `Menu`
/// (more compact than Mac's segmented Picker on a phone-width screen).
/// Search submits on Return; empty query falls through to a "browse"
/// listing (top results across the chosen source).
struct HubBrowseView: View {
@Bindable var vm: SkillsViewModel
var body: some View {
VStack(spacing: 0) {
toolbar
Divider()
content
}
}
@ViewBuilder
private var toolbar: some View {
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search skills…", text: $vm.hubQuery)
.textFieldStyle(.roundedBorder)
.submitLabel(.search)
.onSubmit { vm.searchHub() }
Menu {
Picker("Source", selection: $vm.hubSource) {
ForEach(vm.hubSources, id: \.self) { src in
Text(src).tag(src)
}
}
} label: {
HStack(spacing: 4) {
Text(vm.hubSource)
.font(.callout)
Image(systemName: "chevron.down")
.font(.caption2)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
HStack(spacing: 8) {
Button {
vm.searchHub()
} label: {
Label("Search", systemImage: "magnifyingglass")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(vm.isHubLoading)
Button {
vm.browseHub()
} label: {
Label("Browse", systemImage: "books.vertical")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(vm.isHubLoading)
Spacer()
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
@ViewBuilder
private var content: some View {
if vm.hubResults.isEmpty {
ContentUnavailableView {
Label("Browse the Hub", systemImage: "books.vertical")
} description: {
Text("Search for a skill or tap Browse to see top results across registries (skills.sh, official, etc.).")
.font(.caption)
} actions: {
Button {
vm.browseHub()
} label: {
Label("Browse top skills", systemImage: "books.vertical")
}
.buttonStyle(.borderedProminent)
.disabled(vm.isHubLoading)
}
} else {
List {
ForEach(vm.hubResults) { hubSkill in
HubSkillRow(skill: hubSkill, isInstalling: vm.isHubLoading) {
vm.installHubSkill(hubSkill)
}
.scarfGoCompactListRow()
}
}
.scarfGoListDensity()
}
}
}
private struct HubSkillRow: View {
let skill: HermesHubSkill
let isInstalling: Bool
let onInstall: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "books.vertical")
.foregroundStyle(.tint)
.font(.title3)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(skill.name)
.font(.callout.monospaced())
.fontWeight(.medium)
if !skill.source.isEmpty {
Text(skill.source)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.tint.opacity(0.15))
.clipShape(Capsule())
}
}
if !skill.description.isEmpty {
Text(skill.description)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
}
}
Spacer(minLength: 8)
Button {
onInstall()
} label: {
Image(systemName: "arrow.down.circle.fill")
.font(.title2)
.foregroundStyle(.tint)
}
.buttonStyle(.plain)
.disabled(isInstalling)
.accessibilityLabel("Install \(skill.name)")
.accessibilityHint(skill.description)
}
.padding(.vertical, 4)
.accessibilityElement(children: .contain)
}
}
@@ -0,0 +1,54 @@
import SwiftUI
import ScarfCore
/// Installed skills sub-tab. Category-grouped list; tapping a row
/// pushes `SkillDetailView` for that skill. Filtering uses the VM's
/// `filteredCategories` derivation so the search field works against
/// the same model the Mac uses.
struct InstalledSkillsListView: View {
@Bindable var vm: SkillsViewModel
var body: some View {
Group {
if vm.isLoading && vm.categories.isEmpty {
ProgressView("Scanning skills…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if vm.categories.isEmpty {
ContentUnavailableView {
Label("No skills installed", systemImage: "lightbulb")
} description: {
Text("Browse the Hub tab to install one, or run `hermes skills install <name>` on the remote.")
.font(.caption)
}
} else {
listContent
}
}
}
@ViewBuilder
private var listContent: some View {
List {
ForEach(vm.filteredCategories) { category in
Section(category.name) {
ForEach(category.skills) { skill in
NavigationLink {
SkillDetailView(skill: skill, vm: vm)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(skill.name)
.font(.body)
Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.scarfGoCompactListRow()
}
}
}
}
.scarfGoListDensity()
.searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always))
}
}
@@ -0,0 +1,126 @@
import SwiftUI
import ScarfCore
/// Installed skill detail. Shows location + required-config warning
/// banner + file picker + content viewer. Edit and Uninstall buttons
/// live in the toolbar.
struct SkillDetailView: View {
let skill: HermesSkill
@Bindable var vm: SkillsViewModel
@State private var showEditor: Bool = false
var body: some View {
List {
Section("Location") {
LabeledContent("Category", value: skill.category)
Text(skill.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if !vm.missingConfig.isEmpty {
Section {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 4) {
Text("Required config not set")
.font(.callout)
.fontWeight(.semibold)
Text("Add these keys to ~/.hermes/config.yaml:")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(vm.missingConfig, id: \.self) { key in
Text("\(key)")
.font(.caption.monospaced())
}
}
}
.padding(.vertical, 6)
}
}
if !skill.files.isEmpty {
Section("Files") {
ForEach(skill.files, id: \.self) { file in
Button {
vm.selectFile(file)
} label: {
HStack {
Text(file)
.font(.callout.monospaced())
Spacer()
if vm.selectedFileName == file {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
.font(.caption)
}
}
}
.buttonStyle(.plain)
.scarfGoCompactListRow()
}
}
}
if vm.selectedFileName != nil {
Section("Content") {
if vm.skillContent.isEmpty {
Text("(empty file)")
.font(.caption)
.foregroundStyle(.tertiary)
} else if vm.isMarkdownFile {
Text(markdown(vm.skillContent))
.font(.callout)
.textSelection(.enabled)
} else {
Text(vm.skillContent)
.font(.footnote.monospaced())
.textSelection(.enabled)
}
}
}
}
.scarfGoListDensity()
.navigationTitle(skill.name)
.navigationBarTitleDisplayMode(.inline)
.task {
// Selecting the skill (re)loads its main file content +
// missingConfig diagnostics. Idempotent on re-appears.
vm.selectSkill(skill)
}
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
if vm.selectedFileName != nil {
Button {
vm.startEditing()
showEditor = true
} label: {
Label("Edit", systemImage: "pencil")
}
}
Menu {
Button(role: .destructive) {
vm.uninstallHubSkill(skill.id)
} label: {
Label("Uninstall", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(isPresented: $showEditor) {
SkillEditorSheet(vm: vm, fileName: vm.selectedFileName ?? "")
}
}
private func markdown(_ raw: String) -> AttributedString {
let opts = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace
)
return (try? AttributedString(markdown: raw, options: opts)) ?? AttributedString(raw)
}
}
@@ -0,0 +1,39 @@
import SwiftUI
import ScarfCore
/// Sheet-presented TextEditor for the currently-selected skill file.
/// Save commits via `vm.saveEdit()` (which calls `transport.writeFile`);
/// Cancel discards. Validation lives entirely in the VM
/// (`isValidSkillPath` guard) so the sheet is purely UI.
struct SkillEditorSheet: View {
@Bindable var vm: SkillsViewModel
let fileName: String
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
TextEditor(text: $vm.editText)
.font(.footnote.monospaced())
.padding(8)
.navigationTitle(fileName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
vm.cancelEditing()
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
vm.saveEdit()
dismiss()
}
.fontWeight(.semibold)
}
}
}
.presentationDetents([.large])
}
}
-284
View File
@@ -1,284 +0,0 @@
import SwiftUI
import ScarfCore
/// iOS Skills browser. Read-only list grouped by category. Tapping
/// a skill shows its files + on-disk path enough for a user to
/// verify what's installed without opening Terminal.
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"
)!
init(config: IOSServerConfig) {
self.config = config
let ctx = config.toServerContext(id: Self.sharedContextID)
_vm = State(initialValue: IOSSkillsViewModel(context: ctx))
}
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")
.foregroundStyle(.orange)
}
}
if vm.categories.isEmpty, !vm.isLoading {
Section {
VStack(alignment: .leading, spacing: 6) {
Text("No skills installed")
.font(.headline)
Text("Skills live under `~/.hermes/skills/<category>/<name>/` on the remote. Install them from the Mac app or by cloning directly.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
} else {
ForEach(vm.categories) { category in
Section(category.name) {
ForEach(category.skills) { skill in
NavigationLink {
SkillDetailView(skill: skill)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(skill.name)
.font(.body)
Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.scarfGoCompactListRow()
}
}
}
}
}
.scarfGoListDensity()
.navigationTitle("Skills")
.navigationBarTitleDisplayMode(.inline)
.overlay {
if vm.isLoading && vm.categories.isEmpty {
ProgressView("Scanning skills…")
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.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
}
}
}
private struct SkillDetailView: View {
let skill: HermesSkill
@Environment(\.serverContext) private var serverContext
@State private var npxStatus: SkillPrereqService.Status?
var body: some View {
List {
Section("Location") {
LabeledContent("Category", value: skill.category)
Text(skill.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
// v2.5 design-md prereq surface the skill needs `npx`
// (Node.js 18+) on the host. iOS read-only banner: same
// wording as the Mac one, no install button (the user is
// already going to need a shell to fix this).
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)
}
}
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)
}
}
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 !skill.files.isEmpty {
Section("Files") {
ForEach(skill.files, id: \.self) { file in
Text(file)
.font(.caption.monospaced())
}
}
}
}
.navigationTitle(skill.name)
.navigationBarTitleDisplayMode(.inline)
.task(id: skill.id) {
// Only probe when this skill needs it. design-md is the
// only skill in v2.5 with a host-side prereq surface; the
// probe runs once per appear and isn't cached across
// navigation events (cheap single SSH `which` call).
guard skill.name.lowercased() == "design-md" else {
npxStatus = nil
return
}
let svc = SkillPrereqService(context: serverContext)
npxStatus = await svc.probe(binary: "npx")
}
}
/// Render a list of strings as wrapping pill chips. Used for
/// allowed_tools / related_skills / dependencies sections (v2.5
/// SKILL.md frontmatter).
@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)
}
}
/// Minimal flow-layout for chip rows (wraps onto multiple lines when
/// content overflows the available width). Built-in `Layout` API,
/// no third-party dep. Used by the Skills detail view for the v2.5
/// allowed_tools / related_skills / dependencies sections.
private 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)
}
}
}
+112
View File
@@ -0,0 +1,112 @@
import SwiftUI
import ScarfCore
/// iOS Skills tab 3-tab segmented surface mirroring the Mac
/// `SkillsView`. Owns one `SkillsViewModel` (ScarfCore-side, unified
/// in v2.5) shared across the three sub-tabs so installed-list state +
/// hub query/results + update results all live in one place.
///
/// Sub-tabs:
/// - **Installed**: category-grouped list. Tap a skill to view its
/// files, edit content, or uninstall.
/// - **Browse Hub**: search + source picker. Tap to install. Calls
/// remote `hermes skills search/browse` over SSH.
/// - **Updates**: check + update-all buttons. Calls remote
/// `hermes skills check / update --yes`.
struct SkillsView: View {
let config: IOSServerConfig
@State private var vm: SkillsViewModel
@State private var currentTab: Tab = .installed
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1"
)!
enum Tab: String, CaseIterable, Identifiable {
case installed = "Installed"
case hub = "Browse Hub"
case updates = "Updates"
var id: String { rawValue }
var displayName: String { rawValue }
}
init(config: IOSServerConfig) {
self.config = config
let ctx = config.toServerContext(id: Self.sharedContextID)
_vm = State(initialValue: SkillsViewModel(context: ctx))
}
var body: some View {
VStack(spacing: 0) {
tabPicker
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 6)
statusBanner
Divider()
content
}
.navigationTitle(titleString)
.navigationBarTitleDisplayMode(.inline)
.task { await vm.load() }
.refreshable { await vm.load() }
}
private var titleString: String {
vm.totalSkillCount > 0 ? "Skills (\(vm.totalSkillCount))" : "Skills"
}
@ViewBuilder
private var tabPicker: some View {
Picker("Section", selection: $currentTab) {
ForEach(Tab.allCases) { tab in
Text(tab.displayName).tag(tab)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var statusBanner: some View {
if let msg = vm.hubMessage {
HStack(spacing: 6) {
if vm.isHubLoading {
ProgressView()
.controlSize(.small)
}
Text(msg)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.08))
} else if vm.isHubLoading {
HStack(spacing: 6) {
ProgressView()
.controlSize(.small)
Text("Working…")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.08))
}
}
@ViewBuilder
private var content: some View {
switch currentTab {
case .installed:
InstalledSkillsListView(vm: vm)
case .hub:
HubBrowseView(vm: vm)
case .updates:
UpdatesView(vm: vm)
}
}
}
@@ -0,0 +1,87 @@
import SwiftUI
import ScarfCore
/// Updates sub-tab. Mirrors Mac: Check button populates `vm.updates`;
/// Update All button is enabled only when there's at least one
/// available update. Both calls run remote `hermes skills` over SSH;
/// the parse logic is shared with Mac via `HermesSkillsHubParser`.
struct UpdatesView: View {
@Bindable var vm: SkillsViewModel
var body: some View {
VStack(spacing: 0) {
toolbar
Divider()
content
}
}
@ViewBuilder
private var toolbar: some View {
HStack(spacing: 8) {
Button {
vm.checkForUpdates()
} label: {
Label("Check for Updates", systemImage: "arrow.triangle.2.circlepath")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(vm.isHubLoading)
if !vm.updates.isEmpty {
Button {
vm.updateAll()
} label: {
Label("Update All", systemImage: "arrow.down.app")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(vm.isHubLoading)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
@ViewBuilder
private var content: some View {
if vm.updates.isEmpty {
ContentUnavailableView {
Label("No updates", systemImage: "checkmark.circle.fill")
} description: {
Text("Tap Check for Updates to query each installed skill against its source registry.")
.font(.caption)
}
} else {
List {
ForEach(vm.updates) { update in
HStack(spacing: 10) {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(.orange)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(update.identifier)
.font(.callout.monospaced())
HStack(spacing: 6) {
Text(update.currentVersion)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
Image(systemName: "arrow.right")
.font(.caption2)
.foregroundStyle(.tertiary)
Text(update.availableVersion)
.font(.caption.monospaced())
.foregroundStyle(.green)
}
}
Spacer()
}
.padding(.vertical, 4)
.scarfGoCompactListRow()
}
}
.scarfGoListDensity()
}
}
}