Files
scarf/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift
T
Alan Wizemann 0a4f8de492 feat(scarfmon): Phase 3a — diagnostic measure points for chat-render bursts
Adds four targeted measure points so the next baseline capture can
attribute the bubble-re-render storm and the slow sendPrompt to a
specific cause:

- mac.RichChatMessageList.body — distinguishes "the parent is
  re-issuing the ForEach" from "the bubbles are re-rendering on their
  own". If list.body fires once and bubble.body fires N times, churn
  is in the bubbles; if list.body fires N times, the ForEach itself
  is being rebuilt.
- finalizeStreamingMessage (interval) — pinpoints the end-of-stream
  burst trigger. The 20-bubble re-eval burst we saw at the close of
  each turn lines up with this call; measuring it surfaces whether
  it's the streaming-id rewrite, the turn-duration assignment, or
  something downstream.
- firstByte / firstThoughtByte (event) — fires once per turn on the
  first chunk after currentTurnStart is set. Splits user-tap → first
  byte (network + Hermes thinking, the dominant component of the 7-11s
  sendPrompt) from first byte → turn end (Scarf streaming render).
- loadConfig caller hint via os.Logger — when ScarfMon is in Full mode,
  logs the first stack frame above each loadConfig call to the
  com.scarf.mon subsystem so mystery callers (the read at t=264282
  with no apparent trigger in the prior baseline) become traceable
  via `log stream`. Symbol-only, no PII, free outside Full mode.

All four are pure additions — no behavior change, same zero-cost
default-off semantics as Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:47:29 +02:00

321 lines
14 KiB
Swift

