DEV Community

NEE
NEE

Posted on

Deep Dive into SwiftWork (Part 4): Data Layer and Services — SwiftData, State Restore, Markdown Rendering

The previous three posts covered how events flow from the SDK to the UI, how the timeline renders, and how tool cards visualize. This final post looks at SwiftWork's infrastructure — how data is stored, how state is restored, how Markdown is rendered, how code is highlighted, and how API keys are managed.

These components are independent, but all essential to making the app usable.

SwiftData Model Layer

SwiftWork uses SwiftData for persistence, registering four models:

// SwiftWorkApp.swift
.modelContainer(for: [
    Session.self,
    Event.self,
    AppConfiguration.self,
    PermissionRule.self
])
Enter fullscreen mode Exit fullscreen mode

Session

@Model
final class Session {
    @Attribute(.unique) var id: UUID
    var title: String
    var createdAt: Date
    var updatedAt: Date
    var workspacePath: String?
    @Relationship(deleteRule: .cascade, inverse: \Event.session)
    var events: [Event]
}
Enter fullscreen mode Exit fullscreen mode

@Relationship(deleteRule: .cascade) means deleting a Session automatically deletes all its Events. workspacePath is optional — users can assign different working directories to each session.

Event

@Model
final class Event {
    @Attribute(.unique) var id: UUID
    var sessionID: UUID
    var eventType: String
    var rawData: Data        // JSON serialized AgentEvent
    var timestamp: Date
    var order: Int
    var session: Session?
}
Enter fullscreen mode Exit fullscreen mode

This design was covered in the first post — rawData is the entire AgentEvent serialized as a JSON blob. The reason for not splitting it into separate fields is that the metadata structure varies by event type. Splitting into fields would result in many empty columns and frequent schema changes.

AppConfiguration

@Model
final class AppConfiguration {
    @Attribute(.unique) var id: UUID
    var key: String
    var value: Data
    var updatedAt: Date
}
Enter fullscreen mode Exit fullscreen mode

A generic key-value store. It uses SwiftData instead of UserDefaults because SwiftData supports async access, data migration, and iCloud sync (which may be needed in the future). The stored values include:

  • hasCompletedOnboarding — whether the initial onboarding is complete
  • selectedModel — the user's chosen model
  • lastActiveSessionID — the last active session ID
  • windowFrame — window position and size
  • inspectorVisible — whether the Inspector panel is visible

AppStateManager: App State Restoration

AppStateManager is responsible for restoring the user's working state after the app restarts — the previously open session, window position, and Inspector panel visibility.

@MainActor
@Observable
final class AppStateManager {
    var lastActiveSessionID: UUID?
    var windowFrame: NSRect?
    var isInspectorVisible: Bool = false

    func loadAppState() {
        lastActiveSessionID = loadUUID(key: "lastActiveSessionID")
        windowFrame = loadNSRect(key: "windowFrame")
        isInspectorVisible = loadBool(key: "inspectorVisible")
    }

    func saveLastActiveSessionID(_ id: UUID?) { ... }
    func saveWindowFrame(_ frame: NSRect) { ... }
    func saveInspectorVisibility(_ visible: Bool) { ... }
}
Enter fullscreen mode Exit fullscreen mode

Under the hood, it uses AppConfiguration's key-value storage:

private func saveString(_ string: String, forKey key: String) {
    let descriptor = FetchDescriptor<AppConfiguration>(
        predicate: #Predicate { $0.key == key }
    )
    if let existing = try? modelContext.fetch(descriptor).first {
        existing.value = Data(string.utf8)
    } else {
        let config = AppConfiguration(key: key, value: Data(string.utf8))
        modelContext.insert(config)
    }
    try? modelContext.save()
}
Enter fullscreen mode Exit fullscreen mode

This is an upsert pattern — first check if it exists, update if found, insert if not. loadNSRect converts the string back to NSRect (using NSRectFromString), and loadBool compares against the string "true".

Save Timing

State saving is not done all at once when the app exits, but distributed across various trigger points:

State Save trigger
lastActiveSessionID When the user switches sessions (SessionViewModel.selectSession)
windowFrame On window move/resize (500ms throttle) + on app exit
inspectorVisible When the Inspector panel is toggled

Window position saving is throttled — didMoveNotification and didResizeNotification fire very frequently, and writing to SwiftData on every event is wasteful. A 500ms Task.sleep acts as a debounce, so only the final move/resize is actually saved:

