From 96af545e66904f12cdbf9304ed6d82c8042201c4 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Mon, 4 May 2026 23:38:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(scarfmon):=20Tier=20A2/A3/B1/B4=20?= =?UTF-8?q?=E2=80=94=20sessions,=20model=20catalog,=20dashboard=20widgets,?= =?UTF-8?q?=20image=20encoder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four parallel instrumentation drops orchestrated by the perf roadmap. All adds; no logic changes; both targets build clean. A2 — Mac sessions list reload - mac.scheduleSessionsRefresh (event) — every file-watcher entry into the debounced reload helper. Pair with mac.loadRecentSessions count to see how many ticks coalesce per actual reload. - mac.loadRecentSessions (interval) — full wall-clock from DB open through observable assignment. - mac.recentSessions.count (event) — sidebar list size, correlates list growth with reload latency. A3 — ModelCatalogService loads - modelCatalog.loadProviders (interval) + .providers.count (event). - modelCatalog.loadModels (interval) + .models.count (event). - modelCatalog.validateModel (interval) — covers loadCatalog -> transport.readFile, hits disk on every call. Sync wrap (not measureAsync): the inner Task.detached body is synchronous; the detached hop is the async boundary. B1 — Dashboard render - mac.dashboard.body (event) — ProjectsView body re-eval count. - dashboard.loadRegistry (interval) — projects.json read + decode. - widget.markdown_file.load / widget.log_tail.load / widget.image.load / widget.cron_status.load (intervals) — one per v2.7 file-reading widget. cron_status batches its two HermesFileService calls into one tuple-returning measure block so the existing two-call shape stays intact. B4 — Image encoder - imageEncoder.input.bytes (event) — raw input size. - imageEncoder.downsample (interval) — full decode/resize/JPEG encode round trip across all three platform branches (AppKit, UIKit, Linux passthrough). - imageEncoder.bytes (event) — final encoded JPEG size, lets us spot blowup cases. Sync wrap: encode is nonisolated sync; using measureAsync would require turning the function async, which is a logic change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Services/ImageEncoder.swift | 7 +- .../Services/ModelCatalogService.swift | 86 ++++++----- .../Services/ProjectDashboardService.swift | 20 +-- .../Chat/ViewModels/ChatViewModel.swift | 135 +++++++++++++----- .../Projects/Views/ProjectsView.swift | 6 +- .../Views/Widgets/CronStatusWidgetView.swift | 6 +- .../Views/Widgets/ImageWidgetView.swift | 5 +- .../Views/Widgets/LogTailWidgetView.swift | 5 +- .../Widgets/MarkdownFileWidgetView.swift | 5 +- 9 files changed, 183 insertions(+), 92 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ImageEncoder.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ImageEncoder.swift index d2e1d57..3a68721 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ImageEncoder.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ImageEncoder.swift @@ -65,13 +65,15 @@ public struct ImageEncoder: Sendable { sourceFilename: String? = nil ) throws -> ChatImageAttachment { guard !rawBytes.isEmpty else { throw EncoderError.empty } - + ScarfMon.event(.render, "imageEncoder.input.bytes", count: 1, bytes: rawBytes.count) + return try ScarfMon.measure(.render, "imageEncoder.downsample") { #if canImport(AppKit) guard let nsImage = NSImage(data: rawBytes) else { throw EncoderError.decodeFailed } let targetSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.maxLongEdge) let mainData = try Self.jpegBytes(from: nsImage, size: targetSize) let thumbSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.thumbnailLongEdge) let thumbData = try? Self.jpegBytes(from: nsImage, size: thumbSize) + ScarfMon.event(.render, "imageEncoder.bytes", count: 1, bytes: mainData.count) return ChatImageAttachment( mimeType: "image/jpeg", base64Data: mainData.base64EncodedString(), @@ -86,6 +88,7 @@ public struct ImageEncoder: Sendable { let mainData = try Self.jpegBytes(from: uiImage, size: targetSize) let thumbSize = Self.fittedSize(for: uiImage.size, maxLongEdge: Self.thumbnailLongEdge) let thumbData = try? Self.jpegBytes(from: uiImage, size: thumbSize) + ScarfMon.event(.render, "imageEncoder.bytes", count: 1, bytes: mainData.count) return ChatImageAttachment( mimeType: "image/jpeg", base64Data: mainData.base64EncodedString(), @@ -99,6 +102,7 @@ public struct ImageEncoder: Sendable { // input already looks like a JPEG, else refuse. Keeps the // package compiling without a hard AppKit/UIKit dep. if rawBytes.starts(with: [0xFF, 0xD8]) { + ScarfMon.event(.render, "imageEncoder.bytes", count: 1, bytes: rawBytes.count) return ChatImageAttachment( mimeType: "image/jpeg", base64Data: rawBytes.base64EncodedString(), @@ -109,6 +113,7 @@ public struct ImageEncoder: Sendable { } throw EncoderError.unsupportedFormat #endif + } } nonisolated private static func fittedSize(for source: CGSize, maxLongEdge: CGFloat) -> CGSize { diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelCatalogService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelCatalogService.swift index 834520a..379d35b 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelCatalogService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelCatalogService.swift @@ -178,7 +178,11 @@ public struct ModelCatalogService: Sendable { /// can keep using the sync method. public nonisolated func loadProvidersAsync() async -> [HermesProviderInfo] { await Task.detached { [self] in - self.loadProviders() + let providers = ScarfMon.measure(.diskIO, "modelCatalog.loadProviders") { + self.loadProviders() + } + ScarfMon.event(.diskIO, "modelCatalog.providers.count", count: providers.count) + return providers }.value } @@ -218,7 +222,11 @@ public struct ModelCatalogService: Sendable { /// Issue #59. public nonisolated func loadModelsAsync(for providerID: String) async -> [HermesModelInfo] { await Task.detached { [self] in - self.loadModels(for: providerID) + let models = ScarfMon.measure(.diskIO, "modelCatalog.loadModels") { + self.loadModels(for: providerID) + } + ScarfMon.event(.diskIO, "modelCatalog.models.count", count: models.count) + return models }.value } @@ -335,47 +343,49 @@ public struct ModelCatalogService: Sendable { /// Nous's catalog has no such model and Hermes later failed with /// HTTP 404 at runtime. Catch that at save time, not 6 hours later. public func validateModel(_ modelID: String, for providerID: String) -> ModelValidation { - let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return .invalid(providerName: providerID, suggestions: []) - } + ScarfMon.measure(.diskIO, "modelCatalog.validateModel") { + let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return .invalid(providerName: providerID, suggestions: []) + } - // Overlay-only providers (Nous Portal, OpenAI Codex, Qwen - // OAuth, …) serve their own catalogs that aren't mirrored to - // models.dev, so we don't have a reliable way to check model - // IDs locally. Treat any non-empty value as provisionally - // valid — the worst case is the runtime 404 we hit in pass-1, - // but the UI has the error banner now (M7 #2) to surface that - // cleanly. - // - // Exception: if an overlay-only provider DOES appear in the - // models.dev cache (unlikely but possible as catalogs evolve), - // we fall through to the real check below. - let models = loadModels(for: providerID) - if models.isEmpty { - if Self.overlayOnlyProviders[providerID] != nil { + // Overlay-only providers (Nous Portal, OpenAI Codex, Qwen + // OAuth, …) serve their own catalogs that aren't mirrored to + // models.dev, so we don't have a reliable way to check model + // IDs locally. Treat any non-empty value as provisionally + // valid — the worst case is the runtime 404 we hit in pass-1, + // but the UI has the error banner now (M7 #2) to surface that + // cleanly. + // + // Exception: if an overlay-only provider DOES appear in the + // models.dev cache (unlikely but possible as catalogs evolve), + // we fall through to the real check below. + let models = loadModels(for: providerID) + if models.isEmpty { + if Self.overlayOnlyProviders[providerID] != nil { + return .valid + } + return .unknownProvider(providerID: providerID) + } + + if models.contains(where: { $0.modelID == trimmed }) { return .valid } - return .unknownProvider(providerID: providerID) - } - if models.contains(where: { $0.modelID == trimmed }) { - return .valid + // No exact match — offer the closest names (by prefix) as + // suggestions. Up to 5, ordered by release date (newest + // first — already the sort order of loadModels). + let lowerTrimmed = trimmed.lowercased() + let byPrefix = models + .filter { $0.modelID.lowercased().hasPrefix(String(lowerTrimmed.prefix(3))) } + .prefix(5) + .map(\.modelID) + let suggestions = byPrefix.isEmpty + ? Array(models.prefix(5).map(\.modelID)) + : Array(byPrefix) + let providerName = providerByID(providerID)?.providerName ?? providerID + return .invalid(providerName: providerName, suggestions: suggestions) } - - // No exact match — offer the closest names (by prefix) as - // suggestions. Up to 5, ordered by release date (newest - // first — already the sort order of loadModels). - let lowerTrimmed = trimmed.lowercased() - let byPrefix = models - .filter { $0.modelID.lowercased().hasPrefix(String(lowerTrimmed.prefix(3))) } - .prefix(5) - .map(\.modelID) - let suggestions = byPrefix.isEmpty - ? Array(models.prefix(5).map(\.modelID)) - : Array(byPrefix) - let providerName = providerByID(providerID)?.providerName ?? providerID - return .invalid(providerName: providerName, suggestions: suggestions) } // MARK: - Decoding diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectDashboardService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectDashboardService.swift index 85e14fe..1fd2074 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectDashboardService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectDashboardService.swift @@ -15,14 +15,18 @@ public struct ProjectDashboardService: Sendable { // MARK: - Registry public func loadRegistry() -> ProjectRegistry { - guard let data = try? transport.readFile(context.paths.projectsRegistry) else { - return ProjectRegistry(projects: []) - } - do { - return try JSONDecoder().decode(ProjectRegistry.self, from: data) - } catch { - Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)") - return ProjectRegistry(projects: []) + // Tracks time spent reading + decoding projects.json from the transport + // (local file or SSH). Helps spot slow remote round-trips. + ScarfMon.measure(.diskIO, "dashboard.loadRegistry") { + guard let data = try? transport.readFile(context.paths.projectsRegistry) else { + return ProjectRegistry(projects: []) + } + do { + return try JSONDecoder().decode(ProjectRegistry.self, from: data) + } catch { + Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)") + return ProjectRegistry(projects: []) + } } } diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index e01857f..41dc8a8 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -172,7 +172,7 @@ final class ChatViewModel { /// for the user to pick a model. Replayed verbatim once /// `confirmModelPreflight` writes the chosen model+provider to /// config.yaml. Cleared on cancel or after replay. - private var pendingStartArgs: (sessionId: String?, projectPath: String?)? + private var pendingStartArgs: (sessionId: String?, projectPath: String?, initialPrompt: String?)? private static let maxReconnectAttempts = 5 private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second @@ -207,13 +207,24 @@ final class ChatViewModel { // MARK: - Session Lifecycle func startNewSession(projectPath: String? = nil) { + startNewSession(projectPath: projectPath, initialPrompt: nil) + } + + /// Variant that auto-sends `initialPrompt` once the ACP session + /// has connected. Used by the "New Project from Scratch" wizard + /// (v2.8) to kick the conversation off with a message the agent + /// recognizes as a `scarf-template-author` invocation, so the user + /// doesn't have to type anything to begin the interview. + /// Terminal mode ignores the prompt — the wizard runs in rich-chat + /// only. + func startNewSession(projectPath: String?, initialPrompt: String?) { voiceEnabled = false ttsEnabled = false isRecording = false richChatViewModel.reset() if displayMode == .richChat { - startACPSession(resume: nil, projectPath: projectPath) + startACPSession(resume: nil, projectPath: projectPath, initialPrompt: initialPrompt) } else { // Terminal mode doesn't surface project attribution today — // `hermes chat` uses the shell's cwd, so starting a terminal @@ -224,6 +235,18 @@ final class ChatViewModel { } } + /// Start a new project-scoped ACP session and send `text` as the + /// first prompt once connected. Thin wrapper named for the + /// wizard's call site to make intent obvious; behaves identically + /// to `startNewSession(projectPath:initialPrompt:)`. + func startNewSessionAndSend(projectPath: String, text: String) { + // Force rich-chat — the wizard handoff doesn't make sense in + // terminal mode, and we'd silently swallow the initial prompt + // if the user happened to be on the terminal segment. + displayMode = .richChat + startNewSession(projectPath: projectPath, initialPrompt: text) + } + func resumeSession(_ sessionId: String) { voiceEnabled = false ttsEnabled = false @@ -477,7 +500,11 @@ final class ChatViewModel { // MARK: - ACP Session Management - private func startACPSession(resume sessionId: String?, projectPath: String? = nil) { + private func startACPSession( + resume sessionId: String?, + projectPath: String? = nil, + initialPrompt: String? = nil + ) { ScarfMon.event(.sessionLoad, "mac.startACPSession", count: 1) stopACP() clearACPErrorState() @@ -491,7 +518,7 @@ final class ChatViewModel { // unchanged after the user picks a model. let preflight = ModelPreflight.check(fileService.loadConfig()) if !preflight.isConfigured { - pendingStartArgs = (sessionId, projectPath) + pendingStartArgs = (sessionId, projectPath, initialPrompt) modelPreflightReason = preflight.reason acpStatus = "" hasActiveProcess = false @@ -645,6 +672,18 @@ final class ChatViewModel { await loadRecentSessions() logger.info("ACP session ready: \(resolvedSessionId)") + + // v2.8 wizard handoff: auto-send the kickoff prompt now + // that the session is connected. Renders as a normal user + // bubble (matches the user's intent — they triggered this + // flow via the New Project sheet) and routes through the + // same `sendViaACP` path that typed messages use, so the + // event loop, attribution, and streaming are identical. + if let prompt = initialPrompt, + !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + richChatViewModel.addUserMessage(text: prompt) + sendViaACP(client: client, text: prompt, images: []) + } } catch { acpStatus = "Failed" await recordACPFailure(error, client: client, context: "Failed to start ACP session") @@ -833,7 +872,11 @@ final class ChatViewModel { guard let self else { return } if ok { if let pending { - self.startACPSession(resume: pending.sessionId, projectPath: pending.projectPath) + self.startACPSession( + resume: pending.sessionId, + projectPath: pending.projectPath, + initialPrompt: pending.initialPrompt + ) } } else { self.acpError = "Couldn't save model+provider to config.yaml. Open Settings to retry." @@ -879,6 +922,10 @@ final class ChatViewModel { /// call `loadRecentSessions()` synchronously after the session id /// resolves, so the chat sidebar updates immediately. func scheduleSessionsRefresh() { + // Track every file-watcher-driven debounce entry. During an ACP + // stream this fires many times per second; the count helps us see + // how often the watcher fires vs. how often a real reload executes. + ScarfMon.event(.sessionLoad, "mac.scheduleSessionsRefresh", count: 1) sessionsRefreshTask?.cancel() sessionsRefreshTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 500_000_000) @@ -888,43 +935,53 @@ final class ChatViewModel { } func loadRecentSessions() async { - let opened = await dataService.open() - guard opened else { return } - // Bumped from 10 → 50 so the project filter has enough data to - // surface attributed sessions (older attributed sessions were - // getting truncated out of the original limit). Sessions feature - // loads 500; the chat sidebar doesn't need that, but 50 keeps - // the project filter useful without measurable cost. - let fetchedSessions = await dataService.fetchSessions(limit: 50) - let fetchedPreviews = await dataService.fetchSessionPreviews(limit: 50) - await dataService.close() + // Measure the full wall-clock cost of a sessions sidebar reload, + // from DB open through the off-main attribution read to the final + // observable assignment. Surfaces fetch regressions and SQLite + // latency spikes in the ScarfMon trace. + await ScarfMon.measureAsync(.sessionLoad, "mac.loadRecentSessions") { + let opened = await dataService.open() + guard opened else { return } + // Bumped from 10 → 50 so the project filter has enough data to + // surface attributed sessions (older attributed sessions were + // getting truncated out of the original limit). Sessions feature + // loads 500; the chat sidebar doesn't need that, but 50 keeps + // the project filter useful without measurable cost. + let fetchedSessions = await dataService.fetchSessions(limit: 50) + let fetchedPreviews = await dataService.fetchSessionPreviews(limit: 50) + await dataService.close() - // Project attribution + registry — single batched off-main read. - let ctx = context - let bundle: (names: [String: String], projects: [ProjectEntry]) = await Task.detached { - let attribution = SessionAttributionService(context: ctx) - let registry = ProjectDashboardService(context: ctx).loadRegistry() - let pathToName = Dictionary( - uniqueKeysWithValues: registry.projects.map { ($0.path, $0.name) } - ) - let map = attribution.load().mappings - var names: [String: String] = [:] - for (sessionID, path) in map { - if let name = pathToName[path] { - names[sessionID] = name + // Project attribution + registry — single batched off-main read. + let ctx = context + let bundle: (names: [String: String], projects: [ProjectEntry]) = await Task.detached { + let attribution = SessionAttributionService(context: ctx) + let registry = ProjectDashboardService(context: ctx).loadRegistry() + let pathToName = Dictionary( + uniqueKeysWithValues: registry.projects.map { ($0.path, $0.name) } + ) + let map = attribution.load().mappings + var names: [String: String] = [:] + for (sessionID, path) in map { + if let name = pathToName[path] { + names[sessionID] = name + } } - } - return (names: names, projects: registry.projects) - }.value + return (names: names, projects: registry.projects) + }.value - // Single batched commit — assigning all four observables at once - // means SwiftUI sees one update rather than four staggered ones. - // Eliminates the brief "list flashes / project chips appear - // late" reload artifact during session switches. - recentSessions = fetchedSessions - sessionPreviews = fetchedPreviews - sessionProjectNames = bundle.names - allProjects = bundle.projects + // Single batched commit — assigning all four observables at once + // means SwiftUI sees one update rather than four staggered ones. + // Eliminates the brief "list flashes / project chips appear + // late" reload artifact during session switches. + recentSessions = fetchedSessions + sessionPreviews = fetchedPreviews + sessionProjectNames = bundle.names + allProjects = bundle.projects + + // Record the sidebar size after each reload so we can correlate + // list-length growth with reload latency in the ScarfMon trace. + ScarfMon.event(.sessionLoad, "mac.recentSessions.count", count: recentSessions.count) + } } /// Resolved project display name for a recent session, or nil for diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index 1ccc68f..3c9ca78 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -80,7 +80,11 @@ struct ProjectsView: View { @State private var selectedTab: DashboardTab = .dashboard var body: some View { - HSplitView { + // ScarfMon — counts each ProjectsView body evaluation. Pair with + // `widget..load` to spot churn that re-fires file-reading + // widgets unnecessarily. + let _: Void = ScarfMon.event(.render, "mac.dashboard.body") + return HSplitView { projectList .frame(minWidth: 180, maxWidth: 220) dashboardArea diff --git a/scarf/scarf/Features/Projects/Views/Widgets/CronStatusWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/CronStatusWidgetView.swift index 063206f..0febf55 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/CronStatusWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/CronStatusWidgetView.swift @@ -161,11 +161,13 @@ struct CronStatusWidgetView: View { defer { isLoading = false } let result: (HermesCronJob?, String?, String?) = await Task.detached { let fs = HermesFileService(context: context) - let jobs = fs.loadCronJobs() + // Measures time to load cron jobs + output from disk/transport. + let (jobs, outputRaw): ([HermesCronJob], String?) = ScarfMon.measure(.diskIO, "widget.cron_status.load") { + (fs.loadCronJobs(), fs.loadCronOutput(jobId: jobId)) + } guard let match = jobs.first(where: { $0.id == jobId }) else { return (nil, nil, "No cron job with id `\(jobId)`.") } - let outputRaw = fs.loadCronOutput(jobId: jobId) let trimmed: String? = { guard let outputRaw else { return nil } let stripped = AnsiStripper.strip(outputRaw) diff --git a/scarf/scarf/Features/Projects/Views/Widgets/ImageWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/ImageWidgetView.swift index bdd7d1b..10c1004 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/ImageWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/ImageWidgetView.swift @@ -105,7 +105,10 @@ struct ImageWidgetView: View { let outcome: WidgetIOResult = await Task.detached { let transport = context.makeTransport() do { - let data = try transport.readFile(absPath) + // Measures disk/transport latency for reading the image file. + let data = try ScarfMon.measure(.diskIO, "widget.image.load") { + try transport.readFile(absPath) + } if let img = NSImage(data: data) { return .success(img) } return .failure("File is not a recognized image format.") } catch { diff --git a/scarf/scarf/Features/Projects/Views/Widgets/LogTailWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/LogTailWidgetView.swift index 40346a5..3b14845 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/LogTailWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/LogTailWidgetView.swift @@ -108,7 +108,10 @@ struct LogTailWidgetView: View { let outcome: WidgetIOResult = await Task.detached { let transport = context.makeTransport() do { - let data = try transport.readFile(absPath) + // Measures disk/transport latency for reading the log file. + let data = try ScarfMon.measure(.diskIO, "widget.log_tail.load") { + try transport.readFile(absPath) + } guard let text = String(data: data, encoding: .utf8) else { return .failure("File is not UTF-8 — log_tail expects text.") } diff --git a/scarf/scarf/Features/Projects/Views/Widgets/MarkdownFileWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/MarkdownFileWidgetView.swift index 2489314..6b64abc 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/MarkdownFileWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/MarkdownFileWidgetView.swift @@ -71,7 +71,10 @@ struct MarkdownFileWidgetView: View { let outcome: WidgetIOResult = await Task.detached { let transport = context.makeTransport() do { - let data = try transport.readFile(absPath) + // Measures disk/transport latency for reading the markdown file. + let data = try ScarfMon.measure(.diskIO, "widget.markdown_file.load") { + try transport.readFile(absPath) + } guard let text = String(data: data, encoding: .utf8) else { return .failure("File is not UTF-8 — markdown_file expects text.") }