import SwiftUI
import ScarfCore
struct RichChatMessageList: View {
let groups: [MessageGroup]
let isWorking: Bool
/// True while the ACP session is being established or restored used to
/// swap the empty-state placeholder for a progress indicator so the user
/// knows something is happening while history loads.
var isLoadingSession: Bool = false
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
var scrollTrigger: UUID = UUID()
/// Wall-clock turn durations indexed by assistant-message id.
/// Threaded through to `MessageGroupView` `RichMessageBubble` so the
/// bubble's metadata footer can render the v2.5 stopwatch pill.
/// Defaults empty so callers that don't care can omit it.
var turnDurations: [Int: TimeInterval] = [:]
/// Show the "Load earlier messages" button at the top of the
/// transcript when the underlying session has more on-disk
/// history that hasn't been paged in yet. Hidden by default so
/// existing callers who haven't opted in see no UI change.
var hasMoreHistory: Bool = false
var isLoadingEarlier: Bool = false
var onLoadEarlier: (() -> Void)? = nil
/// Scrolling strategy: plain `VStack` (not `LazyVStack`) plus
/// `.defaultScrollAnchor(.bottom)`.
///
/// `LazyVStack` was causing the classic "loaded session shows whitespace
/// and the chat is above" bug: lazy rows return estimated heights before
/// they render, `.defaultScrollAnchor(.bottom)` positions the viewport
/// at the *estimated* bottom (which overshoots the real content), and
/// when rows materialize and real heights land, the viewport ends up
/// past the content. Attempts to correct via `proxy.scrollTo(lastID)`
/// failed because unrendered rows have no resolvable ID.
///
/// Switching to `VStack` materializes every row immediately, so
/// `.defaultScrollAnchor(.bottom)` has real heights to work with and
/// can't overshoot. For typical Hermes sessions (<500 messages) the
/// first-render cost is acceptable. If ever needed for huge sessions
/// we can reintroduce lazy with a preference-key-based height
/// measurement, but that's a much larger change.
var body: some View {
// 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 {
// Fill the scroll view's visible height so Spacers
// can vertically center the placeholder. Previously
// `.padding(.vertical, 80)` left the placeholder
// floating at whatever y-offset `.defaultScrollAnchor(.bottom)`
// settled on usually near the bottom of the pane.
VStack {
Spacer(minLength: 0)
if isLoadingSession {
loadingState
} else {
emptyState
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
.containerRelativeFrame(.vertical)
.transition(.opacity)
}
if hasMoreHistory, let onLoadEarlier {
Button {
onLoadEarlier()
} label: {
HStack(spacing: 6) {
if isLoadingEarlier {
ProgressView().scaleEffect(0.7)
} else {
Image(systemName: "arrow.up.circle")
.font(.caption)
}
Text(isLoadingEarlier ? "Loading earlier…" : "Load earlier messages")
.font(.caption)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.regularMaterial, in: Capsule())
}
.buttonStyle(.plain)
.disabled(isLoadingEarlier)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
}
ForEach(groups) { group in
MessageGroupView(group: group, turnDurations: turnDurations)
.equatable()
.id("group-\(group.id)")
}
if isWorking {
typingIndicator
.id("typing-indicator")
}
}
.padding()
.animation(.easeInOut(duration: 0.15), value: isLoadingSession)
.animation(.easeInOut(duration: 0.15), value: groups.isEmpty)
}
.defaultScrollAnchor(.bottom)
.onChange(of: scrollTrigger) {
let target = lastAnchorID
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo(target, anchor: .bottom)
}
}
}
}
/// Anchor ID used by the explicit scrollTrigger path. Prefers the typing
/// indicator when visible (so we scroll to the very bottom of the
/// current turn), otherwise the last group.
private var lastAnchorID: String {
if isWorking { return "typing-indicator" }
if let last = groups.last { return "group-\(last.id)" }
return "group-0"
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "bubble.left.and.text.bubble.right")
.font(.system(size: 40))
.foregroundStyle(.tertiary)
Text("Chat Messages")
.font(.title3)
.fontWeight(.semibold)
Text("Messages will appear here as the conversation progresses.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
private var loadingState: some View {
VStack(spacing: 14) {
ProgressView()
.controlSize(.large)
Text("Loading session…")
.font(.callout)
.foregroundStyle(.secondary)
}
}
private var typingIndicator: some View {
HStack {
HStack(spacing: 4) {
ForEach(0..<3, id: \.self) { _ in
Circle()
.fill(.secondary)
.frame(width: 6, height: 6)
.opacity(0.6)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
Spacer(minLength: 80)
}
.symbolEffect(.pulse)
}
}
struct MessageGroupView: View, Equatable {
let group: MessageGroup
/// Wall-clock turn durations keyed by assistant-message id (v2.5).
/// Forwarded into `RichMessageBubble` so the metadata footer can
/// render the stopwatch pill. Defaults empty so existing callers
/// that haven't been updated yet still compile.
var turnDurations: [Int: TimeInterval] = [:]
@Environment(ChatViewModel.self) private var chatViewModel
/// Read here so the toolSummary pill knows whether to render as
/// always-visible (today's behavior) or as a tappable inspector
/// shortcut when per-call tool cards are hidden (issue #47).
@AppStorage(ChatDensityKeys.toolCardStyle)
private var toolCardStyleRaw: String = ToolCardStyle.full.rawValue
private var toolCardStyle: ToolCardStyle {
ToolCardStyle(rawValue: toolCardStyleRaw) ?? .full
}
/// Equatable short-circuit for SwiftUI: when the trailing group's
/// streaming bubble grows, only that group's `==` returns false.
/// All earlier groups skip body re-evaluation, dropping per-chunk
/// render work from O(n) to O(1) for settled groups (issue #46).
///
/// What participates:
/// - `group.id` (primary key stable sequential index).
/// - assistant-message id list (additions / finalize-id-flip).
/// - For the streaming message (id == 0): content, reasoning,
/// reasoningContent, toolCalls.count the only fields that
/// mutate while streaming.
/// - `turnDurations[msg.id]` for assistants in this group only
/// the dict is large and shared across groups, but each group
/// only renders its own entries.
/// - `group.toolResults.count` append-only within a group.
static func == (lhs: MessageGroupView, rhs: MessageGroupView) -> Bool {
guard lhs.group.id == rhs.group.id else { return false }
guard lhs.group.userMessage?.id == rhs.group.userMessage?.id else { return false }
guard lhs.group.userMessage?.content == rhs.group.userMessage?.content else { return false }
guard lhs.group.assistantMessages.count == rhs.group.assistantMessages.count else { return false }
for (l, r) in zip(lhs.group.assistantMessages, rhs.group.assistantMessages) {
if l.id != r.id { return false }
if l.id == 0 {
if l.content != r.content { return false }
if l.reasoning != r.reasoning { return false }
if l.reasoningContent != r.reasoningContent { return false }
if l.toolCalls.count != r.toolCalls.count { return false }
}
}
if lhs.group.toolResults.count != rhs.group.toolResults.count { return false }
for msg in lhs.group.assistantMessages where msg.isAssistant && msg.id != 0 {
if lhs.turnDurations[msg.id] != rhs.turnDurations[msg.id] { return false }
}
return true
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let user = group.userMessage {
RichMessageBubble(message: user, toolResults: [:])
.equatable()
}
// Identify by array offset rather than `message.id`. The
// streaming assistant message starts with id=0 and gets a
// new negative id when finalized using `\.id` would make
// SwiftUI think the bubble disappeared and a new one appeared
// (destroying + recreating the view, which manifests as the
// chat flashing or jumping right when the prompt completes).
// Within a single group the assistant messages are
// append-only, so offset is a stable identity for the
// group's lifetime.
let assistantMessages = group.assistantMessages.filter(\.isAssistant)
ForEach(Array(assistantMessages.enumerated()), id: \.offset) { _, message in
RichMessageBubble(
message: message,
toolResults: group.toolResults,
turnDuration: turnDurations[message.id]
)
.equatable()
}
// When per-call tool cards are visible, the summary pill
// is informational only. When tool cards are hidden
// (issue #47), this pill becomes the only chrome surfacing
// tool activity AND the only path back into the inspector
// pane render it on every group with calls (not just >1)
// and make it tappable to focus the first call.
let showSummary = (toolCardStyle == .hidden)
? group.toolCallCount > 0
: group.toolCallCount > 1
if showSummary {
toolSummary
}
}
}
@ViewBuilder
private var toolSummary: some View {
let kinds = group.toolKindCounts
if !kinds.isEmpty {
let firstCallId = group.assistantMessages
.flatMap(\.toolCalls)
.first?.callId
let isInteractive = (toolCardStyle == .hidden) && firstCallId != nil
Group {
if isInteractive, let firstCallId {
Button {
chatViewModel.focusedToolCallId = firstCallId
} label: {
toolSummaryPill(kinds, interactive: true)
}
.buttonStyle(.plain)
.help("Click to inspect tool calls")
} else {
toolSummaryPill(kinds, interactive: false)
}
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 2)
}
}
@ViewBuilder
private func toolSummaryPill(_ kinds: [ToolKind: Int], interactive: Bool) -> some View {
HStack(spacing: 4) {
Image(systemName: "wrench")
.font(.caption2)
Text(summaryText(kinds))
.font(.caption2)
if interactive {
Image(systemName: "arrow.up.right.square")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.foregroundStyle(.tertiary)
}
private func summaryText(_ kinds: [ToolKind: Int]) -> String {
let total = kinds.values.reduce(0, +)
let parts = kinds.sorted(by: { $0.value > $1.value })
.map { "\($0.value) \($0.key.rawValue)" }
.joined(separator: ", ")
return "Used \(total) tools (\(parts))"
}
}