// ContentView.swift
let saveWindowFrameThrottled: (Notification) -> Void = { _ in
    saveTask?.cancel()
    saveTask = Task { @MainActor in
        try? await Task.sleep(for: .milliseconds(500))
        guard !Task.isCancelled else { return }
        if let window = mainWindow {
            appStateManager.saveWindowFrame(window.frame)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Restoration Flow

When the app launches, ContentView.task triggers the restoration:

.task {
    settingsViewModel.configure(modelContext: modelContext)
    hasCompletedOnboarding = settingsViewModel.isAPIKeyConfigured
        && !settingsViewModel.isFirstLaunch

    if hasCompletedOnboarding == true {
        configureAndRestoreState()
    }
}
Enter fullscreen mode Exit fullscreen mode

configureAndRestoreState restores state in order:

  1. Initialize AppStateManager, loading saved state
  2. Initialize SessionViewModel, fetching the session list
  3. Select the session matching lastActiveSessionID
  4. Restore isInspectorVisible
  5. Restore window position (if the window reference has arrived)

Window position restoration has a timing issue — WindowAccessor's callback is asynchronous, so the window reference may arrive after task completes. That's why onChange(of: mainWindow) also handles restoration:

.onChange(of: mainWindow) { _, newWindow in
    if let newWindow {
        restoreWindowFrame(in: newWindow)
    }
}
Enter fullscreen mode Exit fullscreen mode

MarkdownRenderer: Visitor Pattern for Markdown Rendering

Agent responses are in Markdown format — headings, lists, code blocks, bold text, links. SwiftWork uses Apple's swift-markdown library to parse Markdown, then traverses the AST with the Visitor pattern to generate SwiftUI views.

Why Not Use an Existing Markdown Rendering Component

There aren't many Markdown rendering components on macOS. AttributedString(markdown:) only supports basic formatting (bold, links) and doesn't support code blocks, tables, or block quotes. WebView-based solutions (rendering Markdown to HTML via Markdown.js) introduce WebKit dependencies and memory overhead. Writing a custom Visitor gives precise control over how each element renders, without adding extra dependencies.

Visitor Implementation

private struct MarkdownToViewsVisitor: @preconcurrency MarkupVisitor {
    private(set) var views: [AnyView] = []

    mutating func visitHeading(_ heading: Heading) -> Result { ... }
    mutating func visitParagraph(_ paragraph: Paragraph) -> Result { ... }
    mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Result { ... }
    mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> Result { ... }
    mutating func visitOrderedList(_ orderedList: OrderedList) -> Result { ... }
    mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> Result { ... }
    mutating func visitTable(_ table: Table) -> Result { ... }
    mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> Result { ... }
}
Enter fullscreen mode Exit fullscreen mode

Each visit method handles one Markdown node type, appending the generated view to the views array. Finally, MarkdownRenderer.render() returns this array, and MarkdownContentView renders it with ForEach.

Inline Format Handling

Inline formatting within paragraphs and list items (bold, italic, inline code, links) is handled by collectAttributedString. It recursively traverses child nodes to build an AttributedString:

private mutating func collectAttributedString(from markup: any Markup) -> AttributedString {
    var result = AttributedString()
    for child in markup.children {
        if let strong = child as? Strong {
            var s = collectAttributedString(from: strong)
            s.font = .body.bold()
            result.append(s)
        } else if let emphasis = child as? Emphasis {
            var e = collectAttributedString(from: emphasis)
            e.font = .body.italic()
            result.append(e)
        } else if let inlineCode = child as? InlineCode {
            var codeAttr = AttributedString(inlineCode.code)
            codeAttr.backgroundColor = Color.primary.opacity(0.06)
            codeAttr.font = .system(.body, design: .monospaced)
            result.append(codeAttr)
        } else if let link = child as? MarkdownLink {
            var linkAttr = AttributedString(collectInlineText(from: link))
            linkAttr.foregroundColor = Color.accentColor
            linkAttr.underlineStyle = .single
            linkAttr.link = URL(string: link.destination)
            result.append(linkAttr)
        }
        // ... SoftBreak, LineBreak, Strikethrough
    }
    return result
}
Enter fullscreen mode Exit fullscreen mode

AttributedString is a rich text type natively supported by SwiftUI. Passing it to SwiftUI.Text(attributed) causes SwiftUI to render with the specified font, color, and backgroundColor. Inline code gets a gray-background monospaced font, and links get blue underlines.

Type Name Conflicts

swift-markdown and SwiftUI have type name conflicts — both define Text, Link, and other types. The solution uses typealiases:

private typealias MarkdownText = Markdown.Text
private typealias MarkdownLink = Markdown.Link
Enter fullscreen mode Exit fullscreen mode

Inside the visitor, MarkdownText and MarkdownLink reference swift-markdown types, while SwiftUI.Text references SwiftUI types.

CodeHighlighter: Splash Code Highlighting

Code block highlighting uses John Sundell's Splash library. Currently only Swift syntax highlighting is supported; other languages fall back to monospaced plain text:

enum CodeHighlighter {
    static func highlight(code: String, language: String?) -> AnyView {
        let trimmedLanguage = language?.lowercased()
        if trimmedLanguage == "swift" {
            return highlightedSwiftView(code: code)
        } else {
            return plainCodeView(code: code)
        }
    }

    private static func highlightedSwiftView(code: String) -> AnyView {
        let theme = Theme.sundellsColors(withFont: Splash.Font(size: 13))
        let format = AttributedStringOutputFormat(theme: theme)
        let highlighter = SyntaxHighlighter(format: format)
        let attributed = try? AttributedString(highlighter.highlight(code), including: \.appKit)
        return AnyView(Text(attributed ?? AttributedString(code)))
    }
}
Enter fullscreen mode Exit fullscreen mode

Splash's pipeline: source code string -> SyntaxHighlighter -> AttributedStringOutputFormat -> NSAttributedString -> AttributedString -> SwiftUI.Text.

Why only Swift? Because Splash only supports Swift. To support Python/JavaScript/Bash, you'd need a multi-language highlighting library (like a Swift wrapper for Highlight.js) or Tree-sitter. Currently, Swift code blocks are highlighted most frequently (SwiftWork itself is a Swift project), so Swift-only support is sufficient for now.

KeychainManager: Secure API Key Storage

API keys should not be stored in plaintext in SwiftData or UserDefaults. SwiftWork uses the macOS Keychain:

struct KeychainManager: KeychainManaging, Sendable {
    func save(key: String, data: Data) throws {
        let query = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: key
        ]
        let status = SecItemAdd(query.merging([kSecValueData: data]), nil)
        if status == errSecDuplicateItem {
            SecItemUpdate(query, [kSecValueData: data])
        }
    }

    func load(key: String) throws -> Data? {
        let query = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: key,
            kSecReturnData: true,
            kSecMatchLimit: kSecMatchLimitOne
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query, &result)
        if status == errSecItemNotFound { return nil }
        return result as? Data
    }
}
Enter fullscreen mode Exit fullscreen mode

The KeychainManaging protocol abstracts the underlying implementation, making it easy to mock during testing. Protocol extensions provide convenience methods like saveAPIKey/getAPIKey/deleteAPIKey.

Keychain storage has two advantages: data is encrypted at the system level, and it is not subject to App Sandbox file access restrictions.

TitleGenerator: Auto-Generating Session Titles

Newly created sessions have the title "New Session". After the Agent completes its first execution, TitleGenerator uses an LLM to generate a short title based on the conversation content:

enum TitleGenerator {
    static func generate(events: [AgentEvent], apiKey: String, ...) async -> String? {
        guard !apiKey.isEmpty else { return nil }

        let messages = events
            .filter { $0.type == .userMessage || $0.type == .assistant }
            .suffix(10)  // Only take the last 10 messages
            .map { ["role": ..., "content": String($0.content.prefix(500))] }

        let body = [
            "model": model,
            "max_tokens": 50,
            "system": "Generate a short title based on the following conversation (maximum 20 characters). Output only the title.",
            "messages": messages
        ]
        // Call LLM API, return title text
    }
}
Enter fullscreen mode Exit fullscreen mode

The trigger lives in WorkspaceView.setupTitleGeneration — via the AgentBridge.onResult callback, it fires when the Agent finishes execution and the session title is still "New Session":

agentBridge.onResult = { [weak session] _ in
    guard let session, session.title == "New Session" else { return }
    if let title = await TitleGenerator.generate(events: events, ...) {
        sessionViewModel.updateSessionTitle(session, title: title)
    }
}
Enter fullscreen mode Exit fullscreen mode

This is a lightweight LLM call — only a 50-token output limit, a short system prompt, and it takes the last 10 messages with each truncated to 500 characters. In practice, latency is 1-2 seconds and doesn't affect the user experience.

Summary

SwiftWork's data layer and service components each have their own responsibilities:

Component Responsibility
SwiftData Session/Event/AppConfiguration persistence
AppStateManager App state restoration (session, window, panel)
EventStore Event persistence protocol, SwiftData implementation
MarkdownRenderer swift-markdown AST -> SwiftUI views
CodeHighlighter Splash syntax highlighting (Swift)
KeychainManager Secure API key storage
TitleGenerator LLM auto-generated session titles

They form the "support layer" beneath the core pipeline (AgentBridge -> EventMapper -> TimelineView) covered in previous posts. The app would still run without them, but the user experience would be significantly worse — no persistence means starting from scratch on every restart, no Markdown rendering means Agent responses are raw text, and no Keychain management means API keys are stored in plaintext.


Deep Dive into SwiftWork Series:

GitHub: SwiftWork | Open Agent SDK

Top comments (0)