diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index 1fc69ef..60905a9 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -641,11 +641,23 @@ public final class RichChatViewModel { } private func appendMessageChunk(text: String) { + // ScarfMon "first byte" — fires once per turn, on the first + // visible message chunk. Splits "user tap → first byte" + // (network + Hermes thinking) from "first byte → turn end" + // (streaming + Scarf rendering) so we can attribute slow-feel + // bugs to the right side. `bytes` carries the first chunk's + // size, not the full turn. + if streamingAssistantText.isEmpty && currentTurnStart != nil { + ScarfMon.event(.chatStream, "firstByte", count: 1, bytes: text.utf8.count) + } streamingAssistantText += text upsertStreamingMessage() } private func appendThoughtChunk(text: String) { + if streamingThinkingText.isEmpty && currentTurnStart != nil { + ScarfMon.event(.chatStream, "firstThoughtByte", count: 1, bytes: text.utf8.count) + } streamingThinkingText += text upsertStreamingMessage() } @@ -858,6 +870,12 @@ public final class RichChatViewModel { /// Convert the streaming message (id=0) into a permanent message and reset streaming state. private func finalizeStreamingMessage() { + ScarfMon.measure(.chatStream, "finalizeStreamingMessage") { + _finalizeStreamingMessageImpl() + } + } + + private func _finalizeStreamingMessageImpl() { guard let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) else { return } // Only finalize if there's actual content diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 246daf0..bfd9abf 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -18,11 +18,24 @@ struct HermesFileService: Sendable { nonisolated func loadConfig() -> HermesConfig { ScarfMon.measure(.diskIO, "loadConfig") { + // ScarfMon — when Full mode is on, log the first stack + // frame above this call to the perf Logger channel so + // mystery callers (e.g. config reads with no user action) + // can be identified by tailing + // `log stream --predicate 'subsystem == "com.scarf.mon"'`. + // Symbol-only — no addresses, no PII. Backtrace alloc is + // gated on isActive so it's free outside Full mode. + if ScarfMon.isActive { + let caller = Thread.callStackSymbols.dropFirst(2).first ?? "" + Self.perfLogger.debug("loadConfig caller: \(caller, privacy: .public)") + } guard let content = readFile(context.paths.configYAML) else { return .empty } return parseConfig(content) } } + private static let perfLogger = Logger(subsystem: "com.scarf.mon", category: "HermesFileService") + /// Error-surfacing config load. Used by Dashboard to show the user a /// specific reason when config.yaml can't be read on a remote host /// (permission denied, missing file, sqlite3 not installed, etc.) diff --git a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift index 850f947..14241ce 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift @@ -41,7 +41,12 @@ struct RichChatMessageList: View { /// we can reintroduce lazy with a preference-key-based height /// measurement, but that's a much larger change. var body: some View { - ScrollViewReader { proxy in + // ScarfMon — confirms whether the parent re-issues the + // ForEach. If this fires once and we still see RichMessageBubble.body + // burst N times, churn lives inside the bubbles (or in their inputs). + // If this fires N times, the ForEach itself is being rebuilt. + let _: Void = ScarfMon.event(.chatRender, "mac.RichChatMessageList.body") + return ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 16) { if groups.isEmpty && !isWorking {