Skip to content

Adding a Feature Module

Alan Wizemann edited this page Apr 20, 2026 · 4 revisions

Adding a Feature Module

The MVVM-F recipe. Adding a feature touches 4 existing files and creates 2 new ones.

Directory shape

scarf/scarf/scarf/Features/MyFeature/
  Views/
    MyFeatureView.swift
  ViewModels/
    MyFeatureViewModel.swift

Both subdirectories are conventional — even features that only have one view of each follow this shape so file discovery is consistent.

Step 1: Create the ViewModel

import Observation

@Observable
final class MyFeatureViewModel {
    let context: ServerContext
    private let fileService: HermesFileService

    init(context: ServerContext) {
        self.context = context
        self.fileService = HermesFileService(context: context)
    }

    var items: [MyModel] = []
    var loadError: String?

    func load() async {
        do {
            items = try await fetchItems()
            loadError = nil
        } catch {
            loadError = error.localizedDescription
        }
    }
}

Conventions:

  • Always take ServerContext in init so the feature works against any window's bound server.
  • Construct any services inside init; don't hand them in.
  • Use @Observable (Swift macro), not ObservableObject.
  • Public state is var properties; mutate them on MainActor. Async work runs nonisolated and assigns the final value back on the main actor.

Step 2: Create the View

struct MyFeatureView: View {
    @State private var viewModel: MyFeatureViewModel
    @Environment(AppCoordinator.self) private var coordinator
    @Environment(HermesFileWatcher.self) private var fileWatcher

    init(context: ServerContext) {
        _viewModel = State(initialValue: MyFeatureViewModel(context: context))
    }

    var body: some View {
        List(viewModel.items) { item in /* … */ }
            .navigationTitle("My Feature")
            .task { await viewModel.load() }
            .task(id: fileWatcher.lastChangeDate) {
                // Re-load when ~/.hermes/ changes
                await viewModel.load()
            }
    }
}

Conventions:

  • View takes ServerContext in its init; it's the only initializer parameter.
  • @State private var viewModel: MyFeatureViewModel@State is the right wrapper for @Observable classes inside views.
  • Read coordinator and watcher from @Environment.
  • Use .task(id:) for reactive reloads — make sure you include every dependency in the id, or changes to a missing one won't trigger reload.

Step 3: Add the SidebarSection case

In Navigation/AppCoordinator.swift:

enum SidebarSection: String, CaseIterable, Identifiable {
    // … existing cases …
    case myFeature = "My Feature"

    var icon: String {
        switch self {
        // … existing icons …
        case .myFeature: return "star.fill"   // pick an SF Symbol
        }
    }
}

Step 4: Register in SidebarView

In Navigation/SidebarView.swift, add the case to the right Section:

Section("Interact") {
    ForEach([SidebarSection.chat, .memory, .skills, .myFeature]) { section in
        Label(section.rawValue, systemImage: section.icon).tag(section)
    }
}

Pick the section thematically — Monitor for views, Interact for talking-to-Hermes, Configure for setup, Manage for operational.

Step 5: Wire routing

In ContentView.swift's detailView switch:

switch coordinator.selectedSection {
// … existing cases …
case .myFeature: MyFeatureView(context: serverContext)
}

Step 6: (If your feature uses a new service)

If you needed a new service to back this feature, add it under Core/Services/ and inject any shared instance in ContextBoundRoot via .environment(...). See Adding a Service.

Cross-feature rules

The hard rules (CLAUDE.md):

  • Features never import sibling features. If MyFeature needs data another feature also uses, the data lives in a service, not in that other feature.
  • Cross-feature navigation goes through AppCoordinator. Set coordinator.selectedSection = .otherFeature and (if needed) coordinator.selectedSessionId = ....

Files touched

  • ✏️ Navigation/AppCoordinator.swift — 1 enum case, 1 icon line.
  • ✏️ Navigation/SidebarView.swift — add to the right Section.
  • ✏️ ContentView.swift — 1 switch case.
  • ✏️ scarfApp.swift — only if you needed to inject a new shared service.
  • Features/MyFeature/Views/MyFeatureView.swift — new.
  • Features/MyFeature/ViewModels/MyFeatureViewModel.swift — new.

Total: ~5-10 lines across 4 existing files, plus 2 new files.


Last updated: 2026-04-20 — Scarf v2.0.1

Clone this wiki locally